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..96119a3e5 --- /dev/null +++ b/apps/arstack_studio/CMakeLists.txt @@ -0,0 +1,88 @@ +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/DockFrame.qml + qml/DarkToolButton.qml + qml/TelemetryDock.qml + qml/WorkflowBar.qml + qml/CalmButton.qml + qml/NumericField.qml + qml/StateBadge.qml + qml/ProfileInspector.qml + qml/ConfigurationHub.qml + qml/PtpExpertPanel.qml + qml/DeviceExpertPanel.qml + qml/WaveformExpertPanel.qml + qml/SignalMatrix.qml + qml/QualityEditor.qml + qml/SignalPreview.qml + qml/StatusToast.qml + RESOURCES + assets/InterVariable.ttf + assets/INTER_LICENSE.txt + assets/LUCIDE_LICENSE.txt + assets/lucide/x.svg + assets/lucide/external-link.svg + assets/lucide/chevron-up.svg + assets/lucide/chevron-down.svg + assets/lucide/radio-tower.svg + assets/lucide/scale.svg + assets/lucide/circle-off.svg + assets/lucide/activity.svg + assets/lucide/circle-check.svg + assets/lucide/settings-2.svg + assets/lucide/upload.svg + assets/lucide/square.svg + assets/lucide/play.svg + assets/lucide/panels-top-left.svg + assets/lucide/folder-open.svg + assets/lucide/scan-search.svg + assets/lucide/clock-3.svg +) + +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..c682ae54d --- /dev/null +++ b/apps/arstack_studio/README.md @@ -0,0 +1,179 @@ +# 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; +- smart USB discovery with firmware-handshake verification and sequential + read-only fallback probing when Windows exposes only generic COM metadata; +- explicit `IDENTIFY` device identity backed by the ESP32-P4 factory MAC, so + the normal workflow never asks the operator to choose a COM port; +- live magnitude, phase, editable AC frequency, DC, enable and quality controls; +- mandatory live apply after verified connection; no mode switch can leave the + operator editing a stale local copy while output is running; +- an ARSVIN-compatible CT-saturation test shape with bounded DC offset, + harmonic, order, and clip controls in Expert mode; +- START / STOP / ZERO / SHOW and runtime telemetry; +- keyboard-first 4I + 4V matrix navigation; +- 3-phase linkage; +- persistent generated phasor and waveform preview; +- PTP Lab TX status, bounded runtime configuration, start/stop, and counters; +- 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. + +CT Saturation is a deterministic test-waveform approximation, not a calibrated +electromagnetic CT model. Its preview and parameters are labeled accordingly. + +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 +``` + +On the Windows development workstation used for this phase, Qt is installed at +`D:\Qt\6.8.3\msvc2022_64`. Build and launch with one command: + +```powershell +.\apps\arstack_studio\run-windows.ps1 +``` + +Use `-NoLaunch` for a build-only verification. The launcher discovers the +installed Visual Studio C++ toolchain with `vswhere`, reuses its bundled Ninja, +and does not modify the user's global `PATH`. + +## P2 fast-workflow ribbon + +The compact Home / View / Engineering ribbon carries ARSVIN's short operator +loop into the native Qt surface without copying its visual styling. Home keeps +Balanced, Zero, Check, Configure, Deploy, Start, and Stop one action away. View +owns dock visibility and detachment; Engineering owns SCL, smart discovery, +PTP refresh, and diagnostics. Device and output state stay in the header/footer +instead of being repeated as large workflow cards. + +Keyboard operators can use `Ctrl+O`, `Ctrl+B`, `Ctrl+0`, `Ctrl+K`, `Ctrl+D`, +`F5`, and `F6`. Check reports readiness through the existing non-modal status +surface; it does not bypass the deployment gate. + +## P2 dock shell + +The main window now behaves as a compact engineering shell rather than a fixed +form. Engineering source, profile, scaling, quick setup, and runtime details +live in a dedicated Smart / Expert Configuration window instead of consuming +permanent space beside the injector. Phasor View and Waveform View are separate +right-side docks; each can be hidden or detached into its own real Qt window. +Status History and Output Monitor share a bottom dock that starts collapsed. +The View ribbon restores closed panels, detaches either plot, and expands the +bottom monitor. + +The shell uses nested draggable splitters: editor vs preview docks, Phasor vs +Waveform, and workspace vs bottom monitor. Every pane keeps a protective +minimum size, while its split can be resized directly like an engineering MDI +shell. The complete monitor header is clickable for smooth expand/collapse. + +Smart mode is the default and guides device recognition, PTP inspection, and SV +profile selection. Expert mode exposes the detailed SV profile compiler plus +bounded PTP domain, transportSpecific, VLAN, interval, and peer-delay controls. +Deploy and Start remain disabled until the serial response proves the connected +port is the ARStack ESP32-P4 injector. + +PTP remains explicitly labeled as a laboratory timing companion. The firmware +serial surface now supports `PTP SHOW`, `PTP START`, `PTP STOP`, and bounded +`PTP CONFIG`; none of these claims external grandmaster lock or changes the +truthful `smpSynch` boundary. + +The safety-critical editor remains the stable center pane. Split sizes are +operator-adjustable, while arbitrary drag-reordering and layout persistence +remain follow-up work; detaching a view must never move or duplicate the output +controls. + +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 `IDENTIFY`, SHOW, and PROFILE SHOW using updated firmware; +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. DC and CT-saturation SHAPE commands with observed on-wire sample values; +7. PTP Lab TX configuration/counters and explicit non-lock truth labeling; +8. generated-vs-on-wire identity comparison using a trusted capture path. + +## UI direction + +The desktop visual target is a calm premium engineering instrument: + +- Inter Variable is bundled under the SIL Open Font License and used for all + interface, numeric, status, and plot-label text; no monospace family is used; +- compact Lucide SVG icons are bundled with upstream license notices for the + ribbon and dock controls; +- 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/assets/INTER_LICENSE.txt b/apps/arstack_studio/assets/INTER_LICENSE.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/apps/arstack_studio/assets/INTER_LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/arstack_studio/assets/InterVariable.ttf b/apps/arstack_studio/assets/InterVariable.ttf new file mode 100644 index 000000000..4ab79e010 Binary files /dev/null and b/apps/arstack_studio/assets/InterVariable.ttf differ diff --git a/apps/arstack_studio/assets/LUCIDE_LICENSE.txt b/apps/arstack_studio/assets/LUCIDE_LICENSE.txt new file mode 100644 index 000000000..adcf16b39 --- /dev/null +++ b/apps/arstack_studio/assets/LUCIDE_LICENSE.txt @@ -0,0 +1,40 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +Some Lucide icons are derived from the Feather project and remain covered by +the MIT License. + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/arstack_studio/assets/lucide/activity.svg b/apps/arstack_studio/assets/lucide/activity.svg new file mode 100644 index 000000000..328370794 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/activity.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/chevron-down.svg b/apps/arstack_studio/assets/lucide/chevron-down.svg new file mode 100644 index 000000000..a97e994b3 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/chevron-up.svg b/apps/arstack_studio/assets/lucide/chevron-up.svg new file mode 100644 index 000000000..cc1ec44ff --- /dev/null +++ b/apps/arstack_studio/assets/lucide/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/circle-check.svg b/apps/arstack_studio/assets/lucide/circle-check.svg new file mode 100644 index 000000000..ae7167845 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/circle-check.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/circle-off.svg b/apps/arstack_studio/assets/lucide/circle-off.svg new file mode 100644 index 000000000..a254a8b04 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/circle-off.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/clock-3.svg b/apps/arstack_studio/assets/lucide/clock-3.svg new file mode 100644 index 000000000..202654f1d --- /dev/null +++ b/apps/arstack_studio/assets/lucide/clock-3.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/external-link.svg b/apps/arstack_studio/assets/lucide/external-link.svg new file mode 100644 index 000000000..a111301d0 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/external-link.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/folder-open.svg b/apps/arstack_studio/assets/lucide/folder-open.svg new file mode 100644 index 000000000..7e28117c0 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/folder-open.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/panels-top-left.svg b/apps/arstack_studio/assets/lucide/panels-top-left.svg new file mode 100644 index 000000000..f355b7b9e --- /dev/null +++ b/apps/arstack_studio/assets/lucide/panels-top-left.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/play.svg b/apps/arstack_studio/assets/lucide/play.svg new file mode 100644 index 000000000..1dcf0afaf --- /dev/null +++ b/apps/arstack_studio/assets/lucide/play.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/radio-tower.svg b/apps/arstack_studio/assets/lucide/radio-tower.svg new file mode 100644 index 000000000..b24729ce9 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/radio-tower.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/scale.svg b/apps/arstack_studio/assets/lucide/scale.svg new file mode 100644 index 000000000..0d5f346a3 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/scale.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/scan-search.svg b/apps/arstack_studio/assets/lucide/scan-search.svg new file mode 100644 index 000000000..eda5979e0 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/scan-search.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/settings-2.svg b/apps/arstack_studio/assets/lucide/settings-2.svg new file mode 100644 index 000000000..4680c14d1 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/settings-2.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/square.svg b/apps/arstack_studio/assets/lucide/square.svg new file mode 100644 index 000000000..c3de14945 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/square.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/upload.svg b/apps/arstack_studio/assets/lucide/upload.svg new file mode 100644 index 000000000..2457a0832 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/upload.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/x.svg b/apps/arstack_studio/assets/lucide/x.svg new file mode 100644 index 000000000..8917e5382 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/x.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml new file mode 100644 index 000000000..72d43dd19 --- /dev/null +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -0,0 +1,70 @@ +// 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" + property url iconSource + property real iconSize: 16 + property string toolTipText: "" + + implicitHeight: theme ? theme.controlHeight : 36 + implicitWidth: Math.max(70, contentItem.implicitWidth + 24) + font.family: uiFont + font.pixelSize: theme ? theme.labelSize : 11 + font.weight: Font.DemiBold + activeFocusOnTab: true + scale: down ? 0.985 : 1.0 + Behavior on scale { NumberAnimation { duration: 70 } } + + 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" : + (activeFocus ? theme.accent : hovered ? "#3a4a5b" : theme.line) + readonly property color activeText: !enabled ? theme.muted2 : + tone === "success" ? "#d6f4e6" : + tone === "danger" ? "#ffdce0" : theme.textSoft + + contentItem: Row { + spacing: control.iconSource.toString().length > 0 ? 7 : 0 + anchors.centerIn: parent + Image { + visible: control.iconSource.toString().length > 0 + width: control.iconSize + height: control.iconSize + anchors.verticalCenter: parent.verticalCenter + source: control.iconSource + sourceSize.width: control.iconSize * 2 + sourceSize.height: control.iconSize * 2 + opacity: control.enabled ? 0.9 : 0.32 + } + Text { + text: control.text + color: control.activeText + font: control.font + anchors.verticalCenter: parent.verticalCenter + } + } + + ToolTip.visible: hovered && toolTipText.length > 0 + ToolTip.text: toolTipText + ToolTip.delay: 500 + + background: Rectangle { + radius: control.theme ? control.theme.controlRadius : 7 + color: control.activeFill + border.width: 1 + border.color: control.activeEdge + Behavior on color { ColorAnimation { duration: 90 } } + Behavior on border.color { ColorAnimation { duration: 90 } } + } +} diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml new file mode 100644 index 000000000..6c57629f1 --- /dev/null +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: hub + property var theme + property var controller + property var device + property var profiles + property string uiFont: "Inter" + property string monoFont: "Inter" + + function openEngineeringFile() { profileInspector.openEngineeringFile() } + + component ExpertTab: TabButton { + implicitHeight: 36 + font.family: hub.uiFont + font.pixelSize: 9 + font.weight: checked ? Font.DemiBold : Font.Medium + contentItem: Label { + text: parent.text + color: parent.checked ? hub.theme.text : hub.theme.muted + font: parent.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.checked ? hub.theme.raised : hub.theme.surface2 + border.width: 1 + border.color: parent.checked ? hub.theme.line : hub.theme.lineSoft + Rectangle { + visible: parent.parent.checked + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 28 + anchors.rightMargin: 28 + height: 2 + color: hub.theme.accent + } + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + ColumnLayout { + spacing: 0 + Label { text: "EXPERT CONFIGURATION"; color: hub.theme.muted; font.family: hub.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } + Label { text: "Protocol & timing setup"; color: hub.theme.text; font.family: hub.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + Label { + text: hub.device.deviceVerified + ? "ESP32-P4 · ID " + hub.device.deviceId.slice(-6) + : "Offline editing" + color: hub.device.deviceVerified ? hub.theme.green : hub.theme.muted + font.family: hub.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + + TabBar { + id: expertTabs + Layout.fillWidth: true + implicitHeight: 36 + background: Rectangle { color: hub.theme.surface2; radius: 6 } + ExpertTab { text: "SV Profile" } + ExpertTab { text: "Waveform" } + ExpertTab { text: "PTP Lab" } + ExpertTab { text: "Device Protocol" } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: expertTabs.currentIndex + + ProfileInspector { + id: profileInspector + theme: hub.theme + controller: hub.controller + device: hub.device + profiles: hub.profiles + uiFont: hub.uiFont + monoFont: hub.monoFont + compact: false + } + WaveformExpertPanel { + theme: hub.theme + controller: hub.controller + uiFont: hub.uiFont + monoFont: hub.monoFont + } + PtpExpertPanel { + theme: hub.theme + device: hub.device + uiFont: hub.uiFont + monoFont: hub.monoFont + } + DeviceExpertPanel { + theme: hub.theme + device: hub.device + uiFont: hub.uiFont + monoFont: hub.monoFont + } + } + } +} diff --git a/apps/arstack_studio/qml/DarkToolButton.qml b/apps/arstack_studio/qml/DarkToolButton.qml new file mode 100644 index 000000000..f21242095 --- /dev/null +++ b/apps/arstack_studio/qml/DarkToolButton.qml @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +ToolButton { + id: control + property var theme + property string uiFont: "Inter" + property url iconSource + property real iconSize: 18 + + implicitWidth: 32 + implicitHeight: 30 + font.family: uiFont + font.pixelSize: theme.bodySize + + contentItem: Item { + Image { + visible: control.iconSource.toString().length > 0 + anchors.centerIn: parent + width: control.iconSize + height: control.iconSize + source: control.iconSource + sourceSize.width: control.iconSize * 2 + sourceSize.height: control.iconSize * 2 + opacity: control.enabled ? (control.hovered ? 1 : 0.82) : 0.32 + } + Label { + visible: control.iconSource.toString().length === 0 + anchors.fill: parent + text: control.text + color: control.enabled ? (control.hovered ? "#ffffff" : theme.textSoft) : theme.muted2 + font: control.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + background: Rectangle { + radius: 6 + color: control.down ? theme.accentSoft : (control.hovered ? theme.raisedHover : "transparent") + border.width: control.activeFocus ? 1 : 0 + border.color: theme.accent + } +} diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml new file mode 100644 index 000000000..b09b8446c --- /dev/null +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var device + property string uiFont: "Inter" + property string monoFont: "Inter" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Label { text: "Device protocol"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } + Label { + Layout.fillWidth: true + text: "The normal workflow discovers and verifies the injector automatically. Manual serial selection lives here only for commissioning and recovery." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + lineHeight: 1.35 + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 104 + radius: 8 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.device.deviceVerified ? "#2c674e" : panel.theme.lineSoft + GridLayout { + anchors.fill: parent + anchors.margins: 12 + columns: 2 + columnSpacing: 18 + rowSpacing: 7 + Label { text: "Product"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.deviceVerified ? panel.device.deviceProduct : "Not identified"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { text: "Hardware / ID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.deviceVerified ? "ESP32-P4 / " + panel.device.deviceId : "-"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: "GUI protocol"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.deviceVerified ? "v" + panel.device.protocolVersion : "-"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Label { text: "MANUAL RECOVERY"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } + + RowLayout { + Layout.fillWidth: true + ComboBox { + id: recoveryPort + Layout.preferredWidth: 170 + model: panel.device.ports + enabled: !panel.device.connected + font.family: panel.uiFont + font.pixelSize: 9 + onPressedChanged: if (pressed) panel.device.refreshPorts() + } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; onClicked: panel.device.refreshPorts() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: panel.device.connected ? "danger" : "accent" + text: panel.device.connected ? "Disconnect" : "Verify selected port" + onClicked: panel.device.connected ? panel.device.disconnectPort() : panel.device.connectPort(recoveryPort.currentText) + } + Item { Layout.fillWidth: true } + } + + Label { + Layout.fillWidth: true + text: panel.device.discoveryStatus + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + Item { Layout.fillHeight: true } + } +} diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml new file mode 100644 index 000000000..236e791af --- /dev/null +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: frame + + default property alias contentData: contentHost.data + property var theme + property string titleText: "View" + property string statusText: "" + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool detachable: false + property bool closable: true + + signal detachRequested() + signal closeRequested() + + color: theme.surface + radius: theme.panelRadius + border.width: 1 + border.color: theme.line + clip: true + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: theme.raised + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 7 + + Rectangle { width: 3; height: 14; radius: 2; color: theme.accent } + Label { + Layout.fillWidth: true + text: frame.titleText + color: theme.textSoft + font.family: frame.uiFont + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + Label { + visible: frame.statusText.length > 0 + text: frame.statusText + color: theme.muted + font.family: frame.monoFont + font.pixelSize: theme.captionSize - 1 + verticalAlignment: Text.AlignVCenter + } + DarkToolButton { + visible: frame.detachable + iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + theme: frame.theme + uiFont: frame.uiFont + onClicked: frame.detachRequested() + ToolTip.visible: hovered + ToolTip.text: "Detach into a separate window" + } + DarkToolButton { + visible: frame.closable + iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") + iconSize: 19 + theme: frame.theme + uiFont: frame.uiFont + onClicked: frame.closeRequested() + ToolTip.visible: hovered + ToolTip.text: "Close this view" + } + } + + Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: theme.lineSoft } + } + + Item { + id: contentHost + Layout.fillWidth: true + Layout.fillHeight: true + } + } +} diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml new file mode 100644 index 000000000..31d6d77d2 --- /dev/null +++ b/apps/arstack_studio/qml/Main.qml @@ -0,0 +1,1142 @@ +// 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: interFont.status === FontLoader.Ready ? interFont.name : "Inter" + property string monoFont: uiFont + property bool phaseLink: false + property bool profileDirty: false + property real currentScale: 1000.0 + property real voltageScale: 100.0 + property real signalFrequency: 50.0 + property real previousAcFrequency: 50.0 + property bool ctSaturationEnabled: false + property real ctDcOffsetPercent: 30.0 + property real ctHarmonicPercent: 28.0 + property int ctHarmonicOrder: 2 + property real ctClipPercent: 60.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 + property bool phasorDockVisible: true + property bool phasorDetached: false + property bool waveformDockVisible: true + property bool waveformDetached: false + property bool telemetryDockVisible: true + property bool telemetryExpanded: false + + font.family: root.uiFont + + FontLoader { + id: interFont + source: Qt.resolvedUrl("../assets/InterVariable.ttf") + } + + readonly property bool compactLayout: width < 1300 + readonly property bool selectedProfileDeployable: + sclProfiles.selectedProfile.compatibilityClass === "A" && + sclProfiles.selectedProfile.deviceSupport === "ready" + readonly property bool canDeploy: + device.deviceVerified && !device.running && selectedProfileDeployable && !device.profileDeploying + readonly property bool canStart: + device.deviceVerified && !device.running && + (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) + readonly property string instrumentState: { + if (device.discovering || (device.connected && !device.deviceVerified)) return "VERIFYING" + 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 === "VERIFYING" || 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.discovering || (device.connected && !device.deviceVerified)) return device.discoveryStatus + if (!device.connected) return device.discoveryStatus + 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 onDeviceVerifiedChanged() { + if (device.deviceVerified) + Qt.callLater(root.applyAllSignals) + } + } + + Timer { + interval: 650 + running: true + repeat: false + onTriggered: device.autoDetectAndConnect() + } + + 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" } + } + + ListModel { + id: statusHistoryModel + ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready. Edit locally or connect an injector."; isError: false } + } + + function showMessage(message, error) { + transientMessage = message + transientError = error === true + statusHistoryModel.append({ + timeText: new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"), + messageText: message, + isError: error === true + }) + while (statusHistoryModel.count > 100) + statusHistoryModel.remove(0) + messageTimer.restart() + } + + function detachPhasor() { + phasorDockVisible = false + phasorDetached = true + detachedPhasorWindow.raise() + detachedPhasorWindow.requestActivate() + } + + function detachWaveform() { + waveformDockVisible = false + waveformDetached = true + detachedWaveformWindow.raise() + detachedWaveformWindow.requestActivate() + } + + function openEngineeringFile() { + profilePanel.openEngineeringFile() + } + + function openConfiguration() { + configurationWindow.show() + configurationWindow.raise() + configurationWindow.requestActivate() + } + + function openDiagnostics() { + diagnosticsDialog.open() + } + + function deploySelectedProfile() { + if (canDeploy && device.deployProfile(sclProfiles.selectedProfile)) + profileDirty = true + } + + function runReadinessCheck() { + if (sclProfiles.fatalError.length) { + showMessage(sclProfiles.fatalError, true) + return + } + if (sclProfiles.hasProfiles && !selectedProfileDeployable) { + showMessage("Selected stream is valid SCL but outside the current ESP32-P4 deployment boundary.", true) + return + } + if (!device.deviceVerified) { + showMessage(sclProfiles.hasProfiles ? + "Profile is valid. Waiting for injector recognition before deployment." : + "Development setpoints are ready. Waiting for injector recognition.", false) + return + } + if (sclProfiles.hasProfiles && (profileDirty || !device.profileArmed)) { + showMessage("Profile is valid and the device is connected. Deploy to arm output.", false) + return + } + showMessage(device.running ? + "Output is running and live apply is active." : + "Ready to start. Device and output state are consistent.", false) + } + + Shortcut { sequence: "Ctrl+O"; onActivated: root.openEngineeringFile() } + Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } + Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } + Shortcut { sequence: "Ctrl+K"; onActivated: root.runReadinessCheck() } + Shortcut { sequence: "Ctrl+D"; onActivated: root.deploySelectedProfile() } + Shortcut { sequence: "F5"; enabled: root.canStart; onActivated: device.start() } + Shortcut { sequence: "F6"; enabled: device.connected && device.running; onActivated: device.stop() } + + 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) && + (signalFrequency === 0 ? Math.abs(value) : value) <= maximumMagnitude(group) && + (signalFrequency === 0 || value >= 0) + } + + 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() + if (waveformPanel) + waveformPanel.requestPaint() + if (detachedPhasorPanel) + detachedPhasorPanel.requestPaint() + if (detachedWaveformPanel) + detachedWaveformPanel.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.deviceVerified) + 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.deviceVerified) { + if (phaseLink && row < 3) + sendLinkedGroup(group) + else + sendSignal(group, row) + } + return true + } + + function applyAllSignals() { + if (!device.deviceVerified) + return + device.setFrequency(signalFrequency) + for (var group = 0; group < 2; ++group) + applyGroupSignals(group) + device.setCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + } + + function setFrequencyValue(value) { + if (!validFrequency(value)) + return false + signalFrequency = value + if (value > 0) + previousAcFrequency = value + frequencyField.text = value.toFixed(3) + frequencyField.invalidInput = false + refreshPreview() + if (device.deviceVerified) + device.setFrequency(value) + return true + } + + function setWaveformMode(mode) { + if (mode === "DC") { + if (ctSaturationEnabled) + setCtSaturation(false) + if (signalFrequency > 0) + previousAcFrequency = signalFrequency + setFrequencyValue(0) + showMessage("DC mode selected. Magnitude is an instantaneous signed value; phase is not used.", false) + } else { + setFrequencyValue(previousAcFrequency > 0 ? previousAcFrequency : 50) + showMessage("AC mode selected. Frequency and phase controls are active.", false) + } + } + + function setCtSaturation(enabled) { + if (enabled && signalFrequency === 0) { + showMessage("CT saturation shaping requires AC mode.", true) + return false + } + ctSaturationEnabled = enabled + refreshPreview() + if (device.deviceVerified) + device.setCtSaturation(enabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + showMessage(enabled + ? "CT saturation stress enabled · DC offset + 2nd harmonic + clipping approximation." + : "CT saturation stress disabled.", false) + 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.deviceVerified) + 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.deviceVerified) + device.zero() + } + + function setActiveQuality(value) { + var unsignedValue = Number(value) >>> 0 + groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) + activeQuality = unsignedValue + if (device.deviceVerified) + 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: 50 + color: studioTheme.chrome + border.width: 1 + border.color: studioTheme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 15 + spacing: 9 + + Rectangle { + width: 30 + height: 30 + radius: 6 + color: "#101b27" + border.width: 1 + border.color: "#315071" + Image { + anchors.centerIn: parent + width: 17 + height: 17 + source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + sourceSize.width: 34 + sourceSize.height: 34 + } + } + + ColumnLayout { + spacing: 0 + Layout.alignment: Qt.AlignVCenter + Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.Bold; font.letterSpacing: 1.0; verticalAlignment: Text.AlignVCenter } + Label { text: "SMV Injector"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + } + + Item { Layout.fillWidth: true } + + Rectangle { + Layout.preferredWidth: root.compactLayout ? 160 : 190 + implicitHeight: 32 + radius: 7 + color: studioTheme.surface2 + border.width: 1 + border.color: device.deviceVerified ? "#2c674e" : (device.discovering ? "#705827" : studioTheme.lineSoft) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + Rectangle { + width: 8; height: 8; radius: 4 + color: device.deviceVerified ? studioTheme.green : (device.discovering ? studioTheme.amber : studioTheme.muted2) + } + Label { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: device.deviceVerified ? "Injector ready" : (device.discovering ? "Recognizing..." : "Injector offline") + color: device.deviceVerified ? studioTheme.green : studioTheme.textSoft + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + Label { + text: device.deviceVerified ? "•••" : "↻" + color: studioTheme.accent + font.family: root.uiFont + font.pixelSize: 13 + font.weight: Font.Bold + verticalAlignment: Text.AlignVCenter + } + } + MouseArea { + id: identityMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !device.discovering + onClicked: device.deviceVerified ? root.openConfiguration() : device.autoDetectAndConnect() + } + ToolTip.visible: identityMouse.containsMouse + ToolTip.text: device.deviceVerified + ? "ARStack ESP32-P4 · ID " + device.deviceId.slice(-6) + " · Protocol v" + device.protocolVersion + : device.discoveryStatus + ToolTip.delay: 450 + } + } + } + + footer: Rectangle { + height: 48 + 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 } + + Label { + Layout.maximumWidth: root.compactLayout ? 320 : 520 + text: root.stateReason + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: studioTheme.captionSize + elide: Text.ElideRight + } + + Item { Layout.fillWidth: true } + + Label { + visible: !root.compactLayout + text: "Ctrl+O source · Ctrl+K check · F5 start · F6 stop" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: studioTheme.captionSize + } + + CalmButton { + theme: studioTheme + uiFont: root.uiFont + text: "Diagnostics" + onClicked: diagnosticsDialog.open() + } + } + } + + Window { + id: configurationWindow + width: 760 + height: 780 + minimumWidth: 640 + minimumHeight: 660 + visible: false + title: "ARStack Studio · Configuration" + color: studioTheme.bg + + onClosing: function(close) { + close.accepted = false + configurationWindow.hide() + } + + DockFrame { + anchors.fill: parent + anchors.margins: 10 + theme: studioTheme + titleText: "Smart & Expert Configuration" + statusText: device.deviceVerified ? "ESP32-P4 VERIFIED" : "OFFLINE EDIT" + uiFont: root.uiFont + monoFont: root.monoFont + closable: false + + ConfigurationHub { + id: profilePanel + anchors.fill: parent + theme: studioTheme + controller: root + device: device + profiles: sclProfiles + uiFont: root.uiFont + monoFont: root.monoFont + } + } + } + + Window { + id: detachedPhasorWindow + width: 560 + height: 580 + minimumWidth: 400 + minimumHeight: 400 + visible: root.phasorDetached + title: "ARStack Studio · Phasor View" + color: studioTheme.bg + + onClosing: function(close) { + close.accepted = false + root.phasorDetached = false + root.phasorDockVisible = true + } + + Rectangle { + anchors.fill: parent + anchors.margins: 10 + color: studioTheme.bg + + SignalPreview { + id: detachedPhasorPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: false + viewMode: "phasor" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } + } + } + + Window { + id: detachedWaveformWindow + width: 720 + height: 480 + minimumWidth: 480 + minimumHeight: 340 + visible: root.waveformDetached + title: "ARStack Studio · Waveform View" + color: studioTheme.bg + + onClosing: function(close) { + close.accepted = false + root.waveformDetached = false + root.waveformDockVisible = true + } + + Rectangle { + anchors.fill: parent + anchors.margins: 10 + color: studioTheme.bg + + SignalPreview { + id: detachedWaveformPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: false + viewMode: "waveform" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: root.compactLayout ? 9 : 11 + spacing: root.compactLayout ? 9 : 11 + + WorkflowBar { + Layout.fillWidth: true + Layout.preferredHeight: 94 + Layout.minimumHeight: 94 + theme: studioTheme + controller: root + device: device + profiles: sclProfiles + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + } + + SplitView { + id: shellSplit + Layout.fillWidth: true + Layout.fillHeight: true + orientation: Qt.Vertical + handle: Rectangle { + implicitHeight: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: parent.width + height: 1 + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitVCursor } + } + + SplitView { + id: workspaceSplit + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 390 + orientation: Qt.Horizontal + handle: Rectangle { + implicitWidth: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: 1 + height: parent.height + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitHCursor } + } + + SurfacePanel { + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumWidth: root.compactLayout ? 500 : 550 + SplitView.preferredWidth: root.compactLayout ? 700 : 860 + 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: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + 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 { + 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 + + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") } + + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + + ColumnLayout { + spacing: 0 + Label { text: root.signalFrequency === 0 ? "DC MODE" : "EDITABLE FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + RowLayout { + spacing: 5 + NumericField { + id: frequencyField + theme: studioTheme + monoFont: root.monoFont + compact: root.compactLayout + implicitWidth: 84 + text: "50.000" + suffixText: "Hz" + enabled: root.signalFrequency > 0 + 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 + if (value > 0) root.previousAcFrequency = value + root.refreshPreview() + if (device.deviceVerified) 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("AC frequency must be greater than 0 and not exceed 1000 Hz.", true) + } else { + text = value.toFixed(3) + } + } + } + } + } + + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } + + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + + CheckBox { + enabled: root.signalFrequency > 0 + checked: root.phaseLink + text: "3-phase link" + onToggled: root.phaseLink = checked + font.family: root.uiFont + font.pixelSize: 9 + } + + Item { Layout.fillWidth: true } + } + } + + 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: root.signalFrequency === 0 ? "A DC" : "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: root.signalFrequency === 0 ? "V DC" : "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: studioTheme.labelSize; font.weight: Font.Bold } + Label { + text: root.signalFrequency === 0 + ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" + : 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 + } + } + } + } + + SplitView { + visible: (root.phasorDockVisible && !root.phasorDetached) || + (root.waveformDockVisible && !root.waveformDetached) + SplitView.preferredWidth: root.compactLayout ? 300 : 380 + SplitView.minimumWidth: root.compactLayout ? 270 : 300 + SplitView.maximumWidth: 680 + SplitView.fillHeight: true + orientation: Qt.Vertical + handle: Rectangle { + implicitHeight: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: parent.width + height: 1 + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitVCursor } + } + + DockFrame { + visible: root.phasorDockVisible && !root.phasorDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 + theme: studioTheme + titleText: "Phasor View" + statusText: "GENERATED" + uiFont: root.uiFont + monoFont: root.monoFont + detachable: true + onDetachRequested: root.detachPhasor() + onCloseRequested: root.phasorDockVisible = false + + SignalPreview { + id: previewPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "phasor" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } + } + + DockFrame { + visible: root.waveformDockVisible && !root.waveformDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 + theme: studioTheme + titleText: "Waveform View" + statusText: root.signalFrequency.toFixed(3) + " Hz" + uiFont: root.uiFont + monoFont: root.monoFont + detachable: true + onDetachRequested: root.detachWaveform() + onCloseRequested: root.waveformDockVisible = false + + SignalPreview { + id: waveformPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "waveform" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } + } + } + } + + TelemetryDock { + visible: root.telemetryDockVisible + SplitView.fillWidth: true + SplitView.minimumHeight: root.telemetryExpanded ? 88 : 32 + SplitView.maximumHeight: root.telemetryExpanded ? 320 : 32 + SplitView.preferredHeight: root.telemetryExpanded ? 124 : 32 + Behavior on SplitView.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } + theme: studioTheme + device: device + currentModel: currentModel + voltageModel: voltageModel + historyModel: statusHistoryModel + uiFont: root.uiFont + monoFont: root.monoFont + expanded: root.telemetryExpanded + onExpandedChanged: root.telemetryExpanded = expanded + onCloseRequested: root.telemetryDockVisible = false + } + } + } + + StatusToast { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 72 + theme: studioTheme + uiFont: root.uiFont + message: root.toastMessage + error: root.toastError + } +} diff --git a/apps/arstack_studio/qml/NumericField.qml b/apps/arstack_studio/qml/NumericField.qml new file mode 100644 index 000000000..1a7bd44ec --- /dev/null +++ b/apps/arstack_studio/qml/NumericField.qml @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +TextField { + id: field + property var theme + property string monoFont: "Inter" + property bool invalidInput: false + property bool compact: false + property string suffixText: "" + + selectByMouse: true + horizontalAlignment: Text.AlignRight + verticalAlignment: TextInput.AlignVCenter + 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: suffixText.length > 0 ? 28 : 8 + implicitHeight: compact ? 31 : 34 + + Label { + visible: field.suffixText.length > 0 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: field.suffixText + color: field.theme.muted + font.family: field.monoFont + font.pixelSize: field.compact ? 8 : 9 + } + + 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 } } + } +} diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml new file mode 100644 index 000000000..8c68de41e --- /dev/null +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -0,0 +1,361 @@ +// 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: "Inter" + 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 + } + + function openEngineeringFile() { + engineeringFileDialog.open() + } + + 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: panel.theme.captionSize + 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: panel.theme.captionSize - 1 + 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: panel.theme.labelSize + 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: panel.theme.captionSize + 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: panel.theme.labelSize + 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: panel.theme.captionSize - 1 + 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: panel.theme.captionSize + 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: panel.theme.captionSize } + Label { Layout.fillWidth: true; text: panel.profile.svId || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideMiddle } + Label { text: "DataSet"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { Layout.fillWidth: true; text: panel.profile.dataSetReference || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideMiddle } + Label { text: "MAC / APPID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { Layout.fillWidth: true; text: (panel.profile.destinationMac || "—") + " · " + (panel.profile.appIdHex || "—"); color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideRight } + Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { text: panel.profile.publisherRate ? panel.profile.publisherRate + " fps" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } + Label { text: "VLAN"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { text: panel.profile.vlanPresent ? "PCP " + panel.profile.vlanPriority + " · VID " + panel.profile.vlanId : "untagged"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } + Label { text: "Payload"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { text: panel.profile.payloadBytes ? panel.profile.payloadBytes + " B · " + panel.profile.channelLeafCount + " leaves" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } + } + } + + 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: panel.theme.captionSize + 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: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + Label { + Layout.fillWidth: true + text: "Confirm the evidenced smpCnt modulus before deployment." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + 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: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + + 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: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + 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: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + 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: panel.theme.captionSize; 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: panel.theme.captionSize; 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: panel.theme.captionSize; 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: panel.theme.captionSize; Layout.alignment: Qt.AlignRight } + } + } +} diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml new file mode 100644 index 000000000..9c6da8b07 --- /dev/null +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var device + property string uiFont: "Inter" + property string monoFont: "Inter" + + function applyProfile() { + device.configurePtp({ + "domain": Number(domainField.text), + "transportSpecific": Number(transportField.text), + "vlanEnabled": vlanCheck.checked, + "vlanId": Number(vlanField.text), + "vlanPriority": Number(pcpField.text), + "announceIntervalMs": Number(announceField.text), + "syncIntervalMs": Number(syncField.text), + "respondToPeerDelay": pdelayCheck.checked + }) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "PTP LAB TIMING"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + Label { text: "Expert profile"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + StateBadge { + theme: panel.theme + monoFont: panel.uiFont + state: panel.device.ptpStatus.toUpperCase() + stateColor: panel.device.ptpRunning ? panel.theme.amber : (panel.device.ptpAvailable ? panel.theme.green : panel.theme.muted) + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 58 + radius: 7 + color: panel.theme.amberSoft + border.width: 1 + border.color: "#6a5529" + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + Label { text: "Laboratory timing companion"; color: panel.theme.amber; font.family: panel.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { Layout.fillWidth: true; text: "This transmitter is not a GPS-backed grandmaster and does not prove that SV is synchronized."; wrapMode: Text.WordWrap; color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9 } + } + } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: 10 + rowSpacing: 9 + + Label { text: "Domain"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: domainField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpDomain === "-" ? "0" : panel.device.ptpDomain; validator: IntValidator { bottom: 0; top: 255 } } + Label { text: "transportSpecific"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: transportField; theme: panel.theme; monoFont: panel.uiFont; text: "0"; validator: IntValidator { bottom: 0; top: 15 } } + + Label { text: "Announce"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } + Label { text: "Sync"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "250"; validator: IntValidator { bottom: 20; top: 5000 } } + + CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } + Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } + } + + CheckBox { + id: pdelayCheck + text: "Respond to peer-delay requests using ESP32-P4 hardware timestamps" + checked: true + font.family: panel.uiFont + font.pixelSize: 9 + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + + GridLayout { + Layout.fillWidth: true + columns: 3 + rowSpacing: 8 + columnSpacing: 14 + Label { text: "Announce TX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "Sync TX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "TX failures"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.ptpAnnounceSent; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Label { text: panel.device.ptpSyncSent; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Label { text: panel.device.ptpTxFailures; color: Number(panel.device.ptpTxFailures) > 0 ? panel.theme.red : panel.theme.text; font.family: panel.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; enabled: panel.device.deviceVerified; onClicked: panel.device.sendPtpShow() } + Item { Layout.fillWidth: true } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Apply profile"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.applyProfile() } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "danger"; text: "Stop PTP"; enabled: panel.device.ptpRunning; onClicked: panel.device.stopPtp() } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "accent"; text: "Start PTP"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.device.startPtp() } + } + } +} diff --git a/apps/arstack_studio/qml/QualityEditor.qml b/apps/arstack_studio/qml/QualityEditor.qml new file mode 100644 index 000000000..72750e269 --- /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: "Inter" + 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 + } +} diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml new file mode 100644 index 000000000..1b0806538 --- /dev/null +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -0,0 +1,262 @@ +// 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: "Inter" + 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 + verticalAlignment: Text.AlignVCenter + } + Item { Layout.fillWidth: true } + Label { + text: matrix.unitText + color: matrix.theme.muted + font.family: matrix.uiFont + font.pixelSize: 9 + verticalAlignment: Text.AlignVCenter + } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 26 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 7 + Label { text: "ON"; Layout.preferredWidth: 28; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "CH"; Layout.preferredWidth: 34; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "MAGNITUDE"; Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + } + + 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 + Layout.alignment: Qt.AlignVCenter + checked: signalRow.channelEnabled + onToggled: { + matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) + matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) + matrix.controller.refreshPreview() + if (matrix.device.deviceVerified) + matrix.device.setEnabled(signalRow.sid, checked) + } + } + + RowLayout { + Layout.preferredWidth: 34 + Layout.alignment: Qt.AlignVCenter + 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 + verticalAlignment: Text.AlignVCenter + } + } + + NumericField { + id: magnitudeField + Layout.fillWidth: true + theme: matrix.theme + monoFont: matrix.monoFont + compact: matrix.compact + suffixText: matrix.groupIndex === 0 ? "A" : "V" + 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 + enabled: matrix.controller.signalFrequency > 0 + Layout.preferredWidth: matrix.compact ? 82 : 98 + theme: matrix.theme + monoFont: matrix.monoFont + compact: matrix.compact + suffixText: "°" + 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 } + } +} diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml new file mode 100644 index 000000000..f92fccf57 --- /dev/null +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -0,0 +1,271 @@ +// 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: "Inter" + property bool compact: false + property string activeSignal: "Ia" + property string activeUnit: "A" + property real activeMagnitude: 1 + property real activePhase: 0 + property real signalFrequency: 50 + property bool ctSaturationEnabled: false + property real ctDcOffsetPercent: 30 + property real ctHarmonicPercent: 28 + property int ctHarmonicOrder: 2 + property real ctClipPercent: 60 + property bool showHeader: true + property string viewMode: "both" // both, phasor, waveform + + function requestPaint() { + phasor.requestPaint() + waveform.requestPaint() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: preview.compact ? 11 : 14 + spacing: 8 + + RowLayout { + visible: preview.showHeader + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "GENERATED SETPOINT"; color: preview.theme.muted; font.family: preview.uiFont; font.pixelSize: preview.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + 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: preview.theme.captionSize; font.weight: Font.Bold } + } + + RowLayout { + visible: preview.viewMode !== "waveform" + 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.signalFrequency === 0 + ? preview.activeSignal + " · " + preview.activeMagnitude.toFixed(3) + " " + preview.activeUnit + " DC" + : 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 + visible: preview.viewMode !== "waveform" + 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() + } + + ctx.fillStyle = "#8794a3" + ctx.font = "500 10px '" + preview.uiFont + "'" + ctx.textBaseline = "middle" + ctx.textAlign = "left" + ctx.fillText("0°", cx + r + 7, cy) + ctx.textAlign = "center" + ctx.fillText("90°", cx, cy - r - 9) + ctx.textAlign = "right" + ctx.fillText("180°", cx - r - 7, cy) + ctx.textAlign = "center" + ctx.fillText("270°", cx, cy + r + 10) + + if (preview.signalFrequency === 0) { + ctx.fillStyle = "#778493" + ctx.font = "500 11px '" + preview.uiFont + "'" + ctx.textAlign = "center" + ctx.fillText("DC · phasor not applicable", cx, cy + 4) + return + } + + function drawGroup(model, scale, isCurrent) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, Math.abs(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 = isCurrent ? (active ? 1 : 0.88) : (active ? 0.82 : 0.52) + ctx.strokeStyle = signal.traceColor + ctx.fillStyle = signal.traceColor + ctx.lineWidth = isCurrent ? (active ? 3.0 : 2.2) : (active ? 2.2 : 1.45) + ctx.lineCap = "round" + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(ex, ey) + ctx.stroke() + 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 11px '" + preview.uiFont + "'" + ctx.fillText(signal.signalId, ex + 7, ey - 7) + } + ctx.restore() + } + } + + drawGroup(preview.voltageModel, 1.0, false) + drawGroup(preview.currentModel, 0.78, true) + } + } + + RowLayout { + visible: preview.viewMode !== "phasor" + 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 === 0 ? "DC · constant" : preview.signalFrequency.toFixed(3) + " Hz · 40 ms" + (preview.ctSaturationEnabled ? " · CT SAT" : ""); color: preview.ctSaturationEnabled ? preview.theme.amber : preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8 } + } + + Canvas { + id: waveform + visible: preview.viewMode !== "phasor" + 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 = 24 + 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.fillStyle = "#687585" + ctx.font = "500 8px '" + preview.uiFont + "'" + ctx.textAlign = "center" + ctx.textBaseline = "top" + for (var tick = 0; tick <= 4; ++tick) { + var tickX = left + plotWidth * tick / 4 + ctx.fillText((tick * 10) + " ms", tickX, top + plotHeight + 6) + } + 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, isCurrent) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, Math.abs(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, Math.abs(signal.magnitude) / maxMagnitude) + var phaseRadians = signal.phase * Math.PI / 180 + ctx.save() + ctx.strokeStyle = signal.traceColor + ctx.globalAlpha = isCurrent ? (active ? 1 : 0.88) : (active ? 0.82 : 0.52) + ctx.lineWidth = active ? 2.6 : 2.0 + ctx.lineCap = "round" + ctx.beginPath() + for (var point = 0; point <= 180; ++point) { + var ratio = point / 180 + var time = 0.04 * ratio + var sample = preview.signalFrequency === 0 + ? (signal.magnitude < 0 ? -1.0 : 1.0) + : Math.sin(2 * Math.PI * preview.signalFrequency * time + phaseRadians) + if (isCurrent && preview.ctSaturationEnabled && preview.signalFrequency > 0) { + var electricalAngle = 2 * Math.PI * preview.signalFrequency * time + phaseRadians + sample += (preview.ctHarmonicPercent / 100.0) * + Math.sin(electricalAngle * preview.ctHarmonicOrder) + sample += preview.ctDcOffsetPercent / 100.0 + var clip = Math.abs(preview.ctClipPercent / 100.0) + sample = Math.max(-clip, Math.min(clip, sample)) + } + 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 + } + } +} diff --git a/apps/arstack_studio/qml/StateBadge.qml b/apps/arstack_studio/qml/StateBadge.qml new file mode 100644 index 000000000..79260a3ad --- /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: "Inter" + + implicitHeight: 26 + 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: badge.theme.captionSize + font.weight: Font.Bold + font.letterSpacing: 0.45 + } +} diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml new file mode 100644 index 000000000..da68a7a7c --- /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 + } +} diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml new file mode 100644 index 000000000..374c37c67 --- /dev/null +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -0,0 +1,36 @@ +// 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" + + // Shared density and typography tokens. Keep engineering data compact, + // but never depend on tiny text to make the workspace fit. + readonly property int captionSize: 10 + readonly property int labelSize: 11 + readonly property int bodySize: 12 + readonly property int subtitleSize: 15 + readonly property int titleSize: 20 + readonly property int controlHeight: 36 + readonly property int panelRadius: 10 + readonly property int controlRadius: 7 +} diff --git a/apps/arstack_studio/qml/SurfacePanel.qml b/apps/arstack_studio/qml/SurfacePanel.qml new file mode 100644 index 000000000..e83130ad4 --- /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: theme ? theme.panelRadius : 10 + border.width: 1 + border.color: theme ? theme.lineSoft : "#1c252f" +} diff --git a/apps/arstack_studio/qml/TelemetryDock.qml b/apps/arstack_studio/qml/TelemetryDock.qml new file mode 100644 index 000000000..a48b2ce15 --- /dev/null +++ b/apps/arstack_studio/qml/TelemetryDock.qml @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: telemetry + + property var theme + property var device + property var currentModel + property var voltageModel + property var historyModel + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool expanded: true + + signal closeRequested() + + implicitHeight: 32 + Layout.preferredHeight: expanded ? 124 : 32 + color: theme.surface + radius: theme.panelRadius + border.width: 1 + border.color: theme.line + clip: true + Behavior on Layout.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + id: headerBar + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: headerMouse.containsMouse ? theme.raisedHover : theme.raised + Behavior on color { ColorAnimation { duration: 90 } } + + MouseArea { + id: headerMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: telemetry.expanded = !telemetry.expanded + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 7 + + Rectangle { width: 3; height: 14; radius: 2; color: telemetry.device.running ? theme.green : theme.accent } + Label { + text: "Status & output monitor" + color: theme.textSoft + font.family: telemetry.uiFont + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + Label { + text: telemetry.device.running ? "LIVE" : "STANDBY" + color: telemetry.device.running ? theme.green : theme.muted + font.family: telemetry.monoFont + font.pixelSize: theme.captionSize - 1 + font.weight: Font.Bold + verticalAlignment: Text.AlignVCenter + } + Item { Layout.fillWidth: true } + Label { + text: "FPS " + telemetry.device.fps + " · MISSED " + telemetry.device.missed + " · TX FAIL " + telemetry.device.txFailures + color: theme.muted + font.family: telemetry.monoFont + font.pixelSize: theme.captionSize - 1 + verticalAlignment: Text.AlignVCenter + } + DarkToolButton { + theme: telemetry.theme + uiFont: telemetry.uiFont + iconSource: telemetry.expanded + ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") + : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") + onClicked: telemetry.expanded = !telemetry.expanded + ToolTip.visible: hovered + ToolTip.text: telemetry.expanded ? "Collapse monitor" : "Expand monitor" + } + DarkToolButton { + theme: telemetry.theme + uiFont: telemetry.uiFont + iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") + iconSize: 19 + onClicked: telemetry.closeRequested() + ToolTip.visible: hovered + ToolTip.text: "Close monitor" + } + } + } + + RowLayout { + visible: opacity > 0 + opacity: telemetry.expanded ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: telemetry.expanded ? 180 : 110; easing.type: Easing.OutCubic } } + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 9 + spacing: 10 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 1.25 + radius: theme.controlRadius + color: theme.surface2 + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 3 + Label { + text: "STATUS HISTORY" + color: theme.muted + font.family: telemetry.uiFont + font.pixelSize: theme.captionSize - 1 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + model: telemetry.historyModel + clip: true + spacing: 2 + delegate: RowLayout { + required property string timeText + required property string messageText + required property bool isError + width: ListView.view.width + spacing: 7 + Label { text: timeText; color: theme.muted; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize - 1 } + Label { Layout.fillWidth: true; text: messageText; color: isError ? theme.red : theme.textSoft; font.family: telemetry.uiFont; font.pixelSize: theme.captionSize; elide: Text.ElideRight } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 0.75 + radius: theme.controlRadius + color: theme.surface2 + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 4 + Label { + text: "OUTPUT CHANNELS" + color: theme.muted + font.family: telemetry.uiFont + font.pixelSize: theme.captionSize - 1 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + RowLayout { + Layout.fillWidth: true + spacing: 10 + Repeater { + model: telemetry.currentModel + delegate: RowLayout { + required property string signalId + required property bool enabled + spacing: 4 + Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.green : theme.muted2 } + Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } + } + } + } + RowLayout { + Layout.fillWidth: true + spacing: 10 + Repeater { + model: telemetry.voltageModel + delegate: RowLayout { + required property string signalId + required property bool enabled + spacing: 4 + Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.accent : theme.muted2 } + Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } + } + } + } + } + } + } + } +} diff --git a/apps/arstack_studio/qml/WaveformExpertPanel.qml b/apps/arstack_studio/qml/WaveformExpertPanel.qml new file mode 100644 index 000000000..fdc888d96 --- /dev/null +++ b/apps/arstack_studio/qml/WaveformExpertPanel.qml @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var controller + property string uiFont: "Inter" + property string monoFont: "Inter" + + function applyShape() { + controller.ctDcOffsetPercent = Number(dcField.text) + controller.ctHarmonicPercent = Number(harmonicField.text) + controller.ctHarmonicOrder = Number(orderField.text) + controller.ctClipPercent = Number(clipField.text) + controller.setCtSaturation(enabledCheck.checked) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Label { text: "Waveform stress"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } + Label { Layout.fillWidth: true; text: "CT saturation uses the ARSVIN-compatible publisher approximation: DC offset, harmonic content, and current clipping. It is a protection-test stress waveform, not a calibrated electromagnetic CT model."; wrapMode: Text.WordWrap; color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 10; lineHeight: 1.35 } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 62 + radius: 8 + color: panel.controller.ctSaturationEnabled ? panel.theme.amberSoft : panel.theme.surface2 + border.width: 1 + border.color: panel.controller.ctSaturationEnabled ? "#705827" : panel.theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.margins: 12 + CheckBox { id: enabledCheck; checked: panel.controller.ctSaturationEnabled; text: "Enable CT saturation stress"; font.family: panel.uiFont; font.pixelSize: 10 } + Item { Layout.fillWidth: true } + Label { text: panel.controller.ctSaturationEnabled ? "ACTIVE" : "CLEAN SINE"; color: panel.controller.ctSaturationEnabled ? panel.theme.amber : panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.Bold } + } + } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: 12 + rowSpacing: 10 + Label { text: "Current DC offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: dcField; theme: panel.theme; monoFont: panel.uiFont; text: panel.controller.ctDcOffsetPercent.toFixed(1); validator: DoubleValidator { bottom: -300; top: 300; decimals: 1 } } + Label { text: "% of peak"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Item { Layout.fillWidth: true } + + Label { text: "Harmonic"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: harmonicField; theme: panel.theme; monoFont: panel.uiFont; text: panel.controller.ctHarmonicPercent.toFixed(1); validator: DoubleValidator { bottom: 0; top: 300; decimals: 1 } } + Label { text: "%"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + NumericField { id: orderField; theme: panel.theme; monoFont: panel.uiFont; text: String(panel.controller.ctHarmonicOrder); validator: IntValidator { bottom: 2; top: 63 } } + + Label { text: "Current clip"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: clipField; theme: panel.theme; monoFont: panel.uiFont; text: panel.controller.ctClipPercent.toFixed(1); validator: DoubleValidator { bottom: 1; top: 1000; decimals: 1 } } + Label { text: "% of peak"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Item { Layout.fillWidth: true } + } + + RowLayout { + Layout.fillWidth: true + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "ARSVIN CT preset"; onClicked: { dcField.text = "30.0"; harmonicField.text = "28.0"; orderField.text = "2"; clipField.text = "60.0"; enabledCheck.checked = true; panel.applyShape() } } + Item { Layout.fillWidth: true } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "accent"; text: "Apply waveform"; enabled: panel.controller.signalFrequency > 0; onClicked: panel.applyShape() } + } + + Item { Layout.fillHeight: true } + } +} diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml new file mode 100644 index 000000000..f481bc370 --- /dev/null +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: ribbon + + property var controller + property var device + property var profiles + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool compact: false + property int activeTab: 0 + + implicitHeight: 94 + color: theme.raised + border.color: theme.line + + component RibbonTab: TabButton { + implicitWidth: 104 + implicitHeight: 32 + font.family: ribbon.uiFont + font.pixelSize: 10 + font.weight: checked ? Font.DemiBold : Font.Medium + contentItem: Label { + text: parent.text + color: parent.checked ? ribbon.theme.text : ribbon.theme.muted + font: parent.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.checked ? ribbon.theme.surface2 : "transparent" + radius: 6 + Rectangle { + visible: parent.parent.checked + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 14 + anchors.rightMargin: 14 + height: 2 + radius: 1 + color: ribbon.theme.accent + } + } + } + + component RibbonAction: CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + implicitHeight: 42 + iconSize: 16 + } + + component RibbonDivider: Rectangle { + width: 1 + height: 32 + color: ribbon.theme.lineSoft + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 33 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 2 + + RibbonTab { text: "Home"; checked: ribbon.activeTab === 0; onClicked: ribbon.activeTab = 0 } + RibbonTab { text: "View"; checked: ribbon.activeTab === 1; onClicked: ribbon.activeTab = 1 } + RibbonTab { text: "Engineering"; checked: ribbon.activeTab === 2; onClicked: ribbon.activeTab = 2 } + Item { Layout.fillWidth: true } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: ribbon.theme.lineSoft } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: ribbon.activeTab + + RowLayout { + Layout.leftMargin: 11 + Layout.rightMargin: 11 + spacing: 7 + + RibbonAction { + text: "Balanced" + iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") + toolTipText: "Apply a balanced three-phase setpoint" + onClicked: ribbon.controller.balanced() + } + RibbonAction { + text: "Zero" + iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") + toolTipText: "Set all magnitudes to zero" + onClicked: ribbon.controller.zeroAll() + } + RibbonAction { + visible: !ribbon.compact + text: "CT Saturation" + iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") + tone: ribbon.controller.ctSaturationEnabled ? "accent" : "neutral" + enabled: ribbon.controller.signalFrequency > 0 + toolTipText: ribbon.controller.ctSaturationEnabled ? "Disable CT saturation stress" : "Enable CT saturation stress" + onClicked: ribbon.controller.setCtSaturation(!ribbon.controller.ctSaturationEnabled) + } + RibbonDivider {} + RibbonAction { + visible: !ribbon.compact + text: "Check" + iconSource: Qt.resolvedUrl("../assets/lucide/circle-check.svg") + toolTipText: "Run readiness checks" + onClicked: ribbon.controller.runReadinessCheck() + } + RibbonAction { + text: "Configure" + iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + toolTipText: "Open Smart and Expert configuration" + onClicked: ribbon.controller.openConfiguration() + } + Item { Layout.fillWidth: true } + RibbonDivider {} + RibbonAction { + visible: !ribbon.compact + tone: "accent" + text: ribbon.device.profileDeploying ? "Deploying..." : "Deploy" + iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") + enabled: ribbon.controller.canDeploy + toolTipText: "Deploy the validated SV profile" + onClicked: ribbon.controller.deploySelectedProfile() + } + RibbonAction { + tone: "danger" + text: "Stop" + iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") + implicitWidth: 78 + enabled: ribbon.device.deviceVerified && ribbon.device.running + onClicked: ribbon.device.stop() + } + RibbonAction { + tone: "success" + text: "Start" + iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") + implicitWidth: 88 + enabled: ribbon.controller.canStart + onClicked: ribbon.device.start() + } + } + + RowLayout { + Layout.leftMargin: 11 + Layout.rightMargin: 11 + spacing: 7 + + RibbonAction { + text: "Phasor" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + tone: ribbon.controller.phasorDockVisible || ribbon.controller.phasorDetached ? "accent" : "neutral" + onClicked: { + ribbon.controller.phasorDetached = false + ribbon.controller.phasorDockVisible = !ribbon.controller.phasorDockVisible + } + } + RibbonAction { + text: "Waveform" + iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") + tone: ribbon.controller.waveformDockVisible || ribbon.controller.waveformDetached ? "accent" : "neutral" + onClicked: { + ribbon.controller.waveformDetached = false + ribbon.controller.waveformDockVisible = !ribbon.controller.waveformDockVisible + } + } + RibbonAction { + text: "Monitor" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + tone: ribbon.controller.telemetryDockVisible ? "accent" : "neutral" + onClicked: ribbon.controller.telemetryDockVisible = !ribbon.controller.telemetryDockVisible + } + RibbonDivider {} + RibbonAction { + visible: !ribbon.compact + text: "Detach phasor" + iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + enabled: ribbon.controller.phasorDockVisible && !ribbon.controller.phasorDetached + onClicked: ribbon.controller.detachPhasor() + } + RibbonAction { + visible: !ribbon.compact + text: "Detach waveform" + iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + enabled: ribbon.controller.waveformDockVisible && !ribbon.controller.waveformDetached + onClicked: ribbon.controller.detachWaveform() + } + Item { Layout.fillWidth: true } + RibbonAction { + text: ribbon.controller.telemetryExpanded ? "Collapse" : "Expand" + iconSource: ribbon.controller.telemetryExpanded + ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") + : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") + onClicked: ribbon.controller.telemetryExpanded = !ribbon.controller.telemetryExpanded + } + } + + RowLayout { + Layout.leftMargin: 11 + Layout.rightMargin: 11 + spacing: 7 + + RibbonAction { + text: "Configuration" + iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + onClicked: ribbon.controller.openConfiguration() + } + RibbonAction { + text: "Open SCL" + iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") + onClicked: ribbon.controller.openEngineeringFile() + } + RibbonAction { + text: "Detect device" + iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") + enabled: !ribbon.device.connected + onClicked: ribbon.device.autoDetectAndConnect() + } + RibbonAction { + text: "Refresh PTP" + iconSource: Qt.resolvedUrl("../assets/lucide/clock-3.svg") + enabled: ribbon.device.deviceVerified + onClicked: ribbon.device.sendPtpShow() + } + Item { Layout.fillWidth: true } + RibbonAction { + text: "Diagnostics" + iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") + onClicked: ribbon.controller.openDiagnostics() + } + } + } + } +} diff --git a/apps/arstack_studio/run-windows.ps1 b/apps/arstack_studio/run-windows.ps1 new file mode 100644 index 000000000..801d4fed3 --- /dev/null +++ b/apps/arstack_studio/run-windows.ps1 @@ -0,0 +1,74 @@ +param( + [string]$QtRoot = 'D:\Qt\6.8.3\msvc2022_64', + [ValidateSet('Debug', 'Release', 'RelWithDebInfo')] + [string]$Configuration = 'Release', + [switch]$NoLaunch +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath (Join-Path $QtRoot 'bin\Qt6Core.dll'))) { + throw "Qt runtime was not found at '$QtRoot'. Install Qt 6.8.3 MSVC 2022 with Qt SerialPort first." +} + +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path -LiteralPath $vswhere)) { + throw 'Visual Studio Installer (vswhere.exe) was not found.' +} + +$visualStudio = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (-not $visualStudio) { + throw 'A Visual Studio installation with the C++ x64 toolchain was not found.' +} + +$developerCommand = Join-Path $visualStudio 'Common7\Tools\VsDevCmd.bat' +if (-not (Test-Path -LiteralPath $developerCommand)) { + throw "Visual Studio developer command was not found at '$developerCommand'." +} + +$environmentLines = & $env:ComSpec /s /c "`"$developerCommand`" -no_logo -arch=x64 -host_arch=x64 && set" +$developerPath = $null +foreach ($line in $environmentLines) { + $separator = $line.IndexOf('=') + if ($separator -gt 0) { + $name = $line.Substring(0, $separator) + $value = $line.Substring($separator + 1) + if ($name -ceq 'PATH') { + $developerPath = $value + } elseif ($name -ine 'Path') { + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + } +} +if ($developerPath) { + $env:Path = $developerPath +} + +$ninja = Join-Path $visualStudio 'Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe' +if (-not (Test-Path -LiteralPath $ninja)) { + throw "The Visual Studio Ninja executable was not found at '$ninja'." +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$buildDirectory = Join-Path $repoRoot 'build-arstack-studio-qt' + +& cmake -S $PSScriptRoot -B $buildDirectory -G Ninja ` + "-DCMAKE_MAKE_PROGRAM=$ninja" ` + "-DCMAKE_PREFIX_PATH=$QtRoot" ` + "-DCMAKE_BUILD_TYPE=$Configuration" +if ($LASTEXITCODE -ne 0) { throw "Qt configure failed with exit code $LASTEXITCODE." } + +& cmake --build $buildDirectory --target arstack_studio --parallel +if ($LASTEXITCODE -ne 0) { throw "Qt build failed with exit code $LASTEXITCODE." } + +$executable = Join-Path $buildDirectory 'arstack_studio.exe' +if (-not (Test-Path -LiteralPath $executable)) { + throw "Build completed but '$executable' was not created." +} + +if (-not $NoLaunch) { + $env:PATH = (Join-Path $QtRoot 'bin') + [IO.Path]::PathSeparator + $env:PATH + Start-Process -FilePath $executable -WorkingDirectory $buildDirectory +} + +Write-Host "ARStack Studio ready: $executable" diff --git a/apps/arstack_studio/run.cmd b/apps/arstack_studio/run.cmd new file mode 100644 index 000000000..6694f8974 --- /dev/null +++ b/apps/arstack_studio/run.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-windows.ps1" %* +exit /b %ERRORLEVEL% diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp new file mode 100644 index 000000000..4e7aa23a4 --- /dev/null +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -0,0 +1,668 @@ +// 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}; +const QRegularExpression kIdentityExpression{ + QStringLiteral("ARSTACK identity product=([A-Z0-9_-]+) target=ESP32-P4 protocol=(\\d+) device_id=([A-Fa-f0-9]{12})"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpStatusExpression{ + QStringLiteral("PTP status=(RUNNING|STOPPED) Announce=(\\d+) Sync=(\\d+) FollowUp=(\\d+) PdelayFrames=(\\d+) TXfail=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpConfigExpression{ + QStringLiteral("PTP config domain=(\\d+) transportSpecific=0x([0-9A-Fa-f]+) VLAN=(\\S+) Announce=(\\d+) ms Sync=(\\d+) ms Pdelay=(ON|OFF)"), + QRegularExpression::CaseInsensitiveOption}; + +struct PortCandidate { + QString name; + int confidence{}; +}; + +[[nodiscard]] int portConfidence(const QSerialPortInfo& info) { + int score = 0; + if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100; + const QString identity = QStringLiteral("%1 %2 %3") + .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower(); + if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; + else if (identity.contains(QStringLiteral("esp32"))) score += 65; + if (identity.contains(QStringLiteral("espressif"))) score += 60; + if (identity.contains(QStringLiteral("usb jtag")) || + identity.contains(QStringLiteral("usb serial"))) score += 15; + return score; +} + +[[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()); + } + }); + + verificationTimer_.setSingleShot(true); + verificationTimer_.setInterval(2600); + connect(&verificationTimer_, &QTimer::timeout, this, [this] { + if (!serial_.isOpen() || deviceVerified_) return; + const bool wasAutomatic = automaticConnection_; + const bool continueProbing = wasAutomatic && genericProbeActive_ && !probeQueue_.isEmpty(); + disconnectPort(); + if (continueProbing) { + QTimer::singleShot(0, this, [this] { static_cast(tryNextProbe()); }); + return; + } + genericProbeActive_ = false; + setDiscoveryState( + wasAutomatic + ? QStringLiteral("A connected serial device did not identify as an ARStack injector.") + : QStringLiteral("The selected serial port did not answer as an ARStack injector."), + false); + }); + + refreshPorts(); +} + +QStringList DeviceController::ports() const { return ports_; } +QString DeviceController::recommendedPort() const { return recommendedPort_; } +QString DeviceController::discoveryStatus() const { return discoveryStatus_; } +bool DeviceController::discovering() const noexcept { return discovering_; } +bool DeviceController::deviceVerified() const noexcept { return deviceVerified_; } +QString DeviceController::deviceProduct() const { return deviceProduct_; } +QString DeviceController::deviceId() const { return deviceId_; } +QString DeviceController::protocolVersion() const { return protocolVersion_; } +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_; } +bool DeviceController::ptpAvailable() const noexcept { return ptpAvailable_; } +bool DeviceController::ptpRunning() const noexcept { return ptpRunning_; } +QString DeviceController::ptpStatus() const { return ptpStatus_; } +QString DeviceController::ptpDomain() const { return ptpDomain_; } +QString DeviceController::ptpTransportSpecific() const { return ptpTransportSpecific_; } +QString DeviceController::ptpVlan() const { return ptpVlan_; } +QString DeviceController::ptpAnnounceSent() const { return ptpAnnounceSent_; } +QString DeviceController::ptpSyncSent() const { return ptpSyncSent_; } +QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } + +void DeviceController::refreshPorts() { + QList candidates; + for (const auto& info : QSerialPortInfo::availablePorts()) { + candidates.push_back({info.portName(), portConfidence(info)}); + } + std::sort(candidates.begin(), candidates.end(), [](const PortCandidate& left, const PortCandidate& right) { + if (left.confidence != right.confidence) return left.confidence > right.confidence; + return left.name.localeAwareCompare(right.name) < 0; + }); + + QStringList discovered; + QStringList highConfidence; + for (const auto& candidate : candidates) { + discovered.push_back(candidate.name); + if (candidate.confidence >= 60) highConfidence.push_back(candidate.name); + } + discovered.removeDuplicates(); + const QString recommended = highConfidence.size() == 1 ? highConfidence.front() : QString{}; + const bool portsDidChange = discovered != ports_; + const bool recommendationChanged = recommended != recommendedPort_; + ports_ = std::move(discovered); + recommendedPort_ = recommended; + if (portsDidChange) emit portsChanged(); + if (recommendationChanged) emit discoveryChanged(); + + if (!serial_.isOpen() && !discovering_) { + if (!recommendedPort_.isEmpty()) { + setDiscoveryState(QStringLiteral("Compatible ESP32-P4 USB device found."), false); + } else if (highConfidence.size() > 1) { + setDiscoveryState(QStringLiteral("Multiple Espressif devices found; choose the intended injector."), false); + } else { + setDiscoveryState(QStringLiteral("No verified injector found; setpoints remain editable offline."), false); + } + } +} + +bool DeviceController::connectPort(const QString& portName) { + return connectPortInternal(portName, false); +} + +bool DeviceController::autoDetectAndConnect() { + if (deviceVerified_) return true; + refreshPorts(); + probeQueue_.clear(); + genericProbeActive_ = false; + if (!recommendedPort_.isEmpty()) { + setDiscoveryState( + QStringLiteral("Compatible device found. Verifying ARStack identity..."), true); + return connectPortInternal(recommendedPort_, true); + } + if (ports_.isEmpty()) return false; + + // Windows often exposes ESP USB CDC as the generic "USB Serial Device". + // Probe one port at a time with read-only SHOW commands and trust only the + // firmware-specific response grammar parsed by processLine(). + probeQueue_ = ports_; + genericProbeActive_ = true; + return tryNextProbe(); +} + +bool DeviceController::tryNextProbe() { + while (!probeQueue_.isEmpty()) { + const QString port = probeQueue_.takeFirst(); + setDiscoveryState(QStringLiteral("Checking connected devices for an ARStack injector..."), true); + if (connectPortInternal(port, true)) return true; + } + genericProbeActive_ = false; + setDiscoveryState(QStringLiteral("No ARStack injector answered; manual port selection remains available."), false); + return false; +} + +bool DeviceController::connectPortInternal(const QString& portName, const bool automatic) { + if (portName.trimmed().isEmpty()) { + setError(QStringLiteral("Select a serial port first.")); + return false; + } + if (serial_.isOpen()) disconnectPort(); + + if (!automatic) { + probeQueue_.clear(); + genericProbeActive_ = false; + } + automaticConnection_ = automatic; + deviceVerified_ = false; + setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); + + 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)) { + automaticConnection_ = false; + if (!automatic) { + setDiscoveryState(QStringLiteral("The selected serial port could not be opened."), false); + 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(); + verificationTimer_.setInterval(genericProbeActive_ ? 1100 : 2600); + verificationTimer_.start(); + static_cast(sendCommand(QStringLiteral("IDENTIFY"))); + static_cast(sendCommand(QStringLiteral("SHOW"))); + static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); + return true; +} + +void DeviceController::disconnectPort() { + const bool wasConnected = serial_.isOpen(); + verificationTimer_.stop(); + if (serial_.isOpen()) serial_.close(); + pendingRx_.clear(); + if (running_) { + running_ = false; + emit runningChanged(); + } + if (profileDeploying_ || profileArmed_) { + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + } + resetTelemetry(); + const bool wasVerified = deviceVerified_; + deviceVerified_ = false; + deviceProduct_.clear(); + deviceId_.clear(); + protocolVersion_.clear(); + discovering_ = false; + automaticConnection_ = false; + ptpAvailable_ = false; + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("Waiting for device"); + emit discoveryChanged(); + emit ptpStateChanged(); + if (wasVerified) emit deviceVerifiedChanged(); + 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 (!std::isfinite(hz) || hz < 0.0 || hz > 1000.0) { + setError(QStringLiteral("Frequency must be within 0..1000 Hz (0 = DC).")); + return false; + } + const auto millihertz = std::llround(hz * 1000.0); + if (millihertz < 0 || millihertz > std::numeric_limits::max()) return false; + if (!sendCommand(QStringLiteral("FREQ %1").arg(millihertz))) return false; + signalFrequencyHz_ = hz; + return true; +} + +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) || + (signalFrequencyHz_ > 0.0 && 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 (!deviceVerified_) { + 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; +} + +bool DeviceController::setCtSaturation( + const bool enabled, + const double dcOffsetPercent, + const double harmonicPercent, + const int harmonicOrder, + const double clipPercent) { + if (enabled && signalFrequencyHz_ == 0.0) { + setError(QStringLiteral("CT saturation shaping requires AC mode.")); + return false; + } + if (!std::isfinite(dcOffsetPercent) || dcOffsetPercent < -300.0 || dcOffsetPercent > 300.0 || + !std::isfinite(harmonicPercent) || harmonicPercent < 0.0 || harmonicPercent > 300.0 || + harmonicOrder < 2 || harmonicOrder > 63 || !std::isfinite(clipPercent) || + clipPercent < 1.0 || clipPercent > 1000.0) { + setError(QStringLiteral("CT saturation parameters are outside the bounded lab range.")); + return false; + } + return sendCommand(QStringLiteral("SHAPE CT %1 %2 %3 %4 %5") + .arg(enabled ? 1 : 0) + .arg(std::llround(dcOffsetPercent * 10.0)) + .arg(std::llround(harmonicPercent * 10.0)) + .arg(harmonicOrder) + .arg(std::llround(clipPercent * 10.0))); +} + +bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } +bool DeviceController::startPtp() { return sendCommand(QStringLiteral("PTP START")); } +bool DeviceController::stopPtp() { return sendCommand(QStringLiteral("PTP STOP")); } + +bool DeviceController::configurePtp(const QVariantMap& profile) { + if (!deviceVerified_ || ptpRunning_) { + setError(ptpRunning_ + ? QStringLiteral("Stop PTP Lab TX before applying expert timing settings.") + : QStringLiteral("Verify the ESP32-P4 before configuring PTP.")); + return false; + } + const auto domain = profile.value(QStringLiteral("domain")).toUInt(); + const auto transport = profile.value(QStringLiteral("transportSpecific")).toUInt(); + const bool vlanEnabled = profile.value(QStringLiteral("vlanEnabled")).toBool(); + const auto vlanId = profile.value(QStringLiteral("vlanId")).toUInt(); + const auto pcp = profile.value(QStringLiteral("vlanPriority")).toUInt(); + const auto announceMs = profile.value(QStringLiteral("announceIntervalMs")).toUInt(); + const auto syncMs = profile.value(QStringLiteral("syncIntervalMs")).toUInt(); + const bool pdelay = profile.value(QStringLiteral("respondToPeerDelay")).toBool(); + if (domain > 255U || transport > 15U || (vlanEnabled && (vlanId == 0U || vlanId > 4094U)) || + pcp > 7U || announceMs < 100U || announceMs > 10000U || + syncMs < 20U || syncMs > 5000U) { + setError(QStringLiteral("PTP expert profile is outside the firmware safety bounds.")); + return false; + } + return sendCommand(QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") + .arg(domain).arg(transport).arg(vlanEnabled ? 1 : 0).arg(vlanId).arg(pcp) + .arg(announceMs).arg(syncMs).arg(pdelay ? 1 : 0)); +} + +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::setDiscoveryState(const QString& status, const bool active) { + if (discoveryStatus_ == status && discovering_ == active) return; + discoveryStatus_ = status; + discovering_ = active; + emit discoveryChanged(); +} + +void DeviceController::markDeviceVerified() { + if (deviceVerified_) return; + verificationTimer_.stop(); + deviceVerified_ = true; + automaticConnection_ = false; + genericProbeActive_ = false; + probeQueue_.clear(); + setDiscoveryState(QStringLiteral("ARStack ESP32-P4 identity verified."), false); + emit deviceVerifiedChanged(); + emit deviceMessage(QStringLiteral("ESP32-P4 recognized. Device is ready.")); + static_cast(sendPtpShow()); +} + +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(); + } + + match = kIdentityExpression.match(line); + if (match.hasMatch()) { + deviceProduct_ = match.captured(1); + protocolVersion_ = match.captured(2); + deviceId_ = match.captured(3).toUpper(); + markDeviceVerified(); + } + + match = kPtpStatusExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpRunning_ = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + ptpAnnounceSent_ = match.captured(2); + ptpSyncSent_ = match.captured(3); + ptpTxFailures_ = match.captured(6); + ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : QStringLiteral("Lab TX stopped"); + emit ptpStateChanged(); + } + + match = kPtpConfigExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpDomain_ = match.captured(1); + ptpTransportSpecific_ = QStringLiteral("0x%1").arg(match.captured(2).toUpper()); + ptpVlan_ = match.captured(3); + emit ptpStateChanged(); + } + + if (line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive)) { + ptpAvailable_ = false; + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("Not enabled in firmware"); + emit ptpStateChanged(); + } + if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) { + emit deviceMessage(QStringLiteral("PTP expert profile accepted.")); + static_cast(sendPtpShow()); + } + + 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..fe4942199 --- /dev/null +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +class DeviceController : public QObject { + Q_OBJECT + Q_PROPERTY(QStringList ports READ ports NOTIFY portsChanged) + Q_PROPERTY(QString recommendedPort READ recommendedPort NOTIFY discoveryChanged) + Q_PROPERTY(QString discoveryStatus READ discoveryStatus NOTIFY discoveryChanged) + Q_PROPERTY(bool discovering READ discovering NOTIFY discoveryChanged) + Q_PROPERTY(bool deviceVerified READ deviceVerified NOTIFY deviceVerifiedChanged) + Q_PROPERTY(QString deviceProduct READ deviceProduct NOTIFY deviceVerifiedChanged) + Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceVerifiedChanged) + Q_PROPERTY(QString protocolVersion READ protocolVersion NOTIFY deviceVerifiedChanged) + 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) + Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpStatus READ ptpStatus NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpDomain READ ptpDomain NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpTransportSpecific READ ptpTransportSpecific NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpVlan READ ptpVlan NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpAnnounceSent READ ptpAnnounceSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpSyncSent READ ptpSyncSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpTxFailures READ ptpTxFailures NOTIFY ptpStateChanged) + +public: + explicit DeviceController(QObject* parent = nullptr); + + [[nodiscard]] QStringList ports() const; + [[nodiscard]] QString recommendedPort() const; + [[nodiscard]] QString discoveryStatus() const; + [[nodiscard]] bool discovering() const noexcept; + [[nodiscard]] bool deviceVerified() const noexcept; + [[nodiscard]] QString deviceProduct() const; + [[nodiscard]] QString deviceId() const; + [[nodiscard]] QString protocolVersion() 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; + [[nodiscard]] bool ptpAvailable() const noexcept; + [[nodiscard]] bool ptpRunning() const noexcept; + [[nodiscard]] QString ptpStatus() const; + [[nodiscard]] QString ptpDomain() const; + [[nodiscard]] QString ptpTransportSpecific() const; + [[nodiscard]] QString ptpVlan() const; + [[nodiscard]] QString ptpAnnounceSent() const; + [[nodiscard]] QString ptpSyncSent() const; + [[nodiscard]] QString ptpTxFailures() const; + + Q_INVOKABLE void refreshPorts(); + Q_INVOKABLE bool autoDetectAndConnect(); + 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 setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); + Q_INVOKABLE bool deployProfile(const QVariantMap& profile); + Q_INVOKABLE bool sendPtpShow(); + Q_INVOKABLE bool startPtp(); + Q_INVOKABLE bool stopPtp(); + Q_INVOKABLE bool configurePtp(const QVariantMap& profile); + Q_INVOKABLE void clearLog(); + +signals: + void portsChanged(); + void discoveryChanged(); + void deviceVerifiedChanged(); + void connectedChanged(); + void runningChanged(); + void lastErrorChanged(); + void logTextChanged(); + void telemetryChanged(); + void profileStateChanged(); + void ptpStateChanged(); + void deviceMessage(const QString& message); + +private: + bool connectPortInternal(const QString& portName, bool automatic); + bool tryNextProbe(); + bool sendCommand(const QString& command); + void markDeviceVerified(); + void setDiscoveryState(const QString& status, bool active); + 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_; + QTimer verificationTimer_; + QStringList ports_; + QString recommendedPort_; + QStringList probeQueue_; + QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; + QString deviceProduct_; + QString deviceId_; + QString protocolVersion_; + 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 discovering_{false}; + bool deviceVerified_{false}; + bool automaticConnection_{false}; + bool genericProbeActive_{false}; + bool profileArmed_{false}; + bool profileDeploying_{false}; + bool ptpAvailable_{false}; + bool ptpRunning_{false}; + double signalFrequencyHz_{50.0}; + QString ptpStatus_{QStringLiteral("Waiting for device")}; + QString ptpDomain_{QStringLiteral("-")}; + QString ptpTransportSpecific_{QStringLiteral("-")}; + QString ptpVlan_{QStringLiteral("-")}; + QString ptpAnnounceSent_{QStringLiteral("-")}; + QString ptpSyncSent_{QStringLiteral("-")}; + QString ptpTxFailures_{QStringLiteral("-")}; +}; 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/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 936d64843..5841dc392 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -12,6 +12,7 @@ #endif static const char *TAG = "ar_smv_eth"; +static esp_eth_handle_t s_eth_handle = NULL; // Bench-validated ESP32-P4 Ethernet-board RMII wiring. // REF_CLK is supplied by the onboard PHY as a 50 MHz input to ESP32-P4. @@ -73,6 +74,7 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) phy->del(phy); return NULL; } + s_eth_handle = handle; #if CONFIG_AR_PTP_LAB_TX // The PTP task starts independently and tolerates link-not-ready TX failures @@ -86,3 +88,16 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) AR_PHY_ADDRESS, AR_MDC_GPIO, AR_MDIO_GPIO, AR_RMII_REF_CLK_GPIO); return handle; } + +bool ar_esp32p4_ptp_start(void) +{ +#if CONFIG_AR_PTP_LAB_TX + if (s_eth_handle == NULL) { + return false; + } + ar_ptp_lab_start(s_eth_handle); + return true; +#else + return false; +#endif +} diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.h b/embedded/esp32p4_smv_injector/main/ethernet_port.h index a5e4726eb..2c52ffe40 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.h +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.h @@ -2,6 +2,7 @@ #pragma once #include "esp_eth.h" +#include #ifdef __cplusplus extern "C" { @@ -18,6 +19,9 @@ extern "C" { */ esp_eth_handle_t ar_esp32p4_eth_init(void); +/** Restart the optional PTP lab runtime on the initialized Ethernet device. */ +bool ar_esp32p4_ptp_start(void); + #ifdef __cplusplus } #endif diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 683397519..406a07269 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -1,11 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "live_control.hpp" +#include "ethernet_port.h" #include "profile_control.hpp" #include "esp_log.h" +#include "esp_mac.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sdkconfig.h" + +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#endif #include #include @@ -95,14 +102,142 @@ bool parse_u32(const char* text, std::uint32_t& value) noexcept { void print_help() noexcept { ESP_LOGI(kTag, "Live control commands (bench serial transport):"); - ESP_LOGI(kTag, " START | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " IDENTIFY | START | STOP | SHOW | ZERO | HELP"); ESP_LOGI(kTag, " FREQ "); ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); ESP_LOGI(kTag, " QUALITY "); + ESP_LOGI(kTag, " SHAPE CT <0|1> "); + ESP_LOGI(kTag, " PTP SHOW | PTP START | PTP STOP"); + ESP_LOGI(kTag, " PTP CONFIG "); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } +void print_identity() noexcept { + std::array device_id{}; + if (esp_read_mac(device_id.data(), ESP_MAC_EFUSE_FACTORY) != ESP_OK) { + ESP_LOGE(kTag, "ARSTACK identity unavailable: factory device ID read failed"); + return; + } + ESP_LOGI(kTag, + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X", + static_cast(device_id[0]), static_cast(device_id[1]), + static_cast(device_id[2]), static_cast(device_id[3]), + static_cast(device_id[4]), static_cast(device_id[5])); +} + +void print_ptp_state() noexcept { +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_lab_status_t status{}; + ar_ptp_lab_config_t config{}; + if (!ar_ptp_lab_get_status(&status) || !ar_ptp_lab_get_config(&config)) { + ESP_LOGE(kTag, "PTP status unavailable"); + return; + } + ESP_LOGI(kTag, + "PTP status=%s Announce=%llu Sync=%llu FollowUp=%llu PdelayFrames=%llu TXfail=%llu", + status.is_running ? "RUNNING" : "STOPPED", + static_cast(status.announce_sent), + static_cast(status.sync_sent), + static_cast(status.follow_up_sent), + static_cast(status.peer_delay_frames_sent), + static_cast(status.tx_failure_count)); + if (config.vlan_enabled) { + ESP_LOGI(kTag, + "PTP config domain=%u transportSpecific=0x%X VLAN=%u/%u Announce=%lu ms Sync=%lu ms Pdelay=%s", + static_cast(config.domain_number), + static_cast(config.transport_specific), + static_cast(config.vlan_id), + static_cast(config.vlan_priority), + static_cast(config.announce_interval_ms), + static_cast(config.sync_interval_ms), + config.respond_to_peer_delay ? "ON" : "OFF"); + } else { + ESP_LOGI(kTag, + "PTP config domain=%u transportSpecific=0x%X VLAN=OFF Announce=%lu ms Sync=%lu ms Pdelay=%s", + static_cast(config.domain_number), + static_cast(config.transport_specific), + static_cast(config.announce_interval_ms), + static_cast(config.sync_interval_ms), + config.respond_to_peer_delay ? "ON" : "OFF"); + } +#else + ESP_LOGW(kTag, "PTP unavailable: firmware built without CONFIG_AR_PTP_LAB_TX"); +#endif +} + +void handle_ptp_command(char* save) noexcept { + char* subcommand = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (subcommand == nullptr) { + ESP_LOGE(kTag, "PTP subcommand required"); + return; + } + uppercase_ascii(subcommand); + + if (std::strcmp(subcommand, "SHOW") == 0) { + if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: PTP SHOW"); + else print_ptp_state(); + return; + } +#if CONFIG_AR_PTP_LAB_TX + if (std::strcmp(subcommand, "START") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: PTP START"); + return; + } + if (!ar_esp32p4_ptp_start()) ESP_LOGE(kTag, "PTP start rejected: Ethernet is unavailable"); + else ESP_LOGI(kTag, "PTP start accepted"); + print_ptp_state(); + return; + } + if (std::strcmp(subcommand, "STOP") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: PTP STOP"); + return; + } + ar_ptp_lab_stop(); + ESP_LOGI(kTag, "PTP stop requested"); + return; + } + if (std::strcmp(subcommand, "CONFIG") == 0) { + std::array values{}; + for (auto& value : values) { + if (!parse_u32(strtok_r(nullptr, kTokenDelimiters.data(), &save), value)) { + ESP_LOGE(kTag, "Usage: PTP CONFIG "); + return; + } + } + if (has_extra_token(&save) || values[0] > 255U || values[1] > 15U || values[2] > 1U || + values[3] > 4094U || values[4] > 7U || values[5] < 100U || values[5] > 10000U || + values[6] < 20U || values[6] > 5000U || values[7] > 1U) { + ESP_LOGE(kTag, "PTP configuration rejected: value outside safe bounds"); + return; + } + ar_ptp_lab_config_t config{}; + if (!ar_ptp_lab_get_config(&config)) { + ESP_LOGE(kTag, "PTP configuration unavailable"); + return; + } + config.domain_number = static_cast(values[0]); + config.transport_specific = static_cast(values[1]); + config.vlan_enabled = values[2] != 0U; + config.vlan_id = static_cast(values[3]); + config.vlan_priority = static_cast(values[4]); + config.announce_interval_ms = values[5]; + config.sync_interval_ms = values[6]; + config.respond_to_peer_delay = values[7] != 0U; + if (!ar_ptp_lab_configure(&config)) { + ESP_LOGE(kTag, "PTP configuration rejected: stop PTP first or correct the profile"); + return; + } + ESP_LOGI(kTag, "PTP configuration accepted"); + print_ptp_state(); + return; + } +#endif + ESP_LOGE(kTag, "Unknown or unavailable PTP subcommand '%s'", subcommand); +} + void print_state() noexcept { const auto state = g_signal_bank.snapshot(); ESP_LOGI(kTag, @@ -140,6 +275,12 @@ void handle_line(char* line) noexcept { if (command == nullptr) return; uppercase_ascii(command); + if (std::strcmp(command, "IDENTIFY") == 0) { + if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: IDENTIFY"); + else print_identity(); + return; + } + if (std::strcmp(command, "START") == 0) { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: START"); @@ -181,6 +322,10 @@ void handle_line(char* line) noexcept { handle_profile_command(save); return; } + if (std::strcmp(command, "PTP") == 0) { + handle_ptp_command(save); + return; + } if (std::strcmp(command, "ZERO") == 0) { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: ZERO"); @@ -204,6 +349,47 @@ void handle_line(char* line) noexcept { return; } + if (std::strcmp(command, "SHAPE") == 0) { + char* shape_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (shape_text == nullptr) { + ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); + return; + } + uppercase_ascii(shape_text); + char* enabled_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* dc_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* harmonic_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* order_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* clip_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + std::uint32_t enabled{}; + std::int32_t dc_offset{}; + std::uint32_t harmonic{}; + std::uint32_t order{}; + std::uint32_t clip{}; + if (std::strcmp(shape_text, "CT") != 0 || !parse_u32(enabled_text, enabled) || + !parse_i32(dc_text, dc_offset) || !parse_u32(harmonic_text, harmonic) || + !parse_u32(order_text, order) || !parse_u32(clip_text, clip) || has_extra_token(&save) || + enabled > 1U || dc_offset < -3000 || dc_offset > 3000 || harmonic > 3000U || + order < 2U || order > 63U || clip < 10U || clip > 10000U) { + ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); + return; + } + auto state = g_signal_bank.snapshot(); + state.current_shape.enabled = enabled != 0U; + state.current_shape.dc_offset_permille = dc_offset; + state.current_shape.harmonic_permille = harmonic; + state.current_shape.harmonic_order = static_cast(order); + state.current_shape.clip_permille = clip; + static_cast(publish_state(state)); + ESP_LOGI(kTag, "CT saturation shape %s: DC=%ld.%u%% H%u=%lu.%u%% clip=%lu.%u%%", + enabled ? "enabled" : "disabled", + static_cast(dc_offset / 10), static_cast(std::abs(dc_offset % 10)), + static_cast(order), + static_cast(harmonic / 10U), static_cast(harmonic % 10U), + static_cast(clip / 10U), static_cast(clip % 10U)); + return; + } + if (std::strcmp(command, "SET") == 0 || std::strcmp(command, "ENABLE") == 0 || std::strcmp(command, "QUALITY") == 0) { 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..6123f21ad --- /dev/null +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/publisher_profile.hpp" + +#include +#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; + } + + // The current ESP32-P4 runtime emits a fixed wire order: + // IA, IB, IC, IN, UA, UB, UC, UN, with one Quality word after each value. + // Do not accept a merely shape-compatible DataSet: that would silently map + // unrelated INT32 members onto the fixed injector channels. + constexpr std::array expected_values{ + "tctr1.amp.instmag.i", + "tctr2.amp.instmag.i", + "tctr3.amp.instmag.i", + "tctr4.amp.instmag.i", + "tvtr1.vol.instmag.i", + "tvtr2.vol.instmag.i", + "tvtr3.vol.instmag.i", + "tvtr4.vol.instmag.i", + }; + constexpr std::array expected_qualities{ + "tctr1.amp.q", + "tctr2.amp.q", + "tctr3.amp.q", + "tctr4.amp.q", + "tvtr1.vol.q", + "tvtr2.vol.q", + "tvtr3.vol.q", + "tvtr4.vol.q", + }; + + 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; + } + } + + for (std::size_t signal = 0U; signal < expected_values.size(); ++signal) { + const auto value_reference = + sv_support_lower_copy(profile.channels[signal * 2U].signal_reference); + const auto quality_reference = + sv_support_lower_copy(profile.channels[signal * 2U + 1U].signal_reference); + if (value_reference.find(expected_values[signal]) == std::string::npos || + quality_reference.find(expected_qualities[signal]) == std::string::npos) { + 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/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp index 37ca82617..83dce1d7f 100644 --- a/include/ariec61850/sampled_values/live_signal_state.hpp +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -2,6 +2,7 @@ #pragma once +#include #include #include #include @@ -27,11 +28,22 @@ struct SvLiveChannelState final { friend bool operator==(const SvLiveChannelState&, const SvLiveChannelState&) = default; }; +struct SvCurrentWaveformShape final { + bool enabled{false}; + std::int32_t dc_offset_permille{0}; + std::uint32_t harmonic_permille{0}; + std::uint8_t harmonic_order{2}; + std::uint32_t clip_permille{1000}; + + friend bool operator==(const SvCurrentWaveformShape&, const SvCurrentWaveformShape&) = default; +}; + struct SvLiveSignalState final { // Fundamental signal frequency in millihertz. This is deliberately // independent of the SV publisher event rate. std::uint32_t frequency_millihz{50000U}; std::array channels{}; + SvCurrentWaveformShape current_shape{}; std::uint64_t generation{}; friend bool operator==(const SvLiveSignalState&, const SvLiveSignalState&) = default; @@ -96,7 +108,9 @@ class SvLiveSignalBank final { } [[nodiscard]] static bool validate(const SvLiveSignalState& state) noexcept { - if (state.frequency_millihz < 1000U || state.frequency_millihz > 1000000U) { + // 0 mHz is the explicit DC mode. AC remains bounded to 1..1000 Hz. + if (state.frequency_millihz != 0U && + (state.frequency_millihz < 1000U || state.frequency_millihz > 1000000U)) { return false; } for (const auto& channel : state.channels) { @@ -108,6 +122,15 @@ class SvLiveSignalBank final { return false; } } + if (state.current_shape.dc_offset_permille < -3000 || + state.current_shape.dc_offset_permille > 3000 || + state.current_shape.harmonic_permille > 3000U || + state.current_shape.harmonic_order < 2U || + state.current_shape.harmonic_order > 63U || + state.current_shape.clip_permille < 10U || + state.current_shape.clip_permille > 10000U) { + return false; + } return true; } @@ -175,12 +198,30 @@ class SvFixedPointSineEngine final { continue; } + if (state.frequency_millihz == 0U) { + // In DC mode the engineering magnitude is an instantaneous + // signed value. Phase is intentionally ignored. + row[index] = channel.rms_counts; + continue; + } + const auto offset = phase_from_millidegrees(channel.phase_millidegrees); const std::uint32_t phase = base_phase + offset; const auto lut_index = static_cast(phase >> (32U - 12U)); - const std::int64_t sine_q30 = sine_lut_[lut_index & (kLiveSineLutSize - 1U)]; + std::int64_t sine_q30 = sine_lut_[lut_index & (kLiveSineLutSize - 1U)]; + if (index < 4U && state.current_shape.enabled) { + const auto harmonic_phase = phase * state.current_shape.harmonic_order; + const auto harmonic_index = static_cast(harmonic_phase >> (32U - 12U)); + const std::int64_t harmonic_q30 = sine_lut_[harmonic_index & (kLiveSineLutSize - 1U)]; + constexpr std::int64_t q30 = std::int64_t{1} << 30U; + sine_q30 += (harmonic_q30 * state.current_shape.harmonic_permille) / std::int64_t{1000}; + sine_q30 += (q30 * state.current_shape.dc_offset_permille) / std::int64_t{1000}; + const std::int64_t clip_q30 = + (q30 * state.current_shape.clip_permille) / std::int64_t{1000}; + sine_q30 = std::clamp(sine_q30, -clip_q30, clip_q30); + } const std::int64_t peak_counts = rms_to_peak_counts(channel.rms_counts); - const std::int64_t value = (sine_q30 * peak_counts) >> 30U; + const std::int64_t value = scale_q30(peak_counts, sine_q30); row[index] = clamp_i32(value); } @@ -234,6 +275,18 @@ class SvFixedPointSineEngine final { return (static_cast(rms) * sqrt2_q30) >> 30U; } + [[nodiscard]] static std::int64_t scale_q30( + const std::int64_t value, + const std::int64_t factor_q30) noexcept { + // Splitting the Q30 factor avoids overflowing int64_t when extreme + // clip/DC/harmonic settings meet near-INT32 signal magnitudes. The + // validated shape range keeps both products below int64_t limits. + constexpr std::int64_t q30 = std::int64_t{1} << 30U; + const std::int64_t whole = factor_q30 / q30; + const std::int64_t remainder = factor_q30 % q30; + return value * whole + (value * remainder) / q30; + } + [[nodiscard]] static std::int32_t clamp_i32(const std::int64_t value) noexcept { if (value > std::numeric_limits::max()) { return std::numeric_limits::max(); diff --git a/tests/test_live_signal.cpp b/tests/test_live_signal.cpp index 0f353d30e..5b9becb30 100644 --- a/tests/test_live_signal.cpp +++ b/tests/test_live_signal.cpp @@ -41,10 +41,60 @@ void bank_commits_complete_generations() { CHECK(after.channels[4].rms_counts == 12000); CHECK(after.channels[4].phase_millidegrees == 15000); - auto invalid = after; - invalid.frequency_millihz = 0U; + auto dc = after; + dc.frequency_millihz = 0U; + CHECK(bank.publish(dc)); + CHECK(bank.snapshot().frequency_millihz == 0U); + + auto invalid = dc; + invalid.frequency_millihz = 500U; CHECK(!bank.publish(invalid)); - CHECK(bank.snapshot() == after); + CHECK(bank.snapshot().frequency_millihz == 0U); +} + +void dc_engine_outputs_constant_engineering_values() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.frequency_millihz = 0U; + state.channels[0].rms_counts = 1234; + state.channels[0].phase_millidegrees = -90000; + state.channels[1].rms_counts = -4321; + + const auto phase_before = engine.phase_accumulator(); + for (int sample = 0; sample < 12; ++sample) { + const auto row = engine.next(state, 4000U); + CHECK(row[0] == 1234); + CHECK(row[1] == -4321); + } + CHECK(engine.phase_accumulator() == phase_before); +} + +void ct_saturation_shape_is_bounded_and_asymmetric() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.current_shape.enabled = true; + state.current_shape.dc_offset_permille = 300; + state.current_shape.harmonic_permille = 280U; + state.current_shape.harmonic_order = 2U; + state.current_shape.clip_permille = 600U; + + std::int32_t maximum = std::numeric_limits::min(); + std::int32_t minimum = std::numeric_limits::max(); + std::int64_t sum{}; + for (int sample = 0; sample < 80; ++sample) { + const auto row = engine.next(state, 4000U); + maximum = std::max(maximum, row[0]); + minimum = std::min(minimum, row[0]); + sum += row[0]; + } + CHECK(maximum <= 850); + CHECK(minimum >= -850); + CHECK(maximum > 700); + CHECK(sum > 0); } void fixed_point_engine_preserves_frequency_and_phase() { @@ -121,6 +171,8 @@ int main() { try { bank_commits_complete_generations(); fixed_point_engine_preserves_frequency_and_phase(); + dc_engine_outputs_constant_engineering_values(); + ct_saturation_shape_is_bounded_and_asymmetric(); independent_channels_change_without_stream_restart(); std::cout << "[PASS] live Sampled Values signal state\n"; return 0; 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;