From ba0d31f4a3744231ca598eaf12dff574264bfab4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:53:15 +0700 Subject: [PATCH 01/24] Add native Qt ARStack Studio build scaffold --- apps/arstack_studio/CMakeLists.txt | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/arstack_studio/CMakeLists.txt diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt new file mode 100644 index 000000000..215b0822f --- /dev/null +++ b/apps/arstack_studio/CMakeLists.txt @@ -0,0 +1,44 @@ +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 Quick QuickControls2) + +# 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 +) + +qt_add_qml_module(arstack_studio + URI ARStack.Studio + VERSION 1.0 + QML_FILES + qml/Main.qml +) + +target_link_libraries(arstack_studio PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Quick + Qt6::QuickControls2 + ARIEC61850::core +) + +target_compile_features(arstack_studio PRIVATE cxx_std_20) + +if(WIN32) + set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) +endif() From f2a10bbe85c8550b07077c8ded06d274a150a569 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:53:38 +0700 Subject: [PATCH 02/24] Add Qt SCL profile model interface --- apps/arstack_studio/src/SclProfileModel.hpp | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 apps/arstack_studio/src/SclProfileModel.hpp diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp new file mode 100644 index 000000000..2dc455f0d --- /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 final : 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_; +}; From f05116d3a1763e1b254f23abc6135d1d6ae00746 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:54:34 +0700 Subject: [PATCH 03/24] Implement direct C++ SCL compilation for Qt --- apps/arstack_studio/src/SclProfileModel.cpp | 323 ++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 apps/arstack_studio/src/SclProfileModel.cpp diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp new file mode 100644 index 000000000..ce908e6d9 --- /dev/null +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SclProfileModel.hpp" + +#include "ariec61850/scl/parser.hpp" + +#include +#include + +#include +#include +#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; + +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; +} + +std::string lowerCopy(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return text; +} + +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"); + } +} + +// Mirrors the currently proven ESP32-P4 acceptance boundary used by the +// existing profile-inspection tool. The Qt PR does not enable live deployment +// yet; before that gate is enabled this policy should be extracted into one +// shared core helper so browser/tool/Qt cannot drift. +bool currentDeviceLayoutSupported(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 = 0; 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 || lowerCopy(channel.basic_type) != "int32") return false; + } else if (!channel.is_quality) { + return false; + } + } + return true; +} +} // 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(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; + endResetModel(); + emit selectedIndexChanged(); + emit selectedProfileChanged(); + 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"); + if (currentDeviceLayoutSupported(profile)) { + row.deviceSupport = QStringLiteral("ready"); + } else if (row.compatibilityClass == QStringLiteral("B")) { + row.deviceSupport = QStringLiteral("needs-counter-confirmation"); + } else { + row.deviceSupport = QStringLiteral("unsupported-layout"); + } + } + 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())); + return map; +} From 73795a1ddb34abc8627aa0e47af603042512777c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:55:21 +0700 Subject: [PATCH 04/24] Add Qt application entry point --- apps/arstack_studio/src/main.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 apps/arstack_studio/src/main.cpp diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp new file mode 100644 index 000000000..1b1be42e9 --- /dev/null +++ b/apps/arstack_studio/src/main.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#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"); + + QQmlApplicationEngine engine; + QObject::connect( + &engine, + &QQmlApplicationEngine::objectCreationFailed, + &app, + [] { QCoreApplication::exit(-1); }, + Qt::QueuedConnection); + engine.loadFromModule("ARStack.Studio", "Main"); + + return app.exec(); +} From 25fb84399f9918a38f61b7e6e5b85382c973bd70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:57:30 +0700 Subject: [PATCH 05/24] Add premium Qt Quick SMV injection workspace --- apps/arstack_studio/qml/Main.qml | 614 +++++++++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 apps/arstack_studio/qml/Main.qml diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml new file mode 100644 index 000000000..073d678ee --- /dev/null +++ b/apps/arstack_studio/qml/Main.qml @@ -0,0 +1,614 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import ARStack.Studio 1.0 + +ApplicationWindow { + id: root + width: 1480 + height: 900 + minimumWidth: 1180 + minimumHeight: 720 + visible: true + title: "ARStack Studio · SMV Injector" + color: palette.bg + + property var palette: ({ + bg: "#090d12", + chrome: "#0c1117", + surface: "#11171f", + raised: "#151d27", + quiet: "#0e141b", + line: "#26313d", + lineSoft: "#1c252f", + text: "#edf2f7", + textSoft: "#c2ccd7", + muted: "#778493", + muted2: "#566271", + accent: "#69a9ff", + green: "#58d49d", + amber: "#e1b25a", + red: "#ff727f" + }) + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property bool phaseLink: false + property string activeSignal: "Ia" + property string activeUnit: "A" + property real activeMagnitude: 1.0 + property real activePhase: 0.0 + + SclProfileModel { id: sclProfiles } + + ListModel { + id: currentModel + ListElement { signalId: "Ia"; magnitude: 1.000; phase: 0.0; enabled: true; traceColor: "#ff6673" } + ListElement { signalId: "Ib"; magnitude: 1.000; phase: -120.0; enabled: true; traceColor: "#e6b552" } + ListElement { signalId: "Ic"; magnitude: 1.000; phase: 120.0; enabled: true; traceColor: "#59a7ff" } + ListElement { signalId: "In"; magnitude: 0.000; phase: 0.0; enabled: true; traceColor: "#9a88df" } + } + ListModel { + id: voltageModel + ListElement { signalId: "Ua"; magnitude: 57.740; phase: 0.0; enabled: true; traceColor: "#ff6673" } + ListElement { signalId: "Ub"; magnitude: 57.740; phase: -120.0; enabled: true; traceColor: "#e6b552" } + ListElement { signalId: "Uc"; magnitude: 57.740; phase: 120.0; enabled: true; traceColor: "#59a7ff" } + ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; traceColor: "#9a88df" } + } + + function modelForGroup(group) { return group === 0 ? currentModel : voltageModel } + function repeaterForGroup(group) { return group === 0 ? currentRepeater : voltageRepeater } + function focusMatrix(group, row, column) { + var g = group + var r = row + while (r < 0) { g = (g + 1) % 2; r += 4 } + while (r > 3) { g = (g + 1) % 2; r -= 4 } + var item = repeaterForGroup(g).itemAt(r) + if (!item) return + var editor = column === 0 ? item.magnitudeEditor : item.phaseEditor + editor.forceActiveFocus() + editor.selectAll() + } + function moveCell(group, row, column, key) { + if (key === Qt.Key_Up) focusMatrix(group, row - 1, column) + else if (key === Qt.Key_Down) focusMatrix(group, row + 1, column) + else if (key === Qt.Key_Left) { + if (column === 1) focusMatrix(group, row, 0) + else focusMatrix(group, row - 1, 1) + } else if (key === Qt.Key_Right) { + if (column === 0) focusMatrix(group, row, 1) + else focusMatrix(group, row + 1, 0) + } + } + function normalizeAngle(value) { + var v = value % 360 + if (v > 180) v -= 360 + if (v <= -180) v += 360 + return v + } + function updateSignal(group, row, field, value) { + if (isNaN(value)) return + var m = modelForGroup(group) + if (field === "magnitude" && value < 0) return + m.setProperty(row, field, value) + if (phaseLink && row < 3) { + if (field === "magnitude") { + for (var i = 0; i < 3; ++i) m.setProperty(i, "magnitude", value) + } else { + var base = value + if (row === 1) base += 120 + if (row === 2) base -= 120 + m.setProperty(0, "phase", normalizeAngle(base)) + m.setProperty(1, "phase", normalizeAngle(base - 120)) + m.setProperty(2, "phase", normalizeAngle(base + 120)) + } + } + activeSignal = m.get(row).signalId + activeUnit = group === 0 ? "A" : "V" + activeMagnitude = m.get(row).magnitude + activePhase = m.get(row).phase + phasor.requestPaint() + waveform.requestPaint() + } + function loadBalanced() { + var cm = [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", cm[i]) + voltageModel.setProperty(i, "magnitude", i < 3 ? 57.74 : 0.0) + voltageModel.setProperty(i, "phase", cm[i]) + } + phasor.requestPaint(); waveform.requestPaint() + } + function zeroAll() { + for (var i = 0; i < 4; ++i) { + currentModel.setProperty(i, "magnitude", 0.0) + voltageModel.setProperty(i, "magnitude", 0.0) + } + phasor.requestPaint(); waveform.requestPaint() + } + + component QuietLabel: Label { + color: root.palette.muted + font.family: root.uiFont + font.pixelSize: 11 + font.weight: Font.Medium + } + component SectionKicker: Label { + color: root.palette.muted + font.family: root.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 1.4 + textFormat: Text.PlainText + } + component Surface: Rectangle { + color: root.palette.surface + radius: 10 + border.width: 1 + border.color: root.palette.lineSoft + } + component CalmButton: Button { + id: calmButton + implicitHeight: 36 + font.family: root.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + contentItem: Text { + text: calmButton.text + color: calmButton.enabled ? root.palette.textSoft : root.palette.muted2 + font: calmButton.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 7 + color: calmButton.down ? "#1b2530" : calmButton.hovered ? "#17202a" : root.palette.raised + border.width: 1 + border.color: calmButton.hovered ? "#3a4a5b" : root.palette.line + } + } + + FileDialog { + id: sclFileDialog + title: "Open IEC 61850 engineering file" + nameFilters: ["IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", "All files (*)"] + onAccepted: sclProfiles.loadFile(selectedFile) + } + + header: Rectangle { + height: 58 + color: root.palette.chrome + border.color: root.palette.lineSoft + border.width: 0 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 18 + RowLayout { + spacing: 10 + Rectangle { + width: 30; height: 30; radius: 7 + color: "#101b27"; border.color: "#315071" + Canvas { + anchors.fill: parent + onPaint: { + var c = getContext("2d"); c.clearRect(0,0,width,height) + c.strokeStyle = root.palette.accent; c.lineWidth = 1.4 + c.beginPath(); c.moveTo(7,8); c.lineTo(23,13); c.lineTo(7,18); c.stroke() + c.beginPath(); c.moveTo(7,22); c.lineTo(23,17); c.stroke() + } + } + } + ColumnLayout { + spacing: 0 + Label { text: "ARSTACK61850"; color: root.palette.muted; font.family: root.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 1.2 } + Label { text: "SMV Injector"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } + } + } + Item { Layout.fillWidth: true } + QuietLabel { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved stream") : "No SCL profile" } + Rectangle { width: 1; height: 20; color: root.palette.line } + RowLayout { + spacing: 8 + Rectangle { width: 7; height: 7; radius: 4; color: "#65717e" } + QuietLabel { text: "Device offline" } + CalmButton { text: "Connect"; enabled: false; ToolTip.visible: hovered; ToolTip.text: "Qt serial transport is the next integration step" } + } + } + } + + footer: Rectangle { + height: 62 + color: root.palette.chrome + border.color: root.palette.lineSoft + border.width: 1 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 16 + RowLayout { + spacing: 9 + Rectangle { width: 8; height: 8; radius: 4; color: root.palette.muted2 } + ColumnLayout { + spacing: 1 + Label { text: "DEVICE OFFLINE"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } + QuietLabel { text: "Setpoints editable · native PROFILE transport not armed yet" } + } + } + Item { Layout.fillWidth: true } + Label { + text: activeSignal + " " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠" + activePhase.toFixed(2) + "°" + color: root.palette.muted + font.family: root.monoFont + font.pixelSize: 10 + } + Rectangle { width: 1; height: 20; color: root.palette.line } + CalmButton { text: "Deploy profile"; enabled: false } + CalmButton { text: "Stop"; enabled: false } + Button { + id: startButton + text: "▶ Start live" + enabled: false + implicitWidth: 126; implicitHeight: 38 + font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold + contentItem: Text { text: startButton.text; color: startButton.enabled ? "#d8f5e7" : root.palette.muted2; font: startButton.font; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: 8; color: startButton.enabled ? "#194d38" : "#121820"; border.color: startButton.enabled ? "#347a59" : root.palette.lineSoft } + } + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + Surface { + Layout.preferredWidth: 238 + Layout.fillHeight: true + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 14 + ColumnLayout { + spacing: 3 + SectionKicker { text: "ENGINEERING SOURCE" } + Label { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Compiled SCL") : "Development profile"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } + QuietLabel { text: sclProfiles.sourceName.length ? sclProfiles.sourceName : "Import SCL / CID / SCD / IID"; elide: Text.ElideMiddle; Layout.fillWidth: true } + } + + CalmButton { Layout.fillWidth: true; text: "Open SCL / CID"; onClicked: sclFileDialog.open() } + + ColumnLayout { + visible: sclProfiles.hasProfiles + spacing: 7 + SectionKicker { text: "RESOLVED STREAM" } + ComboBox { + id: streamCombo + Layout.fillWidth: true + model: sclProfiles + textRole: "control" + currentIndex: sclProfiles.selectedIndex + onActivated: sclProfiles.selectStream(currentIndex) + font.family: root.uiFont + font.pixelSize: 11 + } + GridLayout { + columns: 2 + columnSpacing: 8; rowSpacing: 6 + QuietLabel { text: "Class" } + Label { text: sclProfiles.selectedProfile.compatibilityClass ? "CLASS " + sclProfiles.selectedProfile.compatibilityClass : "—"; color: sclProfiles.selectedProfile.compatibilityClass === "A" ? root.palette.green : sclProfiles.selectedProfile.compatibilityClass === "B" ? root.palette.amber : root.palette.red; font.family: root.uiFont; font.pixelSize: 11; font.weight: Font.DemiBold } + QuietLabel { text: "Support" } + Label { text: sclProfiles.selectedProfile.deviceSupport || "—"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 10 } + QuietLabel { text: "APPID" } + Label { text: sclProfiles.selectedProfile.appIdHex || "—"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } + QuietLabel { text: "Rate" } + Label { text: sclProfiles.selectedProfile.publisherRate ? sclProfiles.selectedProfile.publisherRate + "/s" : "—"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } + QuietLabel { text: "VLAN" } + Label { text: sclProfiles.selectedProfile.vlanPresent ? "P" + sclProfiles.selectedProfile.vlanPriority + " · VID " + sclProfiles.selectedProfile.vlanId : "untagged"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: root.palette.lineSoft } + + ColumnLayout { + visible: sclProfiles.selectedProfile.compatibilityClass === "B" + spacing: 7 + SectionKicker { text: "COUNTER POLICY" } + QuietLabel { Layout.fillWidth: true; wrapMode: Text.WordWrap; text: "Candidate smpCnt cycle needs explicit engineering or observed-wire confirmation." } + RowLayout { + TextField { id: counterField; Layout.fillWidth: true; placeholderText: "modulus"; text: sclProfiles.selectedProfile.counterModulus || ""; font.family: root.monoFont; validator: IntValidator { bottom: 1; top: 65535 } } + CalmButton { text: "Confirm"; onClicked: sclProfiles.confirmCounterModulus(parseInt(counterField.text)) } + } + } + + ColumnLayout { + spacing: 7 + SectionKicker { text: "QUICK SETUP" } + CalmButton { Layout.fillWidth: true; text: "Balanced 3-phase"; onClicked: root.loadBalanced() } + CalmButton { Layout.fillWidth: true; text: "Zero output"; onClicked: root.zeroAll() } + } + + Item { Layout.fillHeight: true } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + SectionKicker { text: "RUNTIME" } + Repeater { + model: ["Rate", "Missed", "TX fail", "Generation"] + RowLayout { + Layout.fillWidth: true + QuietLabel { text: modelData } + Item { Layout.fillWidth: true } + Label { text: "—"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } + } + } + } + } + } + + Surface { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: 560 + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 14 + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 2 + SectionKicker { text: "INJECTION WORKSPACE" } + Label { text: "Manual Injection"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 24; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + Rectangle { + width: 88; height: 28; radius: 7; color: root.palette.quiet; border.color: root.palette.lineSoft + Row { anchors.centerIn: parent; spacing: 7; Rectangle { width: 6; height: 6; radius: 3; color: root.palette.muted2 } QuietLabel { text: "STOPPED" } } + } + } + + Rectangle { + Layout.fillWidth: true + height: 62 + radius: 9 + color: root.palette.quiet + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14; anchors.rightMargin: 14 + spacing: 12 + ColumnLayout { + spacing: 1 + SectionKicker { text: "FREQUENCY" } + RowLayout { + spacing: 5 + TextField { id: frequencyField; text: "50.000"; implicitWidth: 86; color: root.palette.text; font.family: root.monoFont; font.pixelSize: 17; selectByMouse: true; background: Item {} } + QuietLabel { text: "Hz" } + } + } + CalmButton { text: "50"; onClicked: frequencyField.text = "50.000" } + CalmButton { text: "60"; onClicked: frequencyField.text = "60.000" } + Rectangle { width: 1; height: 28; color: root.palette.lineSoft } + CheckBox { + id: linkCheck + checked: root.phaseLink + onToggled: root.phaseLink = checked + text: "3-phase link" + font.family: root.uiFont; font.pixelSize: 11 + } + Item { Layout.fillWidth: true } + CheckBox { checked: true; text: "Live apply"; enabled: false; font.family: root.uiFont; font.pixelSize: 11; ToolTip.visible: hovered; ToolTip.text: "Enabled when native serial transport lands" } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + SignalMatrix { + id: currentMatrix + Layout.fillWidth: true; Layout.fillHeight: true + title: "Current"; symbol: "I"; unit: "A RMS"; groupIndex: 0; signalModel: currentModel; signalRepeater: currentRepeater + } + SignalMatrix { + id: voltageMatrix + Layout.fillWidth: true; Layout.fillHeight: true + title: "Voltage"; symbol: "U"; unit: "V RMS"; groupIndex: 1; signalModel: voltageModel; signalRepeater: voltageRepeater + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { width: 7; height: 7; radius: 4; color: root.palette.accent } + Label { text: activeSignal; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 11; font.weight: Font.DemiBold } + QuietLabel { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } + Item { Layout.fillWidth: true } + QuietLabel { text: "↑↓ channel ←→ field Enter next" } + } + } + } + + Surface { + Layout.preferredWidth: 386 + Layout.fillHeight: true + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 2 + SectionKicker { text: "GENERATED SETPOINT" } + Label { text: "Signal Preview"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 20; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + QuietLabel { text: "LOCAL" } + } + + ColumnLayout { + Layout.fillWidth: true; Layout.fillHeight: true; Layout.preferredHeight: 1 + spacing: 6 + RowLayout { Layout.fillWidth: true; Label { text: "Phasor"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } Item { Layout.fillWidth: true } Label { text: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠ " + activePhase.toFixed(2) + "°"; color: root.palette.muted; font.family: root.monoFont; font.pixelSize: 9 } } + Canvas { + id: phasor + Layout.fillWidth: true; Layout.fillHeight: true + onWidthChanged: requestPaint(); onHeightChanged: requestPaint() + onPaint: { + var ctx = getContext("2d"); ctx.reset(); + var w = width, h = height, cx = w / 2, cy = h / 2, r = Math.min(w, h) * 0.38 + 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 deg = 0; deg < 180; deg += 30) { var a = deg * Math.PI / 180; var dx = Math.cos(a) * r; var dy = Math.sin(a) * r; ctx.beginPath(); ctx.moveTo(cx-dx,cy-dy);ctx.lineTo(cx+dx,cy+dy);ctx.stroke() } + function draw(model, scale, dashed) { + var max = 0.0001 + for (var i=0;i<4;++i) max = Math.max(max, model.get(i).magnitude) + for (var j=0;j<4;++j) { + var s=model.get(j); if (!s.enabled || s.magnitude <= 0) continue + var angle=s.phase*Math.PI/180; var length=r*scale*(0.28+0.72*Math.min(1,s.magnitude/max)); var x=cx+Math.cos(angle)*length; var y=cy-Math.sin(angle)*length + var active=s.signalId===root.activeSignal + ctx.save(); ctx.globalAlpha=active?1:0.70; ctx.strokeStyle=s.traceColor; ctx.fillStyle=s.traceColor; ctx.lineWidth=active?3.2:2.0; ctx.setLineDash(dashed?[5,4]:[]); ctx.lineCap="round"; ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(x,y);ctx.stroke();ctx.setLineDash([]) + var head=active?8:6;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-Math.cos(angle-.45)*head,y+Math.sin(angle-.45)*head);ctx.lineTo(x-Math.cos(angle+.45)*head,y+Math.sin(angle+.45)*head);ctx.closePath();ctx.fill() + if(active){ctx.font="600 11px Inter";ctx.fillText(s.signalId,x+8,y-7)}ctx.restore() + } + } + draw(voltageModel,1.0,false); draw(currentModel,0.78,true) + } + } + } + + ColumnLayout { + Layout.fillWidth: true; Layout.fillHeight: true; Layout.preferredHeight: 0.85 + spacing: 6 + RowLayout { Layout.fillWidth: true; Label { text: "Waveform"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } Item { Layout.fillWidth: true } Label { text: frequencyField.text + " Hz · 40 ms"; color: root.palette.muted; font.family: root.monoFont; font.pixelSize: 9 } } + Canvas { + id: waveform + Layout.fillWidth: true; Layout.fillHeight: true + onWidthChanged: requestPaint(); onHeightChanged: requestPaint() + onPaint: { + var ctx=getContext("2d");ctx.reset();var w=width,h=height,left=25,right=8,top=12,bottom=14,pw=w-left-right,ph=h-top-bottom,gap=16,lane=(ph-gap)/2,vy=top+lane/2,iy=top+lane+gap+lane/2,f=parseFloat(frequencyField.text)||50 + ctx.lineWidth=1;ctx.strokeStyle="#202b36";for(var gx=0;gx<=8;++gx){var x=left+pw*gx/8;ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,top+ph);ctx.stroke()}ctx.strokeStyle="#33404d";[vy,iy].forEach(function(y){ctx.beginPath();ctx.moveTo(left,y);ctx.lineTo(left+pw,y);ctx.stroke()}) + function draw(model,center,dashed){var max=.0001;for(var i=0;i<4;++i)max=Math.max(max,model.get(i).magnitude);for(var j=0;j<4;++j){var s=model.get(j);if(!s.enabled||s.magnitude<=0)continue;var active=s.signalId===root.activeSignal,amp=lane*.39*Math.min(1,s.magnitude/max),pr=s.phase*Math.PI/180;ctx.save();ctx.strokeStyle=s.traceColor;ctx.globalAlpha=active?1:.64;ctx.lineWidth=active?2.6:1.7;ctx.lineCap="round";ctx.setLineDash(dashed?[5,4]:[]);ctx.beginPath();for(var p=0;p<=220;++p){var ratio=p/220,t=.04*ratio,val=Math.sin(2*Math.PI*f*t+pr),x=left+pw*ratio,y=center-val*amp;if(p===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)}ctx.stroke();ctx.restore()}} + draw(voltageModel,vy,false);draw(currentModel,iy,true) + } + } + } + QuietLabel { Layout.fillWidth: true; horizontalAlignment: Text.AlignRight; text: "Generated setpoint · not independent on-wire proof" } + } + } + } + + component SignalMatrix: Rectangle { + id: matrix + required property string title + required property string symbol + required property string unit + required property int groupIndex + required property var signalModel + required property var signalRepeater + color: root.palette.quiet + radius: 9 + border.color: root.palette.lineSoft + ColumnLayout { + anchors.fill: parent + spacing: 0 + RowLayout { + Layout.fillWidth: true; Layout.preferredHeight: 44 + Layout.leftMargin: 12; Layout.rightMargin: 12 + Rectangle { width: 23; height: 23; radius: 5; color: "#152235"; Label { anchors.centerIn: parent; text: matrix.symbol; color: root.palette.accent; font.family: root.monoFont; font.pixelSize: 10; font.weight: Font.Bold } } + Label { text: matrix.title; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Item { Layout.fillWidth: true } + QuietLabel { text: matrix.unit } + } + Rectangle { Layout.fillWidth: true; height: 1; color: root.palette.lineSoft } + RowLayout { + Layout.fillWidth: true; Layout.preferredHeight: 30 + Layout.leftMargin: 10; Layout.rightMargin: 10; spacing: 8 + QuietLabel { text: "ON"; Layout.preferredWidth: 28; font.pixelSize: 9 } + QuietLabel { text: "CH"; Layout.preferredWidth: 32; font.pixelSize: 9 } + QuietLabel { text: "MAGNITUDE"; Layout.fillWidth: true; font.pixelSize: 9 } + QuietLabel { text: "PHASE"; Layout.preferredWidth: 112; font.pixelSize: 9 } + } + Repeater { + id: internalRepeater + model: matrix.signalModel + delegate: Rectangle { + id: signalRow + required property int index + required property string signalId + required property real magnitude + required property real phase + required property bool enabled + required property string traceColor + property alias magnitudeEditor: magField + property alias phaseEditor: phaseField + width: parent ? parent.width : 0 + Layout.fillWidth: true + Layout.preferredHeight: 58 + color: (magField.activeFocus || phaseField.activeFocus) ? "#141d27" : "transparent" + border.width: 0 + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10; anchors.rightMargin: 10 + spacing: 8 + CheckBox { + Layout.preferredWidth: 28 + checked: signalRow.enabled + onToggled: matrix.signalModel.setProperty(signalRow.index, "enabled", checked) + } + RowLayout { + Layout.preferredWidth: 32; spacing: 5 + Rectangle { width: 6; height: 6; radius: 3; color: signalRow.traceColor } + Label { text: signalRow.signalId; color: root.palette.text; font.family: root.monoFont; font.pixelSize: 12; font.weight: Font.DemiBold } + } + TextField { + id: magField + Layout.fillWidth: true + text: signalRow.magnitude.toFixed(3) + selectByMouse: true + color: root.palette.text + font.family: root.monoFont; font.pixelSize: 14; font.weight: Font.DemiBold + horizontalAlignment: Text.AlignRight + background: Rectangle { radius: 6; color: magField.activeFocus ? "#0c141d" : "#111820"; border.width: magField.activeFocus ? 1 : 0; border.color: root.palette.accent } + onActiveFocusChanged: if (activeFocus) { root.activeSignal=signalRow.signalId; root.activeUnit=matrix.groupIndex===0?"A":"V"; root.activeMagnitude=signalRow.magnitude; root.activePhase=signalRow.phase; selectAll(); phasor.requestPaint(); waveform.requestPaint() } + onEditingFinished: root.updateSignal(matrix.groupIndex, signalRow.index, "magnitude", parseFloat(text)) + Keys.onPressed: function(event) { if ([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(event.key)>=0) { root.moveCell(matrix.groupIndex,signalRow.index,0,event.key); event.accepted=true } else if(event.key===Qt.Key_Return||event.key===Qt.Key_Enter){root.focusMatrix(matrix.groupIndex,signalRow.index+1,0);event.accepted=true} } + } + TextField { + id: phaseField + Layout.preferredWidth: 112 + text: signalRow.phase.toFixed(2) + selectByMouse: true + color: root.palette.text + font.family: root.monoFont; font.pixelSize: 14; font.weight: Font.DemiBold + horizontalAlignment: Text.AlignRight + background: Rectangle { radius: 6; color: phaseField.activeFocus ? "#0c141d" : "#111820"; border.width: phaseField.activeFocus ? 1 : 0; border.color: root.palette.accent } + onActiveFocusChanged: if (activeFocus) { root.activeSignal=signalRow.signalId; root.activeUnit=matrix.groupIndex===0?"A":"V"; root.activeMagnitude=signalRow.magnitude; root.activePhase=signalRow.phase; selectAll(); phasor.requestPaint(); waveform.requestPaint() } + onEditingFinished: root.updateSignal(matrix.groupIndex, signalRow.index, "phase", parseFloat(text)) + Keys.onPressed: function(event) { if ([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(event.key)>=0) { root.moveCell(matrix.groupIndex,signalRow.index,1,event.key); event.accepted=true } else if(event.key===Qt.Key_Return||event.key===Qt.Key_Enter){root.focusMatrix(matrix.groupIndex,signalRow.index+1,1);event.accepted=true} } + } + } + Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: root.palette.lineSoft; opacity: 0.72 } + } + } + Component.onCompleted: { + // Expose the internal repeater to the root keyboard navigator. + if (matrix.groupIndex === 0) currentRepeaterProxy.target = internalRepeater + else voltageRepeaterProxy.target = internalRepeater + } + } + } + + // Proxy objects give root.focusMatrix() stable IDs while repeaters live inside + // component instances. + QtObject { id: currentRepeaterProxy; property var target: null; function itemAt(i) { return target ? target.itemAt(i) : null } } + QtObject { id: voltageRepeaterProxy; property var target: null; function itemAt(i) { return target ? target.itemAt(i) : null } } + property var currentRepeater: currentRepeaterProxy + property var voltageRepeater: voltageRepeaterProxy +} From ec547ff025f71404a35b7fcc1113531790164fb2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:00:14 +0700 Subject: [PATCH 06/24] Simplify native Qt premium injection workspace --- apps/arstack_studio/qml/Main.qml | 640 +++++++++++++------------------ 1 file changed, 271 insertions(+), 369 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 073d678ee..c3f91ae66 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -13,25 +13,27 @@ ApplicationWindow { minimumHeight: 720 visible: true title: "ARStack Studio · SMV Injector" - color: palette.bg - property var palette: ({ - bg: "#090d12", - chrome: "#0c1117", - surface: "#11171f", - raised: "#151d27", - quiet: "#0e141b", - line: "#26313d", - lineSoft: "#1c252f", - text: "#edf2f7", - textSoft: "#c2ccd7", - muted: "#778493", - muted2: "#566271", - accent: "#69a9ff", - green: "#58d49d", - amber: "#e1b25a", - red: "#ff727f" - }) + QtObject { + id: theme + 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 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 green: "#58d49d" + readonly property color amber: "#e1b25a" + readonly property color red: "#ff727f" + } + + color: theme.bg property string uiFont: "Inter" property string monoFont: "Cascadia Mono" property bool phaseLink: false @@ -57,40 +59,34 @@ ApplicationWindow { ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; traceColor: "#9a88df" } } - function modelForGroup(group) { return group === 0 ? currentModel : voltageModel } - function repeaterForGroup(group) { return group === 0 ? currentRepeater : voltageRepeater } - function focusMatrix(group, row, column) { + function groupModel(group) { return group === 0 ? currentModel : voltageModel } + function groupRepeater(group) { return group === 0 ? currentRep : voltageRep } + function focusCell(group, row, column) { var g = group var r = row - while (r < 0) { g = (g + 1) % 2; r += 4 } - while (r > 3) { g = (g + 1) % 2; r -= 4 } - var item = repeaterForGroup(g).itemAt(r) + if (r < 0) { g = (g + 1) % 2; r = 3 } + if (r > 3) { g = (g + 1) % 2; r = 0 } + var item = groupRepeater(g).itemAt(r) if (!item) return - var editor = column === 0 ? item.magnitudeEditor : item.phaseEditor - editor.forceActiveFocus() - editor.selectAll() + var field = column === 0 ? item.magEditor : item.phaseEditor + field.forceActiveFocus() + field.selectAll() } - function moveCell(group, row, column, key) { - if (key === Qt.Key_Up) focusMatrix(group, row - 1, column) - else if (key === Qt.Key_Down) focusMatrix(group, row + 1, column) - else if (key === Qt.Key_Left) { - if (column === 1) focusMatrix(group, row, 0) - else focusMatrix(group, row - 1, 1) - } else if (key === Qt.Key_Right) { - if (column === 0) focusMatrix(group, row, 1) - else focusMatrix(group, row + 1, 0) - } + 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 normalizeAngle(value) { + function normalizedAngle(value) { var v = value % 360 if (v > 180) v -= 360 if (v <= -180) v += 360 return v } - function updateSignal(group, row, field, value) { - if (isNaN(value)) return - var m = modelForGroup(group) - if (field === "magnitude" && value < 0) return + function editSignal(group, row, field, value) { + if (isNaN(value) || (field === "magnitude" && value < 0)) return + var m = groupModel(group) m.setProperty(row, field, value) if (phaseLink && row < 3) { if (field === "magnitude") { @@ -99,25 +95,26 @@ ApplicationWindow { var base = value if (row === 1) base += 120 if (row === 2) base -= 120 - m.setProperty(0, "phase", normalizeAngle(base)) - m.setProperty(1, "phase", normalizeAngle(base - 120)) - m.setProperty(2, "phase", normalizeAngle(base + 120)) + m.setProperty(0, "phase", normalizedAngle(base)) + m.setProperty(1, "phase", normalizedAngle(base - 120)) + m.setProperty(2, "phase", normalizedAngle(base + 120)) } } - activeSignal = m.get(row).signalId + var s = m.get(row) + activeSignal = s.signalId activeUnit = group === 0 ? "A" : "V" - activeMagnitude = m.get(row).magnitude - activePhase = m.get(row).phase + activeMagnitude = s.magnitude + activePhase = s.phase phasor.requestPaint() waveform.requestPaint() } - function loadBalanced() { - var cm = [0, -120, 120, 0] + 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", cm[i]) + currentModel.setProperty(i, "phase", angles[i]) voltageModel.setProperty(i, "magnitude", i < 3 ? 57.74 : 0.0) - voltageModel.setProperty(i, "phase", cm[i]) + voltageModel.setProperty(i, "phase", angles[i]) } phasor.requestPaint(); waveform.requestPaint() } @@ -129,49 +126,47 @@ ApplicationWindow { phasor.requestPaint(); waveform.requestPaint() } - component QuietLabel: Label { - color: root.palette.muted - font.family: root.uiFont - font.pixelSize: 11 - font.weight: Font.Medium - } - component SectionKicker: Label { - color: root.palette.muted - font.family: root.uiFont - font.pixelSize: 10 - font.weight: Font.DemiBold - font.letterSpacing: 1.4 - textFormat: Text.PlainText - } - component Surface: Rectangle { - color: root.palette.surface - radius: 10 - border.width: 1 - border.color: root.palette.lineSoft - } component CalmButton: Button { - id: calmButton + id: control implicitHeight: 36 font.family: root.uiFont font.pixelSize: 12 font.weight: Font.DemiBold contentItem: Text { - text: calmButton.text - color: calmButton.enabled ? root.palette.textSoft : root.palette.muted2 - font: calmButton.font + text: control.text + color: control.enabled ? theme.textSoft : theme.muted2 + font: control.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: 7 - color: calmButton.down ? "#1b2530" : calmButton.hovered ? "#17202a" : root.palette.raised + color: control.down ? "#1b2530" : control.hovered ? "#17202a" : theme.raised border.width: 1 - border.color: calmButton.hovered ? "#3a4a5b" : root.palette.line + border.color: control.hovered ? "#3a4a5b" : theme.line } } + component Kicker: Label { + color: theme.muted + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 1.25 + } + component Quiet: Label { + color: theme.muted + font.family: root.uiFont + font.pixelSize: 10 + } + component Surface: Rectangle { + color: theme.surface + radius: 10 + border.width: 1 + border.color: theme.lineSoft + } FileDialog { - id: sclFileDialog + id: fileDialog title: "Open IEC 61850 engineering file" nameFilters: ["IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", "All files (*)"] onAccepted: sclProfiles.loadFile(selectedFile) @@ -179,85 +174,52 @@ ApplicationWindow { header: Rectangle { height: 58 - color: root.palette.chrome - border.color: root.palette.lineSoft + color: theme.chrome border.width: 0 RowLayout { anchors.fill: parent anchors.leftMargin: 18 anchors.rightMargin: 18 - spacing: 18 - RowLayout { - spacing: 10 - Rectangle { - width: 30; height: 30; radius: 7 - color: "#101b27"; border.color: "#315071" - Canvas { - anchors.fill: parent - onPaint: { - var c = getContext("2d"); c.clearRect(0,0,width,height) - c.strokeStyle = root.palette.accent; c.lineWidth = 1.4 - c.beginPath(); c.moveTo(7,8); c.lineTo(23,13); c.lineTo(7,18); c.stroke() - c.beginPath(); c.moveTo(7,22); c.lineTo(23,17); c.stroke() - } - } - } - ColumnLayout { - spacing: 0 - Label { text: "ARSTACK61850"; color: root.palette.muted; font.family: root.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 1.2 } - Label { text: "SMV Injector"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } - } + spacing: 16 + Rectangle { + width: 30; height: 30; radius: 7; color: "#101b27"; border.color: "#315071" + Label { anchors.centerIn: parent; text: "≋"; color: theme.accent; font.pixelSize: 18 } } - Item { Layout.fillWidth: true } - QuietLabel { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved stream") : "No SCL profile" } - Rectangle { width: 1; height: 20; color: root.palette.line } - RowLayout { - spacing: 8 - Rectangle { width: 7; height: 7; radius: 4; color: "#65717e" } - QuietLabel { text: "Device offline" } - CalmButton { text: "Connect"; enabled: false; ToolTip.visible: hovered; ToolTip.text: "Qt serial transport is the next integration step" } + ColumnLayout { + spacing: 0 + Label { text: "ARSTACK61850"; color: theme.muted; font.family: root.uiFont; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 1.3 } + Label { text: "SMV Injector"; color: theme.text; font.family: root.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } } + Item { Layout.fillWidth: true } + Label { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "No SCL profile"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 10 } + Rectangle { width: 1; height: 20; color: theme.line } + Rectangle { width: 7; height: 7; radius: 4; color: theme.muted2 } + Quiet { text: "Device offline" } + CalmButton { text: "Connect"; enabled: false; ToolTip.visible: hovered; ToolTip.text: "Native serial control follows in the next tranche" } } } footer: Rectangle { height: 62 - color: root.palette.chrome - border.color: root.palette.lineSoft + color: theme.chrome border.width: 1 + border.color: theme.lineSoft RowLayout { anchors.fill: parent anchors.leftMargin: 18 anchors.rightMargin: 18 - spacing: 16 - RowLayout { - spacing: 9 - Rectangle { width: 8; height: 8; radius: 4; color: root.palette.muted2 } - ColumnLayout { - spacing: 1 - Label { text: "DEVICE OFFLINE"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } - QuietLabel { text: "Setpoints editable · native PROFILE transport not armed yet" } - } + spacing: 14 + Rectangle { width: 8; height: 8; radius: 4; color: theme.muted2 } + ColumnLayout { + spacing: 1 + Label { text: "DEVICE OFFLINE"; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 9; font.weight: Font.Bold } + Quiet { text: "Setpoints editable · PROFILE transport intentionally not armed yet" } } Item { Layout.fillWidth: true } - Label { - text: activeSignal + " " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠" + activePhase.toFixed(2) + "°" - color: root.palette.muted - font.family: root.monoFont - font.pixelSize: 10 - } - Rectangle { width: 1; height: 20; color: root.palette.line } + Label { text: activeSignal + " " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠" + activePhase.toFixed(2) + "°"; color: theme.muted; font.family: root.monoFont; font.pixelSize: 9 } CalmButton { text: "Deploy profile"; enabled: false } CalmButton { text: "Stop"; enabled: false } - Button { - id: startButton - text: "▶ Start live" - enabled: false - implicitWidth: 126; implicitHeight: 38 - font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold - contentItem: Text { text: startButton.text; color: startButton.enabled ? "#d8f5e7" : root.palette.muted2; font: startButton.font; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - background: Rectangle { radius: 8; color: startButton.enabled ? "#194d38" : "#121820"; border.color: startButton.enabled ? "#347a59" : root.palette.lineSoft } - } + CalmButton { text: "▶ Start live"; enabled: false; implicitWidth: 120 } } } @@ -272,22 +234,21 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent anchors.margins: 16 - spacing: 14 + spacing: 13 ColumnLayout { spacing: 3 - SectionKicker { text: "ENGINEERING SOURCE" } - Label { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Compiled SCL") : "Development profile"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } - QuietLabel { text: sclProfiles.sourceName.length ? sclProfiles.sourceName : "Import SCL / CID / SCD / IID"; elide: Text.ElideMiddle; Layout.fillWidth: true } + Kicker { text: "ENGINEERING SOURCE" } + Label { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Compiled SCL") : "Development profile"; color: theme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } + Quiet { Layout.fillWidth: true; elide: Text.ElideMiddle; text: sclProfiles.sourceName.length ? sclProfiles.sourceName : "SCL / CID / SCD / IID" } } - - CalmButton { Layout.fillWidth: true; text: "Open SCL / CID"; onClicked: sclFileDialog.open() } + CalmButton { Layout.fillWidth: true; text: "Open SCL / CID"; onClicked: fileDialog.open() } ColumnLayout { visible: sclProfiles.hasProfiles + Layout.fillWidth: true spacing: 7 - SectionKicker { text: "RESOLVED STREAM" } + Kicker { text: "RESOLVED STREAM" } ComboBox { - id: streamCombo Layout.fillWidth: true model: sclProfiles textRole: "control" @@ -297,55 +258,48 @@ ApplicationWindow { font.pixelSize: 11 } GridLayout { + Layout.fillWidth: true columns: 2 columnSpacing: 8; rowSpacing: 6 - QuietLabel { text: "Class" } - Label { text: sclProfiles.selectedProfile.compatibilityClass ? "CLASS " + sclProfiles.selectedProfile.compatibilityClass : "—"; color: sclProfiles.selectedProfile.compatibilityClass === "A" ? root.palette.green : sclProfiles.selectedProfile.compatibilityClass === "B" ? root.palette.amber : root.palette.red; font.family: root.uiFont; font.pixelSize: 11; font.weight: Font.DemiBold } - QuietLabel { text: "Support" } - Label { text: sclProfiles.selectedProfile.deviceSupport || "—"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 10 } - QuietLabel { text: "APPID" } - Label { text: sclProfiles.selectedProfile.appIdHex || "—"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } - QuietLabel { text: "Rate" } - Label { text: sclProfiles.selectedProfile.publisherRate ? sclProfiles.selectedProfile.publisherRate + "/s" : "—"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } - QuietLabel { text: "VLAN" } - Label { text: sclProfiles.selectedProfile.vlanPresent ? "P" + sclProfiles.selectedProfile.vlanPriority + " · VID " + sclProfiles.selectedProfile.vlanId : "untagged"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } + Quiet { text: "Class" } + Label { text: "CLASS " + (sclProfiles.selectedProfile.compatibilityClass || "—"); color: sclProfiles.selectedProfile.compatibilityClass === "A" ? theme.green : sclProfiles.selectedProfile.compatibilityClass === "B" ? theme.amber : theme.red; font.family: root.uiFont; font.pixelSize: 10; font.weight: Font.Bold } + Quiet { text: "Support" } + Label { text: sclProfiles.selectedProfile.deviceSupport || "—"; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 10; elide: Text.ElideRight; Layout.fillWidth: true } + Quiet { text: "APPID" } + Label { text: sclProfiles.selectedProfile.appIdHex || "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Quiet { text: "Rate" } + Label { text: sclProfiles.selectedProfile.publisherRate ? sclProfiles.selectedProfile.publisherRate + "/s" : "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Quiet { text: "VLAN" } + Label { text: sclProfiles.selectedProfile.vlanPresent ? "P" + sclProfiles.selectedProfile.vlanPriority + " · " + sclProfiles.selectedProfile.vlanId : "untagged"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } } } - Rectangle { Layout.fillWidth: true; height: 1; color: root.palette.lineSoft } - ColumnLayout { visible: sclProfiles.selectedProfile.compatibilityClass === "B" + Layout.fillWidth: true spacing: 7 - SectionKicker { text: "COUNTER POLICY" } - QuietLabel { Layout.fillWidth: true; wrapMode: Text.WordWrap; text: "Candidate smpCnt cycle needs explicit engineering or observed-wire confirmation." } + Kicker { text: "COUNTER POLICY" } + Quiet { Layout.fillWidth: true; wrapMode: Text.WordWrap; text: "Candidate smpCnt modulus requires explicit profile/evidence confirmation before deployment." } RowLayout { - TextField { id: counterField; Layout.fillWidth: true; placeholderText: "modulus"; text: sclProfiles.selectedProfile.counterModulus || ""; font.family: root.monoFont; validator: IntValidator { bottom: 1; top: 65535 } } + Layout.fillWidth: true + TextField { id: counterField; Layout.fillWidth: true; text: sclProfiles.selectedProfile.counterModulus || ""; placeholderText: "modulus"; font.family: root.monoFont; validator: IntValidator { bottom: 1; top: 65535 } } CalmButton { text: "Confirm"; onClicked: sclProfiles.confirmCounterModulus(parseInt(counterField.text)) } } } - ColumnLayout { - spacing: 7 - SectionKicker { text: "QUICK SETUP" } - CalmButton { Layout.fillWidth: true; text: "Balanced 3-phase"; onClicked: root.loadBalanced() } - CalmButton { Layout.fillWidth: true; text: "Zero output"; onClicked: root.zeroAll() } - } - + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + Kicker { text: "QUICK SETUP" } + CalmButton { Layout.fillWidth: true; text: "Balanced 3-phase"; onClicked: root.balanced() } + CalmButton { Layout.fillWidth: true; text: "Zero output"; onClicked: root.zeroAll() } Item { Layout.fillHeight: true } - - ColumnLayout { - Layout.fillWidth: true - spacing: 4 - SectionKicker { text: "RUNTIME" } - Repeater { - model: ["Rate", "Missed", "TX fail", "Generation"] - RowLayout { - Layout.fillWidth: true - QuietLabel { text: modelData } - Item { Layout.fillWidth: true } - Label { text: "—"; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 10 } - } + Kicker { text: "RUNTIME" } + Repeater { + model: ["Rate", "Missed", "TX fail", "Generation"] + RowLayout { + Layout.fillWidth: true + Quiet { text: modelData } + Item { Layout.fillWidth: true } + Label { text: "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } } } } @@ -354,55 +308,44 @@ ApplicationWindow { Surface { Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumWidth: 560 + Layout.minimumWidth: 570 ColumnLayout { anchors.fill: parent anchors.margins: 18 - spacing: 14 + spacing: 13 RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 2 - SectionKicker { text: "INJECTION WORKSPACE" } - Label { text: "Manual Injection"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 24; font.weight: Font.DemiBold } + Kicker { text: "INJECTION WORKSPACE" } + Label { text: "Manual Injection"; color: theme.text; font.family: root.uiFont; font.pixelSize: 23; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - Rectangle { - width: 88; height: 28; radius: 7; color: root.palette.quiet; border.color: root.palette.lineSoft - Row { anchors.centerIn: parent; spacing: 7; Rectangle { width: 6; height: 6; radius: 3; color: root.palette.muted2 } QuietLabel { text: "STOPPED" } } - } + Quiet { text: "STOPPED" } } - Rectangle { Layout.fillWidth: true - height: 62 + height: 58 radius: 9 - color: root.palette.quiet + color: theme.surface2 RowLayout { anchors.fill: parent anchors.leftMargin: 14; anchors.rightMargin: 14 - spacing: 12 + spacing: 10 ColumnLayout { - spacing: 1 - SectionKicker { text: "FREQUENCY" } + spacing: 0 + Kicker { text: "FREQUENCY" } RowLayout { - spacing: 5 - TextField { id: frequencyField; text: "50.000"; implicitWidth: 86; color: root.palette.text; font.family: root.monoFont; font.pixelSize: 17; selectByMouse: true; background: Item {} } - QuietLabel { text: "Hz" } + TextField { id: frequencyField; text: "50.000"; implicitWidth: 82; selectByMouse: true; color: theme.text; font.family: root.monoFont; font.pixelSize: 16; background: Item {}; onTextEdited: waveform.requestPaint() } + Quiet { text: "Hz" } } } - CalmButton { text: "50"; onClicked: frequencyField.text = "50.000" } - CalmButton { text: "60"; onClicked: frequencyField.text = "60.000" } - Rectangle { width: 1; height: 28; color: root.palette.lineSoft } - CheckBox { - id: linkCheck - checked: root.phaseLink - onToggled: root.phaseLink = checked - text: "3-phase link" - font.family: root.uiFont; font.pixelSize: 11 - } + CalmButton { text: "50"; onClicked: { frequencyField.text = "50.000"; waveform.requestPaint() } } + CalmButton { text: "60"; onClicked: { frequencyField.text = "60.000"; waveform.requestPaint() } } + Rectangle { width: 1; height: 28; color: theme.lineSoft } + CheckBox { checked: root.phaseLink; text: "3-phase link"; onToggled: root.phaseLink = checked; font.family: root.uiFont; font.pixelSize: 11 } Item { Layout.fillWidth: true } - CheckBox { checked: true; text: "Live apply"; enabled: false; font.family: root.uiFont; font.pixelSize: 11; ToolTip.visible: hovered; ToolTip.text: "Enabled when native serial transport lands" } + CheckBox { checked: true; enabled: false; text: "Live apply"; font.family: root.uiFont; font.pixelSize: 11 } } } @@ -410,26 +353,48 @@ ApplicationWindow { Layout.fillWidth: true Layout.fillHeight: true spacing: 12 - SignalMatrix { - id: currentMatrix + + Rectangle { Layout.fillWidth: true; Layout.fillHeight: true - title: "Current"; symbol: "I"; unit: "A RMS"; groupIndex: 0; signalModel: currentModel; signalRepeater: currentRepeater + color: theme.surface2; radius: 9; border.color: theme.lineSoft + ColumnLayout { + anchors.fill: parent; spacing: 0 + MatrixHeader { titleText: "Current"; symbolText: "I"; unitText: "A RMS" } + MatrixColumns {} + Repeater { + id: currentRep + model: currentModel + SignalRow { groupIndex: 0; rowIndex: index; sourceModel: currentModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; phaseColor: traceColor } + } + Item { Layout.fillHeight: true } + } } - SignalMatrix { - id: voltageMatrix + + Rectangle { Layout.fillWidth: true; Layout.fillHeight: true - title: "Voltage"; symbol: "U"; unit: "V RMS"; groupIndex: 1; signalModel: voltageModel; signalRepeater: voltageRepeater + color: theme.surface2; radius: 9; border.color: theme.lineSoft + ColumnLayout { + anchors.fill: parent; spacing: 0 + MatrixHeader { titleText: "Voltage"; symbolText: "U"; unitText: "V RMS" } + MatrixColumns {} + Repeater { + id: voltageRep + model: voltageModel + SignalRow { groupIndex: 1; rowIndex: index; sourceModel: voltageModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; phaseColor: traceColor } + } + Item { Layout.fillHeight: true } + } } } RowLayout { Layout.fillWidth: true - spacing: 8 - Rectangle { width: 7; height: 7; radius: 4; color: root.palette.accent } - Label { text: activeSignal; color: root.palette.textSoft; font.family: root.monoFont; font.pixelSize: 11; font.weight: Font.DemiBold } - QuietLabel { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } + spacing: 7 + Rectangle { width: 7; height: 7; radius: 4; color: theme.accent } + Label { text: activeSignal; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 10; font.weight: Font.Bold } + Quiet { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } Item { Layout.fillWidth: true } - QuietLabel { text: "↑↓ channel ←→ field Enter next" } + Quiet { text: "↑↓ channel ←→ field Enter next" } } } } @@ -440,175 +405,112 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent anchors.margins: 16 - spacing: 12 + spacing: 10 RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 2 - SectionKicker { text: "GENERATED SETPOINT" } - Label { text: "Signal Preview"; color: root.palette.text; font.family: root.uiFont; font.pixelSize: 20; font.weight: Font.DemiBold } + Kicker { text: "GENERATED SETPOINT" } + Label { text: "Signal Preview"; color: theme.text; font.family: root.uiFont; font.pixelSize: 19; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - QuietLabel { text: "LOCAL" } + Quiet { text: "LOCAL" } } - - ColumnLayout { + PreviewTitle { titleText: "Phasor"; detailText: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠ " + activePhase.toFixed(2) + "°" } + Canvas { + id: phasor Layout.fillWidth: true; Layout.fillHeight: true; Layout.preferredHeight: 1 - spacing: 6 - RowLayout { Layout.fillWidth: true; Label { text: "Phasor"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } Item { Layout.fillWidth: true } Label { text: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠ " + activePhase.toFixed(2) + "°"; color: root.palette.muted; font.family: root.monoFont; font.pixelSize: 9 } } - Canvas { - id: phasor - Layout.fillWidth: true; Layout.fillHeight: true - onWidthChanged: requestPaint(); onHeightChanged: requestPaint() - onPaint: { - var ctx = getContext("2d"); ctx.reset(); - var w = width, h = height, cx = w / 2, cy = h / 2, r = Math.min(w, h) * 0.38 - 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 deg = 0; deg < 180; deg += 30) { var a = deg * Math.PI / 180; var dx = Math.cos(a) * r; var dy = Math.sin(a) * r; ctx.beginPath(); ctx.moveTo(cx-dx,cy-dy);ctx.lineTo(cx+dx,cy+dy);ctx.stroke() } - function draw(model, scale, dashed) { - var max = 0.0001 - for (var i=0;i<4;++i) max = Math.max(max, model.get(i).magnitude) - for (var j=0;j<4;++j) { - var s=model.get(j); if (!s.enabled || s.magnitude <= 0) continue - var angle=s.phase*Math.PI/180; var length=r*scale*(0.28+0.72*Math.min(1,s.magnitude/max)); var x=cx+Math.cos(angle)*length; var y=cy-Math.sin(angle)*length - var active=s.signalId===root.activeSignal - ctx.save(); ctx.globalAlpha=active?1:0.70; ctx.strokeStyle=s.traceColor; ctx.fillStyle=s.traceColor; ctx.lineWidth=active?3.2:2.0; ctx.setLineDash(dashed?[5,4]:[]); ctx.lineCap="round"; ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(x,y);ctx.stroke();ctx.setLineDash([]) - var head=active?8:6;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-Math.cos(angle-.45)*head,y+Math.sin(angle-.45)*head);ctx.lineTo(x-Math.cos(angle+.45)*head,y+Math.sin(angle+.45)*head);ctx.closePath();ctx.fill() - if(active){ctx.font="600 11px Inter";ctx.fillText(s.signalId,x+8,y-7)}ctx.restore() - } - } - draw(voltageModel,1.0,false); draw(currentModel,0.78,true) - } + onWidthChanged: requestPaint(); onHeightChanged: requestPaint() + onPaint: { + var ctx=getContext("2d");ctx.reset();var w=width,h=height,cx=w/2,cy=h/2,r=Math.min(w,h)*.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 deg=0;deg<180;deg+=30){var a=deg*Math.PI/180,dx=Math.cos(a)*r,dy=Math.sin(a)*r;ctx.beginPath();ctx.moveTo(cx-dx,cy-dy);ctx.lineTo(cx+dx,cy+dy);ctx.stroke()} + function draw(m,scale,dash){var max=.0001;for(var i=0;i<4;++i)max=Math.max(max,m.get(i).magnitude);for(var j=0;j<4;++j){var s=m.get(j);if(!s.enabled||s.magnitude<=0)continue;var a=s.phase*Math.PI/180,len=r*scale*(.3+.7*Math.min(1,s.magnitude/max)),x=cx+Math.cos(a)*len,y=cy-Math.sin(a)*len,active=s.signalId===root.activeSignal;ctx.save();ctx.globalAlpha=active?1:.70;ctx.strokeStyle=s.traceColor;ctx.fillStyle=s.traceColor;ctx.lineWidth=active?3.2:2;ctx.lineCap="round";ctx.setLineDash(dash?[5,4]:[]);ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(x,y);ctx.stroke();ctx.setLineDash([]);var head=active?8:6;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-Math.cos(a-.45)*head,y+Math.sin(a-.45)*head);ctx.lineTo(x-Math.cos(a+.45)*head,y+Math.sin(a+.45)*head);ctx.closePath();ctx.fill();if(active){ctx.font="600 11px Inter";ctx.fillText(s.signalId,x+8,y-7)}ctx.restore()}} + draw(voltageModel,1,false);draw(currentModel,.78,true) } } - - ColumnLayout { - Layout.fillWidth: true; Layout.fillHeight: true; Layout.preferredHeight: 0.85 - spacing: 6 - RowLayout { Layout.fillWidth: true; Label { text: "Waveform"; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } Item { Layout.fillWidth: true } Label { text: frequencyField.text + " Hz · 40 ms"; color: root.palette.muted; font.family: root.monoFont; font.pixelSize: 9 } } - Canvas { - id: waveform - Layout.fillWidth: true; Layout.fillHeight: true - onWidthChanged: requestPaint(); onHeightChanged: requestPaint() - onPaint: { - var ctx=getContext("2d");ctx.reset();var w=width,h=height,left=25,right=8,top=12,bottom=14,pw=w-left-right,ph=h-top-bottom,gap=16,lane=(ph-gap)/2,vy=top+lane/2,iy=top+lane+gap+lane/2,f=parseFloat(frequencyField.text)||50 - ctx.lineWidth=1;ctx.strokeStyle="#202b36";for(var gx=0;gx<=8;++gx){var x=left+pw*gx/8;ctx.beginPath();ctx.moveTo(x,top);ctx.lineTo(x,top+ph);ctx.stroke()}ctx.strokeStyle="#33404d";[vy,iy].forEach(function(y){ctx.beginPath();ctx.moveTo(left,y);ctx.lineTo(left+pw,y);ctx.stroke()}) - function draw(model,center,dashed){var max=.0001;for(var i=0;i<4;++i)max=Math.max(max,model.get(i).magnitude);for(var j=0;j<4;++j){var s=model.get(j);if(!s.enabled||s.magnitude<=0)continue;var active=s.signalId===root.activeSignal,amp=lane*.39*Math.min(1,s.magnitude/max),pr=s.phase*Math.PI/180;ctx.save();ctx.strokeStyle=s.traceColor;ctx.globalAlpha=active?1:.64;ctx.lineWidth=active?2.6:1.7;ctx.lineCap="round";ctx.setLineDash(dashed?[5,4]:[]);ctx.beginPath();for(var p=0;p<=220;++p){var ratio=p/220,t=.04*ratio,val=Math.sin(2*Math.PI*f*t+pr),x=left+pw*ratio,y=center-val*amp;if(p===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)}ctx.stroke();ctx.restore()}} - draw(voltageModel,vy,false);draw(currentModel,iy,true) - } + PreviewTitle { titleText: "Waveform"; detailText: frequencyField.text + " Hz · 40 ms" } + Canvas { + id: waveform + Layout.fillWidth: true; Layout.fillHeight: true; Layout.preferredHeight: .85 + onWidthChanged: requestPaint(); onHeightChanged: requestPaint() + onPaint: { + var ctx=getContext("2d");ctx.reset();var w=width,h=height,l=25,r=8,t=12,b=14,pw=w-l-r,ph=h-t-b,g=16,lane=(ph-g)/2,vy=t+lane/2,iy=t+lane+g+lane/2,f=parseFloat(frequencyField.text)||50 + ctx.strokeStyle="#202b36";ctx.lineWidth=1;for(var gx=0;gx<=8;++gx){var x=l+pw*gx/8;ctx.beginPath();ctx.moveTo(x,t);ctx.lineTo(x,t+ph);ctx.stroke()}ctx.strokeStyle="#33404d";[vy,iy].forEach(function(y){ctx.beginPath();ctx.moveTo(l,y);ctx.lineTo(l+pw,y);ctx.stroke()}) + function draw(m,center,dash){var max=.0001;for(var i=0;i<4;++i)max=Math.max(max,m.get(i).magnitude);for(var j=0;j<4;++j){var s=m.get(j);if(!s.enabled||s.magnitude<=0)continue;var active=s.signalId===root.activeSignal,amp=lane*.39*Math.min(1,s.magnitude/max),pr=s.phase*Math.PI/180;ctx.save();ctx.strokeStyle=s.traceColor;ctx.globalAlpha=active?1:.64;ctx.lineWidth=active?2.6:1.7;ctx.lineCap="round";ctx.setLineDash(dash?[5,4]:[]);ctx.beginPath();for(var p=0;p<=220;++p){var q=p/220,time=.04*q,val=Math.sin(2*Math.PI*f*time+pr),x=l+pw*q,y=center-val*amp;if(p===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)}ctx.stroke();ctx.restore()}} + draw(voltageModel,vy,false);draw(currentModel,iy,true) } } - QuietLabel { Layout.fillWidth: true; horizontalAlignment: Text.AlignRight; text: "Generated setpoint · not independent on-wire proof" } + Quiet { Layout.fillWidth: true; horizontalAlignment: Text.AlignRight; text: "Generated setpoint · not independent on-wire proof" } } } } - component SignalMatrix: Rectangle { - id: matrix - required property string title - required property string symbol - required property string unit + component MatrixHeader: RowLayout { + required property string titleText + required property string symbolText + required property string unitText + Layout.fillWidth: true; Layout.preferredHeight: 46 + Layout.leftMargin: 12; Layout.rightMargin: 12 + Rectangle { width: 23; height: 23; radius: 5; color: "#152235"; Label { anchors.centerIn: parent; text: symbolText; color: theme.accent; font.family: root.monoFont; font.pixelSize: 10; font.weight: Font.Bold } } + Label { text: titleText; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Item { Layout.fillWidth: true } + Quiet { text: unitText } + } + component MatrixColumns: RowLayout { + Layout.fillWidth: true; Layout.preferredHeight: 30 + Layout.leftMargin: 10; Layout.rightMargin: 10; spacing: 8 + Quiet { text: "ON"; Layout.preferredWidth: 28; font.pixelSize: 8 } + Quiet { text: "CH"; Layout.preferredWidth: 34; font.pixelSize: 8 } + Quiet { text: "MAGNITUDE"; Layout.fillWidth: true; font.pixelSize: 8 } + Quiet { text: "PHASE"; Layout.preferredWidth: 108; font.pixelSize: 8 } + } + component SignalRow: Rectangle { + id: row required property int groupIndex - required property var signalModel - required property var signalRepeater - color: root.palette.quiet - radius: 9 - border.color: root.palette.lineSoft - ColumnLayout { - anchors.fill: parent - spacing: 0 - RowLayout { - Layout.fillWidth: true; Layout.preferredHeight: 44 - Layout.leftMargin: 12; Layout.rightMargin: 12 - Rectangle { width: 23; height: 23; radius: 5; color: "#152235"; Label { anchors.centerIn: parent; text: matrix.symbol; color: root.palette.accent; font.family: root.monoFont; font.pixelSize: 10; font.weight: Font.Bold } } - Label { text: matrix.title; color: root.palette.textSoft; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } - Item { Layout.fillWidth: true } - QuietLabel { text: matrix.unit } - } - Rectangle { Layout.fillWidth: true; height: 1; color: root.palette.lineSoft } - RowLayout { - Layout.fillWidth: true; Layout.preferredHeight: 30 - Layout.leftMargin: 10; Layout.rightMargin: 10; spacing: 8 - QuietLabel { text: "ON"; Layout.preferredWidth: 28; font.pixelSize: 9 } - QuietLabel { text: "CH"; Layout.preferredWidth: 32; font.pixelSize: 9 } - QuietLabel { text: "MAGNITUDE"; Layout.fillWidth: true; font.pixelSize: 9 } - QuietLabel { text: "PHASE"; Layout.preferredWidth: 112; font.pixelSize: 9 } - } - Repeater { - id: internalRepeater - model: matrix.signalModel - delegate: Rectangle { - id: signalRow - required property int index - required property string signalId - required property real magnitude - required property real phase - required property bool enabled - required property string traceColor - property alias magnitudeEditor: magField - property alias phaseEditor: phaseField - width: parent ? parent.width : 0 - Layout.fillWidth: true - Layout.preferredHeight: 58 - color: (magField.activeFocus || phaseField.activeFocus) ? "#141d27" : "transparent" - border.width: 0 - RowLayout { - anchors.fill: parent - anchors.leftMargin: 10; anchors.rightMargin: 10 - spacing: 8 - CheckBox { - Layout.preferredWidth: 28 - checked: signalRow.enabled - onToggled: matrix.signalModel.setProperty(signalRow.index, "enabled", checked) - } - RowLayout { - Layout.preferredWidth: 32; spacing: 5 - Rectangle { width: 6; height: 6; radius: 3; color: signalRow.traceColor } - Label { text: signalRow.signalId; color: root.palette.text; font.family: root.monoFont; font.pixelSize: 12; font.weight: Font.DemiBold } - } - TextField { - id: magField - Layout.fillWidth: true - text: signalRow.magnitude.toFixed(3) - selectByMouse: true - color: root.palette.text - font.family: root.monoFont; font.pixelSize: 14; font.weight: Font.DemiBold - horizontalAlignment: Text.AlignRight - background: Rectangle { radius: 6; color: magField.activeFocus ? "#0c141d" : "#111820"; border.width: magField.activeFocus ? 1 : 0; border.color: root.palette.accent } - onActiveFocusChanged: if (activeFocus) { root.activeSignal=signalRow.signalId; root.activeUnit=matrix.groupIndex===0?"A":"V"; root.activeMagnitude=signalRow.magnitude; root.activePhase=signalRow.phase; selectAll(); phasor.requestPaint(); waveform.requestPaint() } - onEditingFinished: root.updateSignal(matrix.groupIndex, signalRow.index, "magnitude", parseFloat(text)) - Keys.onPressed: function(event) { if ([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(event.key)>=0) { root.moveCell(matrix.groupIndex,signalRow.index,0,event.key); event.accepted=true } else if(event.key===Qt.Key_Return||event.key===Qt.Key_Enter){root.focusMatrix(matrix.groupIndex,signalRow.index+1,0);event.accepted=true} } - } - TextField { - id: phaseField - Layout.preferredWidth: 112 - text: signalRow.phase.toFixed(2) - selectByMouse: true - color: root.palette.text - font.family: root.monoFont; font.pixelSize: 14; font.weight: Font.DemiBold - horizontalAlignment: Text.AlignRight - background: Rectangle { radius: 6; color: phaseField.activeFocus ? "#0c141d" : "#111820"; border.width: phaseField.activeFocus ? 1 : 0; border.color: root.palette.accent } - onActiveFocusChanged: if (activeFocus) { root.activeSignal=signalRow.signalId; root.activeUnit=matrix.groupIndex===0?"A":"V"; root.activeMagnitude=signalRow.magnitude; root.activePhase=signalRow.phase; selectAll(); phasor.requestPaint(); waveform.requestPaint() } - onEditingFinished: root.updateSignal(matrix.groupIndex, signalRow.index, "phase", parseFloat(text)) - Keys.onPressed: function(event) { if ([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(event.key)>=0) { root.moveCell(matrix.groupIndex,signalRow.index,1,event.key); event.accepted=true } else if(event.key===Qt.Key_Return||event.key===Qt.Key_Enter){root.focusMatrix(matrix.groupIndex,signalRow.index+1,1);event.accepted=true} } - } - } - Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: root.palette.lineSoft; opacity: 0.72 } - } + required property int rowIndex + required property var sourceModel + required property string sid + required property real mag + required property real angle + required property bool isEnabled + required property color phaseColor + property alias magEditor: magField + property alias phaseEditor: phaseField + Layout.fillWidth: true; Layout.preferredHeight: 58 + color: (magField.activeFocus || phaseField.activeFocus) ? "#141d27" : "transparent" + RowLayout { + anchors.fill: parent; anchors.leftMargin: 10; anchors.rightMargin: 10; spacing: 8 + CheckBox { Layout.preferredWidth: 28; checked: row.isEnabled; onToggled: { row.sourceModel.setProperty(row.rowIndex,"enabled",checked); phasor.requestPaint(); waveform.requestPaint() } } + RowLayout { Layout.preferredWidth: 34; spacing: 5; Rectangle { width: 6; height: 6; radius: 3; color: row.phaseColor } Label { text: row.sid; color: theme.text; font.family: root.monoFont; font.pixelSize: 11; font.weight: Font.Bold } } + TextField { + id: magField + Layout.fillWidth: true; text: row.mag.toFixed(3); selectByMouse: true; horizontalAlignment: Text.AlignRight + color: theme.text; font.family: root.monoFont; font.pixelSize: 13; font.weight: Font.DemiBold + background: Rectangle { radius: 6; color: magField.activeFocus ? "#0c141d" : "#111820"; border.width: magField.activeFocus ? 1 : 0; border.color: theme.accent } + onActiveFocusChanged: if(activeFocus){root.activeSignal=row.sid;root.activeUnit=row.groupIndex===0?"A":"V";root.activeMagnitude=row.mag;root.activePhase=row.angle;selectAll();phasor.requestPaint();waveform.requestPaint()} + onEditingFinished: root.editSignal(row.groupIndex,row.rowIndex,"magnitude",parseFloat(text)) + Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,0,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.focusCell(row.groupIndex,row.rowIndex+1,0);e.accepted=true}} } - Component.onCompleted: { - // Expose the internal repeater to the root keyboard navigator. - if (matrix.groupIndex === 0) currentRepeaterProxy.target = internalRepeater - else voltageRepeaterProxy.target = internalRepeater + TextField { + id: phaseField + Layout.preferredWidth: 108; text: row.angle.toFixed(2); selectByMouse: true; horizontalAlignment: Text.AlignRight + color: theme.text; font.family: root.monoFont; font.pixelSize: 13; font.weight: Font.DemiBold + background: Rectangle { radius: 6; color: phaseField.activeFocus ? "#0c141d" : "#111820"; border.width: phaseField.activeFocus ? 1 : 0; border.color: theme.accent } + onActiveFocusChanged: if(activeFocus){root.activeSignal=row.sid;root.activeUnit=row.groupIndex===0?"A":"V";root.activeMagnitude=row.mag;root.activePhase=row.angle;selectAll();phasor.requestPaint();waveform.requestPaint()} + onEditingFinished: root.editSignal(row.groupIndex,row.rowIndex,"phase",parseFloat(text)) + Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,1,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.focusCell(row.groupIndex,row.rowIndex+1,1);e.accepted=true}} } } + Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: theme.lineSoft } + } + component PreviewTitle: RowLayout { + required property string titleText + required property string detailText + Layout.fillWidth: true + Label { text: titleText; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 11; font.weight: Font.DemiBold } + Item { Layout.fillWidth: true } + Label { text: detailText; color: theme.muted; font.family: root.monoFont; font.pixelSize: 8 } } - - // Proxy objects give root.focusMatrix() stable IDs while repeaters live inside - // component instances. - QtObject { id: currentRepeaterProxy; property var target: null; function itemAt(i) { return target ? target.itemAt(i) : null } } - QtObject { id: voltageRepeaterProxy; property var target: null; function itemAt(i) { return target ? target.itemAt(i) : null } } - property var currentRepeater: currentRepeaterProxy - property var voltageRepeater: voltageRepeaterProxy } From 36204cfd11a6d869eaaba40e26f6422347a5004e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:00:40 +0700 Subject: [PATCH 07/24] Align Qt build scaffold with Qt 6 CMake guidance --- apps/arstack_studio/CMakeLists.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 215b0822f..a1d25e3f0 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -7,7 +7,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) -find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick QuickControls2) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2) +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. @@ -32,6 +33,7 @@ qt_add_qml_module(arstack_studio target_link_libraries(arstack_studio PRIVATE Qt6::Core Qt6::Gui + Qt6::Qml Qt6::Quick Qt6::QuickControls2 ARIEC61850::core From 98fef69175f5dbd6762dd1e05f782db0ccb8a2a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:03:42 +0700 Subject: [PATCH 08/24] Share ESP32-P4 SV profile support classification --- .../esp32p4_profile_support.hpp | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 include/ariec61850/sampled_values/esp32p4_profile_support.hpp diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp new file mode 100644 index 000000000..75be57a27 --- /dev/null +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/publisher_profile.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class Esp32P4SvProfileSupport { + ready, + needs_counter_confirmation, + unsupported_layout, +}; + +[[nodiscard]] inline std::string_view esp32p4_sv_profile_support_name( + const Esp32P4SvProfileSupport support) noexcept { + switch (support) { + case Esp32P4SvProfileSupport::ready: + return "ready"; + case Esp32P4SvProfileSupport::needs_counter_confirmation: + return "needs-counter-confirmation"; + case Esp32P4SvProfileSupport::unsupported_layout: + return "unsupported-layout"; + } + return "unsupported-layout"; +} + +namespace detail { +[[nodiscard]] inline std::string sv_support_lower_copy(const std::string_view text) { + std::string result; + result.reserve(text.size()); + for (const char ch : text) { + result.push_back(static_cast( + std::tolower(static_cast(ch)))); + } + return result; +} + +[[nodiscard]] inline bool esp32p4_4i4v_layout_matches( + const SvPublisherProfile& profile) { + if (profile.no_asdu != 1U || profile.payload_size_bytes != 64U || + profile.channels.size() != 16U || !profile.publisher_rate_hz.has_value() || + *profile.publisher_rate_hz == 0U || *profile.publisher_rate_hz > 65535U) { + return false; + } + if (profile.asdu_options.refresh_time || profile.asdu_options.security || + profile.asdu_options.synch_source_id) { + return false; + } + for (std::size_t i = 0U; i < profile.channels.size(); ++i) { + const auto& channel = profile.channels[i]; + if (channel.wire_width_bytes != 4U) return false; + if ((i % 2U) == 0U) { + if (channel.is_quality || sv_support_lower_copy(channel.basic_type) != "int32") { + return false; + } + } else if (!channel.is_quality) { + return false; + } + } + return true; +} +} // namespace detail + +// Centralized deployment boundary for the currently proven ESP32-P4 runtime. +// Parsing may accept broader IEC 61850 SV structures; this classifier is only +// the embedded-device deployment gate and deliberately fails closed. +[[nodiscard]] inline Esp32P4SvProfileSupport classify_esp32p4_sv_profile( + const SvPublisherProfile& profile) { + if (!detail::esp32p4_4i4v_layout_matches(profile)) { + return Esp32P4SvProfileSupport::unsupported_layout; + } + if (profile.sample_counter_policy != SvSampleCounterPolicy::explicit_modulus || + !profile.sample_counter_modulus.has_value() || + *profile.sample_counter_modulus == 0U) { + return Esp32P4SvProfileSupport::needs_counter_confirmation; + } + return Esp32P4SvProfileSupport::ready; +} + +} // namespace ar::iec61850::sampled_values From 32be3e32a13967ef8cb0eb51d70820e64e10ab66 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:04:30 +0700 Subject: [PATCH 09/24] Add native Qt serial device controller --- apps/arstack_studio/src/DeviceController.hpp | 96 ++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/arstack_studio/src/DeviceController.hpp diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp new file mode 100644 index 000000000..badba6af2 --- /dev/null +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +class DeviceController final : public QObject { + Q_OBJECT + Q_PROPERTY(QStringList ports READ ports NOTIFY portsChanged) + Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY(QString logText READ logText NOTIFY logTextChanged) + Q_PROPERTY(QString fps READ fps NOTIFY telemetryChanged) + Q_PROPERTY(QString missed READ missed NOTIFY telemetryChanged) + Q_PROPERTY(QString txFailures READ txFailures NOTIFY telemetryChanged) + Q_PROPERTY(QString signalGeneration READ signalGeneration NOTIFY telemetryChanged) + Q_PROPERTY(QString profileGeneration READ profileGeneration NOTIFY profileStateChanged) + Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) + Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) + +public: + explicit DeviceController(QObject* parent = nullptr); + + [[nodiscard]] QStringList ports() const; + [[nodiscard]] bool connected() const noexcept; + [[nodiscard]] bool running() const noexcept; + [[nodiscard]] QString portName() const; + [[nodiscard]] QString lastError() const; + [[nodiscard]] QString logText() const; + [[nodiscard]] QString fps() const; + [[nodiscard]] QString missed() const; + [[nodiscard]] QString txFailures() const; + [[nodiscard]] QString signalGeneration() const; + [[nodiscard]] QString profileGeneration() const; + [[nodiscard]] bool profileArmed() const noexcept; + [[nodiscard]] bool profileDeploying() const noexcept; + + Q_INVOKABLE void refreshPorts(); + Q_INVOKABLE bool connectPort(const QString& portName); + Q_INVOKABLE void disconnectPort(); + Q_INVOKABLE bool sendShow(); + Q_INVOKABLE bool start(); + Q_INVOKABLE bool stop(); + Q_INVOKABLE bool zero(); + Q_INVOKABLE bool setFrequency(double hz); + Q_INVOKABLE bool setSignal( + const QString& signalId, + double magnitude, + double phaseDegrees, + quint32 quality, + double currentCountsPerAmp, + double voltageCountsPerVolt); + Q_INVOKABLE bool setEnabled(const QString& signalId, bool enabled); + Q_INVOKABLE bool setQuality(const QString& signalId, quint32 quality); + Q_INVOKABLE bool deployProfile(const QVariantMap& profile); + Q_INVOKABLE void clearLog(); + +signals: + void portsChanged(); + void connectedChanged(); + void runningChanged(); + void lastErrorChanged(); + void logTextChanged(); + void telemetryChanged(); + void profileStateChanged(); + void deviceMessage(const QString& message); + +private: + bool sendCommand(const QString& command); + void setRunning(bool value); + void setError(const QString& message); + void appendLog(const QString& direction, const QString& line); + void processLine(const QString& rawLine); + void resetTelemetry(); + static QString cleanLine(const QString& rawLine); + static QString utf8Hex(const QString& text); + static QString compactMac(const QString& text); + + QSerialPort serial_; + QStringList ports_; + QByteArray pendingRx_; + QString lastError_; + QString logText_; + QString fps_{QStringLiteral("—")}; + QString missed_{QStringLiteral("—")}; + QString txFailures_{QStringLiteral("—")}; + QString signalGeneration_{QStringLiteral("—")}; + QString profileGeneration_{QStringLiteral("—")}; + bool running_{false}; + bool profileArmed_{false}; + bool profileDeploying_{false}; +}; From ec3ee6b26861b32e1018e5c818d72131ccd9f0d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:05:28 +0700 Subject: [PATCH 10/24] Implement native Qt serial SMV control transport --- apps/arstack_studio/src/DeviceController.cpp | 403 +++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 apps/arstack_studio/src/DeviceController.cpp diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp new file mode 100644 index 000000000..3027498c3 --- /dev/null +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeviceController.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace { +constexpr qint32 kBaudRate = 115200; +const QRegularExpression kAnsiExpression{QStringLiteral("\\x1B\\[[0-9;]*m")}; +const QRegularExpression kStateExpression{ + QStringLiteral("state=(RUNNING|STOPPED).*generation=(\\d+).*signal_frequency=(\\d+)\\s*mHz"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kTimingExpression{ + QStringLiteral("samples=(\\d+)\\s+\\(~(\\d+)\\s+fps\\).*?MC ok=(\\d+) fail=(\\d+).*?missed=(\\d+).*?signal_gen=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kSignalGenerationExpression{ + QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kProfileCommittedExpression{ + QStringLiteral("PROFILE committed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kProfileArmedExpression{ + QStringLiteral("PROFILE armed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; + +[[nodiscard]] bool finitePositive(const double value) noexcept { + return std::isfinite(value) && value > 0.0; +} +} // namespace + +DeviceController::DeviceController(QObject* parent) : QObject(parent) { + connect(&serial_, &QSerialPort::readyRead, this, [this] { + pendingRx_.append(serial_.readAll()); + while (true) { + const auto newline = pendingRx_.indexOf('\n'); + if (newline < 0) break; + QByteArray line = pendingRx_.left(newline); + pendingRx_.remove(0, newline + 1); + if (!line.isEmpty() && line.endsWith('\r')) line.chop(1); + processLine(QString::fromUtf8(line)); + } + }); + + connect(&serial_, &QSerialPort::errorOccurred, this, [this](const QSerialPort::SerialPortError error) { + if (error == QSerialPort::NoError) return; + if (error == QSerialPort::ResourceError || error == QSerialPort::DeviceNotFoundError) { + setError(serial_.errorString()); + disconnectPort(); + } else if (serial_.isOpen()) { + setError(serial_.errorString()); + } + }); + + refreshPorts(); +} + +QStringList DeviceController::ports() const { return ports_; } +bool DeviceController::connected() const noexcept { return serial_.isOpen(); } +bool DeviceController::running() const noexcept { return running_; } +QString DeviceController::portName() const { return serial_.portName(); } +QString DeviceController::lastError() const { return lastError_; } +QString DeviceController::logText() const { return logText_; } +QString DeviceController::fps() const { return fps_; } +QString DeviceController::missed() const { return missed_; } +QString DeviceController::txFailures() const { return txFailures_; } +QString DeviceController::signalGeneration() const { return signalGeneration_; } +QString DeviceController::profileGeneration() const { return profileGeneration_; } +bool DeviceController::profileArmed() const noexcept { return profileArmed_; } +bool DeviceController::profileDeploying() const noexcept { return profileDeploying_; } + +void DeviceController::refreshPorts() { + QStringList discovered; + for (const auto& info : QSerialPortInfo::availablePorts()) { + discovered.push_back(info.portName()); + } + discovered.removeDuplicates(); + std::sort(discovered.begin(), discovered.end(), [](const QString& left, const QString& right) { + return left.localeAwareCompare(right) < 0; + }); + if (discovered == ports_) return; + ports_ = std::move(discovered); + emit portsChanged(); +} + +bool DeviceController::connectPort(const QString& portName) { + if (portName.trimmed().isEmpty()) { + setError(QStringLiteral("Select a serial port first.")); + return false; + } + if (serial_.isOpen()) disconnectPort(); + + serial_.setPortName(portName.trimmed()); + serial_.setBaudRate(kBaudRate); + serial_.setDataBits(QSerialPort::Data8); + serial_.setParity(QSerialPort::NoParity); + serial_.setStopBits(QSerialPort::OneStop); + serial_.setFlowControl(QSerialPort::NoFlowControl); + + if (!serial_.open(QIODevice::ReadWrite)) { + setError(QStringLiteral("Cannot open %1: %2").arg(portName, serial_.errorString())); + return false; + } + + pendingRx_.clear(); + lastError_.clear(); + running_ = false; + profileArmed_ = false; + profileDeploying_ = false; + resetTelemetry(); + appendLog(QStringLiteral("•"), QStringLiteral("Connected %1 at 115200 8N1").arg(serial_.portName())); + emit connectedChanged(); + emit runningChanged(); + emit lastErrorChanged(); + emit profileStateChanged(); + static_cast(sendCommand(QStringLiteral("SHOW"))); + static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); + return true; +} + +void DeviceController::disconnectPort() { + const bool wasConnected = serial_.isOpen(); + if (serial_.isOpen()) serial_.close(); + pendingRx_.clear(); + if (running_) { + running_ = false; + emit runningChanged(); + } + if (profileDeploying_ || profileArmed_) { + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + } + resetTelemetry(); + if (wasConnected) { + appendLog(QStringLiteral("•"), QStringLiteral("Device disconnected")); + emit connectedChanged(); + } +} + +bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); } +bool DeviceController::start() { return sendCommand(QStringLiteral("START")); } +bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); } +bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); } + +bool DeviceController::setFrequency(const double hz) { + if (!finitePositive(hz) || hz > 1000.0) { + setError(QStringLiteral("Frequency must be within 0..1000 Hz.")); + return false; + } + const auto millihertz = std::llround(hz * 1000.0); + if (millihertz <= 0 || millihertz > std::numeric_limits::max()) return false; + return sendCommand(QStringLiteral("FREQ %1").arg(millihertz)); +} + +bool DeviceController::setSignal( + const QString& signalId, + const double magnitude, + const double phaseDegrees, + const quint32 quality, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + const QString id = signalId.trimmed().toUpper(); + static const QStringList validIds{ + QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), + QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + if (!validIds.contains(id) || !std::isfinite(magnitude) || magnitude < 0.0 || !std::isfinite(phaseDegrees)) { + setError(QStringLiteral("Invalid signal setpoint.")); + return false; + } + if (!finitePositive(currentCountsPerAmp) || !finitePositive(voltageCountsPerVolt)) { + setError(QStringLiteral("Engineering scaling must be positive.")); + return false; + } + + const double scale = id.startsWith(QLatin1Char('I')) ? currentCountsPerAmp : voltageCountsPerVolt; + const auto counts = std::llround(magnitude * scale); + const auto phaseMdeg = std::llround(phaseDegrees * 1000.0); + if (counts < std::numeric_limits::min() || counts > std::numeric_limits::max() || + phaseMdeg < std::numeric_limits::min() || phaseMdeg > std::numeric_limits::max()) { + setError(QStringLiteral("Setpoint exceeds firmware wire range.")); + return false; + } + + return sendCommand(QStringLiteral("SET %1 %2 %3 %4") + .arg(id) + .arg(counts) + .arg(phaseMdeg) + .arg(static_cast(quality))); +} + +bool DeviceController::setEnabled(const QString& signalId, const bool enabled) { + return sendCommand(QStringLiteral("ENABLE %1 %2") + .arg(signalId.trimmed().toUpper()) + .arg(enabled ? 1 : 0)); +} + +bool DeviceController::setQuality(const QString& signalId, const quint32 quality) { + return sendCommand(QStringLiteral("QUALITY %1 %2") + .arg(signalId.trimmed().toUpper()) + .arg(static_cast(quality))); +} + +bool DeviceController::deployProfile(const QVariantMap& profile) { + if (!connected()) { + setError(QStringLiteral("Connect the ESP32-P4 before deployment.")); + return false; + } + if (running_) { + setError(QStringLiteral("Stop the publisher before changing profile identity/layout.")); + return false; + } + if (profile.value(QStringLiteral("compatibilityClass")).toString() != QStringLiteral("A") || + profile.value(QStringLiteral("deviceSupport")).toString() != QStringLiteral("ready")) { + setError(QStringLiteral("Only Class A profiles supported by the current ESP32-P4 layout can be deployed.")); + return false; + } + + const QString svId = profile.value(QStringLiteral("svId")).toString(); + const QString dataSet = profile.value(QStringLiteral("dataSetReference")).toString(); + const QString mac = compactMac(profile.value(QStringLiteral("destinationMac")).toString()); + const auto appId = profile.value(QStringLiteral("appId")).toUInt(); + const bool vlanPresent = profile.value(QStringLiteral("vlanPresent")).toBool(); + const auto vlanId = profile.value(QStringLiteral("vlanId")).toUInt(); + const auto pcp = profile.value(QStringLiteral("vlanPriority")).toUInt(); + const auto confRev = profile.value(QStringLiteral("confRev")).toULongLong(); + const auto rate = profile.value(QStringLiteral("publisherRate")).toULongLong(); + const auto modulus = profile.value(QStringLiteral("counterModulus")).toUInt(); + const auto noAsdu = profile.value(QStringLiteral("nofASDU")).toUInt(); + const bool includeDataSet = profile.value(QStringLiteral("includeDataSet")).toBool(); + const bool includeSampleRate = profile.value(QStringLiteral("includeSampleRate")).toBool(); + + const QString idHex = utf8Hex(svId); + const QString dataSetHex = includeDataSet ? utf8Hex(dataSet) : QStringLiteral("-"); + if (svId.isEmpty() || idHex.isEmpty() || idHex.size() > 180 || dataSetHex.size() > 170 || + mac.size() != 12 || appId == 0U || appId > 65535U || vlanId > 4095U || pcp > 7U || + rate == 0U || rate > 65535U || modulus == 0U || modulus > 65535U || noAsdu != 1U) { + setError(QStringLiteral("Compiled profile exceeds the current bounded device bridge.")); + return false; + } + + unsigned flags = 0U; + if (includeDataSet) flags |= 0x1U; + if (includeSampleRate) flags |= 0x2U; + + profileDeploying_ = true; + profileArmed_ = false; + emit profileStateChanged(); + + const QStringList commands{ + QStringLiteral("PROFILE BEGIN"), + QStringLiteral("PROFILE ID %1").arg(idHex), + QStringLiteral("PROFILE DATASET %1").arg(dataSetHex), + QStringLiteral("PROFILE L2 %1 %2 %3 %4 %5") + .arg(appId).arg(mac).arg(vlanPresent ? 1 : 0).arg(vlanId).arg(pcp), + QStringLiteral("PROFILE SV %1 %2 %3 %4 %5") + .arg(confRev).arg(rate).arg(modulus).arg(noAsdu).arg(flags), + QStringLiteral("PROFILE COMMIT"), + QStringLiteral("PROFILE SHOW"), + }; + + for (const auto& command : commands) { + if (!sendCommand(command)) { + profileDeploying_ = false; + emit profileStateChanged(); + return false; + } + } + return true; +} + +void DeviceController::clearLog() { + if (logText_.isEmpty()) return; + logText_.clear(); + emit logTextChanged(); +} + +bool DeviceController::sendCommand(const QString& command) { + if (!serial_.isOpen()) { + setError(QStringLiteral("Device is not connected.")); + return false; + } + const QByteArray bytes = command.toUtf8() + '\n'; + if (serial_.write(bytes) < 0) { + setError(serial_.errorString()); + return false; + } + appendLog(QStringLiteral("→"), command); + return true; +} + +void DeviceController::setRunning(const bool value) { + if (running_ == value) return; + running_ = value; + emit runningChanged(); +} + +void DeviceController::setError(const QString& message) { + if (message.isEmpty() || lastError_ == message) return; + lastError_ = message; + appendLog(QStringLiteral("!"), message); + emit lastErrorChanged(); + emit deviceMessage(message); +} + +void DeviceController::appendLog(const QString& direction, const QString& line) { + const QString stamp = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss")); + logText_.append(QStringLiteral("[%1] %2 %3\n").arg(stamp, direction, line)); + constexpr qsizetype kMaxChars = 60000; + constexpr qsizetype kKeepChars = 48000; + if (logText_.size() > kMaxChars) logText_ = logText_.right(kKeepChars); + emit logTextChanged(); +} + +void DeviceController::processLine(const QString& rawLine) { + const QString line = cleanLine(rawLine); + if (line.isEmpty()) return; + appendLog(QStringLiteral("←"), line); + + if (line.contains(QStringLiteral("START accepted"), Qt::CaseInsensitive)) setRunning(true); + if (line.contains(QStringLiteral("STOP accepted"), Qt::CaseInsensitive)) setRunning(false); + + auto match = kStateExpression.match(line); + if (match.hasMatch()) { + setRunning(match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0); + signalGeneration_ = match.captured(2); + emit telemetryChanged(); + } + + match = kTimingExpression.match(line); + if (match.hasMatch()) { + fps_ = match.captured(2); + txFailures_ = match.captured(4); + missed_ = match.captured(5); + signalGeneration_ = match.captured(6); + emit telemetryChanged(); + } + + match = kSignalGenerationExpression.match(line); + if (match.hasMatch()) { + signalGeneration_ = match.captured(1); + emit telemetryChanged(); + } + + match = kProfileCommittedExpression.match(line); + if (match.hasMatch()) { + profileGeneration_ = match.captured(1); + profileDeploying_ = false; + profileArmed_ = true; + emit profileStateChanged(); + emit deviceMessage(QStringLiteral("SCL profile deployed and armed.")); + } + + match = kProfileArmedExpression.match(line); + if (match.hasMatch()) { + profileGeneration_ = match.captured(1); + profileDeploying_ = false; + profileArmed_ = true; + emit profileStateChanged(); + } + + if (line.contains(QStringLiteral("PROFILE commit rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PROFILE rejected"), Qt::CaseInsensitive)) { + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + setError(QStringLiteral("Device rejected the profile.")); + } +} + +void DeviceController::resetTelemetry() { + fps_ = QStringLiteral("—"); + missed_ = QStringLiteral("—"); + txFailures_ = QStringLiteral("—"); + signalGeneration_ = QStringLiteral("—"); + emit telemetryChanged(); +} + +QString DeviceController::cleanLine(const QString& rawLine) { + QString line = rawLine; + line.remove(kAnsiExpression); + return line.trimmed(); +} + +QString DeviceController::utf8Hex(const QString& text) { + return QString::fromLatin1(text.toUtf8().toHex()); +} + +QString DeviceController::compactMac(const QString& text) { + QString out; + out.reserve(12); + for (const auto ch : text) { + if (ch.isDigit() || (ch.toUpper() >= QLatin1Char('A') && ch.toUpper() <= QLatin1Char('F'))) { + out.append(ch.toUpper()); + } + } + return out; +} From 720d1afd9cd57df30f6c181520454dd77ce87f9f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:06:21 +0700 Subject: [PATCH 11/24] Use shared ESP32 profile gate in Qt SCL model --- apps/arstack_studio/src/SclProfileModel.cpp | 101 +++++++++----------- 1 file changed, 46 insertions(+), 55 deletions(-) diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index ce908e6d9..f20cb03ed 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -2,24 +2,25 @@ #include "SclProfileModel.hpp" +#include "ariec61850/sampled_values/esp32p4_profile_support.hpp" #include "ariec61850/scl/parser.hpp" -#include #include #include #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); @@ -32,18 +33,13 @@ QStringList qstrings(const std::vector& values) { return out; } -std::string lowerCopy(std::string text) { - std::transform(text.begin(), text.end(), text.begin(), [](const unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - return text; -} - 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()); + bytes.push_back(QStringLiteral("%1") + .arg(static_cast(byte), 2, 16, QLatin1Char('0')) + .toUpper()); } return bytes.join(QLatin1Char(':')); } @@ -64,32 +60,10 @@ QString counterPolicyText(const SvSampleCounterPolicy policy) { } } -// Mirrors the currently proven ESP32-P4 acceptance boundary used by the -// existing profile-inspection tool. The Qt PR does not enable live deployment -// yet; before that gate is enabled this policy should be extracted into one -// shared core helper so browser/tool/Qt cannot drift. -bool currentDeviceLayoutSupported(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 = 0; 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 || lowerCopy(channel.basic_type) != "int32") return false; - } else if (!channel.is_quality) { - return false; - } - } - return true; +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 @@ -122,11 +96,15 @@ QVariant SclProfileModel::data(const QModelIndex& index, const int role) const { 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{}; + 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{}; + return p.sample_counter_modulus.has_value() + ? QVariant::fromValue(static_cast(*p.sample_counter_modulus)) + : QVariant{}; default: return {}; } } @@ -157,7 +135,7 @@ QString SclProfileModel::sourceName() const { QString SclProfileModel::documentStatus() const { if (!document_.has_value()) return QStringLiteral("No engineering file loaded"); return QStringLiteral("%1 resolved SV stream%2") - .arg(rows_.size()) + .arg(static_cast(rows_.size())) .arg(rows_.size() == 1U ? QString{} : QStringLiteral("s")); } @@ -184,7 +162,9 @@ QVariantMap SclProfileModel::selectedProfile() const { 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()); + for (auto it = profileMap.cbegin(); it != profileMap.cend(); ++it) { + out.insert(it.key(), it.value()); + } } return out; } @@ -198,7 +178,8 @@ bool SclProfileModel::loadFile(const QUrl& fileUrl) { } try { - auto loaded = ar::iec61850::scl::SclParser{}.load(std::filesystem::path{localPath.toStdWString()}); + auto loaded = ar::iec61850::scl::SclParser{}.load( + std::filesystem::path{localPath.toStdWString()}); beginResetModel(); document_ = std::move(loaded); confirmedCounterModulus_.reset(); @@ -224,9 +205,11 @@ void SclProfileModel::clear() { confirmedCounterModulus_.reset(); rows_.clear(); selectedIndex_ = -1; + fatalError_.clear(); endResetModel(); emit selectedIndexChanged(); emit selectedProfileChanged(); + emit fatalErrorChanged(); emit sourceChanged(); } @@ -277,20 +260,18 @@ void SclProfileModel::rebuildRows() { } 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"); - if (currentDeviceLayoutSupported(profile)) { - row.deviceSupport = QStringLiteral("ready"); - } else if (row.compatibilityClass == QStringLiteral("B")) { - row.deviceSupport = QStringLiteral("needs-counter-confirmation"); - } else { - row.deviceSupport = QStringLiteral("unsupported-layout"); - } + 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); + selectedIndex_ = rows_.empty() + ? -1 + : std::clamp(previousSelection, 0, static_cast(rows_.size()) - 1); if (selectedIndex_ < 0 && !rows_.empty()) selectedIndex_ = 0; endResetModel(); @@ -306,18 +287,28 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co 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("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("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("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; } From dada7456bfca925d86f58382813054c8b94f4e7a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:06:42 +0700 Subject: [PATCH 12/24] Link native Qt serial transport --- apps/arstack_studio/CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index a1d25e3f0..7b811e9ea 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -7,7 +7,7 @@ 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) +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 @@ -21,6 +21,8 @@ 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 @@ -36,6 +38,7 @@ target_link_libraries(arstack_studio PRIVATE Qt6::Qml Qt6::Quick Qt6::QuickControls2 + Qt6::SerialPort ARIEC61850::core ) From 681d8f3d4afc93917ec5c82bf3fd8166b90b981e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:07:01 +0700 Subject: [PATCH 13/24] Expose native serial controller to QML --- apps/arstack_studio/src/main.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 1b1be42e9..d79ea73fd 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "DeviceController.hpp" #include "SclProfileModel.hpp" #include @@ -13,6 +14,7 @@ int main(int argc, char* argv[]) { QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0")); qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); + qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); QQmlApplicationEngine engine; QObject::connect( From f581827ae92cfec2e07a9bec59927af6474fd0a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:09:35 +0700 Subject: [PATCH 14/24] Wire latest SMV profile and live controls into Qt UX --- apps/arstack_studio/qml/Main.qml | 328 +++++++++++++++++++++++++------ 1 file changed, 267 insertions(+), 61 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c3f91ae66..5e936b2aa 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -37,30 +37,72 @@ ApplicationWindow { property string uiFont: "Inter" property string monoFont: "Cascadia Mono" property bool phaseLink: false + property bool liveApply: true + property bool profileDirty: false + property real currentScale: 1000.0 + property real voltageScale: 100.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 int activeQuality: 0 + property string transientMessage: "" + + readonly property bool selectedProfileDeployable: + sclProfiles.selectedProfile.compatibilityClass === "A" && + sclProfiles.selectedProfile.deviceSupport === "ready" + readonly property bool canDeploy: + device.connected && !device.running && selectedProfileDeployable && !device.profileDeploying + readonly property bool canStart: + device.connected && !device.running && (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) SclProfileModel { id: sclProfiles } + DeviceController { id: device } + + Connections { + target: device + function onProfileStateChanged() { + if (device.profileArmed && !device.profileDeploying) root.profileDirty = false + } + function onDeviceMessage(message) { + root.transientMessage = message + messageTimer.restart() + } + } + Timer { id: messageTimer; interval: 2800; onTriggered: root.transientMessage = "" } ListModel { id: currentModel - ListElement { signalId: "Ia"; magnitude: 1.000; phase: 0.0; enabled: true; traceColor: "#ff6673" } - ListElement { signalId: "Ib"; magnitude: 1.000; phase: -120.0; enabled: true; traceColor: "#e6b552" } - ListElement { signalId: "Ic"; magnitude: 1.000; phase: 120.0; enabled: true; traceColor: "#59a7ff" } - ListElement { signalId: "In"; magnitude: 0.000; phase: 0.0; enabled: true; traceColor: "#9a88df" } + 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; traceColor: "#ff6673" } - ListElement { signalId: "Ub"; magnitude: 57.740; phase: -120.0; enabled: true; traceColor: "#e6b552" } - ListElement { signalId: "Uc"; magnitude: 57.740; phase: 120.0; enabled: true; traceColor: "#59a7ff" } - ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; traceColor: "#9a88df" } + ListElement { signalId: "Ua"; magnitude: 57.740; phase: 0.0; enabled: true; quality: 0; traceColor: "#ff6673" } + ListElement { signalId: "Ub"; magnitude: 57.740; phase: -120.0; enabled: true; quality: 0; traceColor: "#e6b552" } + ListElement { signalId: "Uc"; magnitude: 57.740; phase: 120.0; enabled: true; quality: 0; traceColor: "#59a7ff" } + ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; quality: 0; traceColor: "#9a88df" } } function groupModel(group) { return group === 0 ? currentModel : voltageModel } function groupRepeater(group) { return group === 0 ? currentRep : voltageRep } + function scalingForGroup(group) { return group === 0 ? currentScale : voltageScale } + function selectSignal(group, row) { + var s = groupModel(group).get(row) + activeGroup = group + activeRow = row + activeSignal = s.signalId + activeUnit = group === 0 ? "A" : "V" + activeMagnitude = s.magnitude + activePhase = s.phase + activeQuality = s.quality + phasor.requestPaint() + waveform.requestPaint() + } function focusCell(group, row, column) { var g = group var r = row @@ -84,6 +126,15 @@ ApplicationWindow { if (v <= -180) v += 360 return v } + function sendSignal(group, row) { + if (!device.connected) return + var s = groupModel(group).get(row) + device.setSignal(s.signalId, s.magnitude, s.phase, s.quality, currentScale, voltageScale) + } + function sendLinkedGroup(group) { + var m = groupModel(group) + for (var i = 0; i < 3; ++i) sendSignal(group, i) + } function editSignal(group, row, field, value) { if (isNaN(value) || (field === "magnitude" && value < 0)) return var m = groupModel(group) @@ -100,23 +151,29 @@ ApplicationWindow { m.setProperty(2, "phase", normalizedAngle(base + 120)) } } - var s = m.get(row) - activeSignal = s.signalId - activeUnit = group === 0 ? "A" : "V" - activeMagnitude = s.magnitude - activePhase = s.phase - phasor.requestPaint() - waveform.requestPaint() + selectSignal(group, row) + if (liveApply && device.connected) { + if (phaseLink && row < 3) sendLinkedGroup(group) + else sendSignal(group, row) + } + } + function applyAllSignals() { + for (var group = 0; group < 2; ++group) + for (var row = 0; row < 4; ++row) sendSignal(group, row) + device.setFrequency(parseFloat(frequencyField.text)) } 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) } phasor.requestPaint(); waveform.requestPaint() + if (device.connected && liveApply) applyAllSignals() } function zeroAll() { for (var i = 0; i < 4; ++i) { @@ -124,6 +181,13 @@ ApplicationWindow { voltageModel.setProperty(i, "magnitude", 0.0) } phasor.requestPaint(); waveform.requestPaint() + if (device.connected) device.zero() + } + function setActiveQuality(value) { + var m = groupModel(activeGroup) + m.setProperty(activeRow, "quality", value) + activeQuality = value + if (device.connected) device.setQuality(activeSignal, value) } component CalmButton: Button { @@ -169,18 +233,52 @@ ApplicationWindow { id: fileDialog title: "Open IEC 61850 engineering file" nameFilters: ["IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", "All files (*)"] - onAccepted: sclProfiles.loadFile(selectedFile) + onAccepted: { + if (sclProfiles.loadFile(selectedFile)) root.profileDirty = true + } + } + + Dialog { + id: diagnosticsDialog + width: Math.min(root.width * 0.72, 980) + height: Math.min(root.height * 0.72, 620) + anchors.centerIn: Overlay.overlay + modal: true + title: "Device diagnostics" + standardButtons: Dialog.Close + background: Rectangle { color: theme.surface; radius: 10; border.color: theme.line } + contentItem: ColumnLayout { + spacing: 8 + RowLayout { + Layout.fillWidth: true + Quiet { text: device.connected ? device.portName + " · 115200 8N1" : "Device offline" } + Item { Layout.fillWidth: true } + CalmButton { text: "Clear"; onClicked: device.clearLog() } + } + ScrollView { + Layout.fillWidth: true; Layout.fillHeight: true + TextArea { + readOnly: true + text: device.logText + color: theme.textSoft + selectionColor: theme.accent + font.family: root.monoFont + font.pixelSize: 10 + wrapMode: TextEdit.WrapAnywhere + background: Rectangle { color: "#090e14"; radius: 7; border.color: theme.lineSoft } + } + } + } } header: Rectangle { height: 58 color: theme.chrome - border.width: 0 RowLayout { anchors.fill: parent anchors.leftMargin: 18 anchors.rightMargin: 18 - spacing: 16 + spacing: 12 Rectangle { width: 30; height: 30; radius: 7; color: "#101b27"; border.color: "#315071" Label { anchors.centerIn: parent; text: "≋"; color: theme.accent; font.pixelSize: 18 } @@ -191,11 +289,26 @@ ApplicationWindow { Label { text: "SMV Injector"; color: theme.text; font.family: root.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - Label { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "No SCL profile"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 10 } + Label { + text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "Development profile" + color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 10 + } Rectangle { width: 1; height: 20; color: theme.line } - Rectangle { width: 7; height: 7; radius: 4; color: theme.muted2 } - Quiet { text: "Device offline" } - CalmButton { text: "Connect"; enabled: false; ToolTip.visible: hovered; ToolTip.text: "Native serial control follows in the next tranche" } + ComboBox { + id: portCombo + implicitWidth: 116 + model: device.ports + enabled: !device.connected + font.family: root.monoFont + font.pixelSize: 10 + onPressedChanged: if (pressed) device.refreshPorts() + } + Rectangle { width: 7; height: 7; radius: 4; color: device.connected ? theme.green : theme.muted2 } + Quiet { text: device.connected ? device.portName : "Device offline" } + CalmButton { + text: device.connected ? "Disconnect" : "Connect" + onClicked: device.connected ? device.disconnectPort() : device.connectPort(portCombo.currentText) + } } } @@ -208,18 +321,42 @@ ApplicationWindow { anchors.fill: parent anchors.leftMargin: 18 anchors.rightMargin: 18 - spacing: 14 - Rectangle { width: 8; height: 8; radius: 4; color: theme.muted2 } + spacing: 12 + Rectangle { width: 8; height: 8; radius: 4; color: device.running ? theme.green : device.connected ? theme.accent : theme.muted2 } ColumnLayout { spacing: 1 - Label { text: "DEVICE OFFLINE"; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 9; font.weight: Font.Bold } - Quiet { text: "Setpoints editable · PROFILE transport intentionally not armed yet" } + Label { + text: device.running ? "INJECTING" : device.connected ? (profileDirty ? "PROFILE CHANGED" : "READY") : "DEVICE OFFLINE" + color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 9; font.weight: Font.Bold + } + Quiet { + text: device.running ? "ESP32-P4 realtime publisher active" : + device.connected && profileDirty ? "Validate and deploy the selected SCL profile before Start" : + device.connected ? "Setpoints editable · live control armed" : "Setpoints remain editable offline" + } } Item { Layout.fillWidth: true } Label { text: activeSignal + " " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠" + activePhase.toFixed(2) + "°"; color: theme.muted; font.family: root.monoFont; font.pixelSize: 9 } - CalmButton { text: "Deploy profile"; enabled: false } - CalmButton { text: "Stop"; enabled: false } - CalmButton { text: "▶ Start live"; enabled: false; implicitWidth: 120 } + CalmButton { text: "Diagnostics"; onClicked: diagnosticsDialog.open() } + CalmButton { text: "Apply all"; enabled: device.connected; onClicked: root.applyAllSignals() } + CalmButton { + text: device.profileDeploying ? "Deploying…" : "Deploy profile" + enabled: root.canDeploy + onClicked: { + if (device.deployProfile(sclProfiles.selectedProfile)) root.profileDirty = true + } + } + CalmButton { text: "Stop"; enabled: device.connected && device.running; onClicked: device.stop() } + Button { + id: startButton + text: "▶ Start live" + enabled: root.canStart + implicitWidth: 120; implicitHeight: 36 + font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold + contentItem: Text { text: startButton.text; color: startButton.enabled ? "#d6f4e6" : theme.muted2; font: startButton.font; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { radius: 7; color: startButton.enabled ? "#194d38" : "#121820"; border.color: startButton.enabled ? "#347a59" : theme.lineSoft } + onClicked: device.start() + } } } @@ -229,7 +366,7 @@ ApplicationWindow { spacing: 12 Surface { - Layout.preferredWidth: 238 + Layout.preferredWidth: 246 Layout.fillHeight: true ColumnLayout { anchors.fill: parent @@ -241,7 +378,7 @@ ApplicationWindow { Label { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Compiled SCL") : "Development profile"; color: theme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } Quiet { Layout.fillWidth: true; elide: Text.ElideMiddle; text: sclProfiles.sourceName.length ? sclProfiles.sourceName : "SCL / CID / SCD / IID" } } - CalmButton { Layout.fillWidth: true; text: "Open SCL / CID"; onClicked: fileDialog.open() } + CalmButton { Layout.fillWidth: true; text: "Open SCL / CID"; enabled: !device.running; onClicked: fileDialog.open() } ColumnLayout { visible: sclProfiles.hasProfiles @@ -253,7 +390,8 @@ ApplicationWindow { model: sclProfiles textRole: "control" currentIndex: sclProfiles.selectedIndex - onActivated: sclProfiles.selectStream(currentIndex) + enabled: !device.running + onActivated: { sclProfiles.selectStream(currentIndex); root.profileDirty = true } font.family: root.uiFont font.pixelSize: 11 } @@ -264,13 +402,24 @@ ApplicationWindow { Quiet { text: "Class" } Label { text: "CLASS " + (sclProfiles.selectedProfile.compatibilityClass || "—"); color: sclProfiles.selectedProfile.compatibilityClass === "A" ? theme.green : sclProfiles.selectedProfile.compatibilityClass === "B" ? theme.amber : theme.red; font.family: root.uiFont; font.pixelSize: 10; font.weight: Font.Bold } Quiet { text: "Support" } - Label { text: sclProfiles.selectedProfile.deviceSupport || "—"; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 10; elide: Text.ElideRight; Layout.fillWidth: true } + Label { text: sclProfiles.selectedProfile.deviceSupport || "—"; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 9; elide: Text.ElideRight; Layout.fillWidth: true } Quiet { text: "APPID" } Label { text: sclProfiles.selectedProfile.appIdHex || "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } Quiet { text: "Rate" } Label { text: sclProfiles.selectedProfile.publisherRate ? sclProfiles.selectedProfile.publisherRate + "/s" : "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } Quiet { text: "VLAN" } - Label { text: sclProfiles.selectedProfile.vlanPresent ? "P" + sclProfiles.selectedProfile.vlanPriority + " · " + sclProfiles.selectedProfile.vlanId : "untagged"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Label { text: sclProfiles.selectedProfile.vlanPresent ? "P" + sclProfiles.selectedProfile.vlanPriority + " · VID " + sclProfiles.selectedProfile.vlanId : "untagged"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Quiet { text: "Payload" } + Label { text: sclProfiles.selectedProfile.payloadBytes ? sclProfiles.selectedProfile.payloadBytes + " B" : "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + } + Quiet { + Layout.fillWidth: true + visible: (sclProfiles.selectedProfile.warnings || []).length > 0 || (sclProfiles.selectedProfile.errors || []).length > 0 + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + color: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? theme.red : theme.amber + text: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? sclProfiles.selectedProfile.errors[0] : sclProfiles.selectedProfile.warnings[0] } } @@ -283,7 +432,24 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true TextField { id: counterField; Layout.fillWidth: true; text: sclProfiles.selectedProfile.counterModulus || ""; placeholderText: "modulus"; font.family: root.monoFont; validator: IntValidator { bottom: 1; top: 65535 } } - CalmButton { text: "Confirm"; onClicked: sclProfiles.confirmCounterModulus(parseInt(counterField.text)) } + CalmButton { text: "Confirm"; onClicked: { if (sclProfiles.confirmCounterModulus(parseInt(counterField.text))) root.profileDirty = true } } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 6 + Kicker { text: "ENGINEERING SCALING" } + Quiet { Layout.fillWidth: true; wrapMode: Text.WordWrap; text: "Explicit test conversion; generic SCL is not assumed to define physical scaling." } + RowLayout { + Quiet { text: "Current"; Layout.preferredWidth: 48 } + TextField { Layout.fillWidth: true; text: root.currentScale.toString(); font.family: root.monoFont; validator: DoubleValidator { bottom: 0.000001 }; onEditingFinished: { var v=parseFloat(text); if(v>0) root.currentScale=v } } + Quiet { text: "ct/A" } + } + RowLayout { + Quiet { text: "Voltage"; Layout.preferredWidth: 48 } + TextField { Layout.fillWidth: true; text: root.voltageScale.toString(); font.family: root.monoFont; validator: DoubleValidator { bottom: 0.000001 }; onEditingFinished: { var v=parseFloat(text); if(v>0) root.voltageScale=v } } + Quiet { text: "ct/V" } } } @@ -293,14 +459,12 @@ ApplicationWindow { CalmButton { Layout.fillWidth: true; text: "Zero output"; onClicked: root.zeroAll() } Item { Layout.fillHeight: true } Kicker { text: "RUNTIME" } - Repeater { - model: ["Rate", "Missed", "TX fail", "Generation"] - RowLayout { - Layout.fillWidth: true - Quiet { text: modelData } - Item { Layout.fillWidth: true } - Label { text: "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } - } + GridLayout { + Layout.fillWidth: true; columns: 2; columnSpacing: 8; rowSpacing: 5 + Quiet { text: "Rate" } Label { text: device.fps; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Quiet { text: "Missed" } Label { text: device.missed; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Quiet { text: "TX fail" } Label { text: device.txFailures; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Quiet { text: "Generation" } Label { text: device.signalGeneration; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } } } } @@ -308,7 +472,7 @@ ApplicationWindow { Surface { Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumWidth: 570 + Layout.minimumWidth: 560 ColumnLayout { anchors.fill: parent anchors.margins: 18 @@ -321,7 +485,7 @@ ApplicationWindow { Label { text: "Manual Injection"; color: theme.text; font.family: root.uiFont; font.pixelSize: 23; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - Quiet { text: "STOPPED" } + Label { text: device.running ? "● INJECTING" : "STOPPED"; color: device.running ? theme.green : theme.muted; font.family: root.monoFont; font.pixelSize: 9; font.weight: Font.Bold } } Rectangle { Layout.fillWidth: true @@ -336,16 +500,22 @@ ApplicationWindow { spacing: 0 Kicker { text: "FREQUENCY" } RowLayout { - TextField { id: frequencyField; text: "50.000"; implicitWidth: 82; selectByMouse: true; color: theme.text; font.family: root.monoFont; font.pixelSize: 16; background: Item {}; onTextEdited: waveform.requestPaint() } + TextField { + id: frequencyField + text: "50.000"; implicitWidth: 82; selectByMouse: true + color: theme.text; font.family: root.monoFont; font.pixelSize: 16; background: Item {} + onTextEdited: waveform.requestPaint() + onEditingFinished: if (root.liveApply && device.connected) device.setFrequency(parseFloat(text)) + } Quiet { text: "Hz" } } } - CalmButton { text: "50"; onClicked: { frequencyField.text = "50.000"; waveform.requestPaint() } } - CalmButton { text: "60"; onClicked: { frequencyField.text = "60.000"; waveform.requestPaint() } } + CalmButton { text: "50"; onClicked: { frequencyField.text="50.000"; waveform.requestPaint(); if(device.connected&&root.liveApply)device.setFrequency(50) } } + CalmButton { text: "60"; onClicked: { frequencyField.text="60.000"; waveform.requestPaint(); if(device.connected&&root.liveApply)device.setFrequency(60) } } Rectangle { width: 1; height: 28; color: theme.lineSoft } - CheckBox { checked: root.phaseLink; text: "3-phase link"; onToggled: root.phaseLink = checked; font.family: root.uiFont; font.pixelSize: 11 } + CheckBox { checked: root.phaseLink; text: "3-phase link"; onToggled: root.phaseLink=checked; font.family: root.uiFont; font.pixelSize: 11 } Item { Layout.fillWidth: true } - CheckBox { checked: true; enabled: false; text: "Live apply"; font.family: root.uiFont; font.pixelSize: 11 } + CheckBox { checked: root.liveApply; text: "Live apply"; onToggled: root.liveApply=checked; font.family: root.uiFont; font.pixelSize: 11 } } } @@ -353,7 +523,6 @@ ApplicationWindow { Layout.fillWidth: true Layout.fillHeight: true spacing: 12 - Rectangle { Layout.fillWidth: true; Layout.fillHeight: true color: theme.surface2; radius: 9; border.color: theme.lineSoft @@ -364,12 +533,11 @@ ApplicationWindow { Repeater { id: currentRep model: currentModel - SignalRow { groupIndex: 0; rowIndex: index; sourceModel: currentModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; phaseColor: traceColor } + SignalRow { groupIndex: 0; rowIndex: index; sourceModel: currentModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; signalQuality: quality; phaseColor: traceColor } } Item { Layout.fillHeight: true } } } - Rectangle { Layout.fillWidth: true; Layout.fillHeight: true color: theme.surface2; radius: 9; border.color: theme.lineSoft @@ -380,7 +548,7 @@ ApplicationWindow { Repeater { id: voltageRep model: voltageModel - SignalRow { groupIndex: 1; rowIndex: index; sourceModel: voltageModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; phaseColor: traceColor } + SignalRow { groupIndex: 1; rowIndex: index; sourceModel: voltageModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; signalQuality: quality; phaseColor: traceColor } } Item { Layout.fillHeight: true } } @@ -392,9 +560,20 @@ ApplicationWindow { spacing: 7 Rectangle { width: 7; height: 7; radius: 4; color: theme.accent } Label { text: activeSignal; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 10; font.weight: Font.Bold } - Quiet { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } + Quiet { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } Item { Layout.fillWidth: true } - Quiet { text: "↑↓ channel ←→ field Enter next" } + Quiet { text: "Quality" } + TextField { + id: qualityField + implicitWidth: 98 + text: "0x" + Number(root.activeQuality >>> 0).toString(16).padStart(8, "0").toUpperCase() + color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 + onEditingFinished: { + var t=text.trim(); var q=t.toLowerCase().startsWith("0x") ? parseInt(t.substring(2),16) : parseInt(t,10) + if(!isNaN(q) && q>=0) root.setActiveQuality(q >>> 0) + } + } + Quiet { text: "↑↓ channel ←→ field Enter next" } } } } @@ -447,6 +626,24 @@ ApplicationWindow { } } + Rectangle { + visible: root.transientMessage.length > 0 || device.lastError.length > 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 78 + radius: 8 + color: "#18212b" + border.color: device.lastError.length > 0 ? "#70404a" : "#36536f" + implicitWidth: messageLabel.implicitWidth + 28 + implicitHeight: 38 + Label { + id: messageLabel + anchors.centerIn: parent + text: root.transientMessage.length ? root.transientMessage : device.lastError + color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 10 + } + } + component MatrixHeader: RowLayout { required property string titleText required property string symbolText @@ -475,6 +672,7 @@ ApplicationWindow { required property real mag required property real angle required property bool isEnabled + required property int signalQuality required property color phaseColor property alias magEditor: magField property alias phaseEditor: phaseField @@ -482,25 +680,33 @@ ApplicationWindow { color: (magField.activeFocus || phaseField.activeFocus) ? "#141d27" : "transparent" RowLayout { anchors.fill: parent; anchors.leftMargin: 10; anchors.rightMargin: 10; spacing: 8 - CheckBox { Layout.preferredWidth: 28; checked: row.isEnabled; onToggled: { row.sourceModel.setProperty(row.rowIndex,"enabled",checked); phasor.requestPaint(); waveform.requestPaint() } } + CheckBox { + Layout.preferredWidth: 28; checked: row.isEnabled + onToggled: { + row.sourceModel.setProperty(row.rowIndex,"enabled",checked) + root.selectSignal(row.groupIndex,row.rowIndex) + phasor.requestPaint(); waveform.requestPaint() + if(device.connected) device.setEnabled(row.sid,checked) + } + } RowLayout { Layout.preferredWidth: 34; spacing: 5; Rectangle { width: 6; height: 6; radius: 3; color: row.phaseColor } Label { text: row.sid; color: theme.text; font.family: root.monoFont; font.pixelSize: 11; font.weight: Font.Bold } } TextField { id: magField Layout.fillWidth: true; text: row.mag.toFixed(3); selectByMouse: true; horizontalAlignment: Text.AlignRight color: theme.text; font.family: root.monoFont; font.pixelSize: 13; font.weight: Font.DemiBold background: Rectangle { radius: 6; color: magField.activeFocus ? "#0c141d" : "#111820"; border.width: magField.activeFocus ? 1 : 0; border.color: theme.accent } - onActiveFocusChanged: if(activeFocus){root.activeSignal=row.sid;root.activeUnit=row.groupIndex===0?"A":"V";root.activeMagnitude=row.mag;root.activePhase=row.angle;selectAll();phasor.requestPaint();waveform.requestPaint()} + onActiveFocusChanged: if(activeFocus){root.selectSignal(row.groupIndex,row.rowIndex);selectAll()} onEditingFinished: root.editSignal(row.groupIndex,row.rowIndex,"magnitude",parseFloat(text)) - Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,0,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.focusCell(row.groupIndex,row.rowIndex+1,0);e.accepted=true}} + Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,0,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.editSignal(row.groupIndex,row.rowIndex,"magnitude",parseFloat(text));root.focusCell(row.groupIndex,row.rowIndex+1,0);e.accepted=true}} } TextField { id: phaseField Layout.preferredWidth: 108; text: row.angle.toFixed(2); selectByMouse: true; horizontalAlignment: Text.AlignRight color: theme.text; font.family: root.monoFont; font.pixelSize: 13; font.weight: Font.DemiBold background: Rectangle { radius: 6; color: phaseField.activeFocus ? "#0c141d" : "#111820"; border.width: phaseField.activeFocus ? 1 : 0; border.color: theme.accent } - onActiveFocusChanged: if(activeFocus){root.activeSignal=row.sid;root.activeUnit=row.groupIndex===0?"A":"V";root.activeMagnitude=row.mag;root.activePhase=row.angle;selectAll();phasor.requestPaint();waveform.requestPaint()} + onActiveFocusChanged: if(activeFocus){root.selectSignal(row.groupIndex,row.rowIndex);selectAll()} onEditingFinished: root.editSignal(row.groupIndex,row.rowIndex,"phase",parseFloat(text)) - Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,1,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.focusCell(row.groupIndex,row.rowIndex+1,1);e.accepted=true}} + Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,1,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.editSignal(row.groupIndex,row.rowIndex,"phase",parseFloat(text));root.focusCell(row.groupIndex,row.rowIndex+1,1);e.accepted=true}} } } Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: theme.lineSoft } From ca6b9e96d3604b634cf9749552bdb4b92e0d1ee7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:11:49 +0700 Subject: [PATCH 15/24] Add native Qt Studio build and launch smoke CI --- .github/workflows/arstack-studio-qt.yml | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/arstack-studio-qt.yml diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml new file mode 100644 index 000000000..080ac3664 --- /dev/null +++ b/.github/workflows/arstack-studio-qt.yml @@ -0,0 +1,62 @@ +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 + run: >- + cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build + run: cmake --build build-arstack-studio --target arstack_studio --parallel 2 + + - 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.log 2>&1 + status=$? + set -e + cat /tmp/arstack-studio.log + if [ "$status" -ne 124 ]; then + echo "ARStack Studio exited before smoke timeout (status=$status)." + exit "$status" + fi From 22ab07861a39a425f2da8676f67cfd0db1a7dc75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:12:58 +0700 Subject: [PATCH 16/24] Reuse shared ESP32 profile support gate in inspector --- tools/smv_profile_inspect.cpp | 68 ++++++++++------------------------- 1 file changed, 18 insertions(+), 50 deletions(-) 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; From 24d3273fd47bc7362b653691a96859c7af501748 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:19:55 +0700 Subject: [PATCH 17/24] Retain native Qt build logs on CI failures --- .github/workflows/arstack-studio-qt.yml | 32 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 080ac3664..0f0633906 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -39,24 +39,42 @@ jobs: uses: actions/checkout@v4 - name: Configure - run: >- - cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja - -DCMAKE_BUILD_TYPE=Release - -DARIEC61850_WARNINGS_AS_ERRORS=ON + 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 - run: cmake --build build-arstack-studio --target arstack_studio --parallel 2 + 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.log 2>&1 + ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-launch.log 2>&1 status=$? set -e - cat /tmp/arstack-studio.log + 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 From ceca8cdf08cab7b728802ca54af4ac87d210168d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:27:03 +0700 Subject: [PATCH 18/24] Fix Qt QML syntax and streamline native injector workspace --- apps/arstack_studio/qml/Main.qml | 1053 ++++++++++++++++++++++++------ 1 file changed, 861 insertions(+), 192 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 5e936b2aa..c24d0e001 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -13,6 +13,7 @@ ApplicationWindow { minimumHeight: 720 visible: true title: "ARStack Studio · SMV Injector" + color: theme.bg QtObject { id: theme @@ -33,7 +34,6 @@ ApplicationWindow { readonly property color red: "#ff727f" } - color: theme.bg property string uiFont: "Inter" property string monoFont: "Cascadia Mono" property bool phaseLink: false @@ -47,7 +47,7 @@ ApplicationWindow { property string activeUnit: "A" property real activeMagnitude: 1.0 property real activePhase: 0.0 - property int activeQuality: 0 + property real activeQuality: 0 property string transientMessage: "" readonly property bool selectedProfileDeployable: @@ -56,7 +56,8 @@ ApplicationWindow { readonly property bool canDeploy: device.connected && !device.running && selectedProfileDeployable && !device.profileDeploying readonly property bool canStart: - device.connected && !device.running && (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) + device.connected && !device.running && + (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) SclProfileModel { id: sclProfiles } DeviceController { id: device } @@ -64,14 +65,20 @@ ApplicationWindow { Connections { target: device function onProfileStateChanged() { - if (device.profileArmed && !device.profileDeploying) root.profileDirty = false + if (device.profileArmed && !device.profileDeploying) + root.profileDirty = false } function onDeviceMessage(message) { root.transientMessage = message messageTimer.restart() } } - Timer { id: messageTimer; interval: 2800; onTriggered: root.transientMessage = "" } + + Timer { + id: messageTimer + interval: 2800 + onTriggered: root.transientMessage = "" + } ListModel { id: currentModel @@ -80,6 +87,7 @@ ApplicationWindow { 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" } @@ -88,80 +96,124 @@ ApplicationWindow { ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; quality: 0; traceColor: "#9a88df" } } - function groupModel(group) { return group === 0 ? currentModel : voltageModel } - function groupRepeater(group) { return group === 0 ? currentRep : voltageRep } - function scalingForGroup(group) { return group === 0 ? currentScale : voltageScale } + function groupModel(group) { + return group === 0 ? currentModel : voltageModel + } + + function groupRepeater(group) { + return group === 0 ? currentRepeater : voltageRepeater + } + function selectSignal(group, row) { - var s = groupModel(group).get(row) + var signal = groupModel(group).get(row) activeGroup = group activeRow = row - activeSignal = s.signalId + activeSignal = signal.signalId activeUnit = group === 0 ? "A" : "V" - activeMagnitude = s.magnitude - activePhase = s.phase - activeQuality = s.quality + activeMagnitude = signal.magnitude + activePhase = signal.phase + activeQuality = Number(signal.quality) phasor.requestPaint() waveform.requestPaint() } + function focusCell(group, row, column) { - var g = group - var r = row - if (r < 0) { g = (g + 1) % 2; r = 3 } - if (r > 3) { g = (g + 1) % 2; r = 0 } - var item = groupRepeater(g).itemAt(r) - if (!item) return - var field = column === 0 ? item.magEditor : item.phaseEditor - field.forceActiveFocus() - field.selectAll() + var nextGroup = group + var nextRow = row + if (nextRow < 0) { + nextGroup = (nextGroup + 1) % 2 + nextRow = 3 + } + if (nextRow > 3) { + nextGroup = (nextGroup + 1) % 2 + nextRow = 0 + } + var rowItem = groupRepeater(nextGroup).itemAt(nextRow) + if (!rowItem) + return + var editor = column === 0 ? rowItem.magnitudeEditor : rowItem.phaseEditor + editor.forceActiveFocus() + editor.selectAll() } + 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) + 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 normalizedAngle(value) { - var v = value % 360 - if (v > 180) v -= 360 - if (v <= -180) v += 360 - return v + var result = value % 360 + if (result > 180) + result -= 360 + if (result <= -180) + result += 360 + return result } + function sendSignal(group, row) { - if (!device.connected) return - var s = groupModel(group).get(row) - device.setSignal(s.signalId, s.magnitude, s.phase, s.quality, currentScale, voltageScale) + if (!device.connected) + return + var signal = groupModel(group).get(row) + device.setSignal( + signal.signalId, + signal.magnitude, + signal.phase, + Number(signal.quality), + currentScale, + voltageScale) } + function sendLinkedGroup(group) { - var m = groupModel(group) - for (var i = 0; i < 3; ++i) sendSignal(group, i) + for (var i = 0; i < 3; ++i) + sendSignal(group, i) } + function editSignal(group, row, field, value) { - if (isNaN(value) || (field === "magnitude" && value < 0)) return - var m = groupModel(group) - m.setProperty(row, field, value) + if (isNaN(value) || (field === "magnitude" && value < 0)) + return + + var model = groupModel(group) + model.setProperty(row, field, value) + if (phaseLink && row < 3) { if (field === "magnitude") { - for (var i = 0; i < 3; ++i) m.setProperty(i, "magnitude", value) + 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 - m.setProperty(0, "phase", normalizedAngle(base)) - m.setProperty(1, "phase", normalizedAngle(base - 120)) - m.setProperty(2, "phase", normalizedAngle(base + 120)) + 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 (liveApply && device.connected) { - if (phaseLink && row < 3) sendLinkedGroup(group) - else sendSignal(group, row) + if (phaseLink && row < 3) + sendLinkedGroup(group) + else + sendSignal(group, row) } } + function applyAllSignals() { - for (var group = 0; group < 2; ++group) - for (var row = 0; row < 4; ++row) sendSignal(group, row) + for (var group = 0; group < 2; ++group) { + for (var row = 0; row < 4; ++row) + sendSignal(group, row) + } device.setFrequency(parseFloat(frequencyField.text)) } + function balanced() { var angles = [0, -120, 120, 0] for (var i = 0; i < 4; ++i) { @@ -172,44 +224,52 @@ ApplicationWindow { voltageModel.setProperty(i, "phase", angles[i]) voltageModel.setProperty(i, "enabled", true) } - phasor.requestPaint(); waveform.requestPaint() - if (device.connected && liveApply) applyAllSignals() + phasor.requestPaint() + waveform.requestPaint() + if (device.connected && liveApply) + applyAllSignals() } + function zeroAll() { for (var i = 0; i < 4; ++i) { currentModel.setProperty(i, "magnitude", 0.0) voltageModel.setProperty(i, "magnitude", 0.0) } - phasor.requestPaint(); waveform.requestPaint() - if (device.connected) device.zero() + phasor.requestPaint() + waveform.requestPaint() + if (device.connected) + device.zero() } + function setActiveQuality(value) { - var m = groupModel(activeGroup) - m.setProperty(activeRow, "quality", value) - activeQuality = value - if (device.connected) device.setQuality(activeSignal, value) + var unsignedValue = Number(value) >>> 0 + groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) + activeQuality = unsignedValue + if (device.connected) + device.setQuality(activeSignal, unsignedValue) } component CalmButton: Button { - id: control + id: calmButton implicitHeight: 36 font.family: root.uiFont font.pixelSize: 12 font.weight: Font.DemiBold contentItem: Text { - text: control.text - color: control.enabled ? theme.textSoft : theme.muted2 - font: control.font + text: calmButton.text + color: calmButton.enabled ? theme.textSoft : theme.muted2 + font: calmButton.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: 7 - color: control.down ? "#1b2530" : control.hovered ? "#17202a" : theme.raised + color: calmButton.down ? "#1b2530" : calmButton.hovered ? "#17202a" : theme.raised border.width: 1 - border.color: control.hovered ? "#3a4a5b" : theme.line + border.color: calmButton.hovered ? "#3a4a5b" : theme.line } } + component Kicker: Label { color: theme.muted font.family: root.uiFont @@ -217,11 +277,13 @@ ApplicationWindow { font.weight: Font.DemiBold font.letterSpacing: 1.25 } + component Quiet: Label { color: theme.muted font.family: root.uiFont font.pixelSize: 10 } + component Surface: Rectangle { color: theme.surface radius: 10 @@ -232,9 +294,13 @@ ApplicationWindow { FileDialog { id: fileDialog title: "Open IEC 61850 engineering file" - nameFilters: ["IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", "All files (*)"] + nameFilters: [ + "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", + "All files (*)" + ] onAccepted: { - if (sclProfiles.loadFile(selectedFile)) root.profileDirty = true + if (sclProfiles.loadFile(selectedFile)) + root.profileDirty = true } } @@ -246,7 +312,11 @@ ApplicationWindow { modal: true title: "Device diagnostics" standardButtons: Dialog.Close - background: Rectangle { color: theme.surface; radius: 10; border.color: theme.line } + background: Rectangle { + color: theme.surface + radius: 10 + border.color: theme.line + } contentItem: ColumnLayout { spacing: 8 RowLayout { @@ -256,7 +326,8 @@ ApplicationWindow { CalmButton { text: "Clear"; onClicked: device.clearLog() } } ScrollView { - Layout.fillWidth: true; Layout.fillHeight: true + Layout.fillWidth: true + Layout.fillHeight: true TextArea { readOnly: true text: device.logText @@ -265,7 +336,11 @@ ApplicationWindow { font.family: root.monoFont font.pixelSize: 10 wrapMode: TextEdit.WrapAnywhere - background: Rectangle { color: "#090e14"; radius: 7; border.color: theme.lineSoft } + background: Rectangle { + color: "#090e14" + radius: 7 + border.color: theme.lineSoft + } } } } @@ -279,21 +354,53 @@ ApplicationWindow { anchors.leftMargin: 18 anchors.rightMargin: 18 spacing: 12 + Rectangle { - width: 30; height: 30; radius: 7; color: "#101b27"; border.color: "#315071" - Label { anchors.centerIn: parent; text: "≋"; color: theme.accent; font.pixelSize: 18 } + width: 30 + height: 30 + radius: 7 + color: "#101b27" + border.color: "#315071" + Label { + anchors.centerIn: parent + text: "≋" + color: theme.accent + font.pixelSize: 18 + } } + ColumnLayout { spacing: 0 - Label { text: "ARSTACK61850"; color: theme.muted; font.family: root.uiFont; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 1.3 } - Label { text: "SMV Injector"; color: theme.text; font.family: root.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } + Label { + text: "ARSTACK61850" + color: theme.muted + font.family: root.uiFont + font.pixelSize: 8 + font.weight: Font.Bold + font.letterSpacing: 1.3 + } + Label { + text: "SMV Injector" + color: theme.text + font.family: root.uiFont + font.pixelSize: 14 + font.weight: Font.DemiBold + } } + Item { Layout.fillWidth: true } + Label { - text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "Development profile" - color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 10 + text: sclProfiles.hasProfiles ? + (sclProfiles.selectedProfile.svId || "Resolved SV") : + "Development profile" + color: theme.textSoft + font.family: root.monoFont + font.pixelSize: 10 } + Rectangle { width: 1; height: 20; color: theme.line } + ComboBox { id: portCombo implicitWidth: 116 @@ -301,13 +408,29 @@ ApplicationWindow { enabled: !device.connected font.family: root.monoFont font.pixelSize: 10 - onPressedChanged: if (pressed) device.refreshPorts() + onPressedChanged: { + if (pressed) + device.refreshPorts() + } + } + + Rectangle { + width: 7 + height: 7 + radius: 4 + color: device.connected ? theme.green : theme.muted2 } - Rectangle { width: 7; height: 7; radius: 4; color: device.connected ? theme.green : theme.muted2 } + Quiet { text: device.connected ? device.portName : "Device offline" } + CalmButton { text: device.connected ? "Disconnect" : "Connect" - onClicked: device.connected ? device.disconnectPort() : device.connectPort(portCombo.currentText) + onClicked: { + if (device.connected) + device.disconnectPort() + else + device.connectPort(portCombo.currentText) + } } } } @@ -322,39 +445,83 @@ ApplicationWindow { anchors.leftMargin: 18 anchors.rightMargin: 18 spacing: 12 - Rectangle { width: 8; height: 8; radius: 4; color: device.running ? theme.green : device.connected ? theme.accent : theme.muted2 } + + Rectangle { + width: 8 + height: 8 + radius: 4 + color: device.running ? theme.green : device.connected ? theme.accent : theme.muted2 + } + ColumnLayout { spacing: 1 Label { - text: device.running ? "INJECTING" : device.connected ? (profileDirty ? "PROFILE CHANGED" : "READY") : "DEVICE OFFLINE" - color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 9; font.weight: Font.Bold + text: device.running ? "INJECTING" : + device.connected ? (profileDirty ? "PROFILE CHANGED" : "READY") : + "DEVICE OFFLINE" + color: theme.textSoft + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.Bold } Quiet { text: device.running ? "ESP32-P4 realtime publisher active" : device.connected && profileDirty ? "Validate and deploy the selected SCL profile before Start" : - device.connected ? "Setpoints editable · live control armed" : "Setpoints remain editable offline" + device.connected ? "Setpoints editable · live control armed" : + "Setpoints remain editable offline" } } + Item { Layout.fillWidth: true } - Label { text: activeSignal + " " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠" + activePhase.toFixed(2) + "°"; color: theme.muted; font.family: root.monoFont; font.pixelSize: 9 } + + Label { + text: activeSignal + " " + activeMagnitude.toFixed(3) + " " + activeUnit + + " ∠" + activePhase.toFixed(2) + "°" + color: theme.muted + font.family: root.monoFont + font.pixelSize: 9 + } + CalmButton { text: "Diagnostics"; onClicked: diagnosticsDialog.open() } - CalmButton { text: "Apply all"; enabled: device.connected; onClicked: root.applyAllSignals() } + CalmButton { + text: "Apply all" + enabled: device.connected + onClicked: root.applyAllSignals() + } CalmButton { text: device.profileDeploying ? "Deploying…" : "Deploy profile" enabled: root.canDeploy onClicked: { - if (device.deployProfile(sclProfiles.selectedProfile)) root.profileDirty = true + if (device.deployProfile(sclProfiles.selectedProfile)) + root.profileDirty = true } } - CalmButton { text: "Stop"; enabled: device.connected && device.running; onClicked: device.stop() } + CalmButton { + text: "Stop" + enabled: device.connected && device.running + onClicked: device.stop() + } Button { id: startButton text: "▶ Start live" enabled: root.canStart - implicitWidth: 120; implicitHeight: 36 - font.family: root.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold - contentItem: Text { text: startButton.text; color: startButton.enabled ? "#d6f4e6" : theme.muted2; font: startButton.font; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - background: Rectangle { radius: 7; color: startButton.enabled ? "#194d38" : "#121820"; border.color: startButton.enabled ? "#347a59" : theme.lineSoft } + implicitWidth: 120 + implicitHeight: 36 + font.family: root.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + contentItem: Text { + text: startButton.text + color: startButton.enabled ? "#d6f4e6" : theme.muted2 + font: startButton.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 7 + color: startButton.enabled ? "#194d38" : "#121820" + border.color: startButton.enabled ? "#347a59" : theme.lineSoft + } onClicked: device.start() } } @@ -372,13 +539,32 @@ ApplicationWindow { anchors.fill: parent anchors.margins: 16 spacing: 13 + ColumnLayout { spacing: 3 Kicker { text: "ENGINEERING SOURCE" } - Label { text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Compiled SCL") : "Development profile"; color: theme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } - Quiet { Layout.fillWidth: true; elide: Text.ElideMiddle; text: sclProfiles.sourceName.length ? sclProfiles.sourceName : "SCL / CID / SCD / IID" } + Label { + text: sclProfiles.hasProfiles ? + (sclProfiles.selectedProfile.svId || "Compiled SCL") : + "Development profile" + color: theme.text + font.family: root.uiFont + font.pixelSize: 17 + font.weight: Font.DemiBold + } + Quiet { + Layout.fillWidth: true + elide: Text.ElideMiddle + text: sclProfiles.sourceName.length ? sclProfiles.sourceName : "SCL / CID / SCD / IID" + } + } + + CalmButton { + Layout.fillWidth: true + text: "Open SCL / CID" + enabled: !device.running + onClicked: fileDialog.open() } - CalmButton { Layout.fillWidth: true; text: "Open SCL / CID"; enabled: !device.running; onClicked: fileDialog.open() } ColumnLayout { visible: sclProfiles.hasProfiles @@ -391,35 +577,80 @@ ApplicationWindow { textRole: "control" currentIndex: sclProfiles.selectedIndex enabled: !device.running - onActivated: { sclProfiles.selectStream(currentIndex); root.profileDirty = true } font.family: root.uiFont font.pixelSize: 11 + onActivated: { + sclProfiles.selectStream(currentIndex) + root.profileDirty = true + } } GridLayout { Layout.fillWidth: true columns: 2 - columnSpacing: 8; rowSpacing: 6 + columnSpacing: 8 + rowSpacing: 6 Quiet { text: "Class" } - Label { text: "CLASS " + (sclProfiles.selectedProfile.compatibilityClass || "—"); color: sclProfiles.selectedProfile.compatibilityClass === "A" ? theme.green : sclProfiles.selectedProfile.compatibilityClass === "B" ? theme.amber : theme.red; font.family: root.uiFont; font.pixelSize: 10; font.weight: Font.Bold } + Label { + text: "CLASS " + (sclProfiles.selectedProfile.compatibilityClass || "—") + color: sclProfiles.selectedProfile.compatibilityClass === "A" ? theme.green : + sclProfiles.selectedProfile.compatibilityClass === "B" ? theme.amber : theme.red + font.family: root.uiFont + font.pixelSize: 10 + font.weight: Font.Bold + } Quiet { text: "Support" } - Label { text: sclProfiles.selectedProfile.deviceSupport || "—"; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 9; elide: Text.ElideRight; Layout.fillWidth: true } + Label { + text: sclProfiles.selectedProfile.deviceSupport || "—" + color: theme.textSoft + font.family: root.uiFont + font.pixelSize: 9 + elide: Text.ElideRight + Layout.fillWidth: true + } Quiet { text: "APPID" } - Label { text: sclProfiles.selectedProfile.appIdHex || "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Label { + text: sclProfiles.selectedProfile.appIdHex || "—" + color: theme.textSoft + font.family: root.monoFont + font.pixelSize: 9 + } Quiet { text: "Rate" } - Label { text: sclProfiles.selectedProfile.publisherRate ? sclProfiles.selectedProfile.publisherRate + "/s" : "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Label { + text: sclProfiles.selectedProfile.publisherRate ? + sclProfiles.selectedProfile.publisherRate + "/s" : "—" + color: theme.textSoft + font.family: root.monoFont + font.pixelSize: 9 + } Quiet { text: "VLAN" } - Label { text: sclProfiles.selectedProfile.vlanPresent ? "P" + sclProfiles.selectedProfile.vlanPriority + " · VID " + sclProfiles.selectedProfile.vlanId : "untagged"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Label { + text: sclProfiles.selectedProfile.vlanPresent ? + "P" + sclProfiles.selectedProfile.vlanPriority + " · VID " + sclProfiles.selectedProfile.vlanId : + "untagged" + color: theme.textSoft + font.family: root.monoFont + font.pixelSize: 9 + } Quiet { text: "Payload" } - Label { text: sclProfiles.selectedProfile.payloadBytes ? sclProfiles.selectedProfile.payloadBytes + " B" : "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } + Label { + text: sclProfiles.selectedProfile.payloadBytes ? + sclProfiles.selectedProfile.payloadBytes + " B" : "—" + color: theme.textSoft + font.family: root.monoFont + font.pixelSize: 9 + } } Quiet { Layout.fillWidth: true - visible: (sclProfiles.selectedProfile.warnings || []).length > 0 || (sclProfiles.selectedProfile.errors || []).length > 0 + visible: (sclProfiles.selectedProfile.warnings || []).length > 0 || + (sclProfiles.selectedProfile.errors || []).length > 0 wrapMode: Text.WordWrap maximumLineCount: 3 elide: Text.ElideRight - color: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? theme.red : theme.amber - text: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? sclProfiles.selectedProfile.errors[0] : sclProfiles.selectedProfile.warnings[0] + color: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? + theme.red : theme.amber + text: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? + sclProfiles.selectedProfile.errors[0] : sclProfiles.selectedProfile.warnings[0] } } @@ -428,11 +659,28 @@ ApplicationWindow { Layout.fillWidth: true spacing: 7 Kicker { text: "COUNTER POLICY" } - Quiet { Layout.fillWidth: true; wrapMode: Text.WordWrap; text: "Candidate smpCnt modulus requires explicit profile/evidence confirmation before deployment." } + Quiet { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: "Candidate smpCnt modulus requires explicit profile/evidence confirmation before deployment." + } RowLayout { Layout.fillWidth: true - TextField { id: counterField; Layout.fillWidth: true; text: sclProfiles.selectedProfile.counterModulus || ""; placeholderText: "modulus"; font.family: root.monoFont; validator: IntValidator { bottom: 1; top: 65535 } } - CalmButton { text: "Confirm"; onClicked: { if (sclProfiles.confirmCounterModulus(parseInt(counterField.text))) root.profileDirty = true } } + TextField { + id: counterField + Layout.fillWidth: true + text: sclProfiles.selectedProfile.counterModulus || "" + placeholderText: "modulus" + font.family: root.monoFont + validator: IntValidator { bottom: 1; top: 65535 } + } + CalmButton { + text: "Confirm" + onClicked: { + if (sclProfiles.confirmCounterModulus(parseInt(counterField.text))) + root.profileDirty = true + } + } } } @@ -440,15 +688,39 @@ ApplicationWindow { Layout.fillWidth: true spacing: 6 Kicker { text: "ENGINEERING SCALING" } - Quiet { Layout.fillWidth: true; wrapMode: Text.WordWrap; text: "Explicit test conversion; generic SCL is not assumed to define physical scaling." } + Quiet { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: "Explicit test conversion; generic SCL is not assumed to define physical scaling." + } RowLayout { Quiet { text: "Current"; Layout.preferredWidth: 48 } - TextField { Layout.fillWidth: true; text: root.currentScale.toString(); font.family: root.monoFont; validator: DoubleValidator { bottom: 0.000001 }; onEditingFinished: { var v=parseFloat(text); if(v>0) root.currentScale=v } } + TextField { + Layout.fillWidth: true + text: root.currentScale.toString() + font.family: root.monoFont + validator: DoubleValidator { bottom: 0.000001 } + onEditingFinished: { + var value = parseFloat(text) + if (value > 0) + root.currentScale = value + } + } Quiet { text: "ct/A" } } RowLayout { Quiet { text: "Voltage"; Layout.preferredWidth: 48 } - TextField { Layout.fillWidth: true; text: root.voltageScale.toString(); font.family: root.monoFont; validator: DoubleValidator { bottom: 0.000001 }; onEditingFinished: { var v=parseFloat(text); if(v>0) root.voltageScale=v } } + TextField { + Layout.fillWidth: true + text: root.voltageScale.toString() + font.family: root.monoFont + validator: DoubleValidator { bottom: 0.000001 } + onEditingFinished: { + var value = parseFloat(text) + if (value > 0) + root.voltageScale = value + } + } Quiet { text: "ct/V" } } } @@ -457,14 +729,23 @@ ApplicationWindow { Kicker { text: "QUICK SETUP" } CalmButton { Layout.fillWidth: true; text: "Balanced 3-phase"; onClicked: root.balanced() } CalmButton { Layout.fillWidth: true; text: "Zero output"; onClicked: root.zeroAll() } + Item { Layout.fillHeight: true } + Kicker { text: "RUNTIME" } GridLayout { - Layout.fillWidth: true; columns: 2; columnSpacing: 8; rowSpacing: 5 - Quiet { text: "Rate" } Label { text: device.fps; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } - Quiet { text: "Missed" } Label { text: device.missed; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } - Quiet { text: "TX fail" } Label { text: device.txFailures; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } - Quiet { text: "Generation" } Label { text: device.signalGeneration; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Layout.fillWidth: true + columns: 2 + columnSpacing: 8 + rowSpacing: 5 + Quiet { text: "Rate" } + Label { text: device.fps; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Quiet { text: "Missed" } + Label { text: device.missed; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Quiet { text: "TX fail" } + Label { text: device.txFailures; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Quiet { text: "Generation" } + Label { text: device.signalGeneration; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } } } } @@ -477,16 +758,30 @@ ApplicationWindow { anchors.fill: parent anchors.margins: 18 spacing: 13 + RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 2 Kicker { text: "INJECTION WORKSPACE" } - Label { text: "Manual Injection"; color: theme.text; font.family: root.uiFont; font.pixelSize: 23; font.weight: Font.DemiBold } + Label { + text: "Manual Injection" + color: theme.text + font.family: root.uiFont + font.pixelSize: 23 + font.weight: Font.DemiBold + } } Item { Layout.fillWidth: true } - Label { text: device.running ? "● INJECTING" : "STOPPED"; color: device.running ? theme.green : theme.muted; font.family: root.monoFont; font.pixelSize: 9; font.weight: Font.Bold } + Label { + text: device.running ? "● INJECTING" : "STOPPED" + color: device.running ? theme.green : theme.muted + font.family: root.monoFont + font.pixelSize: 9 + font.weight: Font.Bold + } } + Rectangle { Layout.fillWidth: true height: 58 @@ -494,7 +789,8 @@ ApplicationWindow { color: theme.surface2 RowLayout { anchors.fill: parent - anchors.leftMargin: 14; anchors.rightMargin: 14 + anchors.leftMargin: 14 + anchors.rightMargin: 14 spacing: 10 ColumnLayout { spacing: 0 @@ -502,20 +798,56 @@ ApplicationWindow { RowLayout { TextField { id: frequencyField - text: "50.000"; implicitWidth: 82; selectByMouse: true - color: theme.text; font.family: root.monoFont; font.pixelSize: 16; background: Item {} + text: "50.000" + implicitWidth: 82 + selectByMouse: true + color: theme.text + font.family: root.monoFont + font.pixelSize: 16 + background: Item {} onTextEdited: waveform.requestPaint() - onEditingFinished: if (root.liveApply && device.connected) device.setFrequency(parseFloat(text)) + onEditingFinished: { + if (root.liveApply && device.connected) + device.setFrequency(parseFloat(text)) + } } Quiet { text: "Hz" } } } - CalmButton { text: "50"; onClicked: { frequencyField.text="50.000"; waveform.requestPaint(); if(device.connected&&root.liveApply)device.setFrequency(50) } } - CalmButton { text: "60"; onClicked: { frequencyField.text="60.000"; waveform.requestPaint(); if(device.connected&&root.liveApply)device.setFrequency(60) } } + CalmButton { + text: "50" + onClicked: { + frequencyField.text = "50.000" + waveform.requestPaint() + if (device.connected && root.liveApply) + device.setFrequency(50) + } + } + CalmButton { + text: "60" + onClicked: { + frequencyField.text = "60.000" + waveform.requestPaint() + if (device.connected && root.liveApply) + device.setFrequency(60) + } + } Rectangle { width: 1; height: 28; color: theme.lineSoft } - CheckBox { checked: root.phaseLink; text: "3-phase link"; onToggled: root.phaseLink=checked; font.family: root.uiFont; font.pixelSize: 11 } + CheckBox { + checked: root.phaseLink + text: "3-phase link" + onToggled: root.phaseLink = checked + font.family: root.uiFont + font.pixelSize: 11 + } Item { Layout.fillWidth: true } - CheckBox { checked: root.liveApply; text: "Live apply"; onToggled: root.liveApply=checked; font.family: root.uiFont; font.pixelSize: 11 } + CheckBox { + checked: root.liveApply + text: "Live apply" + onToggled: root.liveApply = checked + font.family: root.uiFont + font.pixelSize: 11 + } } } @@ -523,32 +855,62 @@ ApplicationWindow { Layout.fillWidth: true Layout.fillHeight: true spacing: 12 + Rectangle { - Layout.fillWidth: true; Layout.fillHeight: true - color: theme.surface2; radius: 9; border.color: theme.lineSoft + Layout.fillWidth: true + Layout.fillHeight: true + color: theme.surface2 + radius: 9 + border.color: theme.lineSoft ColumnLayout { - anchors.fill: parent; spacing: 0 + anchors.fill: parent + spacing: 0 MatrixHeader { titleText: "Current"; symbolText: "I"; unitText: "A RMS" } MatrixColumns {} Repeater { - id: currentRep + id: currentRepeater model: currentModel - SignalRow { groupIndex: 0; rowIndex: index; sourceModel: currentModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; signalQuality: quality; phaseColor: traceColor } + SignalRow { + groupIndex: 0 + rowIndex: index + sourceModel: currentModel + sid: signalId + mag: magnitude + angle: phase + isEnabled: enabled + signalQuality: quality + phaseColor: traceColor + } } Item { Layout.fillHeight: true } } } + Rectangle { - Layout.fillWidth: true; Layout.fillHeight: true - color: theme.surface2; radius: 9; border.color: theme.lineSoft + Layout.fillWidth: true + Layout.fillHeight: true + color: theme.surface2 + radius: 9 + border.color: theme.lineSoft ColumnLayout { - anchors.fill: parent; spacing: 0 + anchors.fill: parent + spacing: 0 MatrixHeader { titleText: "Voltage"; symbolText: "U"; unitText: "V RMS" } MatrixColumns {} Repeater { - id: voltageRep + id: voltageRepeater model: voltageModel - SignalRow { groupIndex: 1; rowIndex: index; sourceModel: voltageModel; sid: signalId; mag: magnitude; angle: phase; isEnabled: enabled; signalQuality: quality; phaseColor: traceColor } + SignalRow { + groupIndex: 1 + rowIndex: index + sourceModel: voltageModel + sid: signalId + mag: magnitude + angle: phase + isEnabled: enabled + signalQuality: quality + phaseColor: traceColor + } } Item { Layout.fillHeight: true } } @@ -559,18 +921,32 @@ ApplicationWindow { Layout.fillWidth: true spacing: 7 Rectangle { width: 7; height: 7; radius: 4; color: theme.accent } - Label { text: activeSignal; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 10; font.weight: Font.Bold } - Quiet { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } + Label { + text: activeSignal + color: theme.textSoft + font.family: root.monoFont + font.pixelSize: 10 + font.weight: Font.Bold + } + Quiet { + text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + + activePhase.toFixed(2) + "°" + } Item { Layout.fillWidth: true } Quiet { text: "Quality" } TextField { id: qualityField implicitWidth: 98 - text: "0x" + Number(root.activeQuality >>> 0).toString(16).padStart(8, "0").toUpperCase() - color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 + text: "0x" + (Number(root.activeQuality) >>> 0).toString(16).padStart(8, "0").toUpperCase() + color: theme.textSoft + font.family: root.monoFont + font.pixelSize: 9 onEditingFinished: { - var t=text.trim(); var q=t.toLowerCase().startsWith("0x") ? parseInt(t.substring(2),16) : parseInt(t,10) - if(!isNaN(q) && q>=0) root.setActiveQuality(q >>> 0) + var trimmed = text.trim() + var qualityValue = trimmed.toLowerCase().startsWith("0x") ? + parseInt(trimmed.substring(2), 16) : parseInt(trimmed, 10) + if (!isNaN(qualityValue) && qualityValue >= 0) + root.setActiveQuality(qualityValue) } } Quiet { text: "↑↓ channel ←→ field Enter next" } @@ -585,43 +961,210 @@ ApplicationWindow { anchors.fill: parent anchors.margins: 16 spacing: 10 + RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 2 Kicker { text: "GENERATED SETPOINT" } - Label { text: "Signal Preview"; color: theme.text; font.family: root.uiFont; font.pixelSize: 19; font.weight: Font.DemiBold } + Label { + text: "Signal Preview" + color: theme.text + font.family: root.uiFont + font.pixelSize: 19 + font.weight: Font.DemiBold + } } Item { Layout.fillWidth: true } Quiet { text: "LOCAL" } } - PreviewTitle { titleText: "Phasor"; detailText: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠ " + activePhase.toFixed(2) + "°" } + + PreviewTitle { + titleText: "Phasor" + detailText: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + + " ∠ " + activePhase.toFixed(2) + "°" + } + Canvas { id: phasor - Layout.fillWidth: true; Layout.fillHeight: true; Layout.preferredHeight: 1 - onWidthChanged: requestPaint(); onHeightChanged: requestPaint() + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 1 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() onPaint: { - var ctx=getContext("2d");ctx.reset();var w=width,h=height,cx=w/2,cy=h/2,r=Math.min(w,h)*.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 deg=0;deg<180;deg+=30){var a=deg*Math.PI/180,dx=Math.cos(a)*r,dy=Math.sin(a)*r;ctx.beginPath();ctx.moveTo(cx-dx,cy-dy);ctx.lineTo(cx+dx,cy+dy);ctx.stroke()} - function draw(m,scale,dash){var max=.0001;for(var i=0;i<4;++i)max=Math.max(max,m.get(i).magnitude);for(var j=0;j<4;++j){var s=m.get(j);if(!s.enabled||s.magnitude<=0)continue;var a=s.phase*Math.PI/180,len=r*scale*(.3+.7*Math.min(1,s.magnitude/max)),x=cx+Math.cos(a)*len,y=cy-Math.sin(a)*len,active=s.signalId===root.activeSignal;ctx.save();ctx.globalAlpha=active?1:.70;ctx.strokeStyle=s.traceColor;ctx.fillStyle=s.traceColor;ctx.lineWidth=active?3.2:2;ctx.lineCap="round";ctx.setLineDash(dash?[5,4]:[]);ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(x,y);ctx.stroke();ctx.setLineDash([]);var head=active?8:6;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-Math.cos(a-.45)*head,y+Math.sin(a-.45)*head);ctx.lineTo(x-Math.cos(a+.45)*head,y+Math.sin(a+.45)*head);ctx.closePath();ctx.fill();if(active){ctx.font="600 11px Inter";ctx.fillText(s.signalId,x+8,y-7)}ctx.restore()}} - draw(voltageModel,1,false);draw(currentModel,.78,true) + var ctx = getContext("2d") + ctx.reset() + var widthValue = width + var heightValue = height + var centerX = widthValue / 2 + var centerY = heightValue / 2 + var radiusValue = Math.min(widthValue, heightValue) * 0.39 + + ctx.strokeStyle = "#283441" + ctx.lineWidth = 1 + for (var ring = 1; ring <= 3; ++ring) { + ctx.beginPath() + ctx.arc(centerX, centerY, radiusValue * ring / 3, 0, Math.PI * 2) + ctx.stroke() + } + for (var degrees = 0; degrees < 180; degrees += 30) { + var axis = degrees * Math.PI / 180 + var axisX = Math.cos(axis) * radiusValue + var axisY = Math.sin(axis) * radiusValue + ctx.beginPath() + ctx.moveTo(centerX - axisX, centerY - axisY) + ctx.lineTo(centerX + axisX, centerY + axisY) + ctx.stroke() + } + + function drawGroup(model, scale, dashed) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + + for (var j = 0; j < 4; ++j) { + var signal = model.get(j) + if (!signal.enabled || signal.magnitude <= 0) + continue + var angleValue = signal.phase * Math.PI / 180 + var length = radiusValue * scale * + (0.30 + 0.70 * Math.min(1, signal.magnitude / maxMagnitude)) + var endX = centerX + Math.cos(angleValue) * length + var endY = centerY - Math.sin(angleValue) * length + var active = signal.signalId === root.activeSignal + + ctx.save() + ctx.globalAlpha = active ? 1 : 0.70 + ctx.strokeStyle = signal.traceColor + ctx.fillStyle = signal.traceColor + ctx.lineWidth = active ? 3.2 : 2.0 + ctx.lineCap = "round" + ctx.setLineDash(dashed ? [5, 4] : []) + ctx.beginPath() + ctx.moveTo(centerX, centerY) + ctx.lineTo(endX, endY) + ctx.stroke() + ctx.setLineDash([]) + + var arrowHead = active ? 8 : 6 + ctx.beginPath() + ctx.moveTo(endX, endY) + ctx.lineTo( + endX - Math.cos(angleValue - 0.45) * arrowHead, + endY + Math.sin(angleValue - 0.45) * arrowHead) + ctx.lineTo( + endX - Math.cos(angleValue + 0.45) * arrowHead, + endY + Math.sin(angleValue + 0.45) * arrowHead) + ctx.closePath() + ctx.fill() + + if (active) { + ctx.font = "600 11px Inter" + ctx.fillText(signal.signalId, endX + 8, endY - 7) + } + ctx.restore() + } + } + + drawGroup(voltageModel, 1.0, false) + drawGroup(currentModel, 0.78, true) } } - PreviewTitle { titleText: "Waveform"; detailText: frequencyField.text + " Hz · 40 ms" } + + PreviewTitle { + titleText: "Waveform" + detailText: frequencyField.text + " Hz · 40 ms" + } + Canvas { id: waveform - Layout.fillWidth: true; Layout.fillHeight: true; Layout.preferredHeight: .85 - onWidthChanged: requestPaint(); onHeightChanged: requestPaint() + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 0.85 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() onPaint: { - var ctx=getContext("2d");ctx.reset();var w=width,h=height,l=25,r=8,t=12,b=14,pw=w-l-r,ph=h-t-b,g=16,lane=(ph-g)/2,vy=t+lane/2,iy=t+lane+g+lane/2,f=parseFloat(frequencyField.text)||50 - ctx.strokeStyle="#202b36";ctx.lineWidth=1;for(var gx=0;gx<=8;++gx){var x=l+pw*gx/8;ctx.beginPath();ctx.moveTo(x,t);ctx.lineTo(x,t+ph);ctx.stroke()}ctx.strokeStyle="#33404d";[vy,iy].forEach(function(y){ctx.beginPath();ctx.moveTo(l,y);ctx.lineTo(l+pw,y);ctx.stroke()}) - function draw(m,center,dash){var max=.0001;for(var i=0;i<4;++i)max=Math.max(max,m.get(i).magnitude);for(var j=0;j<4;++j){var s=m.get(j);if(!s.enabled||s.magnitude<=0)continue;var active=s.signalId===root.activeSignal,amp=lane*.39*Math.min(1,s.magnitude/max),pr=s.phase*Math.PI/180;ctx.save();ctx.strokeStyle=s.traceColor;ctx.globalAlpha=active?1:.64;ctx.lineWidth=active?2.6:1.7;ctx.lineCap="round";ctx.setLineDash(dash?[5,4]:[]);ctx.beginPath();for(var p=0;p<=220;++p){var q=p/220,time=.04*q,val=Math.sin(2*Math.PI*f*time+pr),x=l+pw*q,y=center-val*amp;if(p===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)}ctx.stroke();ctx.restore()}} - draw(voltageModel,vy,false);draw(currentModel,iy,true) + var ctx = getContext("2d") + ctx.reset() + var left = 25 + var right = 8 + var top = 12 + var bottom = 14 + var plotWidth = width - left - right + var plotHeight = height - top - bottom + var gap = 16 + var laneHeight = (plotHeight - gap) / 2 + var voltageY = top + laneHeight / 2 + var currentY = top + laneHeight + gap + laneHeight / 2 + var frequency = parseFloat(frequencyField.text) || 50 + + ctx.strokeStyle = "#202b36" + ctx.lineWidth = 1 + for (var grid = 0; grid <= 8; ++grid) { + var gridX = left + plotWidth * grid / 8 + ctx.beginPath() + ctx.moveTo(gridX, top) + ctx.lineTo(gridX, top + plotHeight) + ctx.stroke() + } + + ctx.strokeStyle = "#33404d" + var baselines = [voltageY, currentY] + for (var baselineIndex = 0; baselineIndex < baselines.length; ++baselineIndex) { + ctx.beginPath() + ctx.moveTo(left, baselines[baselineIndex]) + ctx.lineTo(left + plotWidth, baselines[baselineIndex]) + ctx.stroke() + } + + function drawWaveGroup(model, center, dashed) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + + for (var j = 0; j < 4; ++j) { + var signal = model.get(j) + if (!signal.enabled || signal.magnitude <= 0) + continue + var active = signal.signalId === root.activeSignal + var amplitude = laneHeight * 0.39 * + Math.min(1, signal.magnitude / maxMagnitude) + var phaseRadians = signal.phase * Math.PI / 180 + + ctx.save() + ctx.strokeStyle = signal.traceColor + ctx.globalAlpha = active ? 1 : 0.64 + ctx.lineWidth = active ? 2.6 : 1.7 + ctx.lineCap = "round" + ctx.setLineDash(dashed ? [5, 4] : []) + ctx.beginPath() + for (var point = 0; point <= 220; ++point) { + var ratio = point / 220 + var time = 0.04 * ratio + var sample = Math.sin(2 * Math.PI * frequency * time + phaseRadians) + var x = left + plotWidth * ratio + var y = center - sample * amplitude + if (point === 0) + ctx.moveTo(x, y) + else + ctx.lineTo(x, y) + } + ctx.stroke() + ctx.restore() + } + } + + drawWaveGroup(voltageModel, voltageY, false) + drawWaveGroup(currentModel, currentY, true) } } - Quiet { Layout.fillWidth: true; horizontalAlignment: Text.AlignRight; text: "Generated setpoint · not independent on-wire proof" } + + Quiet { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: "Generated setpoint · not independent on-wire proof" + } } } } @@ -640,7 +1183,9 @@ ApplicationWindow { id: messageLabel anchors.centerIn: parent text: root.transientMessage.length ? root.transientMessage : device.lastError - color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 10 + color: theme.textSoft + font.family: root.uiFont + font.pixelSize: 10 } } @@ -648,23 +1193,49 @@ ApplicationWindow { required property string titleText required property string symbolText required property string unitText - Layout.fillWidth: true; Layout.preferredHeight: 46 - Layout.leftMargin: 12; Layout.rightMargin: 12 - Rectangle { width: 23; height: 23; radius: 5; color: "#152235"; Label { anchors.centerIn: parent; text: symbolText; color: theme.accent; font.family: root.monoFont; font.pixelSize: 10; font.weight: Font.Bold } } - Label { text: titleText; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Layout.fillWidth: true + Layout.preferredHeight: 46 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Rectangle { + width: 23 + height: 23 + radius: 5 + color: "#152235" + Label { + anchors.centerIn: parent + text: symbolText + color: theme.accent + font.family: root.monoFont + font.pixelSize: 10 + font.weight: Font.Bold + } + } + Label { + text: titleText + color: theme.textSoft + font.family: root.uiFont + font.pixelSize: 13 + font.weight: Font.DemiBold + } Item { Layout.fillWidth: true } Quiet { text: unitText } } + component MatrixColumns: RowLayout { - Layout.fillWidth: true; Layout.preferredHeight: 30 - Layout.leftMargin: 10; Layout.rightMargin: 10; spacing: 8 + Layout.fillWidth: true + Layout.preferredHeight: 30 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + spacing: 8 Quiet { text: "ON"; Layout.preferredWidth: 28; font.pixelSize: 8 } Quiet { text: "CH"; Layout.preferredWidth: 34; font.pixelSize: 8 } Quiet { text: "MAGNITUDE"; Layout.fillWidth: true; font.pixelSize: 8 } Quiet { text: "PHASE"; Layout.preferredWidth: 108; font.pixelSize: 8 } } + component SignalRow: Rectangle { - id: row + id: signalRow required property int groupIndex required property int rowIndex required property var sourceModel @@ -672,51 +1243,149 @@ ApplicationWindow { required property real mag required property real angle required property bool isEnabled - required property int signalQuality + required property real signalQuality required property color phaseColor - property alias magEditor: magField + property alias magnitudeEditor: magnitudeField property alias phaseEditor: phaseField - Layout.fillWidth: true; Layout.preferredHeight: 58 - color: (magField.activeFocus || phaseField.activeFocus) ? "#141d27" : "transparent" + + Layout.fillWidth: true + Layout.preferredHeight: 58 + color: magnitudeField.activeFocus || phaseField.activeFocus ? "#141d27" : "transparent" + RowLayout { - anchors.fill: parent; anchors.leftMargin: 10; anchors.rightMargin: 10; spacing: 8 + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + CheckBox { - Layout.preferredWidth: 28; checked: row.isEnabled + Layout.preferredWidth: 28 + checked: signalRow.isEnabled onToggled: { - row.sourceModel.setProperty(row.rowIndex,"enabled",checked) - root.selectSignal(row.groupIndex,row.rowIndex) - phasor.requestPaint(); waveform.requestPaint() - if(device.connected) device.setEnabled(row.sid,checked) + signalRow.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) + root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) + phasor.requestPaint() + waveform.requestPaint() + if (device.connected) + device.setEnabled(signalRow.sid, checked) + } + } + + RowLayout { + Layout.preferredWidth: 34 + spacing: 5 + Rectangle { + width: 6 + height: 6 + radius: 3 + color: signalRow.phaseColor + } + Label { + text: signalRow.sid + color: theme.text + font.family: root.monoFont + font.pixelSize: 11 + font.weight: Font.Bold } } - RowLayout { Layout.preferredWidth: 34; spacing: 5; Rectangle { width: 6; height: 6; radius: 3; color: row.phaseColor } Label { text: row.sid; color: theme.text; font.family: root.monoFont; font.pixelSize: 11; font.weight: Font.Bold } } + TextField { - id: magField - Layout.fillWidth: true; text: row.mag.toFixed(3); selectByMouse: true; horizontalAlignment: Text.AlignRight - color: theme.text; font.family: root.monoFont; font.pixelSize: 13; font.weight: Font.DemiBold - background: Rectangle { radius: 6; color: magField.activeFocus ? "#0c141d" : "#111820"; border.width: magField.activeFocus ? 1 : 0; border.color: theme.accent } - onActiveFocusChanged: if(activeFocus){root.selectSignal(row.groupIndex,row.rowIndex);selectAll()} - onEditingFinished: root.editSignal(row.groupIndex,row.rowIndex,"magnitude",parseFloat(text)) - Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,0,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.editSignal(row.groupIndex,row.rowIndex,"magnitude",parseFloat(text));root.focusCell(row.groupIndex,row.rowIndex+1,0);e.accepted=true}} + id: magnitudeField + Layout.fillWidth: true + text: signalRow.mag.toFixed(3) + selectByMouse: true + horizontalAlignment: Text.AlignRight + color: theme.text + font.family: root.monoFont + font.pixelSize: 13 + font.weight: Font.DemiBold + background: Rectangle { + radius: 6 + color: magnitudeField.activeFocus ? "#0c141d" : "#111820" + border.width: magnitudeField.activeFocus ? 1 : 0 + border.color: theme.accent + } + onActiveFocusChanged: { + if (activeFocus) { + root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) + selectAll() + } + } + onEditingFinished: + root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "magnitude", parseFloat(text)) + Keys.onPressed: function(event) { + if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + root.navigate(signalRow.groupIndex, signalRow.rowIndex, 0, event.key) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.focusCell(signalRow.groupIndex, signalRow.rowIndex + 1, 0) + event.accepted = true + } + } } + TextField { id: phaseField - Layout.preferredWidth: 108; text: row.angle.toFixed(2); selectByMouse: true; horizontalAlignment: Text.AlignRight - color: theme.text; font.family: root.monoFont; font.pixelSize: 13; font.weight: Font.DemiBold - background: Rectangle { radius: 6; color: phaseField.activeFocus ? "#0c141d" : "#111820"; border.width: phaseField.activeFocus ? 1 : 0; border.color: theme.accent } - onActiveFocusChanged: if(activeFocus){root.selectSignal(row.groupIndex,row.rowIndex);selectAll()} - onEditingFinished: root.editSignal(row.groupIndex,row.rowIndex,"phase",parseFloat(text)) - Keys.onPressed: function(e){if([Qt.Key_Up,Qt.Key_Down,Qt.Key_Left,Qt.Key_Right].indexOf(e.key)>=0){root.navigate(row.groupIndex,row.rowIndex,1,e.key);e.accepted=true}else if(e.key===Qt.Key_Return||e.key===Qt.Key_Enter){root.editSignal(row.groupIndex,row.rowIndex,"phase",parseFloat(text));root.focusCell(row.groupIndex,row.rowIndex+1,1);e.accepted=true}} + Layout.preferredWidth: 108 + text: signalRow.angle.toFixed(2) + selectByMouse: true + horizontalAlignment: Text.AlignRight + color: theme.text + font.family: root.monoFont + font.pixelSize: 13 + font.weight: Font.DemiBold + background: Rectangle { + radius: 6 + color: phaseField.activeFocus ? "#0c141d" : "#111820" + border.width: phaseField.activeFocus ? 1 : 0 + border.color: theme.accent + } + onActiveFocusChanged: { + if (activeFocus) { + root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) + selectAll() + } + } + onEditingFinished: + root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "phase", parseFloat(text)) + Keys.onPressed: function(event) { + if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + root.navigate(signalRow.groupIndex, signalRow.rowIndex, 1, event.key) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.focusCell(signalRow.groupIndex, signalRow.rowIndex + 1, 1) + event.accepted = true + } + } } } - Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: theme.lineSoft } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: theme.lineSoft + } } + component PreviewTitle: RowLayout { required property string titleText required property string detailText Layout.fillWidth: true - Label { text: titleText; color: theme.textSoft; font.family: root.uiFont; font.pixelSize: 11; font.weight: Font.DemiBold } + Label { + text: titleText + color: theme.textSoft + font.family: root.uiFont + font.pixelSize: 11 + font.weight: Font.DemiBold + } Item { Layout.fillWidth: true } - Label { text: detailText; color: theme.muted; font.family: root.monoFont; font.pixelSize: 8 } + Label { + text: detailText + color: theme.muted + font.family: root.monoFont + font.pixelSize: 8 + } } } From fd64c67ad7b89897c78ad268fb6e6a45be947680 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:28:09 +0700 Subject: [PATCH 19/24] Document native Qt SMV Injector scope and workflow --- apps/arstack_studio/README.md | 105 ++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 apps/arstack_studio/README.md diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md new file mode 100644 index 000000000..3bf9b0805 --- /dev/null +++ b/apps/arstack_studio/README.md @@ -0,0 +1,105 @@ +# ARStack Studio — native Qt SMV Injector + +`apps/arstack_studio` is the native Qt 6 / C++ / QML operator surface for the ESP32-P4 Sampled Values injector. + +This branch is intentionally stacked on `feature/smv-scl-profile-bridge` so the desktop application follows the latest SCL/profile and embedded-runtime behavior instead of freezing an older web-GUI snapshot. + +## Architecture + +```text +SCL / CID / SCD / IID + -> ARIEC61850 SclParser + -> SvPublisherProfileCompiler + -> shared ESP32-P4 deployment support classifier + -> Qt SclProfileModel + -> operator selects / validates stream + -> Qt DeviceController (QSerialPort) + -> current ESP32-P4 PROFILE + live-control protocol + -> deterministic publisher on ESP32-P4 +``` + +The Qt application is a presentation and control plane. The PC is not the 4000/4800 fps realtime clock and does not replace the ESP32-P4 publisher. + +## Latest workflow carried into Qt + +- direct native SCL/CID/SCD/IID import using the repository C++ parser; +- resolved SampledValueControl stream selection; +- compatibility Class A / B / C; +- fail-closed ESP32-P4 device-support classification; +- explicit sample-counter-modulus confirmation for Class-B candidate profiles; +- explicit current counts/A and voltage counts/V test scaling; +- immutable profile deployment while STOPPED; +- profile identity: svID, data set, destination MAC, APPID, VLAN/PCP, confRev, publisher rate and counter modulus; +- optional DataSet / SampleRate ASDU-field flags already supported by the current device bridge; +- native serial 115200 8N1 control using Qt SerialPort; +- live magnitude, phase, frequency, enable and quality controls; +- START / STOP / ZERO / SHOW and runtime telemetry; +- keyboard-first 4I + 4V matrix navigation; +- 3-phase linkage; +- persistent generated phasor and waveform preview; +- serial diagnostics surface. + +The current deployment gate intentionally accepts only the embedded layout already supported by the ESP32-P4 runtime. Broader SCL structures may parse correctly while remaining `unsupported-layout`; the GUI does not guess them into the firmware profile. + +## Truthfulness boundary + +The phasor and waveform are **generated setpoint previews**. They visualize the configured engineering state. They are not independent Ethernet on-wire evidence. + +A later Process Bus monitor can add an observed trace and generated-vs-observed comparison without changing this distinction. + +`smpSynch` remains an embedded timing/synchronization truth. The GUI must not claim synchronized output until the runtime has measured synchronization evidence. + +## Build + +Qt 6.5+ is required with: + +- Qt Core / Gui / Qml / Quick / QuickControls2 +- Qt SerialPort +- a C++20 compiler +- CMake 3.24+ + +### Windows example + +From the repository root with a Qt-enabled developer shell: + +```powershell +cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build-arstack-studio --target arstack_studio --parallel +.\build-arstack-studio\arstack_studio.exe +``` + +For redistribution, package the required dynamically linked Qt runtime libraries with the normal Qt deployment tooling and review the applicable Qt/LGPL distribution obligations for the chosen module set. + +## CI + +`.github/workflows/arstack-studio-qt.yml` performs: + +1. standalone Qt configure; +2. native C++ / QML build; +3. headless QML launch smoke test; +4. retained configure/build/launch logs on every run. + +A green desktop CI proves that the application builds and QML instantiates in the hosted environment. It does **not** replace physical ESP32-P4 / Ethernet validation. + +## Physical acceptance still required + +Before this desktop GUI is treated as a proven replacement for the current bench surface, retain hardware evidence for at least: + +1. native serial connect and SHOW/PROFILE SHOW; +2. development 4000 fps START/STOP and live SET/FREQ/ENABLE/QUALITY updates; +3. SCL Class-A profile deployment followed by PROFILE committed/armed confirmation; +4. 4800 fps profile deployment and observed ~4800 fps with the expected smpCnt cycle; +5. live magnitude/phase changes without unintended counter restart; +6. generated-vs-on-wire identity comparison using a trusted capture path. + +## UI direction + +The desktop visual target is a calm premium engineering instrument: + +- Current and Voltage are the primary working matrices; +- SCL/profile information stays available without dominating the work surface; +- phasor and waveform remain persistent; +- Start/Stop remain fixed and obvious; +- values are optimized for keyboard operation; +- no browser/local-web-server dependency is required by the desktop application; +- visualization remains intentionally lightweight before any future custom `QQuickItem`/scene-graph optimization. From b2d2337fb5e68c3a68f23bc56f372ecaae27d1f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:30:26 +0700 Subject: [PATCH 20/24] Allow Qt QML wrapper for SCL profile model --- apps/arstack_studio/src/SclProfileModel.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index 2dc455f0d..a01a7837f 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -12,7 +12,7 @@ #include #include -class SclProfileModel final : public QAbstractListModel { +class SclProfileModel : public QAbstractListModel { Q_OBJECT Q_PROPERTY(QString sourceName READ sourceName NOTIFY sourceChanged) Q_PROPERTY(QString documentStatus READ documentStatus NOTIFY sourceChanged) @@ -87,4 +87,4 @@ class SclProfileModel final : public QAbstractListModel { std::vector rows_; int selectedIndex_{-1}; QString fatalError_; -}; +}; \ No newline at end of file From 387308e249998f56823b107419c5a95b58835636 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 15:30:45 +0700 Subject: [PATCH 21/24] Allow Qt QML wrapper for native device controller --- apps/arstack_studio/src/DeviceController.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index badba6af2..93783bbe6 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -6,7 +6,7 @@ #include #include -class DeviceController final : public QObject { +class DeviceController : public QObject { Q_OBJECT Q_PROPERTY(QStringList ports READ ports NOTIFY portsChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) @@ -93,4 +93,4 @@ class DeviceController final : public QObject { bool running_{false}; bool profileArmed_{false}; bool profileDeploying_{false}; -}; +}; \ No newline at end of file From a77758a2b44752495ee7b40a705d1cc51afd1f42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:21:24 +0700 Subject: [PATCH 22/24] Polish Qt injector P0 live workflow --- apps/arstack_studio/qml/Main.qml | 808 +++++++++++++++++++------------ 1 file changed, 509 insertions(+), 299 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c24d0e001..a65803254 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -9,7 +9,7 @@ ApplicationWindow { id: root width: 1480 height: 900 - minimumWidth: 1180 + minimumWidth: 1080 minimumHeight: 720 visible: true title: "ARStack Studio · SMV Injector" @@ -22,6 +22,7 @@ ApplicationWindow { 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" @@ -29,18 +30,22 @@ ApplicationWindow { 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" } property string uiFont: "Inter" property string monoFont: "Cascadia Mono" property bool phaseLink: false - property bool liveApply: true property bool profileDirty: false property real currentScale: 1000.0 property real voltageScale: 100.0 + property real signalFrequency: 50.0 property string activeSignal: "Ia" property int activeGroup: 0 property int activeRow: 0 @@ -49,7 +54,9 @@ ApplicationWindow { property real activePhase: 0.0 property real activeQuality: 0 property string transientMessage: "" + property bool transientError: false + readonly property bool compactLayout: width < 1300 readonly property bool selectedProfileDeployable: sclProfiles.selectedProfile.compatibilityClass === "A" && sclProfiles.selectedProfile.deviceSupport === "ready" @@ -59,6 +66,34 @@ ApplicationWindow { device.connected && !device.running && (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) + readonly property string instrumentState: { + if (!device.connected) return "OFFLINE" + if (device.profileDeploying) return "DEPLOYING" + if (device.running) return "RUNNING" + if (profileDirty) return "PROFILE CHANGED" + if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "PROFILE BLOCKED" + if (sclProfiles.hasProfiles && !device.profileArmed) return "PROFILE VALIDATED" + if (device.profileArmed) return "ARMED" + return "CONNECTED" + } + readonly property color instrumentStateColor: { + if (instrumentState === "RUNNING") return theme.green + if (instrumentState === "ARMED") return theme.accent + if (instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return theme.amber + if (instrumentState === "PROFILE BLOCKED") return theme.red + return device.connected ? theme.textSoft : theme.muted + } + readonly property string stateReason: { + if (!device.connected) return "Connect the injector; setpoints remain editable offline." + if (device.profileDeploying) return "Committing immutable SCL profile to the device." + if (device.running) return "Live apply active · valid edits update SMV immediately." + if (profileDirty) return "Selected profile changed · deploy before Start." + if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "Selected stream is not deployable on the current ESP32-P4 layout." + 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." + } + SclProfileModel { id: sclProfiles } DeviceController { id: device } @@ -69,15 +104,21 @@ ApplicationWindow { root.profileDirty = false } function onDeviceMessage(message) { - root.transientMessage = message - messageTimer.restart() + root.showMessage(message, false) + } + function onConnectedChanged() { + if (device.connected) + Qt.callLater(root.applyAllSignals) } } Timer { id: messageTimer interval: 2800 - onTriggered: root.transientMessage = "" + onTriggered: { + root.transientMessage = "" + root.transientError = false + } } ListModel { @@ -96,6 +137,12 @@ ApplicationWindow { ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; quality: 0; traceColor: "#9a88df" } } + function showMessage(message, error) { + transientMessage = message + transientError = error === true + messageTimer.restart() + } + function groupModel(group) { return group === 0 ? currentModel : voltageModel } @@ -104,6 +151,49 @@ ApplicationWindow { return group === 0 ? currentRepeater : voltageRepeater } + function parseOperatorNumber(text) { + var normalized = String(text).trim() + .replace(/\s/g, "") + .replace(/degrees?/ig, "") + .replace(/deg/ig, "") + .replace(/°/g, "") + .replace(/Hz/ig, "") + .replace(/[AV]$/i, "") + if (normalized.indexOf(",") >= 0 && normalized.indexOf(".") < 0) + normalized = normalized.replace(",", ".") + if (!normalized.length || normalized === "+" || normalized === "-" || normalized === ".") + return NaN + return Number(normalized) + } + + function maximumMagnitude(group) { + var scale = group === 0 ? currentScale : voltageScale + if (!isFinite(scale) || scale <= 0) + return 0 + return Math.min(1000000000.0, 2147483647.0 / scale) + } + + function validMagnitude(group, value) { + return isFinite(value) && value >= 0 && value <= maximumMagnitude(group) + } + + function validPhase(value) { + return isFinite(value) && Math.abs(value) <= 360000.0 + } + + function validFrequency(value) { + return isFinite(value) && value > 0 && value <= 1000.0 + } + + function normalizedAngle(value) { + var result = value % 360 + if (result > 180) + result -= 360 + if (result <= -180) + result += 360 + return result + } + function selectSignal(group, row) { var signal = groupModel(group).get(row) activeGroup = group @@ -147,20 +237,11 @@ ApplicationWindow { column === 0 ? focusCell(group, row, 1) : focusCell(group, row + 1, 0) } - function normalizedAngle(value) { - var result = value % 360 - if (result > 180) - result -= 360 - if (result <= -180) - result += 360 - return result - } - function sendSignal(group, row) { if (!device.connected) - return + return true var signal = groupModel(group).get(row) - device.setSignal( + return device.setSignal( signal.signalId, signal.magnitude, signal.phase, @@ -170,13 +251,21 @@ ApplicationWindow { } function sendLinkedGroup(group) { + var ok = true for (var i = 0; i < 3; ++i) - sendSignal(group, 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 (isNaN(value) || (field === "magnitude" && value < 0)) - return + if ((field === "magnitude" && !validMagnitude(group, value)) || + (field === "phase" && !validPhase(value))) + return false var model = groupModel(group) model.setProperty(row, field, value) @@ -198,20 +287,21 @@ ApplicationWindow { } selectSignal(group, row) - if (liveApply && device.connected) { + if (device.connected) { if (phaseLink && row < 3) sendLinkedGroup(group) else sendSignal(group, row) } + return true } function applyAllSignals() { - for (var group = 0; group < 2; ++group) { - for (var row = 0; row < 4; ++row) - sendSignal(group, row) - } - device.setFrequency(parseFloat(frequencyField.text)) + if (!device.connected) + return + for (var group = 0; group < 2; ++group) + applyGroupSignals(group) + device.setFrequency(signalFrequency) } function balanced() { @@ -226,7 +316,7 @@ ApplicationWindow { } phasor.requestPaint() waveform.requestPaint() - if (device.connected && liveApply) + if (device.connected) applyAllSignals() } @@ -249,11 +339,32 @@ ApplicationWindow { device.setQuality(activeSignal, unsignedValue) } + component Kicker: Label { + color: theme.muted + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 1.2 + } + + component Quiet: Label { + color: theme.muted + font.family: root.uiFont + font.pixelSize: 10 + } + + component Surface: Rectangle { + color: theme.surface + radius: 9 + border.width: 1 + border.color: theme.lineSoft + } + component CalmButton: Button { id: calmButton - implicitHeight: 36 + implicitHeight: 34 font.family: root.uiFont - font.pixelSize: 12 + font.pixelSize: 11 font.weight: Font.DemiBold contentItem: Text { text: calmButton.text @@ -263,32 +374,55 @@ ApplicationWindow { verticalAlignment: Text.AlignVCenter } background: Rectangle { - radius: 7 - color: calmButton.down ? "#1b2530" : calmButton.hovered ? "#17202a" : theme.raised + radius: 6 + color: calmButton.down ? "#1c2733" : calmButton.hovered ? theme.raisedHover : theme.raised border.width: 1 border.color: calmButton.hovered ? "#3a4a5b" : theme.line } } - component Kicker: Label { - color: theme.muted - font.family: root.uiFont - font.pixelSize: 9 + component NumericField: TextField { + id: numericField + property bool invalidInput: false + property bool compact: false + selectByMouse: true + horizontalAlignment: Text.AlignRight + color: invalidInput ? theme.red : theme.text + selectionColor: theme.accent + selectedTextColor: theme.bg + font.family: root.monoFont + font.pixelSize: compact ? 11 : 13 font.weight: Font.DemiBold - font.letterSpacing: 1.25 - } - - component Quiet: Label { - color: theme.muted - font.family: root.uiFont - font.pixelSize: 10 + leftPadding: 8 + rightPadding: 8 + background: Rectangle { + radius: 6 + color: numericField.activeFocus ? "#0c141d" : "#111820" + border.width: numericField.activeFocus || numericField.invalidInput ? 1 : 0 + border.color: numericField.invalidInput ? theme.red : theme.accent + } } - component Surface: Rectangle { - color: theme.surface - radius: 10 + component StateBadge: Rectangle { + implicitHeight: 24 + implicitWidth: stateText.implicitWidth + 24 + radius: 6 + color: root.instrumentState === "RUNNING" ? theme.greenSoft : + root.instrumentState === "PROFILE BLOCKED" ? theme.redSoft : + (root.instrumentState === "PROFILE CHANGED" || root.instrumentState === "DEPLOYING") ? theme.amberSoft : + theme.accentSoft border.width: 1 - border.color: theme.lineSoft + border.color: Qt.alpha(root.instrumentStateColor, 0.55) + Label { + id: stateText + anchors.centerIn: parent + text: root.instrumentState + color: root.instrumentStateColor + font.family: root.monoFont + font.pixelSize: 9 + font.weight: Font.Bold + font.letterSpacing: 0.5 + } } FileDialog { @@ -314,7 +448,7 @@ ApplicationWindow { standardButtons: Dialog.Close background: Rectangle { color: theme.surface - radius: 10 + radius: 9 border.color: theme.line } contentItem: ColumnLayout { @@ -347,25 +481,28 @@ ApplicationWindow { } header: Rectangle { - height: 58 + height: 56 color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + RowLayout { anchors.fill: parent - anchors.leftMargin: 18 - anchors.rightMargin: 18 - spacing: 12 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 10 Rectangle { - width: 30 - height: 30 - radius: 7 + width: 28 + height: 28 + radius: 6 color: "#101b27" border.color: "#315071" Label { anchors.centerIn: parent text: "≋" color: theme.accent - font.pixelSize: 18 + font.pixelSize: 17 } } @@ -377,7 +514,7 @@ ApplicationWindow { font.family: root.uiFont font.pixelSize: 8 font.weight: Font.Bold - font.letterSpacing: 1.3 + font.letterSpacing: 1.2 } Label { text: "SMV Injector" @@ -390,7 +527,28 @@ ApplicationWindow { Item { Layout.fillWidth: true } + Rectangle { + visible: device.connected + implicitWidth: liveText.implicitWidth + 20 + implicitHeight: 23 + radius: 6 + color: theme.greenSoft + border.width: 1 + border.color: "#2b674d" + Label { + id: liveText + anchors.centerIn: parent + text: "LIVE APPLY" + color: theme.green + font.family: root.monoFont + font.pixelSize: 8 + font.weight: Font.Bold + font.letterSpacing: 0.7 + } + } + Label { + visible: !root.compactLayout text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "Development profile" @@ -403,15 +561,12 @@ ApplicationWindow { ComboBox { id: portCombo - implicitWidth: 116 + implicitWidth: root.compactLayout ? 100 : 116 model: device.ports enabled: !device.connected font.family: root.monoFont font.pixelSize: 10 - onPressedChanged: { - if (pressed) - device.refreshPorts() - } + onPressedChanged: if (pressed) device.refreshPorts() } Rectangle { @@ -421,7 +576,10 @@ ApplicationWindow { color: device.connected ? theme.green : theme.muted2 } - Quiet { text: device.connected ? device.portName : "Device offline" } + Quiet { + visible: !root.compactLayout + text: device.connected ? device.portName : "Device offline" + } CalmButton { text: device.connected ? "Disconnect" : "Connect" @@ -436,79 +594,85 @@ ApplicationWindow { } footer: Rectangle { - height: 62 + height: 66 color: theme.chrome border.width: 1 border.color: theme.lineSoft + RowLayout { anchors.fill: parent - anchors.leftMargin: 18 - anchors.rightMargin: 18 - spacing: 12 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 10 - Rectangle { - width: 8 - height: 8 - radius: 4 - color: device.running ? theme.green : device.connected ? theme.accent : theme.muted2 - } + StateBadge {} ColumnLayout { + Layout.maximumWidth: root.compactLayout ? 300 : 440 spacing: 1 - Label { - text: device.running ? "INJECTING" : - device.connected ? (profileDirty ? "PROFILE CHANGED" : "READY") : - "DEVICE OFFLINE" - color: theme.textSoft - font.family: root.uiFont - font.pixelSize: 9 - font.weight: Font.Bold - } Quiet { - text: device.running ? "ESP32-P4 realtime publisher active" : - device.connected && profileDirty ? "Validate and deploy the selected SCL profile before Start" : - device.connected ? "Setpoints editable · live control armed" : - "Setpoints remain editable offline" + Layout.fillWidth: true + text: root.stateReason + elide: Text.ElideRight + } + Label { + text: "FPS " + device.fps + " · MISSED " + device.missed + " · TX FAIL " + device.txFailures + color: theme.muted + font.family: root.monoFont + font.pixelSize: 8 } } Item { Layout.fillWidth: true } - Label { - text: activeSignal + " " + activeMagnitude.toFixed(3) + " " + activeUnit + - " ∠" + activePhase.toFixed(2) + "°" - color: theme.muted - font.family: root.monoFont - font.pixelSize: 9 - } - - CalmButton { text: "Diagnostics"; onClicked: diagnosticsDialog.open() } CalmButton { - text: "Apply all" - enabled: device.connected - onClicked: root.applyAllSignals() + visible: !root.compactLayout + text: "Diagnostics" + onClicked: diagnosticsDialog.open() } + CalmButton { - text: device.profileDeploying ? "Deploying…" : "Deploy profile" + text: device.profileDeploying ? "Deploying…" : "Deploy" enabled: root.canDeploy onClicked: { if (device.deployProfile(sclProfiles.selectedProfile)) root.profileDirty = true } } - CalmButton { - text: "Stop" + + Button { + id: stopButton + text: "■ Stop" enabled: device.connected && device.running + implicitWidth: 92 + implicitHeight: 36 + font.family: root.uiFont + font.pixelSize: 11 + font.weight: Font.DemiBold + contentItem: Text { + text: stopButton.text + color: stopButton.enabled ? "#ffdce0" : theme.muted2 + font: stopButton.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 6 + color: stopButton.enabled ? theme.redSoft : "#121820" + border.width: 1 + border.color: stopButton.enabled ? "#86434b" : theme.lineSoft + } onClicked: device.stop() } + Button { id: startButton - text: "▶ Start live" + text: "▶ Start" enabled: root.canStart - implicitWidth: 120 + implicitWidth: 104 implicitHeight: 36 font.family: root.uiFont - font.pixelSize: 12 + font.pixelSize: 11 font.weight: Font.DemiBold contentItem: Text { text: startButton.text @@ -518,8 +682,9 @@ ApplicationWindow { verticalAlignment: Text.AlignVCenter } background: Rectangle { - radius: 7 + radius: 6 color: startButton.enabled ? "#194d38" : "#121820" + border.width: 1 border.color: startButton.enabled ? "#347a59" : theme.lineSoft } onClicked: device.start() @@ -529,28 +694,33 @@ ApplicationWindow { RowLayout { anchors.fill: parent - anchors.margins: 12 - spacing: 12 + anchors.margins: root.compactLayout ? 10 : 12 + spacing: root.compactLayout ? 10 : 12 Surface { - Layout.preferredWidth: 246 + Layout.preferredWidth: root.compactLayout ? 206 : 246 + Layout.minimumWidth: root.compactLayout ? 196 : 220 Layout.fillHeight: true + ColumnLayout { anchors.fill: parent - anchors.margins: 16 - spacing: 13 + anchors.margins: root.compactLayout ? 12 : 15 + spacing: root.compactLayout ? 9 : 12 ColumnLayout { - spacing: 3 + Layout.fillWidth: true + spacing: 2 Kicker { text: "ENGINEERING SOURCE" } Label { + Layout.fillWidth: true text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Compiled SCL") : "Development profile" color: theme.text font.family: root.uiFont - font.pixelSize: 17 + font.pixelSize: root.compactLayout ? 14 : 16 font.weight: Font.DemiBold + elide: Text.ElideRight } Quiet { Layout.fillWidth: true @@ -569,7 +739,7 @@ ApplicationWindow { ColumnLayout { visible: sclProfiles.hasProfiles Layout.fillWidth: true - spacing: 7 + spacing: 6 Kicker { text: "RESOLVED STREAM" } ComboBox { Layout.fillWidth: true @@ -578,24 +748,25 @@ ApplicationWindow { currentIndex: sclProfiles.selectedIndex enabled: !device.running font.family: root.uiFont - font.pixelSize: 11 + font.pixelSize: 10 onActivated: { sclProfiles.selectStream(currentIndex) root.profileDirty = true } } + GridLayout { Layout.fillWidth: true columns: 2 columnSpacing: 8 - rowSpacing: 6 + rowSpacing: 5 Quiet { text: "Class" } Label { text: "CLASS " + (sclProfiles.selectedProfile.compatibilityClass || "—") color: sclProfiles.selectedProfile.compatibilityClass === "A" ? theme.green : sclProfiles.selectedProfile.compatibilityClass === "B" ? theme.amber : theme.red font.family: root.uiFont - font.pixelSize: 10 + font.pixelSize: 9 font.weight: Font.Bold } Quiet { text: "Support" } @@ -608,16 +779,10 @@ ApplicationWindow { Layout.fillWidth: true } Quiet { text: "APPID" } - Label { - text: sclProfiles.selectedProfile.appIdHex || "—" - color: theme.textSoft - font.family: root.monoFont - font.pixelSize: 9 - } + Label { text: sclProfiles.selectedProfile.appIdHex || "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } Quiet { text: "Rate" } Label { - text: sclProfiles.selectedProfile.publisherRate ? - sclProfiles.selectedProfile.publisherRate + "/s" : "—" + text: sclProfiles.selectedProfile.publisherRate ? sclProfiles.selectedProfile.publisherRate + "/s" : "—" color: theme.textSoft font.family: root.monoFont font.pixelSize: 9 @@ -625,21 +790,20 @@ ApplicationWindow { Quiet { text: "VLAN" } Label { text: sclProfiles.selectedProfile.vlanPresent ? - "P" + sclProfiles.selectedProfile.vlanPriority + " · VID " + sclProfiles.selectedProfile.vlanId : - "untagged" + "P" + sclProfiles.selectedProfile.vlanPriority + " · " + sclProfiles.selectedProfile.vlanId : "untagged" color: theme.textSoft font.family: root.monoFont font.pixelSize: 9 } Quiet { text: "Payload" } Label { - text: sclProfiles.selectedProfile.payloadBytes ? - sclProfiles.selectedProfile.payloadBytes + " B" : "—" + text: sclProfiles.selectedProfile.payloadBytes ? sclProfiles.selectedProfile.payloadBytes + " B" : "—" color: theme.textSoft font.family: root.monoFont font.pixelSize: 9 } } + Quiet { Layout.fillWidth: true visible: (sclProfiles.selectedProfile.warnings || []).length > 0 || @@ -647,8 +811,7 @@ ApplicationWindow { wrapMode: Text.WordWrap maximumLineCount: 3 elide: Text.ElideRight - color: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? - theme.red : theme.amber + color: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? theme.red : theme.amber text: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? sclProfiles.selectedProfile.errors[0] : sclProfiles.selectedProfile.warnings[0] } @@ -657,27 +820,26 @@ ApplicationWindow { ColumnLayout { visible: sclProfiles.selectedProfile.compatibilityClass === "B" Layout.fillWidth: true - spacing: 7 + spacing: 6 Kicker { text: "COUNTER POLICY" } Quiet { Layout.fillWidth: true wrapMode: Text.WordWrap - text: "Candidate smpCnt modulus requires explicit profile/evidence confirmation before deployment." + text: "Candidate smpCnt modulus requires explicit evidence confirmation." } RowLayout { Layout.fillWidth: true - TextField { + NumericField { id: counterField Layout.fillWidth: true + compact: true text: sclProfiles.selectedProfile.counterModulus || "" - placeholderText: "modulus" - font.family: root.monoFont validator: IntValidator { bottom: 1; top: 65535 } } CalmButton { text: "Confirm" onClicked: { - if (sclProfiles.confirmCounterModulus(parseInt(counterField.text))) + if (counterField.acceptableInput && sclProfiles.confirmCounterModulus(parseInt(counterField.text))) root.profileDirty = true } } @@ -686,39 +848,44 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true - spacing: 6 - Kicker { text: "ENGINEERING SCALING" } - Quiet { - Layout.fillWidth: true - wrapMode: Text.WordWrap - text: "Explicit test conversion; generic SCL is not assumed to define physical scaling." - } + spacing: 5 + Kicker { text: "SCALING" } RowLayout { - Quiet { text: "Current"; Layout.preferredWidth: 48 } - TextField { + Layout.fillWidth: true + Quiet { text: "I"; Layout.preferredWidth: 18 } + NumericField { Layout.fillWidth: true + compact: true text: root.currentScale.toString() - font.family: root.monoFont - validator: DoubleValidator { bottom: 0.000001 } + validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } onEditingFinished: { - var value = parseFloat(text) - if (value > 0) + var value = root.parseOperatorNumber(text) + if (acceptableInput && isFinite(value) && value > 0) { root.currentScale = value + if (device.connected) root.applyGroupSignals(0) + } else { + text = root.currentScale.toString() + } } } Quiet { text: "ct/A" } } RowLayout { - Quiet { text: "Voltage"; Layout.preferredWidth: 48 } - TextField { + Layout.fillWidth: true + Quiet { text: "U"; Layout.preferredWidth: 18 } + NumericField { Layout.fillWidth: true + compact: true text: root.voltageScale.toString() - font.family: root.monoFont - validator: DoubleValidator { bottom: 0.000001 } + validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } onEditingFinished: { - var value = parseFloat(text) - if (value > 0) + var value = root.parseOperatorNumber(text) + if (acceptableInput && isFinite(value) && value > 0) { root.voltageScale = value + if (device.connected) root.applyGroupSignals(1) + } else { + text = root.voltageScale.toString() + } } } Quiet { text: "ct/V" } @@ -737,13 +904,13 @@ ApplicationWindow { Layout.fillWidth: true columns: 2 columnSpacing: 8 - rowSpacing: 5 + rowSpacing: 4 Quiet { text: "Rate" } Label { text: device.fps; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } Quiet { text: "Missed" } - Label { text: device.missed; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Label { text: device.missed; color: Number(device.missed) > 0 ? theme.amber : theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } Quiet { text: "TX fail" } - Label { text: device.txFailures; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } + Label { text: device.txFailures; color: Number(device.txFailures) > 0 ? theme.red : theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } Quiet { text: "Generation" } Label { text: device.signalGeneration; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } } @@ -753,100 +920,133 @@ ApplicationWindow { Surface { Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumWidth: 560 + Layout.minimumWidth: root.compactLayout ? 500 : 560 + ColumnLayout { anchors.fill: parent - anchors.margins: 18 - spacing: 13 + anchors.margins: root.compactLayout ? 14 : 17 + spacing: 11 RowLayout { Layout.fillWidth: true ColumnLayout { - spacing: 2 + spacing: 1 Kicker { text: "INJECTION WORKSPACE" } Label { text: "Manual Injection" color: theme.text font.family: root.uiFont - font.pixelSize: 23 + font.pixelSize: root.compactLayout ? 19 : 22 font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - Label { - text: device.running ? "● INJECTING" : "STOPPED" - color: device.running ? theme.green : theme.muted - font.family: root.monoFont - font.pixelSize: 9 - font.weight: Font.Bold + Rectangle { + implicitWidth: applyText.implicitWidth + 18 + implicitHeight: 24 + radius: 6 + color: device.connected ? theme.greenSoft : "#141b23" + border.width: 1 + border.color: device.connected ? "#2c674e" : theme.lineSoft + Label { + id: applyText + anchors.centerIn: parent + text: device.connected ? "LIVE APPLY" : "LOCAL EDIT" + color: device.connected ? theme.green : theme.muted + font.family: root.monoFont + font.pixelSize: 8 + font.weight: Font.Bold + } } } Rectangle { Layout.fillWidth: true - height: 58 - radius: 9 + height: 56 + radius: 8 color: theme.surface2 + border.width: 1 + border.color: theme.lineSoft + RowLayout { anchors.fill: parent - anchors.leftMargin: 14 - anchors.rightMargin: 14 - spacing: 10 + anchors.leftMargin: 13 + anchors.rightMargin: 13 + spacing: 9 + ColumnLayout { spacing: 0 Kicker { text: "FREQUENCY" } RowLayout { - TextField { + NumericField { id: frequencyField + implicitWidth: 88 text: "50.000" - implicitWidth: 82 - selectByMouse: true - color: theme.text - font.family: root.monoFont - font.pixelSize: 16 - background: Item {} - onTextEdited: waveform.requestPaint() + invalidInput: !acceptableInput + validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } + onTextEdited: { + var value = root.parseOperatorNumber(text) + if (acceptableInput && root.validFrequency(value)) { + invalidInput = false + root.signalFrequency = value + waveform.requestPaint() + if (device.connected) device.setFrequency(value) + } else { + invalidInput = true + } + } onEditingFinished: { - if (root.liveApply && device.connected) - device.setFrequency(parseFloat(text)) + var value = root.parseOperatorNumber(text) + if (!acceptableInput || !root.validFrequency(value)) { + text = root.signalFrequency.toFixed(3) + invalidInput = false + root.showMessage("Frequency must be greater than 0 and not exceed 1000 Hz.", true) + } else { + text = value.toFixed(3) + } } } Quiet { text: "Hz" } } } + CalmButton { text: "50" onClicked: { + root.signalFrequency = 50 frequencyField.text = "50.000" + frequencyField.invalidInput = false waveform.requestPaint() - if (device.connected && root.liveApply) - device.setFrequency(50) + if (device.connected) device.setFrequency(50) } } CalmButton { text: "60" onClicked: { + root.signalFrequency = 60 frequencyField.text = "60.000" + frequencyField.invalidInput = false waveform.requestPaint() - if (device.connected && root.liveApply) - device.setFrequency(60) + if (device.connected) device.setFrequency(60) } } - Rectangle { width: 1; height: 28; color: theme.lineSoft } + + Rectangle { width: 1; height: 26; color: theme.lineSoft } + CheckBox { checked: root.phaseLink text: "3-phase link" onToggled: root.phaseLink = checked font.family: root.uiFont - font.pixelSize: 11 + font.pixelSize: 10 } + Item { Layout.fillWidth: true } - CheckBox { - checked: root.liveApply - text: "Live apply" - onToggled: root.liveApply = checked - font.family: root.uiFont - font.pixelSize: 11 + + Quiet { + visible: !root.compactLayout + text: device.connected ? "Valid edits → SMV immediately" : "Connect to stream edits live" + color: device.connected ? theme.green : theme.muted } } } @@ -854,13 +1054,13 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true Layout.fillHeight: true - spacing: 12 + spacing: 10 Rectangle { Layout.fillWidth: true Layout.fillHeight: true color: theme.surface2 - radius: 9 + radius: 8 border.color: theme.lineSoft ColumnLayout { anchors.fill: parent @@ -890,7 +1090,7 @@ ApplicationWindow { Layout.fillWidth: true Layout.fillHeight: true color: theme.surface2 - radius: 9 + radius: 8 border.color: theme.lineSoft ColumnLayout { anchors.fill: parent @@ -928,50 +1128,54 @@ ApplicationWindow { font.pixelSize: 10 font.weight: Font.Bold } - Quiet { - text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + - activePhase.toFixed(2) + "°" - } + Quiet { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } Item { Layout.fillWidth: true } Quiet { text: "Quality" } - TextField { + NumericField { id: qualityField - implicitWidth: 98 + implicitWidth: 102 + compact: true text: "0x" + (Number(root.activeQuality) >>> 0).toString(16).padStart(8, "0").toUpperCase() - color: theme.textSoft - font.family: root.monoFont - font.pixelSize: 9 onEditingFinished: { var trimmed = text.trim() var qualityValue = trimmed.toLowerCase().startsWith("0x") ? parseInt(trimmed.substring(2), 16) : parseInt(trimmed, 10) - if (!isNaN(qualityValue) && qualityValue >= 0) + if (!isNaN(qualityValue) && qualityValue >= 0 && qualityValue <= 0xFFFFFFFF) { root.setActiveQuality(qualityValue) + text = "0x" + (qualityValue >>> 0).toString(16).padStart(8, "0").toUpperCase() + invalidInput = false + } else { + invalidInput = true + text = "0x" + (Number(root.activeQuality) >>> 0).toString(16).padStart(8, "0").toUpperCase() + root.showMessage("Quality must be a valid 32-bit value.", true) + } } } - Quiet { text: "↑↓ channel ←→ field Enter next" } + Quiet { visible: !root.compactLayout; text: "↑↓ channel ←→ field Enter next" } } } } Surface { - Layout.preferredWidth: 386 + Layout.preferredWidth: root.compactLayout ? 304 : 386 + Layout.minimumWidth: root.compactLayout ? 286 : 330 Layout.fillHeight: true + ColumnLayout { anchors.fill: parent - anchors.margins: 16 - spacing: 10 + anchors.margins: root.compactLayout ? 12 : 15 + spacing: 9 RowLayout { Layout.fillWidth: true ColumnLayout { - spacing: 2 + spacing: 1 Kicker { text: "GENERATED SETPOINT" } Label { text: "Signal Preview" color: theme.text font.family: root.uiFont - font.pixelSize: 19 + font.pixelSize: root.compactLayout ? 16 : 18 font.weight: Font.DemiBold } } @@ -981,8 +1185,7 @@ ApplicationWindow { PreviewTitle { titleText: "Phasor" - detailText: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + - " ∠ " + activePhase.toFixed(2) + "°" + detailText: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠ " + activePhase.toFixed(2) + "°" } Canvas { @@ -995,11 +1198,9 @@ ApplicationWindow { onPaint: { var ctx = getContext("2d") ctx.reset() - var widthValue = width - var heightValue = height - var centerX = widthValue / 2 - var centerY = heightValue / 2 - var radiusValue = Math.min(widthValue, heightValue) * 0.39 + var centerX = width / 2 + var centerY = height / 2 + var radiusValue = Math.min(width, height) * 0.39 ctx.strokeStyle = "#283441" ctx.lineWidth = 1 @@ -1028,8 +1229,7 @@ ApplicationWindow { if (!signal.enabled || signal.magnitude <= 0) continue var angleValue = signal.phase * Math.PI / 180 - var length = radiusValue * scale * - (0.30 + 0.70 * Math.min(1, signal.magnitude / maxMagnitude)) + var length = radiusValue * scale * (0.30 + 0.70 * Math.min(1, signal.magnitude / maxMagnitude)) var endX = centerX + Math.cos(angleValue) * length var endY = centerY - Math.sin(angleValue) * length var active = signal.signalId === root.activeSignal @@ -1038,7 +1238,7 @@ ApplicationWindow { ctx.globalAlpha = active ? 1 : 0.70 ctx.strokeStyle = signal.traceColor ctx.fillStyle = signal.traceColor - ctx.lineWidth = active ? 3.2 : 2.0 + ctx.lineWidth = active ? 3.0 : 1.8 ctx.lineCap = "round" ctx.setLineDash(dashed ? [5, 4] : []) ctx.beginPath() @@ -1050,12 +1250,8 @@ ApplicationWindow { var arrowHead = active ? 8 : 6 ctx.beginPath() ctx.moveTo(endX, endY) - ctx.lineTo( - endX - Math.cos(angleValue - 0.45) * arrowHead, - endY + Math.sin(angleValue - 0.45) * arrowHead) - ctx.lineTo( - endX - Math.cos(angleValue + 0.45) * arrowHead, - endY + Math.sin(angleValue + 0.45) * arrowHead) + ctx.lineTo(endX - Math.cos(angleValue - 0.45) * arrowHead, endY + Math.sin(angleValue - 0.45) * arrowHead) + ctx.lineTo(endX - Math.cos(angleValue + 0.45) * arrowHead, endY + Math.sin(angleValue + 0.45) * arrowHead) ctx.closePath() ctx.fill() @@ -1072,10 +1268,7 @@ ApplicationWindow { } } - PreviewTitle { - titleText: "Waveform" - detailText: frequencyField.text + " Hz · 40 ms" - } + PreviewTitle { titleText: "Waveform"; detailText: root.signalFrequency.toFixed(3) + " Hz · 40 ms" } Canvas { id: waveform @@ -1097,7 +1290,7 @@ ApplicationWindow { var laneHeight = (plotHeight - gap) / 2 var voltageY = top + laneHeight / 2 var currentY = top + laneHeight + gap + laneHeight / 2 - var frequency = parseFloat(frequencyField.text) || 50 + var frequency = root.signalFrequency ctx.strokeStyle = "#202b36" ctx.lineWidth = 1 @@ -1128,27 +1321,24 @@ ApplicationWindow { if (!signal.enabled || signal.magnitude <= 0) continue var active = signal.signalId === root.activeSignal - var amplitude = laneHeight * 0.39 * - Math.min(1, signal.magnitude / maxMagnitude) + var amplitude = laneHeight * 0.39 * Math.min(1, signal.magnitude / maxMagnitude) var phaseRadians = signal.phase * Math.PI / 180 ctx.save() ctx.strokeStyle = signal.traceColor ctx.globalAlpha = active ? 1 : 0.64 - ctx.lineWidth = active ? 2.6 : 1.7 + ctx.lineWidth = active ? 2.5 : 1.6 ctx.lineCap = "round" ctx.setLineDash(dashed ? [5, 4] : []) ctx.beginPath() - for (var point = 0; point <= 220; ++point) { - var ratio = point / 220 + for (var point = 0; point <= 180; ++point) { + var ratio = point / 180 var time = 0.04 * ratio var sample = Math.sin(2 * Math.PI * frequency * time + phaseRadians) var x = left + plotWidth * ratio var y = center - sample * amplitude - if (point === 0) - ctx.moveTo(x, y) - else - ctx.lineTo(x, y) + if (point === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) } ctx.stroke() ctx.restore() @@ -1174,16 +1364,17 @@ ApplicationWindow { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: 78 - radius: 8 + radius: 7 color: "#18212b" - border.color: device.lastError.length > 0 ? "#70404a" : "#36536f" + border.width: 1 + border.color: root.transientError || device.lastError.length > 0 ? "#70404a" : "#36536f" implicitWidth: messageLabel.implicitWidth + 28 implicitHeight: 38 Label { id: messageLabel anchors.centerIn: parent text: root.transientMessage.length ? root.transientMessage : device.lastError - color: theme.textSoft + color: root.transientError || device.lastError.length > 0 ? "#ffb3bb" : theme.textSoft font.family: root.uiFont font.pixelSize: 10 } @@ -1194,12 +1385,12 @@ ApplicationWindow { required property string symbolText required property string unitText Layout.fillWidth: true - Layout.preferredHeight: 46 - Layout.leftMargin: 12 - Layout.rightMargin: 12 + Layout.preferredHeight: 44 + Layout.leftMargin: 11 + Layout.rightMargin: 11 Rectangle { - width: 23 - height: 23 + width: 22 + height: 22 radius: 5 color: "#152235" Label { @@ -1215,7 +1406,7 @@ ApplicationWindow { text: titleText color: theme.textSoft font.family: root.uiFont - font.pixelSize: 13 + font.pixelSize: 12 font.weight: Font.DemiBold } Item { Layout.fillWidth: true } @@ -1224,14 +1415,14 @@ ApplicationWindow { component MatrixColumns: RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 30 - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 8 + Layout.preferredHeight: 28 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 7 Quiet { text: "ON"; Layout.preferredWidth: 28; font.pixelSize: 8 } Quiet { text: "CH"; Layout.preferredWidth: 34; font.pixelSize: 8 } Quiet { text: "MAGNITUDE"; Layout.fillWidth: true; font.pixelSize: 8 } - Quiet { text: "PHASE"; Layout.preferredWidth: 108; font.pixelSize: 8 } + Quiet { text: "PHASE"; Layout.preferredWidth: root.compactLayout ? 86 : 104; font.pixelSize: 8 } } component SignalRow: Rectangle { @@ -1249,14 +1440,23 @@ ApplicationWindow { property alias phaseEditor: phaseField Layout.fillWidth: true - Layout.preferredHeight: 58 + Layout.preferredHeight: root.compactLayout ? 54 : 57 color: magnitudeField.activeFocus || phaseField.activeFocus ? "#141d27" : "transparent" + onMagChanged: { + if (!magnitudeField.activeFocus) + magnitudeField.text = mag.toFixed(3) + } + onAngleChanged: { + if (!phaseField.activeFocus) + phaseField.text = angle.toFixed(2) + } + RowLayout { anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 7 CheckBox { Layout.preferredWidth: 28 @@ -1274,45 +1474,47 @@ ApplicationWindow { RowLayout { Layout.preferredWidth: 34 spacing: 5 - Rectangle { - width: 6 - height: 6 - radius: 3 - color: signalRow.phaseColor - } + Rectangle { width: 6; height: 6; radius: 3; color: signalRow.phaseColor } Label { text: signalRow.sid color: theme.text font.family: root.monoFont - font.pixelSize: 11 + font.pixelSize: 10 font.weight: Font.Bold } } - TextField { + NumericField { id: magnitudeField Layout.fillWidth: true text: signalRow.mag.toFixed(3) - selectByMouse: true - horizontalAlignment: Text.AlignRight - color: theme.text - font.family: root.monoFont - font.pixelSize: 13 - font.weight: Font.DemiBold - background: Rectangle { - radius: 6 - color: magnitudeField.activeFocus ? "#0c141d" : "#111820" - border.width: magnitudeField.activeFocus ? 1 : 0 - border.color: theme.accent - } + invalidInput: false onActiveFocusChanged: { if (activeFocus) { root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) + text = signalRow.mag.toFixed(3) selectAll() } } - onEditingFinished: - root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "magnitude", parseFloat(text)) + onTextEdited: { + var value = root.parseOperatorNumber(text) + if (root.validMagnitude(signalRow.groupIndex, value)) { + invalidInput = false + root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "magnitude", value) + } else { + invalidInput = true + } + } + onEditingFinished: { + var value = root.parseOperatorNumber(text) + if (!root.validMagnitude(signalRow.groupIndex, value)) { + text = signalRow.mag.toFixed(3) + invalidInput = false + root.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) { root.navigate(signalRow.groupIndex, signalRow.rowIndex, 0, event.key) @@ -1324,30 +1526,37 @@ ApplicationWindow { } } - TextField { + NumericField { id: phaseField - Layout.preferredWidth: 108 + Layout.preferredWidth: root.compactLayout ? 86 : 104 text: signalRow.angle.toFixed(2) - selectByMouse: true - horizontalAlignment: Text.AlignRight - color: theme.text - font.family: root.monoFont - font.pixelSize: 13 - font.weight: Font.DemiBold - background: Rectangle { - radius: 6 - color: phaseField.activeFocus ? "#0c141d" : "#111820" - border.width: phaseField.activeFocus ? 1 : 0 - border.color: theme.accent - } + invalidInput: false onActiveFocusChanged: { if (activeFocus) { root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) + text = signalRow.angle.toFixed(2) selectAll() } } - onEditingFinished: - root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "phase", parseFloat(text)) + onTextEdited: { + var value = root.parseOperatorNumber(text) + if (root.validPhase(value)) { + invalidInput = false + root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "phase", value) + } else { + invalidInput = true + } + } + onEditingFinished: { + var value = root.parseOperatorNumber(text) + if (!root.validPhase(value)) { + text = signalRow.angle.toFixed(2) + invalidInput = false + root.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) { root.navigate(signalRow.groupIndex, signalRow.rowIndex, 1, event.key) @@ -1386,6 +1595,7 @@ ApplicationWindow { color: theme.muted font.family: root.monoFont font.pixelSize: 8 + elide: Text.ElideRight } } } From b3940d91126789006a94eace8d0d1636454978e7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:24:00 +0700 Subject: [PATCH 23/24] Fix Qt P0 state badge color --- apps/arstack_studio/qml/Main.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index a65803254..5cfa3d008 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -412,7 +412,7 @@ ApplicationWindow { (root.instrumentState === "PROFILE CHANGED" || root.instrumentState === "DEPLOYING") ? theme.amberSoft : theme.accentSoft border.width: 1 - border.color: Qt.alpha(root.instrumentStateColor, 0.55) + border.color: root.instrumentStateColor Label { id: stateText anchors.centerIn: parent @@ -986,7 +986,7 @@ ApplicationWindow { validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } onTextEdited: { var value = root.parseOperatorNumber(text) - if (acceptableInput && root.validFrequency(value)) { + if (root.validFrequency(value)) { invalidInput = false root.signalFrequency = value waveform.requestPaint() @@ -997,7 +997,7 @@ ApplicationWindow { } onEditingFinished: { var value = root.parseOperatorNumber(text) - if (!acceptableInput || !root.validFrequency(value)) { + if (!root.validFrequency(value)) { text = root.signalFrequency.toFixed(3) invalidInput = false root.showMessage("Frequency must be greater than 0 and not exceed 1000 Hz.", true) From 655d9d555aeb98da368974f82b24b27cf9d090fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:49:00 +0700 Subject: [PATCH 24/24] Polish Qt SMV Injector P1 UX --- apps/arstack_studio/CMakeLists.txt | 10 + apps/arstack_studio/qml/CalmButton.qml | 46 + apps/arstack_studio/qml/Main.qml | 1327 ++++-------------- apps/arstack_studio/qml/NumericField.qml | 31 + apps/arstack_studio/qml/ProfileInspector.qml | 357 +++++ apps/arstack_studio/qml/QualityEditor.qml | 145 ++ apps/arstack_studio/qml/SignalMatrix.qml | 254 ++++ apps/arstack_studio/qml/SignalPreview.qml | 222 +++ apps/arstack_studio/qml/StateBadge.qml | 33 + apps/arstack_studio/qml/StatusToast.qml | 38 + apps/arstack_studio/qml/StudioTheme.qml | 25 + apps/arstack_studio/qml/SurfacePanel.qml | 10 + 12 files changed, 1422 insertions(+), 1076 deletions(-) create mode 100644 apps/arstack_studio/qml/CalmButton.qml create mode 100644 apps/arstack_studio/qml/NumericField.qml create mode 100644 apps/arstack_studio/qml/ProfileInspector.qml create mode 100644 apps/arstack_studio/qml/QualityEditor.qml create mode 100644 apps/arstack_studio/qml/SignalMatrix.qml create mode 100644 apps/arstack_studio/qml/SignalPreview.qml create mode 100644 apps/arstack_studio/qml/StateBadge.qml create mode 100644 apps/arstack_studio/qml/StatusToast.qml create mode 100644 apps/arstack_studio/qml/StudioTheme.qml create mode 100644 apps/arstack_studio/qml/SurfacePanel.qml diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 7b811e9ea..eb5ec7639 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -30,6 +30,16 @@ qt_add_qml_module(arstack_studio VERSION 1.0 QML_FILES qml/Main.qml + qml/StudioTheme.qml + qml/SurfacePanel.qml + qml/CalmButton.qml + qml/NumericField.qml + qml/StateBadge.qml + qml/ProfileInspector.qml + qml/SignalMatrix.qml + qml/QualityEditor.qml + qml/SignalPreview.qml + qml/StatusToast.qml ) target_link_libraries(arstack_studio PRIVATE diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml new file mode 100644 index 000000000..4653edbca --- /dev/null +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Button { + id: control + property var theme + property string uiFont: "Inter" + property string tone: "neutral" + + implicitHeight: 34 + implicitWidth: Math.max(70, contentItem.implicitWidth + 24) + font.family: uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + + readonly property color activeFill: !enabled ? "#121820" : + tone === "success" ? "#194d38" : + tone === "danger" ? theme.redSoft : + tone === "accent" ? theme.accentSoft : + (down ? "#1c2733" : hovered ? theme.raisedHover : theme.raised) + readonly property color activeEdge: !enabled ? theme.lineSoft : + tone === "success" ? "#347a59" : + tone === "danger" ? "#86434b" : + tone === "accent" ? "#315f8d" : + (hovered ? "#3a4a5b" : theme.line) + readonly property color activeText: !enabled ? theme.muted2 : + tone === "success" ? "#d6f4e6" : + tone === "danger" ? "#ffdce0" : theme.textSoft + + contentItem: Text { + text: control.text + color: control.activeText + font: control.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: control.activeFill + border.width: 1 + border.color: control.activeEdge + Behavior on color { ColorAnimation { duration: 90 } } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 5cfa3d008..a0b1a7773 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import QtQuick.Dialogs import ARStack.Studio 1.0 ApplicationWindow { @@ -13,31 +12,9 @@ ApplicationWindow { minimumHeight: 720 visible: true title: "ARStack Studio · SMV Injector" - color: theme.bg - - QtObject { - id: theme - 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" - } + color: studioTheme.bg + + StudioTheme { id: studioTheme } property string uiFont: "Inter" property string monoFont: "Cascadia Mono" @@ -65,7 +42,6 @@ ApplicationWindow { readonly property bool canStart: device.connected && !device.running && (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) - readonly property string instrumentState: { if (!device.connected) return "OFFLINE" if (device.profileDeploying) return "DEPLOYING" @@ -77,22 +53,24 @@ ApplicationWindow { return "CONNECTED" } readonly property color instrumentStateColor: { - if (instrumentState === "RUNNING") return theme.green - if (instrumentState === "ARMED") return theme.accent - if (instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return theme.amber - if (instrumentState === "PROFILE BLOCKED") return theme.red - return device.connected ? theme.textSoft : theme.muted + if (instrumentState === "RUNNING") return studioTheme.green + if (instrumentState === "ARMED") return studioTheme.accent + if (instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return studioTheme.amber + if (instrumentState === "PROFILE BLOCKED") return studioTheme.red + return device.connected ? studioTheme.textSoft : studioTheme.muted } readonly property string stateReason: { if (!device.connected) return "Connect the injector; setpoints remain editable offline." if (device.profileDeploying) return "Committing immutable SCL profile to the device." if (device.running) return "Live apply active · valid edits update SMV immediately." if (profileDirty) return "Selected profile changed · deploy before Start." - if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "Selected stream is not deployable on the current ESP32-P4 layout." + 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 } @@ -114,7 +92,7 @@ ApplicationWindow { Timer { id: messageTimer - interval: 2800 + interval: 2600 onTriggered: { root.transientMessage = "" root.transientError = false @@ -147,8 +125,8 @@ ApplicationWindow { return group === 0 ? currentModel : voltageModel } - function groupRepeater(group) { - return group === 0 ? currentRepeater : voltageRepeater + function groupMatrix(group) { + return group === 0 ? currentMatrix : voltageMatrix } function parseOperatorNumber(text) { @@ -187,13 +165,16 @@ ApplicationWindow { function normalizedAngle(value) { var result = value % 360 - if (result > 180) - result -= 360 - if (result <= -180) - result += 360 + if (result > 180) result -= 360 + if (result <= -180) result += 360 return result } + function refreshPreview() { + if (previewPanel) + previewPanel.requestPaint() + } + function selectSignal(group, row) { var signal = groupModel(group).get(row) activeGroup = group @@ -203,8 +184,7 @@ ApplicationWindow { activeMagnitude = signal.magnitude activePhase = signal.phase activeQuality = Number(signal.quality) - phasor.requestPaint() - waveform.requestPaint() + refreshPreview() } function focusCell(group, row, column) { @@ -218,12 +198,7 @@ ApplicationWindow { nextGroup = (nextGroup + 1) % 2 nextRow = 0 } - var rowItem = groupRepeater(nextGroup).itemAt(nextRow) - if (!rowItem) - return - var editor = column === 0 ? rowItem.magnitudeEditor : rowItem.phaseEditor - editor.forceActiveFocus() - editor.selectAll() + groupMatrix(nextGroup).focusCell(nextRow, column) } function navigate(group, row, column, key) { @@ -276,10 +251,8 @@ ApplicationWindow { model.setProperty(i, "magnitude", value) } else { var base = value - if (row === 1) - base += 120 - if (row === 2) - base -= 120 + 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)) @@ -304,6 +277,18 @@ ApplicationWindow { device.setFrequency(signalFrequency) } + function setFrequencyValue(value) { + if (!validFrequency(value)) + return false + signalFrequency = value + frequencyField.text = value.toFixed(3) + frequencyField.invalidInput = false + refreshPreview() + if (device.connected) + device.setFrequency(value) + return true + } + function balanced() { var angles = [0, -120, 120, 0] for (var i = 0; i < 4; ++i) { @@ -314,8 +299,8 @@ ApplicationWindow { voltageModel.setProperty(i, "phase", angles[i]) voltageModel.setProperty(i, "enabled", true) } - phasor.requestPaint() - waveform.requestPaint() + selectSignal(activeGroup, activeRow) + refreshPreview() if (device.connected) applyAllSignals() } @@ -325,8 +310,8 @@ ApplicationWindow { currentModel.setProperty(i, "magnitude", 0.0) voltageModel.setProperty(i, "magnitude", 0.0) } - phasor.requestPaint() - waveform.requestPaint() + selectSignal(activeGroup, activeRow) + refreshPreview() if (device.connected) device.zero() } @@ -339,125 +324,32 @@ ApplicationWindow { device.setQuality(activeSignal, unsignedValue) } - component Kicker: Label { - color: theme.muted - font.family: root.uiFont - font.pixelSize: 9 - font.weight: Font.DemiBold - font.letterSpacing: 1.2 - } - - component Quiet: Label { - color: theme.muted - font.family: root.uiFont - font.pixelSize: 10 - } - - component Surface: Rectangle { - color: theme.surface - radius: 9 - border.width: 1 - border.color: theme.lineSoft - } - - component CalmButton: Button { - id: calmButton - implicitHeight: 34 - font.family: root.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold - contentItem: Text { - text: calmButton.text - color: calmButton.enabled ? theme.textSoft : theme.muted2 - font: calmButton.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - radius: 6 - color: calmButton.down ? "#1c2733" : calmButton.hovered ? theme.raisedHover : theme.raised - border.width: 1 - border.color: calmButton.hovered ? "#3a4a5b" : theme.line - } - } - - component NumericField: TextField { - id: numericField - property bool invalidInput: false - property bool compact: false - selectByMouse: true - horizontalAlignment: Text.AlignRight - color: invalidInput ? theme.red : theme.text - selectionColor: theme.accent - selectedTextColor: theme.bg - font.family: root.monoFont - font.pixelSize: compact ? 11 : 13 - font.weight: Font.DemiBold - leftPadding: 8 - rightPadding: 8 - background: Rectangle { - radius: 6 - color: numericField.activeFocus ? "#0c141d" : "#111820" - border.width: numericField.activeFocus || numericField.invalidInput ? 1 : 0 - border.color: numericField.invalidInput ? theme.red : theme.accent - } - } - - component StateBadge: Rectangle { - implicitHeight: 24 - implicitWidth: stateText.implicitWidth + 24 - radius: 6 - color: root.instrumentState === "RUNNING" ? theme.greenSoft : - root.instrumentState === "PROFILE BLOCKED" ? theme.redSoft : - (root.instrumentState === "PROFILE CHANGED" || root.instrumentState === "DEPLOYING") ? theme.amberSoft : - theme.accentSoft - border.width: 1 - border.color: root.instrumentStateColor - Label { - id: stateText - anchors.centerIn: parent - text: root.instrumentState - color: root.instrumentStateColor - font.family: root.monoFont - font.pixelSize: 9 - font.weight: Font.Bold - font.letterSpacing: 0.5 - } - } - - FileDialog { - id: fileDialog - title: "Open IEC 61850 engineering file" - nameFilters: [ - "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", - "All files (*)" - ] - onAccepted: { - if (sclProfiles.loadFile(selectedFile)) - root.profileDirty = true - } - } - Dialog { id: diagnosticsDialog - width: Math.min(root.width * 0.72, 980) - height: Math.min(root.height * 0.72, 620) + 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: theme.surface - radius: 9 - border.color: theme.line + color: studioTheme.surface + radius: 8 + border.width: 1 + border.color: studioTheme.line } contentItem: ColumnLayout { spacing: 8 RowLayout { Layout.fillWidth: true - Quiet { text: device.connected ? device.portName + " · 115200 8N1" : "Device offline" } + 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 { text: "Clear"; onClicked: device.clearLog() } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Clear"; onClicked: device.clearLog() } } ScrollView { Layout.fillWidth: true @@ -465,161 +357,139 @@ ApplicationWindow { TextArea { readOnly: true text: device.logText - color: theme.textSoft - selectionColor: theme.accent + color: studioTheme.textSoft + selectionColor: studioTheme.accent font.family: root.monoFont - font.pixelSize: 10 + font.pixelSize: 9 wrapMode: TextEdit.WrapAnywhere - background: Rectangle { - color: "#090e14" - radius: 7 - border.color: theme.lineSoft - } + background: Rectangle { color: "#090e14"; radius: 6; border.width: 1; border.color: studioTheme.lineSoft } } } } } header: Rectangle { - height: 56 - color: theme.chrome + height: 54 + color: studioTheme.chrome border.width: 1 - border.color: theme.lineSoft + border.color: studioTheme.lineSoft RowLayout { anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 10 + anchors.leftMargin: 15 + anchors.rightMargin: 15 + spacing: 9 Rectangle { - width: 28 - height: 28 + width: 27 + height: 27 radius: 6 color: "#101b27" + border.width: 1 border.color: "#315071" - Label { - anchors.centerIn: parent - text: "≋" - color: theme.accent - font.pixelSize: 17 - } + Label { anchors.centerIn: parent; text: "≋"; color: studioTheme.accent; font.pixelSize: 16 } } ColumnLayout { spacing: 0 - Label { - text: "ARSTACK61850" - color: theme.muted - font.family: root.uiFont - font.pixelSize: 8 - font.weight: Font.Bold - font.letterSpacing: 1.2 - } - Label { - text: "SMV Injector" - color: theme.text - font.family: root.uiFont - font.pixelSize: 14 - font.weight: Font.DemiBold - } + Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 1.15 } + Label { text: "SMV Injector"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } Rectangle { visible: device.connected - implicitWidth: liveText.implicitWidth + 20 - implicitHeight: 23 - radius: 6 - color: theme.greenSoft + implicitWidth: liveApplyText.implicitWidth + 18 + implicitHeight: 22 + radius: 5 + color: studioTheme.greenSoft border.width: 1 border.color: "#2b674d" Label { - id: liveText + id: liveApplyText anchors.centerIn: parent text: "LIVE APPLY" - color: theme.green + color: studioTheme.green font.family: root.monoFont - font.pixelSize: 8 + font.pixelSize: 7 font.weight: Font.Bold - font.letterSpacing: 0.7 + font.letterSpacing: 0.65 } } Label { visible: !root.compactLayout - text: sclProfiles.hasProfiles ? - (sclProfiles.selectedProfile.svId || "Resolved SV") : - "Development profile" - color: theme.textSoft + text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "Development profile" + color: studioTheme.textSoft font.family: root.monoFont - font.pixelSize: 10 + font.pixelSize: 9 } - Rectangle { width: 1; height: 20; color: theme.line } + Rectangle { width: 1; height: 19; color: studioTheme.line } ComboBox { id: portCombo - implicitWidth: root.compactLayout ? 100 : 116 + implicitWidth: root.compactLayout ? 96 : 112 model: device.ports enabled: !device.connected font.family: root.monoFont - font.pixelSize: 10 + font.pixelSize: 9 onPressedChanged: if (pressed) device.refreshPorts() } - Rectangle { - width: 7 - height: 7 - radius: 4 - color: device.connected ? theme.green : theme.muted2 - } + Rectangle { width: 7; height: 7; radius: 4; color: device.connected ? studioTheme.green : studioTheme.muted2 } - Quiet { + Label { visible: !root.compactLayout text: device.connected ? device.portName : "Device offline" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 } CalmButton { + theme: studioTheme + uiFont: root.uiFont text: device.connected ? "Disconnect" : "Connect" onClicked: { - if (device.connected) - device.disconnectPort() - else - device.connectPort(portCombo.currentText) + if (device.connected) device.disconnectPort() + else device.connectPort(portCombo.currentText) } } } } footer: Rectangle { - height: 66 - color: theme.chrome + height: 62 + color: studioTheme.chrome border.width: 1 - border.color: theme.lineSoft + border.color: studioTheme.lineSoft RowLayout { anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 10 + anchors.leftMargin: 15 + anchors.rightMargin: 15 + spacing: 9 - StateBadge {} + StateBadge { theme: studioTheme; state: root.instrumentState; stateColor: root.instrumentStateColor; monoFont: root.monoFont } ColumnLayout { - Layout.maximumWidth: root.compactLayout ? 300 : 440 + Layout.maximumWidth: root.compactLayout ? 280 : 420 spacing: 1 - Quiet { + Label { Layout.fillWidth: true text: root.stateReason + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 elide: Text.ElideRight } Label { text: "FPS " + device.fps + " · MISSED " + device.missed + " · TX FAIL " + device.txFailures - color: theme.muted + color: studioTheme.muted font.family: root.monoFont - font.pixelSize: 8 + font.pixelSize: 7 } } @@ -627,66 +497,40 @@ ApplicationWindow { CalmButton { visible: !root.compactLayout + theme: studioTheme + uiFont: root.uiFont text: "Diagnostics" onClicked: diagnosticsDialog.open() } CalmButton { + theme: studioTheme + uiFont: root.uiFont + tone: "accent" text: device.profileDeploying ? "Deploying…" : "Deploy" enabled: root.canDeploy - onClicked: { - if (device.deployProfile(sclProfiles.selectedProfile)) - root.profileDirty = true - } + onClicked: if (device.deployProfile(sclProfiles.selectedProfile)) root.profileDirty = true } - Button { - id: stopButton + CalmButton { + theme: studioTheme + uiFont: root.uiFont + tone: "danger" text: "■ Stop" + implicitWidth: 88 + implicitHeight: 35 enabled: device.connected && device.running - implicitWidth: 92 - implicitHeight: 36 - font.family: root.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold - contentItem: Text { - text: stopButton.text - color: stopButton.enabled ? "#ffdce0" : theme.muted2 - font: stopButton.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - radius: 6 - color: stopButton.enabled ? theme.redSoft : "#121820" - border.width: 1 - border.color: stopButton.enabled ? "#86434b" : theme.lineSoft - } onClicked: device.stop() } - Button { - id: startButton + CalmButton { + theme: studioTheme + uiFont: root.uiFont + tone: "success" text: "▶ Start" + implicitWidth: 98 + implicitHeight: 35 enabled: root.canStart - implicitWidth: 104 - implicitHeight: 36 - font.family: root.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold - contentItem: Text { - text: startButton.text - color: startButton.enabled ? "#d6f4e6" : theme.muted2 - font: startButton.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - radius: 6 - color: startButton.enabled ? "#194d38" : "#121820" - border.width: 1 - border.color: startButton.enabled ? "#347a59" : theme.lineSoft - } onClicked: device.start() } } @@ -694,267 +538,55 @@ ApplicationWindow { RowLayout { anchors.fill: parent - anchors.margins: root.compactLayout ? 10 : 12 - spacing: root.compactLayout ? 10 : 12 + anchors.margins: root.compactLayout ? 9 : 11 + spacing: root.compactLayout ? 9 : 11 - Surface { - Layout.preferredWidth: root.compactLayout ? 206 : 246 - Layout.minimumWidth: root.compactLayout ? 196 : 220 + ProfileInspector { + Layout.preferredWidth: root.compactLayout ? 200 : 238 + Layout.minimumWidth: root.compactLayout ? 192 : 214 Layout.fillHeight: true - - ColumnLayout { - anchors.fill: parent - anchors.margins: root.compactLayout ? 12 : 15 - spacing: root.compactLayout ? 9 : 12 - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - Kicker { text: "ENGINEERING SOURCE" } - Label { - Layout.fillWidth: true - text: sclProfiles.hasProfiles ? - (sclProfiles.selectedProfile.svId || "Compiled SCL") : - "Development profile" - color: theme.text - font.family: root.uiFont - font.pixelSize: root.compactLayout ? 14 : 16 - font.weight: Font.DemiBold - elide: Text.ElideRight - } - Quiet { - Layout.fillWidth: true - elide: Text.ElideMiddle - text: sclProfiles.sourceName.length ? sclProfiles.sourceName : "SCL / CID / SCD / IID" - } - } - - CalmButton { - Layout.fillWidth: true - text: "Open SCL / CID" - enabled: !device.running - onClicked: fileDialog.open() - } - - ColumnLayout { - visible: sclProfiles.hasProfiles - Layout.fillWidth: true - spacing: 6 - Kicker { text: "RESOLVED STREAM" } - ComboBox { - Layout.fillWidth: true - model: sclProfiles - textRole: "control" - currentIndex: sclProfiles.selectedIndex - enabled: !device.running - font.family: root.uiFont - font.pixelSize: 10 - onActivated: { - sclProfiles.selectStream(currentIndex) - root.profileDirty = true - } - } - - GridLayout { - Layout.fillWidth: true - columns: 2 - columnSpacing: 8 - rowSpacing: 5 - Quiet { text: "Class" } - Label { - text: "CLASS " + (sclProfiles.selectedProfile.compatibilityClass || "—") - color: sclProfiles.selectedProfile.compatibilityClass === "A" ? theme.green : - sclProfiles.selectedProfile.compatibilityClass === "B" ? theme.amber : theme.red - font.family: root.uiFont - font.pixelSize: 9 - font.weight: Font.Bold - } - Quiet { text: "Support" } - Label { - text: sclProfiles.selectedProfile.deviceSupport || "—" - color: theme.textSoft - font.family: root.uiFont - font.pixelSize: 9 - elide: Text.ElideRight - Layout.fillWidth: true - } - Quiet { text: "APPID" } - Label { text: sclProfiles.selectedProfile.appIdHex || "—"; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9 } - Quiet { text: "Rate" } - Label { - text: sclProfiles.selectedProfile.publisherRate ? sclProfiles.selectedProfile.publisherRate + "/s" : "—" - color: theme.textSoft - font.family: root.monoFont - font.pixelSize: 9 - } - Quiet { text: "VLAN" } - Label { - text: sclProfiles.selectedProfile.vlanPresent ? - "P" + sclProfiles.selectedProfile.vlanPriority + " · " + sclProfiles.selectedProfile.vlanId : "untagged" - color: theme.textSoft - font.family: root.monoFont - font.pixelSize: 9 - } - Quiet { text: "Payload" } - Label { - text: sclProfiles.selectedProfile.payloadBytes ? sclProfiles.selectedProfile.payloadBytes + " B" : "—" - color: theme.textSoft - font.family: root.monoFont - font.pixelSize: 9 - } - } - - Quiet { - Layout.fillWidth: true - visible: (sclProfiles.selectedProfile.warnings || []).length > 0 || - (sclProfiles.selectedProfile.errors || []).length > 0 - wrapMode: Text.WordWrap - maximumLineCount: 3 - elide: Text.ElideRight - color: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? theme.red : theme.amber - text: sclProfiles.selectedProfile.errors && sclProfiles.selectedProfile.errors.length ? - sclProfiles.selectedProfile.errors[0] : sclProfiles.selectedProfile.warnings[0] - } - } - - ColumnLayout { - visible: sclProfiles.selectedProfile.compatibilityClass === "B" - Layout.fillWidth: true - spacing: 6 - Kicker { text: "COUNTER POLICY" } - Quiet { - Layout.fillWidth: true - wrapMode: Text.WordWrap - text: "Candidate smpCnt modulus requires explicit evidence confirmation." - } - RowLayout { - Layout.fillWidth: true - NumericField { - id: counterField - Layout.fillWidth: true - compact: true - text: sclProfiles.selectedProfile.counterModulus || "" - validator: IntValidator { bottom: 1; top: 65535 } - } - CalmButton { - text: "Confirm" - onClicked: { - if (counterField.acceptableInput && sclProfiles.confirmCounterModulus(parseInt(counterField.text))) - root.profileDirty = true - } - } - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 5 - Kicker { text: "SCALING" } - RowLayout { - Layout.fillWidth: true - Quiet { text: "I"; Layout.preferredWidth: 18 } - NumericField { - Layout.fillWidth: true - compact: true - text: root.currentScale.toString() - validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (acceptableInput && isFinite(value) && value > 0) { - root.currentScale = value - if (device.connected) root.applyGroupSignals(0) - } else { - text = root.currentScale.toString() - } - } - } - Quiet { text: "ct/A" } - } - RowLayout { - Layout.fillWidth: true - Quiet { text: "U"; Layout.preferredWidth: 18 } - NumericField { - Layout.fillWidth: true - compact: true - text: root.voltageScale.toString() - validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (acceptableInput && isFinite(value) && value > 0) { - root.voltageScale = value - if (device.connected) root.applyGroupSignals(1) - } else { - text = root.voltageScale.toString() - } - } - } - Quiet { text: "ct/V" } - } - } - - Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } - Kicker { text: "QUICK SETUP" } - CalmButton { Layout.fillWidth: true; text: "Balanced 3-phase"; onClicked: root.balanced() } - CalmButton { Layout.fillWidth: true; text: "Zero output"; onClicked: root.zeroAll() } - - Item { Layout.fillHeight: true } - - Kicker { text: "RUNTIME" } - GridLayout { - Layout.fillWidth: true - columns: 2 - columnSpacing: 8 - rowSpacing: 4 - Quiet { text: "Rate" } - Label { text: device.fps; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } - Quiet { text: "Missed" } - Label { text: device.missed; color: Number(device.missed) > 0 ? theme.amber : theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } - Quiet { text: "TX fail" } - Label { text: device.txFailures; color: Number(device.txFailures) > 0 ? theme.red : theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } - Quiet { text: "Generation" } - Label { text: device.signalGeneration; color: theme.textSoft; font.family: root.monoFont; font.pixelSize: 9; Layout.alignment: Qt.AlignRight } - } - } + theme: studioTheme + controller: root + device: device + profiles: sclProfiles + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout } - Surface { + SurfacePanel { Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumWidth: root.compactLayout ? 500 : 560 + Layout.minimumWidth: root.compactLayout ? 500 : 550 + theme: studioTheme ColumnLayout { anchors.fill: parent - anchors.margins: root.compactLayout ? 14 : 17 - spacing: 11 + anchors.margins: root.compactLayout ? 12 : 15 + spacing: 9 RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 1 - Kicker { text: "INJECTION WORKSPACE" } - Label { - text: "Manual Injection" - color: theme.text - font.family: root.uiFont - font.pixelSize: root.compactLayout ? 19 : 22 - font.weight: Font.DemiBold - } + Label { text: "INJECTION WORKSPACE"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { text: "Manual Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } Rectangle { - implicitWidth: applyText.implicitWidth + 18 - implicitHeight: 24 - radius: 6 - color: device.connected ? theme.greenSoft : "#141b23" + implicitWidth: workspaceApplyText.implicitWidth + 17 + implicitHeight: 22 + radius: 5 + color: device.connected ? studioTheme.greenSoft : "#141b23" border.width: 1 - border.color: device.connected ? "#2c674e" : theme.lineSoft + border.color: device.connected ? "#2c674e" : studioTheme.lineSoft Label { - id: applyText + id: workspaceApplyText anchors.centerIn: parent text: device.connected ? "LIVE APPLY" : "LOCAL EDIT" - color: device.connected ? theme.green : theme.muted + color: device.connected ? studioTheme.green : studioTheme.muted font.family: root.monoFont - font.pixelSize: 8 + font.pixelSize: 7 font.weight: Font.Bold } } @@ -962,34 +594,37 @@ ApplicationWindow { Rectangle { Layout.fillWidth: true - height: 56 - radius: 8 - color: theme.surface2 + height: 53 + radius: 7 + color: studioTheme.surface2 border.width: 1 - border.color: theme.lineSoft + border.color: studioTheme.lineSoft RowLayout { anchors.fill: parent - anchors.leftMargin: 13 - anchors.rightMargin: 13 - spacing: 9 + anchors.leftMargin: 11 + anchors.rightMargin: 11 + spacing: 8 ColumnLayout { spacing: 0 - Kicker { text: "FREQUENCY" } + Label { text: "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold; font.letterSpacing: 0.85 } RowLayout { + spacing: 5 NumericField { id: frequencyField - implicitWidth: 88 + theme: studioTheme + monoFont: root.monoFont + compact: root.compactLayout + implicitWidth: 84 text: "50.000" - invalidInput: !acceptableInput 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 - waveform.requestPaint() + root.refreshPreview() if (device.connected) device.setFrequency(value) } else { invalidInput = true @@ -1006,47 +641,31 @@ ApplicationWindow { } } } - Quiet { text: "Hz" } + Label { text: "Hz"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 8 } } } - CalmButton { - text: "50" - onClicked: { - root.signalFrequency = 50 - frequencyField.text = "50.000" - frequencyField.invalidInput = false - waveform.requestPaint() - if (device.connected) device.setFrequency(50) - } - } - CalmButton { - text: "60" - onClicked: { - root.signalFrequency = 60 - frequencyField.text = "60.000" - frequencyField.invalidInput = false - waveform.requestPaint() - if (device.connected) device.setFrequency(60) - } - } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } - Rectangle { width: 1; height: 26; color: theme.lineSoft } + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } CheckBox { checked: root.phaseLink text: "3-phase link" onToggled: root.phaseLink = checked font.family: root.uiFont - font.pixelSize: 10 + font.pixelSize: 9 } Item { Layout.fillWidth: true } - Quiet { + Label { visible: !root.compactLayout - text: device.connected ? "Valid edits → SMV immediately" : "Connect to stream edits live" - color: device.connected ? theme.green : theme.muted + text: device.connected ? "Valid edit → SMV immediately" : "Connect to stream edits live" + color: device.connected ? studioTheme.green : studioTheme.muted + font.family: root.uiFont + font.pixelSize: 8 } } } @@ -1054,548 +673,104 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true Layout.fillHeight: true - spacing: 10 + spacing: 9 - Rectangle { + SignalMatrix { + id: currentMatrix Layout.fillWidth: true Layout.fillHeight: true - color: theme.surface2 - radius: 8 - border.color: theme.lineSoft - ColumnLayout { - anchors.fill: parent - spacing: 0 - MatrixHeader { titleText: "Current"; symbolText: "I"; unitText: "A RMS" } - MatrixColumns {} - Repeater { - id: currentRepeater - model: currentModel - SignalRow { - groupIndex: 0 - rowIndex: index - sourceModel: currentModel - sid: signalId - mag: magnitude - angle: phase - isEnabled: enabled - signalQuality: quality - phaseColor: traceColor - } - } - Item { Layout.fillHeight: true } - } + theme: studioTheme + controller: root + device: device + sourceModel: currentModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 0 + titleText: "Current" + symbolText: "I" + unitText: "A RMS" + compact: root.compactLayout } - Rectangle { + SignalMatrix { + id: voltageMatrix Layout.fillWidth: true Layout.fillHeight: true - color: theme.surface2 - radius: 8 - border.color: theme.lineSoft - ColumnLayout { - anchors.fill: parent - spacing: 0 - MatrixHeader { titleText: "Voltage"; symbolText: "U"; unitText: "V RMS" } - MatrixColumns {} - Repeater { - id: voltageRepeater - model: voltageModel - SignalRow { - groupIndex: 1 - rowIndex: index - sourceModel: voltageModel - sid: signalId - mag: magnitude - angle: phase - isEnabled: enabled - signalQuality: quality - phaseColor: traceColor - } - } - Item { Layout.fillHeight: true } - } + theme: studioTheme + controller: root + device: device + sourceModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 1 + titleText: "Voltage" + symbolText: "U" + unitText: "V RMS" + compact: root.compactLayout } } + Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } + RowLayout { Layout.fillWidth: true - spacing: 7 - Rectangle { width: 7; height: 7; radius: 4; color: theme.accent } + spacing: 8 + Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } + Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: 9; font.weight: Font.Bold } Label { - text: activeSignal - color: theme.textSoft - font.family: root.monoFont - font.pixelSize: 10 - font.weight: Font.Bold + text: root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 } - Quiet { text: activeMagnitude.toFixed(3) + " " + activeUnit + " RMS · " + activePhase.toFixed(2) + "°" } - Item { Layout.fillWidth: true } - Quiet { text: "Quality" } - NumericField { - id: qualityField - implicitWidth: 102 - compact: true - text: "0x" + (Number(root.activeQuality) >>> 0).toString(16).padStart(8, "0").toUpperCase() - onEditingFinished: { - var trimmed = text.trim() - var qualityValue = trimmed.toLowerCase().startsWith("0x") ? - parseInt(trimmed.substring(2), 16) : parseInt(trimmed, 10) - if (!isNaN(qualityValue) && qualityValue >= 0 && qualityValue <= 0xFFFFFFFF) { - root.setActiveQuality(qualityValue) - text = "0x" + (qualityValue >>> 0).toString(16).padStart(8, "0").toUpperCase() - invalidInput = false - } else { - invalidInput = true - text = "0x" + (Number(root.activeQuality) >>> 0).toString(16).padStart(8, "0").toUpperCase() - root.showMessage("Quality must be a valid 32-bit value.", true) - } - } + 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 } - Quiet { visible: !root.compactLayout; text: "↑↓ channel ←→ field Enter next" } } } } - Surface { - Layout.preferredWidth: root.compactLayout ? 304 : 386 - Layout.minimumWidth: root.compactLayout ? 286 : 330 + SignalPreview { + id: previewPanel + Layout.preferredWidth: root.compactLayout ? 290 : 360 + Layout.minimumWidth: root.compactLayout ? 280 : 320 Layout.fillHeight: true - - ColumnLayout { - anchors.fill: parent - anchors.margins: root.compactLayout ? 12 : 15 - spacing: 9 - - RowLayout { - Layout.fillWidth: true - ColumnLayout { - spacing: 1 - Kicker { text: "GENERATED SETPOINT" } - Label { - text: "Signal Preview" - color: theme.text - font.family: root.uiFont - font.pixelSize: root.compactLayout ? 16 : 18 - font.weight: Font.DemiBold - } - } - Item { Layout.fillWidth: true } - Quiet { text: "LOCAL" } - } - - PreviewTitle { - titleText: "Phasor" - detailText: activeSignal + " · " + activeMagnitude.toFixed(3) + " " + activeUnit + " ∠ " + activePhase.toFixed(2) + "°" - } - - Canvas { - id: phasor - Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredHeight: 1 - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - onPaint: { - var ctx = getContext("2d") - ctx.reset() - var centerX = width / 2 - var centerY = height / 2 - var radiusValue = Math.min(width, height) * 0.39 - - ctx.strokeStyle = "#283441" - ctx.lineWidth = 1 - for (var ring = 1; ring <= 3; ++ring) { - ctx.beginPath() - ctx.arc(centerX, centerY, radiusValue * ring / 3, 0, Math.PI * 2) - ctx.stroke() - } - for (var degrees = 0; degrees < 180; degrees += 30) { - var axis = degrees * Math.PI / 180 - var axisX = Math.cos(axis) * radiusValue - var axisY = Math.sin(axis) * radiusValue - ctx.beginPath() - ctx.moveTo(centerX - axisX, centerY - axisY) - ctx.lineTo(centerX + axisX, centerY + axisY) - ctx.stroke() - } - - function drawGroup(model, scale, dashed) { - var maxMagnitude = 0.0001 - for (var i = 0; i < 4; ++i) - maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) - - for (var j = 0; j < 4; ++j) { - var signal = model.get(j) - if (!signal.enabled || signal.magnitude <= 0) - continue - var angleValue = signal.phase * Math.PI / 180 - var length = radiusValue * scale * (0.30 + 0.70 * Math.min(1, signal.magnitude / maxMagnitude)) - var endX = centerX + Math.cos(angleValue) * length - var endY = centerY - Math.sin(angleValue) * length - var active = signal.signalId === root.activeSignal - - ctx.save() - ctx.globalAlpha = active ? 1 : 0.70 - ctx.strokeStyle = signal.traceColor - ctx.fillStyle = signal.traceColor - ctx.lineWidth = active ? 3.0 : 1.8 - ctx.lineCap = "round" - ctx.setLineDash(dashed ? [5, 4] : []) - ctx.beginPath() - ctx.moveTo(centerX, centerY) - ctx.lineTo(endX, endY) - ctx.stroke() - ctx.setLineDash([]) - - var arrowHead = active ? 8 : 6 - ctx.beginPath() - ctx.moveTo(endX, endY) - ctx.lineTo(endX - Math.cos(angleValue - 0.45) * arrowHead, endY + Math.sin(angleValue - 0.45) * arrowHead) - ctx.lineTo(endX - Math.cos(angleValue + 0.45) * arrowHead, endY + Math.sin(angleValue + 0.45) * arrowHead) - ctx.closePath() - ctx.fill() - - if (active) { - ctx.font = "600 11px Inter" - ctx.fillText(signal.signalId, endX + 8, endY - 7) - } - ctx.restore() - } - } - - drawGroup(voltageModel, 1.0, false) - drawGroup(currentModel, 0.78, true) - } - } - - PreviewTitle { titleText: "Waveform"; detailText: root.signalFrequency.toFixed(3) + " Hz · 40 ms" } - - Canvas { - id: waveform - Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredHeight: 0.85 - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - onPaint: { - var ctx = getContext("2d") - ctx.reset() - var left = 25 - var right = 8 - var top = 12 - var bottom = 14 - var plotWidth = width - left - right - var plotHeight = height - top - bottom - var gap = 16 - var laneHeight = (plotHeight - gap) / 2 - var voltageY = top + laneHeight / 2 - var currentY = top + laneHeight + gap + laneHeight / 2 - var frequency = root.signalFrequency - - ctx.strokeStyle = "#202b36" - ctx.lineWidth = 1 - for (var grid = 0; grid <= 8; ++grid) { - var gridX = left + plotWidth * grid / 8 - ctx.beginPath() - ctx.moveTo(gridX, top) - ctx.lineTo(gridX, top + plotHeight) - ctx.stroke() - } - - ctx.strokeStyle = "#33404d" - var baselines = [voltageY, currentY] - for (var baselineIndex = 0; baselineIndex < baselines.length; ++baselineIndex) { - ctx.beginPath() - ctx.moveTo(left, baselines[baselineIndex]) - ctx.lineTo(left + plotWidth, baselines[baselineIndex]) - ctx.stroke() - } - - function drawWaveGroup(model, center, dashed) { - var maxMagnitude = 0.0001 - for (var i = 0; i < 4; ++i) - maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) - - for (var j = 0; j < 4; ++j) { - var signal = model.get(j) - if (!signal.enabled || signal.magnitude <= 0) - continue - var active = signal.signalId === root.activeSignal - var amplitude = laneHeight * 0.39 * Math.min(1, signal.magnitude / maxMagnitude) - var phaseRadians = signal.phase * Math.PI / 180 - - ctx.save() - ctx.strokeStyle = signal.traceColor - ctx.globalAlpha = active ? 1 : 0.64 - ctx.lineWidth = active ? 2.5 : 1.6 - ctx.lineCap = "round" - ctx.setLineDash(dashed ? [5, 4] : []) - ctx.beginPath() - for (var point = 0; point <= 180; ++point) { - var ratio = point / 180 - var time = 0.04 * ratio - var sample = Math.sin(2 * Math.PI * frequency * time + phaseRadians) - var x = left + plotWidth * ratio - var y = center - sample * amplitude - if (point === 0) ctx.moveTo(x, y) - else ctx.lineTo(x, y) - } - ctx.stroke() - ctx.restore() - } - } - - drawWaveGroup(voltageModel, voltageY, false) - drawWaveGroup(currentModel, currentY, true) - } - } - - Quiet { - Layout.fillWidth: true - horizontalAlignment: Text.AlignRight - text: "Generated setpoint · not independent on-wire proof" - } - } + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency } } - Rectangle { - visible: root.transientMessage.length > 0 || device.lastError.length > 0 + StatusToast { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: 78 - radius: 7 - color: "#18212b" - border.width: 1 - border.color: root.transientError || device.lastError.length > 0 ? "#70404a" : "#36536f" - implicitWidth: messageLabel.implicitWidth + 28 - implicitHeight: 38 - Label { - id: messageLabel - anchors.centerIn: parent - text: root.transientMessage.length ? root.transientMessage : device.lastError - color: root.transientError || device.lastError.length > 0 ? "#ffb3bb" : theme.textSoft - font.family: root.uiFont - font.pixelSize: 10 - } - } - - component MatrixHeader: RowLayout { - required property string titleText - required property string symbolText - required property string unitText - Layout.fillWidth: true - Layout.preferredHeight: 44 - Layout.leftMargin: 11 - Layout.rightMargin: 11 - Rectangle { - width: 22 - height: 22 - radius: 5 - color: "#152235" - Label { - anchors.centerIn: parent - text: symbolText - color: theme.accent - font.family: root.monoFont - font.pixelSize: 10 - font.weight: Font.Bold - } - } - Label { - text: titleText - color: theme.textSoft - font.family: root.uiFont - font.pixelSize: 12 - font.weight: Font.DemiBold - } - Item { Layout.fillWidth: true } - Quiet { text: unitText } - } - - component MatrixColumns: RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 28 - Layout.leftMargin: 9 - Layout.rightMargin: 9 - spacing: 7 - Quiet { text: "ON"; Layout.preferredWidth: 28; font.pixelSize: 8 } - Quiet { text: "CH"; Layout.preferredWidth: 34; font.pixelSize: 8 } - Quiet { text: "MAGNITUDE"; Layout.fillWidth: true; font.pixelSize: 8 } - Quiet { text: "PHASE"; Layout.preferredWidth: root.compactLayout ? 86 : 104; font.pixelSize: 8 } - } - - component SignalRow: Rectangle { - id: signalRow - required property int groupIndex - required property int rowIndex - required property var sourceModel - required property string sid - required property real mag - required property real angle - required property bool isEnabled - required property real signalQuality - required property color phaseColor - property alias magnitudeEditor: magnitudeField - property alias phaseEditor: phaseField - - Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 54 : 57 - color: magnitudeField.activeFocus || phaseField.activeFocus ? "#141d27" : "transparent" - - onMagChanged: { - if (!magnitudeField.activeFocus) - magnitudeField.text = mag.toFixed(3) - } - onAngleChanged: { - if (!phaseField.activeFocus) - phaseField.text = angle.toFixed(2) - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 9 - anchors.rightMargin: 9 - spacing: 7 - - CheckBox { - Layout.preferredWidth: 28 - checked: signalRow.isEnabled - onToggled: { - signalRow.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) - root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) - phasor.requestPaint() - waveform.requestPaint() - if (device.connected) - device.setEnabled(signalRow.sid, checked) - } - } - - RowLayout { - Layout.preferredWidth: 34 - spacing: 5 - Rectangle { width: 6; height: 6; radius: 3; color: signalRow.phaseColor } - Label { - text: signalRow.sid - color: theme.text - font.family: root.monoFont - font.pixelSize: 10 - font.weight: Font.Bold - } - } - - NumericField { - id: magnitudeField - Layout.fillWidth: true - text: signalRow.mag.toFixed(3) - invalidInput: false - onActiveFocusChanged: { - if (activeFocus) { - root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) - text = signalRow.mag.toFixed(3) - selectAll() - } - } - onTextEdited: { - var value = root.parseOperatorNumber(text) - if (root.validMagnitude(signalRow.groupIndex, value)) { - invalidInput = false - root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "magnitude", value) - } else { - invalidInput = true - } - } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (!root.validMagnitude(signalRow.groupIndex, value)) { - text = signalRow.mag.toFixed(3) - invalidInput = false - root.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) { - root.navigate(signalRow.groupIndex, signalRow.rowIndex, 0, event.key) - event.accepted = true - } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - root.focusCell(signalRow.groupIndex, signalRow.rowIndex + 1, 0) - event.accepted = true - } - } - } - - NumericField { - id: phaseField - Layout.preferredWidth: root.compactLayout ? 86 : 104 - text: signalRow.angle.toFixed(2) - invalidInput: false - onActiveFocusChanged: { - if (activeFocus) { - root.selectSignal(signalRow.groupIndex, signalRow.rowIndex) - text = signalRow.angle.toFixed(2) - selectAll() - } - } - onTextEdited: { - var value = root.parseOperatorNumber(text) - if (root.validPhase(value)) { - invalidInput = false - root.editSignal(signalRow.groupIndex, signalRow.rowIndex, "phase", value) - } else { - invalidInput = true - } - } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (!root.validPhase(value)) { - text = signalRow.angle.toFixed(2) - invalidInput = false - root.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) { - root.navigate(signalRow.groupIndex, signalRow.rowIndex, 1, event.key) - event.accepted = true - } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - root.focusCell(signalRow.groupIndex, signalRow.rowIndex + 1, 1) - event.accepted = true - } - } - } - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 1 - color: theme.lineSoft - } - } - - component PreviewTitle: RowLayout { - required property string titleText - required property string detailText - Layout.fillWidth: true - Label { - text: titleText - color: theme.textSoft - font.family: root.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold - } - Item { Layout.fillWidth: true } - Label { - text: detailText - color: theme.muted - font.family: root.monoFont - font.pixelSize: 8 - elide: Text.ElideRight - } + anchors.bottomMargin: 72 + theme: studioTheme + uiFont: root.uiFont + message: root.toastMessage + error: root.toastError } -} +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/NumericField.qml b/apps/arstack_studio/qml/NumericField.qml new file mode 100644 index 000000000..a64b1e671 --- /dev/null +++ b/apps/arstack_studio/qml/NumericField.qml @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +TextField { + id: field + property var theme + property string monoFont: "Cascadia Mono" + property bool invalidInput: false + property bool compact: false + + selectByMouse: true + horizontalAlignment: Text.AlignRight + color: invalidInput ? theme.red : theme.text + selectionColor: theme.accent + selectedTextColor: theme.bg + font.family: monoFont + font.pixelSize: compact ? 10 : 12 + font.weight: Font.DemiBold + leftPadding: 8 + rightPadding: 8 + implicitHeight: compact ? 31 : 34 + + background: Rectangle { + radius: 6 + color: field.activeFocus ? "#0c141d" : "#111820" + border.width: field.activeFocus || field.invalidInput ? 1 : 0 + border.color: field.invalidInput ? theme.red : theme.accent + Behavior on color { ColorAnimation { duration: 80 } } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml new file mode 100644 index 000000000..0b298bef1 --- /dev/null +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +SurfacePanel { + id: panel + property var controller + property var device + property var profiles + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property bool compact: false + + readonly property var profile: profiles.selectedProfile + readonly property var issueItems: { + var out = [] + if (profiles.fatalError && profiles.fatalError.length) + out.push({ severity: "error", text: profiles.fatalError }) + var errors = profile.errors || [] + for (var i = 0; i < errors.length && out.length < 2; ++i) + out.push({ severity: "error", text: errors[i] }) + var warnings = profile.warnings || [] + for (var j = 0; j < warnings.length && out.length < 2; ++j) + out.push({ severity: "warning", text: warnings[j] }) + var documentWarnings = profiles.documentWarnings || [] + for (var k = 0; k < documentWarnings.length && out.length < 2; ++k) + out.push({ severity: "warning", text: documentWarnings[k] }) + return out + } + + function kicker(textValue) { + return textValue + } + + FileDialog { + id: engineeringFileDialog + title: "Open IEC 61850 engineering file" + nameFilters: [ + "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", + "All files (*)" + ] + onAccepted: { + if (panel.profiles.loadFile(selectedFile)) { + panel.controller.profileDirty = true + panel.controller.showMessage(panel.profiles.documentStatus, false) + } else { + panel.controller.showMessage(panel.profiles.fatalError || "Unable to load engineering file.", true) + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: panel.compact ? 11 : 14 + spacing: panel.compact ? 8 : 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: "ENGINEERING SOURCE" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + } + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: panel.profiles.hasProfiles ? (panel.profile.svId || "Compiled SCL") : "Development profile" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: panel.compact ? 13 : 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Rectangle { + visible: panel.controller.profileDirty && panel.profiles.hasProfiles + implicitHeight: 20 + implicitWidth: dirtyText.implicitWidth + 14 + radius: 5 + color: panel.theme.amberSoft + border.width: 1 + border.color: "#705a31" + Label { + id: dirtyText + anchors.centerIn: parent + text: "DEPLOY" + color: panel.theme.amber + font.family: panel.monoFont + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + Label { + Layout.fillWidth: true + text: panel.profiles.sourceName.length ? panel.profiles.sourceName : "SCL / CID / SCD / IID" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + elide: Text.ElideMiddle + } + } + + CalmButton { + Layout.fillWidth: true + theme: panel.theme + uiFont: panel.uiFont + text: "Open SCL / CID" + enabled: !panel.device.running + onClicked: engineeringFileDialog.open() + } + + ColumnLayout { + visible: panel.profiles.hasProfiles + Layout.fillWidth: true + spacing: 6 + + Label { + text: "RESOLVED STREAM" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + } + + ComboBox { + Layout.fillWidth: true + model: panel.profiles + textRole: "control" + currentIndex: panel.profiles.selectedIndex + enabled: !panel.device.running + font.family: panel.uiFont + font.pixelSize: 9 + onActivated: { + panel.profiles.selectStream(currentIndex) + panel.controller.profileDirty = true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Rectangle { + implicitHeight: 21 + implicitWidth: classText.implicitWidth + 14 + radius: 5 + color: panel.profile.compatibilityClass === "A" ? panel.theme.greenSoft : + panel.profile.compatibilityClass === "B" ? panel.theme.amberSoft : panel.theme.redSoft + border.width: 1 + border.color: panel.profile.compatibilityClass === "A" ? "#2b674d" : + panel.profile.compatibilityClass === "B" ? "#705a31" : "#78404a" + Label { + id: classText + anchors.centerIn: parent + text: "CLASS " + (panel.profile.compatibilityClass || "—") + color: panel.profile.compatibilityClass === "A" ? panel.theme.green : + panel.profile.compatibilityClass === "B" ? panel.theme.amber : panel.theme.red + font.family: panel.monoFont + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: String(panel.profile.deviceSupport || "—").toUpperCase() + color: panel.profile.deviceSupport === "ready" ? panel.theme.green : panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: 8 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: identityGrid.implicitHeight + 16 + radius: 6 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + + GridLayout { + id: identityGrid + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 9 + anchors.rightMargin: 9 + columns: 2 + columnSpacing: 8 + rowSpacing: 4 + + Label { text: "svID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.fillWidth: true; text: panel.profile.svId || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideMiddle } + Label { text: "DataSet"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.fillWidth: true; text: panel.profile.dataSetReference || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideMiddle } + Label { text: "MAC / APPID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.fillWidth: true; text: (panel.profile.destinationMac || "—") + " · " + (panel.profile.appIdHex || "—"); color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideRight } + Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.profile.publisherRate ? panel.profile.publisherRate + " fps" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + Label { text: "VLAN"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.profile.vlanPresent ? "PCP " + panel.profile.vlanPriority + " · VID " + panel.profile.vlanId : "untagged"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + Label { text: "Payload"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.profile.payloadBytes ? panel.profile.payloadBytes + " B · " + panel.profile.channelLeafCount + " leaves" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + } + } + + ColumnLayout { + visible: panel.issueItems.length > 0 + Layout.fillWidth: true + spacing: 3 + Repeater { + model: panel.issueItems + delegate: Label { + required property var modelData + Layout.fillWidth: true + text: (modelData.severity === "error" ? "× " : "! ") + modelData.text + color: modelData.severity === "error" ? panel.theme.red : panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 8 + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + + ColumnLayout { + visible: panel.profile.compatibilityClass === "B" + Layout.fillWidth: true + spacing: 5 + Label { text: "COUNTER POLICY"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { + Layout.fillWidth: true + text: "Confirm the evidenced smpCnt modulus before deployment." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + NumericField { + id: counterField + Layout.fillWidth: true + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.profile.counterModulus || "" + validator: IntValidator { bottom: 1; top: 65535 } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Confirm" + onClicked: { + if (counterField.acceptableInput && panel.profiles.confirmCounterModulus(parseInt(counterField.text))) { + panel.controller.profileDirty = true + panel.controller.showMessage("Sample-counter modulus confirmed.", false) + } + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + Label { text: "ENGINEERING SCALING"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + + RowLayout { + Layout.fillWidth: true + Label { text: "I"; Layout.preferredWidth: 16; color: panel.theme.muted; font.family: panel.monoFont; font.pixelSize: 9 } + NumericField { + Layout.fillWidth: true + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.controller.currentScale.toString() + validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } + onActiveFocusChanged: if (activeFocus) selectAll() + onEditingFinished: { + var value = panel.controller.parseOperatorNumber(text) + if (acceptableInput && isFinite(value) && value > 0) { + panel.controller.currentScale = value + if (panel.device.connected) panel.controller.applyGroupSignals(0) + } else { + text = panel.controller.currentScale.toString() + } + } + } + Label { text: "ct/A"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + } + + RowLayout { + Layout.fillWidth: true + Label { text: "U"; Layout.preferredWidth: 16; color: panel.theme.muted; font.family: panel.monoFont; font.pixelSize: 9 } + NumericField { + Layout.fillWidth: true + theme: panel.theme + monoFont: panel.monoFont + compact: true + text: panel.controller.voltageScale.toString() + validator: DoubleValidator { bottom: 0.000001; top: 2147483647; decimals: 6 } + onActiveFocusChanged: if (activeFocus) selectAll() + onEditingFinished: { + var value = panel.controller.parseOperatorNumber(text) + if (acceptableInput && isFinite(value) && value > 0) { + panel.controller.voltageScale = value + if (panel.device.connected) panel.controller.applyGroupSignals(1) + } else { + text = panel.controller.voltageScale.toString() + } + } + } + Label { text: "ct/V"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + + Label { text: "QUICK SETUP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + RowLayout { + Layout.fillWidth: true + CalmButton { Layout.fillWidth: true; theme: panel.theme; uiFont: panel.uiFont; text: "Balanced"; onClicked: panel.controller.balanced() } + CalmButton { Layout.fillWidth: true; theme: panel.theme; uiFont: panel.uiFont; text: "Zero"; onClicked: panel.controller.zeroAll() } + } + + Item { Layout.fillHeight: true } + + Label { text: "RUNTIME"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 8 + rowSpacing: 3 + Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.fps; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: "Missed"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.missed; color: Number(panel.device.missed) > 0 ? panel.theme.amber : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: "TX fail"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.txFailures; color: Number(panel.device.txFailures) > 0 ? panel.theme.red : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: "Generation"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.signalGeneration; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/QualityEditor.qml b/apps/arstack_studio/qml/QualityEditor.qml new file mode 100644 index 000000000..d9c358483 --- /dev/null +++ b/apps/arstack_studio/qml/QualityEditor.qml @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +RowLayout { + id: editor + property var theme + property var controller + property var device + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property real qualityValue: 0 + property bool compact: false + + spacing: 7 + + function hexText(value) { + return "0x" + (Number(value) >>> 0).toString(16).padStart(8, "0").toUpperCase() + } + + function validityName(value) { + switch ((Number(value) >>> 0) & 0x03) { + case 0: return "GOOD" + case 1: return "INVALID" + case 2: return "RESERVED" + default: return "QUESTIONABLE" + } + } + + function flagSummary(value) { + var q = Number(value) >>> 0 + var parts = [] + if (q & (1 << 2)) parts.push("overflow") + if (q & (1 << 3)) parts.push("out-of-range") + if (q & (1 << 4)) parts.push("bad-ref") + if (q & (1 << 5)) parts.push("oscillatory") + if (q & (1 << 6)) parts.push("failure") + if (q & (1 << 7)) parts.push("old-data") + if (q & (1 << 8)) parts.push("inconsistent") + if (q & (1 << 9)) parts.push("inaccurate") + if (q & (1 << 11)) parts.push("test") + if (q & (1 << 12)) parts.push("operator-blocked") + return parts.length ? parts.join(" · ") : "no detail flags" + } + + function presetIndex(value) { + var q = Number(value) >>> 0 + if (q === 0) return 0 + if (q === 1) return 1 + if (q === 3) return 2 + if (q === (1 << 11)) return 3 + return -1 + } + + function presetValue(index) { + if (index === 0) return 0 + if (index === 1) return 1 + if (index === 2) return 3 + if (index === 3) return (1 << 11) + return Number(qualityValue) >>> 0 + } + + function applyValue(value) { + controller.setActiveQuality(Number(value) >>> 0) + } + + onQualityValueChanged: { + if (!rawField.activeFocus) + rawField.text = hexText(qualityValue) + } + + Label { + text: "QUALITY" + color: editor.theme.muted + font.family: editor.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + + Rectangle { + implicitHeight: 22 + implicitWidth: validityText.implicitWidth + 16 + radius: 5 + color: editor.validityName(editor.qualityValue) === "GOOD" ? editor.theme.greenSoft : + editor.validityName(editor.qualityValue) === "QUESTIONABLE" ? editor.theme.amberSoft : editor.theme.redSoft + border.width: 1 + border.color: editor.validityName(editor.qualityValue) === "GOOD" ? "#2b674d" : + editor.validityName(editor.qualityValue) === "QUESTIONABLE" ? "#705a31" : "#78404a" + Label { + id: validityText + anchors.centerIn: parent + text: editor.validityName(editor.qualityValue) + color: editor.validityName(editor.qualityValue) === "GOOD" ? editor.theme.green : + editor.validityName(editor.qualityValue) === "QUESTIONABLE" ? editor.theme.amber : editor.theme.red + font.family: editor.monoFont + font.pixelSize: 8 + font.weight: Font.Bold + } + } + + ComboBox { + id: presetBox + implicitWidth: editor.compact ? 104 : 118 + model: ["Good", "Invalid", "Questionable", "Test"] + currentIndex: editor.presetIndex(editor.qualityValue) + font.family: editor.uiFont + font.pixelSize: 9 + onActivated: editor.applyValue(editor.presetValue(currentIndex)) + } + + NumericField { + id: rawField + theme: editor.theme + monoFont: editor.monoFont + compact: true + implicitWidth: editor.compact ? 106 : 120 + Component.onCompleted: text = editor.hexText(editor.qualityValue) + onEditingFinished: { + var trimmed = text.trim() + var parsed = trimmed.toLowerCase().startsWith("0x") ? + parseInt(trimmed.substring(2), 16) : parseInt(trimmed, 10) + if (!isNaN(parsed) && parsed >= 0 && parsed <= 0xFFFFFFFF) { + invalidInput = false + editor.applyValue(parsed) + text = editor.hexText(parsed) + } else { + invalidInput = true + text = editor.hexText(editor.qualityValue) + controller.showMessage("Quality must be a valid 32-bit value.", true) + } + } + } + + Label { + Layout.fillWidth: true + visible: !editor.compact + text: editor.flagSummary(editor.qualityValue) + elide: Text.ElideRight + color: editor.theme.muted + font.family: editor.uiFont + font.pixelSize: 9 + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml new file mode 100644 index 000000000..770e85389 --- /dev/null +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: matrix + property var theme + property var controller + property var device + property var sourceModel + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property int groupIndex: 0 + property string titleText: "Current" + property string symbolText: "I" + property string unitText: "A RMS" + property bool compact: false + + color: theme.surface2 + radius: 7 + border.width: 1 + border.color: theme.lineSoft + + function focusCell(row, column) { + var rowItem = rowRepeater.itemAt(row) + if (!rowItem) + return + var editor = column === 0 ? rowItem.magnitudeEditor : rowItem.phaseEditor + editor.forceActiveFocus() + editor.selectAll() + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 42 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + spacing: 8 + + Rectangle { + width: 21 + height: 21 + radius: 5 + color: "#152235" + Label { + anchors.centerIn: parent + text: matrix.symbolText + color: matrix.theme.accent + font.family: matrix.monoFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } + Label { + text: matrix.titleText + color: matrix.theme.textSoft + font.family: matrix.uiFont + font.pixelSize: 11 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: matrix.unitText + color: matrix.theme.muted + font.family: matrix.uiFont + font.pixelSize: 9 + } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 26 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 7 + Label { text: "ON"; Layout.preferredWidth: 28; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + Label { text: "CH"; Layout.preferredWidth: 34; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + Label { text: "MAGNITUDE"; Layout.fillWidth: true; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold } + } + + Repeater { + id: rowRepeater + model: matrix.sourceModel + + delegate: Rectangle { + id: signalRow + property int rowIndex: index + property string sid: model.signalId + property real mag: model.magnitude + property real angle: model.phase + property bool channelEnabled: model.enabled + property real signalQuality: model.quality + property color phaseColor: model.traceColor + property alias magnitudeEditor: magnitudeField + property alias phaseEditor: phaseField + + Layout.fillWidth: true + Layout.preferredHeight: matrix.compact ? 51 : 55 + color: magnitudeField.activeFocus || phaseField.activeFocus ? "#141d27" : "transparent" + Behavior on color { ColorAnimation { duration: 80 } } + + onMagChanged: if (!magnitudeField.activeFocus) magnitudeField.text = mag.toFixed(3) + onAngleChanged: if (!phaseField.activeFocus) phaseField.text = angle.toFixed(2) + + Rectangle { + visible: matrix.controller.activeSignal === signalRow.sid + width: 2 + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + color: matrix.theme.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 7 + + CheckBox { + Layout.preferredWidth: 28 + checked: signalRow.channelEnabled + onToggled: { + matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) + matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) + matrix.controller.refreshPreview() + if (matrix.device.connected) + matrix.device.setEnabled(signalRow.sid, checked) + } + } + + RowLayout { + Layout.preferredWidth: 34 + spacing: 5 + Rectangle { width: 6; height: 6; radius: 3; color: signalRow.phaseColor } + Label { + text: signalRow.sid + color: matrix.theme.text + font.family: matrix.monoFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } + + NumericField { + id: magnitudeField + Layout.fillWidth: true + theme: matrix.theme + monoFont: matrix.monoFont + compact: matrix.compact + text: signalRow.mag.toFixed(3) + invalidInput: false + onActiveFocusChanged: { + if (activeFocus) { + matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) + text = signalRow.mag.toFixed(3) + selectAll() + } + } + onTextEdited: { + var value = matrix.controller.parseOperatorNumber(text) + if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { + invalidInput = false + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) + } else { + invalidInput = true + } + } + onEditingFinished: { + var value = matrix.controller.parseOperatorNumber(text) + if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { + text = signalRow.mag.toFixed(3) + invalidInput = false + matrix.controller.showMessage(signalRow.sid + " magnitude is outside the valid wire/scaling range.", true) + } else { + text = value.toFixed(3) + } + } + Keys.onPressed: function(event) { + if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 0, event.key) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) + event.accepted = true + } + } + } + + NumericField { + id: phaseField + Layout.preferredWidth: matrix.compact ? 82 : 98 + theme: matrix.theme + monoFont: matrix.monoFont + compact: matrix.compact + text: signalRow.angle.toFixed(2) + invalidInput: false + onActiveFocusChanged: { + if (activeFocus) { + matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) + text = signalRow.angle.toFixed(2) + selectAll() + } + } + onTextEdited: { + var value = matrix.controller.parseOperatorNumber(text) + if (matrix.controller.validPhase(value)) { + invalidInput = false + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) + } else { + invalidInput = true + } + } + onEditingFinished: { + var value = matrix.controller.parseOperatorNumber(text) + if (!matrix.controller.validPhase(value)) { + text = signalRow.angle.toFixed(2) + invalidInput = false + matrix.controller.showMessage(signalRow.sid + " phase must stay within ±360000°.", true) + } else { + text = value.toFixed(2) + } + } + Keys.onPressed: function(event) { + if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 1, event.key) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) + event.accepted = true + } + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: matrix.theme.lineSoft + } + } + } + + Item { Layout.fillHeight: true } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml new file mode 100644 index 000000000..ca20d5ad4 --- /dev/null +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: preview + property var currentModel + property var voltageModel + property string uiFont: "Inter" + property string monoFont: "Cascadia Mono" + property bool compact: false + property string activeSignal: "Ia" + property string activeUnit: "A" + property real activeMagnitude: 1 + property real activePhase: 0 + property real signalFrequency: 50 + + function requestPaint() { + phasor.requestPaint() + waveform.requestPaint() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: preview.compact ? 11 : 14 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "GENERATED SETPOINT"; color: preview.theme.muted; font.family: preview.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { text: "Signal Preview"; color: preview.theme.text; font.family: preview.uiFont; font.pixelSize: preview.compact ? 15 : 17; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + Label { text: "LOCAL"; color: preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8; font.weight: Font.Bold } + } + + RowLayout { + Layout.fillWidth: true + Label { text: "Phasor"; color: preview.theme.textSoft; font.family: preview.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Item { Layout.fillWidth: true } + Label { + Layout.maximumWidth: parent.width * 0.72 + text: preview.activeSignal + " · " + preview.activeMagnitude.toFixed(3) + " " + preview.activeUnit + " ∠ " + preview.activePhase.toFixed(2) + "°" + color: preview.theme.muted + font.family: preview.monoFont + font.pixelSize: 8 + elide: Text.ElideRight + } + } + + Canvas { + id: phasor + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 1 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var cx = width / 2 + var cy = height / 2 + var r = Math.min(width, height) * 0.39 + + ctx.strokeStyle = "#283441" + ctx.lineWidth = 1 + for (var ring = 1; ring <= 3; ++ring) { + ctx.beginPath() + ctx.arc(cx, cy, r * ring / 3, 0, Math.PI * 2) + ctx.stroke() + } + for (var degrees = 0; degrees < 180; degrees += 30) { + var axis = degrees * Math.PI / 180 + var dx = Math.cos(axis) * r + var dy = Math.sin(axis) * r + ctx.beginPath() + ctx.moveTo(cx - dx, cy - dy) + ctx.lineTo(cx + dx, cy + dy) + ctx.stroke() + } + + function drawGroup(model, scale, dashed) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + for (var j = 0; j < 4; ++j) { + var signal = model.get(j) + if (!signal.enabled || signal.magnitude <= 0) + continue + var radians = signal.phase * Math.PI / 180 + var length = r * scale * (0.30 + 0.70 * Math.min(1, signal.magnitude / maxMagnitude)) + var ex = cx + Math.cos(radians) * length + var ey = cy - Math.sin(radians) * length + var active = signal.signalId === preview.activeSignal + ctx.save() + ctx.globalAlpha = active ? 1 : 0.68 + ctx.strokeStyle = signal.traceColor + ctx.fillStyle = signal.traceColor + ctx.lineWidth = active ? 2.8 : 1.7 + ctx.lineCap = "round" + ctx.setLineDash(dashed ? [5, 4] : []) + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(ex, ey) + ctx.stroke() + ctx.setLineDash([]) + var arrow = active ? 8 : 6 + ctx.beginPath() + ctx.moveTo(ex, ey) + ctx.lineTo(ex - Math.cos(radians - 0.45) * arrow, ey + Math.sin(radians - 0.45) * arrow) + ctx.lineTo(ex - Math.cos(radians + 0.45) * arrow, ey + Math.sin(radians + 0.45) * arrow) + ctx.closePath() + ctx.fill() + if (active) { + ctx.font = "600 10px Inter" + ctx.fillText(signal.signalId, ex + 7, ey - 7) + } + ctx.restore() + } + } + + drawGroup(preview.voltageModel, 1.0, false) + drawGroup(preview.currentModel, 0.78, true) + } + } + + RowLayout { + Layout.fillWidth: true + Label { text: "Waveform"; color: preview.theme.textSoft; font.family: preview.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Item { Layout.fillWidth: true } + Label { text: preview.signalFrequency.toFixed(3) + " Hz · 40 ms"; color: preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8 } + } + + Canvas { + id: waveform + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 0.84 + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var left = 24 + var right = 8 + var top = 10 + var bottom = 12 + var plotWidth = width - left - right + var plotHeight = height - top - bottom + var gap = 14 + var laneHeight = (plotHeight - gap) / 2 + var voltageY = top + laneHeight / 2 + var currentY = top + laneHeight + gap + laneHeight / 2 + + ctx.strokeStyle = "#202b36" + ctx.lineWidth = 1 + for (var grid = 0; grid <= 8; ++grid) { + var xg = left + plotWidth * grid / 8 + ctx.beginPath() + ctx.moveTo(xg, top) + ctx.lineTo(xg, top + plotHeight) + ctx.stroke() + } + ctx.strokeStyle = "#33404d" + for (var b = 0; b < 2; ++b) { + var baseline = b === 0 ? voltageY : currentY + ctx.beginPath() + ctx.moveTo(left, baseline) + ctx.lineTo(left + plotWidth, baseline) + ctx.stroke() + } + + function drawWaveGroup(model, center, dashed) { + var maxMagnitude = 0.0001 + for (var i = 0; i < 4; ++i) + maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + for (var j = 0; j < 4; ++j) { + var signal = model.get(j) + if (!signal.enabled || signal.magnitude <= 0) + continue + var active = signal.signalId === preview.activeSignal + var amplitude = laneHeight * 0.39 * Math.min(1, signal.magnitude / maxMagnitude) + var phaseRadians = signal.phase * Math.PI / 180 + ctx.save() + ctx.strokeStyle = signal.traceColor + ctx.globalAlpha = active ? 1 : 0.62 + ctx.lineWidth = active ? 2.3 : 1.5 + ctx.lineCap = "round" + ctx.setLineDash(dashed ? [5, 4] : []) + ctx.beginPath() + for (var point = 0; point <= 180; ++point) { + var ratio = point / 180 + var time = 0.04 * ratio + var sample = Math.sin(2 * Math.PI * preview.signalFrequency * time + phaseRadians) + var x = left + plotWidth * ratio + var y = center - sample * amplitude + if (point === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.stroke() + ctx.restore() + } + } + + drawWaveGroup(preview.voltageModel, voltageY, false) + drawWaveGroup(preview.currentModel, currentY, true) + } + } + + Label { + Layout.fillWidth: true + text: "Generated setpoint · not independent on-wire proof" + horizontalAlignment: Text.AlignRight + color: preview.theme.muted + font.family: preview.uiFont + font.pixelSize: 8 + } + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/StateBadge.qml b/apps/arstack_studio/qml/StateBadge.qml new file mode 100644 index 000000000..5926c291a --- /dev/null +++ b/apps/arstack_studio/qml/StateBadge.qml @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Rectangle { + id: badge + property var theme + property string state: "OFFLINE" + property color stateColor: theme.muted + property string monoFont: "Cascadia Mono" + + implicitHeight: 24 + implicitWidth: stateText.implicitWidth + 24 + radius: 6 + color: state === "RUNNING" ? theme.greenSoft : + state === "PROFILE BLOCKED" ? theme.redSoft : + (state === "PROFILE CHANGED" || state === "DEPLOYING") ? theme.amberSoft : + theme.accentSoft + border.width: 1 + border.color: stateColor + Behavior on color { ColorAnimation { duration: 120 } } + + Label { + id: stateText + anchors.centerIn: parent + text: badge.state + color: badge.stateColor + font.family: badge.monoFont + font.pixelSize: 8 + font.weight: Font.Bold + font.letterSpacing: 0.45 + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml new file mode 100644 index 000000000..2eb22c5d7 --- /dev/null +++ b/apps/arstack_studio/qml/StatusToast.qml @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Rectangle { + id: toast + property var theme + property string uiFont: "Inter" + property string message: "" + property bool error: false + + implicitWidth: Math.min(620, messageLabel.implicitWidth + 30) + implicitHeight: 36 + radius: 6 + color: "#18212b" + border.width: 1 + border.color: error ? "#70404a" : "#36536f" + opacity: message.length > 0 ? 1 : 0 + scale: message.length > 0 ? 1 : 0.985 + visible: opacity > 0 + + Behavior on opacity { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + + Label { + id: messageLabel + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + text: toast.message + color: toast.error ? "#ffb3bb" : toast.theme.textSoft + font.family: toast.uiFont + font.pixelSize: 9 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml new file mode 100644 index 000000000..47a269802 --- /dev/null +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick + +QtObject { + readonly property color bg: "#090d12" + readonly property color chrome: "#0c1117" + readonly property color surface: "#11171f" + readonly property color surface2: "#0e141b" + readonly property color raised: "#151d27" + readonly property color raisedHover: "#1a2430" + readonly property color line: "#26313d" + readonly property color lineSoft: "#1c252f" + readonly property color text: "#edf2f7" + readonly property color textSoft: "#c2ccd7" + readonly property color muted: "#778493" + readonly property color muted2: "#566271" + readonly property color accent: "#69a9ff" + readonly property color accentSoft: "#18314c" + readonly property color green: "#58d49d" + readonly property color greenSoft: "#153a2d" + readonly property color amber: "#e1b25a" + readonly property color amberSoft: "#3b301c" + readonly property color red: "#ff727f" + readonly property color redSoft: "#46252b" +} \ No newline at end of file diff --git a/apps/arstack_studio/qml/SurfacePanel.qml b/apps/arstack_studio/qml/SurfacePanel.qml new file mode 100644 index 000000000..24790fcf2 --- /dev/null +++ b/apps/arstack_studio/qml/SurfacePanel.qml @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick + +Rectangle { + property var theme + color: theme ? theme.surface : "#11171f" + radius: 8 + border.width: 1 + border.color: theme ? theme.lineSoft : "#1c252f" +} \ No newline at end of file