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;