diff --git a/.github/workflows/smv-injector-slint.yml b/.github/workflows/smv-injector-slint.yml new file mode 100644 index 000000000..994827bda --- /dev/null +++ b/.github/workflows/smv-injector-slint.yml @@ -0,0 +1,57 @@ +name: SMV Injector Slint Shell + +on: + push: + paths: + - 'apps/smv_injector_slint/**' + - '.github/workflows/smv-injector-slint.yml' + pull_request: + paths: + - 'apps/smv_injector_slint/**' + - '.github/workflows/smv-injector-slint.yml' + +permissions: + contents: read + +jobs: + native-shell-build: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Slint build dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ninja-build \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libfontconfig-dev \ + libssl-dev + + - name: Select Rust 1.92 + shell: bash + run: | + rustup toolchain install 1.92.0 --profile minimal + rustup default 1.92.0 + rustc --version + + - name: Configure native Slint shell + shell: bash + run: | + cmake -S apps/smv_injector_slint \ + -B build-smv-slint \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release + + - name: Build native Slint shell + shell: bash + run: | + cmake --build build-smv-slint \ + --target arstack_smv_slint_shell \ + --parallel 2 diff --git a/apps/smv_injector_slint/CMakeLists.txt b/apps/smv_injector_slint/CMakeLists.txt new file mode 100644 index 000000000..978e6505b --- /dev/null +++ b/apps/smv_injector_slint/CMakeLists.txt @@ -0,0 +1,52 @@ +cmake_minimum_required(VERSION 3.21) + +project(ARStack61850SmvSlintShell + VERSION 0.1.0 + DESCRIPTION "Native Slint UX shell for the ARStack61850 SMV Injector" + LANGUAGES CXX) + +option(ARSTACK_SLINT_FETCH "Fetch Slint from source when no installed package is found" ON) +set(ARSTACK_SLINT_VERSION "1.17.1" CACHE STRING "Preferred Slint C++ version") + +find_package(Slint ${ARSTACK_SLINT_VERSION} CONFIG QUIET) + +if(NOT Slint_FOUND) + if(NOT ARSTACK_SLINT_FETCH) + message(FATAL_ERROR + "Slint ${ARSTACK_SLINT_VERSION} was not found. Install the Slint C++ SDK, set CMAKE_PREFIX_PATH, " + "or configure with -DARSTACK_SLINT_FETCH=ON.") + endif() + + include(FetchContent) + FetchContent_Declare( + Slint + GIT_REPOSITORY https://github.com/slint-ui/slint.git + GIT_TAG v${ARSTACK_SLINT_VERSION} + SOURCE_SUBDIR api/cpp) + FetchContent_MakeAvailable(Slint) +endif() + +add_executable(arstack_smv_slint_shell + src/main.cpp + src/serial_device.cpp) + +target_compile_features(arstack_smv_slint_shell PRIVATE cxx_std_20) +target_link_libraries(arstack_smv_slint_shell PRIVATE Slint::Slint) + +slint_target_sources(arstack_smv_slint_shell + ui/app-window.slint) + +if(WIN32) + add_custom_command(TARGET arstack_smv_slint_shell POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying Slint runtime dependencies") +endif() + +if(MSVC) + target_compile_options(arstack_smv_slint_shell PRIVATE /W4 /permissive-) +else() + target_compile_options(arstack_smv_slint_shell PRIVATE -Wall -Wextra -Wpedantic) +endif() diff --git a/apps/smv_injector_slint/README.md b/apps/smv_injector_slint/README.md new file mode 100644 index 000000000..4b61cc1b6 --- /dev/null +++ b/apps/smv_injector_slint/README.md @@ -0,0 +1,86 @@ +# ARStack61850 SMV Injection Studio + +Native Slint desktop workstation for fast IEC 61850-9-2 Sampled Values injection. + +The interface combines the fast numeric workflow of professional test equipment with a modern, calm three-view workspace. It uses original ARStack61850 styling and does not copy OMICRON or ARSVIN product assets. + +## Operator workflow + +- Quick Manual table for 4 voltage and 4 current channels. +- Arrow keys move between magnitude, angle, and frequency cells. +- Enter validates and commits; focus selects the complete value for immediate replacement. +- Invalid values remain focused and never reach the device command path. +- Live Apply transmits validated edits to the next coherent generation. +- Phasor and waveform views update from the effective editor state. +- Start, running timer, Stop, TX sequence, and publisher state remain visible during injection. +- Status History and Overload Monitor live in a collapsible lower dock. +- Lucide icons provide a consistent command vocabulary. + +## Native controller + +The C++ controller owns validation and Windows serial transport. The current command protocol is: + +```text +SET 0 +FREQ +ENABLE <0|1> +START +STOP +ZERO +SHOW +``` + +Default validation limits are 0-10,000 A RMS, 0-1,000,000 V RMS, -360 to 360 degrees, and 1-1,000 Hz. Profile identity and layout changes remain a STOP -> validate -> deploy operation; mutable values use the live-control path. + +## Windows quick start + +Requirements: CMake 3.21+, a C++20 MSVC toolchain, and Rust 1.92+ when Slint is fetched from source. + +From the repository root: + +```powershell +.\apps\smv_injector_slint\run.cmd +``` + +On systems with a `D:` drive, the launcher defaults to: + +```text +D:\Build\arstack61850-smv-slint +D:\Tools\Slint\1.17.1 +``` + +Override either location with `ARSTACK_BUILD_DIR` or `ARSTACK_SLINT_DEPS_DIR`. The CMake target copies `slint_cpp.dll` beside the executable after every successful Windows build, preventing a missing-runtime startup error. + +## Manual build + +```powershell +cmake -S apps/smv_injector_slint -B build-smv-slint -DCMAKE_BUILD_TYPE=Release +cmake --build build-smv-slint --target arstack_smv_slint_shell --config Release +``` + +Set `-DARSTACK_SLINT_FETCH=OFF` to require an installed Slint C++ package. + +## Architecture boundary + +```text +Slint workstation views + | + v +C++ validation and presentation controller + |---------------------------| + v v +profile compiler serial device control + | | + +-------------+-------------+ + v + ESP32-P4 mutable state + | + v + realtime SMV publisher +``` + +The UI is never the realtime waveform scheduler. + +## Third-party assets + +Toolbar and dock icons are from [Lucide](https://lucide.dev/) under the ISC License. The license text is stored in `assets/lucide/LICENSE`. diff --git a/apps/smv_injector_slint/assets/lucide/LICENSE b/apps/smv_injector_slint/assets/lucide/LICENSE new file mode 100644 index 000000000..718bb3f0e --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/smv_injector_slint/assets/lucide/chart-no-axes-combined.svg b/apps/smv_injector_slint/assets/lucide/chart-no-axes-combined.svg new file mode 100644 index 000000000..ca3fabf75 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/chart-no-axes-combined.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/apps/smv_injector_slint/assets/lucide/check.svg b/apps/smv_injector_slint/assets/lucide/check.svg new file mode 100644 index 000000000..5abe15274 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/check.svg @@ -0,0 +1,13 @@ + + + diff --git a/apps/smv_injector_slint/assets/lucide/chevron-down.svg b/apps/smv_injector_slint/assets/lucide/chevron-down.svg new file mode 100644 index 000000000..e70d30b88 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/chevron-down.svg @@ -0,0 +1,13 @@ + + + diff --git a/apps/smv_injector_slint/assets/lucide/circle-check-big.svg b/apps/smv_injector_slint/assets/lucide/circle-check-big.svg new file mode 100644 index 000000000..0a656b269 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/circle-check-big.svg @@ -0,0 +1,14 @@ + + + + diff --git a/apps/smv_injector_slint/assets/lucide/circle-slash-2.svg b/apps/smv_injector_slint/assets/lucide/circle-slash-2.svg new file mode 100644 index 000000000..fc6a0465c --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/circle-slash-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/apps/smv_injector_slint/assets/lucide/file-play.svg b/apps/smv_injector_slint/assets/lucide/file-play.svg new file mode 100644 index 000000000..d318cb10c --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/file-play.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/smv_injector_slint/assets/lucide/list-tree.svg b/apps/smv_injector_slint/assets/lucide/list-tree.svg new file mode 100644 index 000000000..c5d33efc2 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/list-tree.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/apps/smv_injector_slint/assets/lucide/orbit.svg b/apps/smv_injector_slint/assets/lucide/orbit.svg new file mode 100644 index 000000000..d67436d16 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/orbit.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/apps/smv_injector_slint/assets/lucide/panels-top-left.svg b/apps/smv_injector_slint/assets/lucide/panels-top-left.svg new file mode 100644 index 000000000..574e4dea5 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/panels-top-left.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/smv_injector_slint/assets/lucide/play.svg b/apps/smv_injector_slint/assets/lucide/play.svg new file mode 100644 index 000000000..199743ed2 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/play.svg @@ -0,0 +1,13 @@ + + + diff --git a/apps/smv_injector_slint/assets/lucide/plug-zap.svg b/apps/smv_injector_slint/assets/lucide/plug-zap.svg new file mode 100644 index 000000000..6376dc020 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/plug-zap.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/apps/smv_injector_slint/assets/lucide/settings.svg b/apps/smv_injector_slint/assets/lucide/settings.svg new file mode 100644 index 000000000..4c6263af9 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/settings.svg @@ -0,0 +1,14 @@ + + + + diff --git a/apps/smv_injector_slint/assets/lucide/square.svg b/apps/smv_injector_slint/assets/lucide/square.svg new file mode 100644 index 000000000..786273355 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/square.svg @@ -0,0 +1,13 @@ + + + diff --git a/apps/smv_injector_slint/assets/lucide/undo-2.svg b/apps/smv_injector_slint/assets/lucide/undo-2.svg new file mode 100644 index 000000000..3e93594c0 --- /dev/null +++ b/apps/smv_injector_slint/assets/lucide/undo-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/apps/smv_injector_slint/run.cmd b/apps/smv_injector_slint/run.cmd new file mode 100644 index 000000000..52f7b4081 --- /dev/null +++ b/apps/smv_injector_slint/run.cmd @@ -0,0 +1,63 @@ +@echo off +setlocal EnableExtensions + +set "APP_DIR=%~dp0" +for %%I in ("%APP_DIR%\..\..") do set "REPO_ROOT=%%~fI" +set "TARGET=arstack_smv_slint_shell" + +if defined ARSTACK_BUILD_DIR ( + set "BUILD_DIR=%ARSTACK_BUILD_DIR%" +) else if exist "D:\" ( + set "BUILD_DIR=D:\Build\arstack61850-smv-slint" +) else ( + set "BUILD_DIR=%REPO_ROOT%\build-smv-slint" +) + +if defined ARSTACK_SLINT_DEPS_DIR ( + set "SLINT_DEPS_DIR=%ARSTACK_SLINT_DEPS_DIR%" +) else if exist "D:\" ( + set "SLINT_DEPS_DIR=D:\Tools\Slint\1.17.1" +) else ( + set "SLINT_DEPS_DIR=%REPO_ROOT%\build-smv-slint-deps" +) + +where cmake >nul 2>nul +if errorlevel 1 ( + echo [ERROR] CMake 3.21 or newer is required. + exit /b 1 +) + +where rustc >nul 2>nul +if errorlevel 1 ( + echo [ERROR] Rust is required when Slint is fetched from source. + echo Install Rust 1.92 or newer, or install the Slint C++ SDK and configure CMAKE_PREFIX_PATH. + exit /b 1 +) + +set "GENERATOR_ARGS=" +set "EXE_PATH=%BUILD_DIR%\Release\%TARGET%.exe" +where ninja >nul 2>nul +if not errorlevel 1 ( + set "GENERATOR_ARGS=-G Ninja" + set "EXE_PATH=%BUILD_DIR%\%TARGET%.exe" +) + +echo [ARStack61850] Configuring native Slint shell... +echo [ARStack61850] Build: %BUILD_DIR% +echo [ARStack61850] Slint cache: %SLINT_DEPS_DIR% +cmake -S "%APP_DIR%" -B "%BUILD_DIR%" %GENERATOR_ARGS% -DCMAKE_BUILD_TYPE=Release -DFETCHCONTENT_BASE_DIR="%SLINT_DEPS_DIR%" +if errorlevel 1 exit /b 1 + +echo [ARStack61850] Building %TARGET%... +cmake --build "%BUILD_DIR%" --target %TARGET% --config Release --parallel 2 +if errorlevel 1 exit /b 1 + +if not exist "%EXE_PATH%" ( + echo [ERROR] Build succeeded but executable was not found at: + echo %EXE_PATH% + exit /b 1 +) + +echo [ARStack61850] Starting SMV Workbench... +start "ARStack61850 SMV Workbench" "%EXE_PATH%" +exit /b 0 diff --git a/apps/smv_injector_slint/src/main.cpp b/apps/smv_injector_slint/src/main.cpp new file mode 100644 index 000000000..1937c788d --- /dev/null +++ b/apps/smv_injector_slint/src/main.cpp @@ -0,0 +1,410 @@ +#include "app-window.h" +#include "serial_device.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr double pi = 3.14159265358979323846; + +struct SignalChannel { + std::string id; + bool current; + bool enabled; + double magnitude; + double phase_degrees; +}; + +using SignalState = std::array; + +SignalState default_signal_state() +{ + return {{{"IA", true, true, 1.0, 0.0}, + {"IB", true, true, 1.0, -120.0}, + {"IC", true, true, 1.0, 120.0}, + {"IN", true, false, 0.0, 0.0}, + {"UA", false, true, 57.74, 0.0}, + {"UB", false, true, 57.74, -120.0}, + {"UC", false, true, 57.74, 120.0}, + {"UN", false, false, 0.0, 0.0}}}; +} + +std::optional channel_index(std::string_view id) +{ + constexpr std::array ids{"IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; + const auto match = std::find(ids.begin(), ids.end(), id); + if (match == ids.end()) { + return std::nullopt; + } + return static_cast(std::distance(ids.begin(), match)); +} + +double group_maximum(const SignalState& state, bool current) +{ + double maximum = 0.0; + for (const auto& channel : state) { + if (channel.current == current && channel.enabled && channel.id.back() != 'N') { + maximum = std::max(maximum, channel.magnitude); + } + } + return maximum; +} + +std::string waveform_path(const SignalChannel& channel, double maximum, double frequency_hz) +{ + constexpr int samples = 96; + constexpr double width = 200.0; + const double center = channel.current ? 75.0 : 25.0; + const double amplitude = channel.enabled && maximum > 0.0 ? 17.5 * channel.magnitude / maximum : 0.0; + const double phase = channel.phase_degrees * pi / 180.0; + + std::ostringstream path; + path.imbue(std::locale::classic()); + path << std::fixed << std::setprecision(2); + for (int sample = 0; sample <= samples; ++sample) { + const double ratio = static_cast(sample) / samples; + const double seconds = ratio * 0.040; + const double x = width * ratio; + const double y = center - amplitude * std::sin((2.0 * pi * frequency_hz * seconds) + phase); + path << (sample == 0 ? "M " : " L ") << x << ' ' << y; + } + return path.str(); +} + +std::string phasor_path(const SignalChannel& channel, double maximum) +{ + if (!channel.enabled || maximum <= 0.0 || channel.magnitude <= 0.0) { + return "M 50 50"; + } + + const double angle = channel.phase_degrees * pi / 180.0; + const double radius = (channel.current ? 37.0 : 32.0) * std::min(1.0, channel.magnitude / maximum); + const double x = 50.0 + std::cos(angle) * radius; + const double y = 50.0 - std::sin(angle) * radius; + const double head = 3.2; + const double left_x = x - std::cos(angle - 0.55) * head; + const double left_y = y + std::sin(angle - 0.55) * head; + const double right_x = x - std::cos(angle + 0.55) * head; + const double right_y = y + std::sin(angle + 0.55) * head; + + std::ostringstream path; + path.imbue(std::locale::classic()); + path << std::fixed << std::setprecision(2) + << "M 50 50 L " << x << ' ' << y + << " M " << left_x << ' ' << left_y << " L " << x << ' ' << y + << " L " << right_x << ' ' << right_y; + return path.str(); +} + +void sync_visuals(const slint::ComponentHandle& ui, const SignalState& state, double frequency_hz) +{ + const auto current_max = group_maximum(state, true); + const auto voltage_max = group_maximum(state, false); + + ui->set_ia_wave_path(waveform_path(state[0], current_max, frequency_hz).c_str()); + ui->set_ib_wave_path(waveform_path(state[1], current_max, frequency_hz).c_str()); + ui->set_ic_wave_path(waveform_path(state[2], current_max, frequency_hz).c_str()); + ui->set_ua_wave_path(waveform_path(state[4], voltage_max, frequency_hz).c_str()); + ui->set_ub_wave_path(waveform_path(state[5], voltage_max, frequency_hz).c_str()); + ui->set_uc_wave_path(waveform_path(state[6], voltage_max, frequency_hz).c_str()); + + ui->set_ia_phasor_path(phasor_path(state[0], current_max).c_str()); + ui->set_ib_phasor_path(phasor_path(state[1], current_max).c_str()); + ui->set_ic_phasor_path(phasor_path(state[2], current_max).c_str()); + ui->set_ua_phasor_path(phasor_path(state[4], voltage_max).c_str()); + ui->set_ub_phasor_path(phasor_path(state[5], voltage_max).c_str()); + ui->set_uc_phasor_path(phasor_path(state[6], voltage_max).c_str()); +} + +void sync_editor_values(const slint::ComponentHandle& ui, const SignalState& state) +{ + ui->set_ia_enabled(state[0].enabled); + ui->set_ib_enabled(state[1].enabled); + ui->set_ic_enabled(state[2].enabled); + ui->set_in_enabled(state[3].enabled); + ui->set_ua_enabled(state[4].enabled); + ui->set_ub_enabled(state[5].enabled); + ui->set_uc_enabled(state[6].enabled); + ui->set_un_enabled(state[7].enabled); + + ui->set_ia_magnitude(static_cast(state[0].magnitude)); + ui->set_ib_magnitude(static_cast(state[1].magnitude)); + ui->set_ic_magnitude(static_cast(state[2].magnitude)); + ui->set_in_magnitude(static_cast(state[3].magnitude)); + ui->set_ua_magnitude(static_cast(state[4].magnitude)); + ui->set_ub_magnitude(static_cast(state[5].magnitude)); + ui->set_uc_magnitude(static_cast(state[6].magnitude)); + ui->set_un_magnitude(static_cast(state[7].magnitude)); + + ui->set_ia_phase(static_cast(state[0].phase_degrees)); + ui->set_ib_phase(static_cast(state[1].phase_degrees)); + ui->set_ic_phase(static_cast(state[2].phase_degrees)); + ui->set_in_phase(static_cast(state[3].phase_degrees)); + ui->set_ua_phase(static_cast(state[4].phase_degrees)); + ui->set_ub_phase(static_cast(state[5].phase_degrees)); + ui->set_uc_phase(static_cast(state[6].phase_degrees)); + ui->set_un_phase(static_cast(state[7].phase_degrees)); +} + +bool valid_signal_value(const SignalChannel& channel, double magnitude, double phase) +{ + const double maximum = channel.current ? 10000.0 : 1000000.0; + return std::isfinite(magnitude) && std::isfinite(phase) && magnitude >= 0.0 && magnitude <= maximum + && phase >= -360.0 && phase <= 360.0; +} + +std::string signal_command(const SignalChannel& channel) +{ + const auto scale = channel.current ? 1000.0 : 100.0; + const auto counts = std::llround(channel.magnitude * scale); + const auto phase_mdeg = std::llround(channel.phase_degrees * 1000.0); + return "SET " + channel.id + " " + std::to_string(counts) + " " + std::to_string(phase_mdeg) + " 0"; +} + +void record_command(const slint::ComponentHandle& ui, std::string_view command) +{ + ui->set_last_command(slint::SharedString(command)); + ui->set_tx_sequence(ui->get_tx_sequence() + 1); + ui->set_status_error(false); +} + +bool transmit(const slint::ComponentHandle& ui, SerialDevice& device, std::string_view command) +{ + if (!device.send_line(command)) { + ui->set_status_message(("Transmit failed: " + device.last_error()).c_str()); + ui->set_status_error(true); + return false; + } + record_command(ui, command); + return true; +} + +void set_balanced_state(SignalState& state) +{ + state = default_signal_state(); +} + +} // namespace + +int main() +{ + auto ui = AppWindow::create(); + auto state = default_signal_state(); + double frequency_hz = 50.0; + SerialDevice device; + + sync_editor_values(ui, state); + sync_visuals(ui, state, frequency_hz); + + const auto ui_weak = slint::ComponentWeakHandle(ui); + + ui->on_action_requested([ui_weak, &device, &state, &frequency_hz](const slint::SharedString& action_value) { + const auto locked = ui_weak.lock(); + if (!locked) { + return; + } + const auto& window = *locked; + const std::string action{std::string_view(action_value)}; + + if (action == "connect-device") { + const std::string port(std::string_view(window->get_serial_port())); + if (!device.open(port)) { + window->set_connected(false); + window->set_status_message(("Connection failed: " + device.last_error()).c_str()); + window->set_status_error(true); + return; + } + window->set_connected(true); + window->set_running(false); + window->set_status_message(("Connected to " + port + " at 115200 baud").c_str()); + window->set_status_error(false); + transmit(window, device, "SHOW"); + return; + } + + if (action == "disconnect-device") { + device.close(); + window->set_connected(false); + window->set_running(false); + window->set_status_message("Device disconnected. Signal values remain staged."); + window->set_status_error(false); + return; + } + + if (action == "start-injection") { + if (transmit(window, device, "START")) { + window->set_run_elapsed_seconds(0); + window->set_running(true); + window->set_status_message("START transmitted. Publisher state set to running."); + } + return; + } + + if (action == "stop-injection") { + if (transmit(window, device, "STOP")) { + window->set_running(false); + window->set_status_message("STOP transmitted. Profile remains armed for the next run."); + } + return; + } + + if (action == "prefault" || action == "manual") { + set_balanced_state(state); + frequency_hz = 50.0; + window->set_frequency_hz(50.0F); + sync_editor_values(window, state); + sync_visuals(window, state, frequency_hz); + window->set_status_message("Balanced Quick Manual values restored and validated."); + window->set_status_error(false); + return; + } + + if (action == "zero-output") { + for (auto& channel : state) { + channel.magnitude = 0.0; + } + sync_editor_values(window, state); + sync_visuals(window, state, frequency_hz); + if (window->get_connected()) { + transmit(window, device, "ZERO"); + } + window->set_status_message("All analog outputs set to zero; waveform preview updated."); + window->set_status_error(false); + return; + } + + if (action == "apply-all") { + if (!window->get_connected()) { + window->set_status_message("Apply all staged values: connect the ESP32-P4 first."); + window->set_status_error(true); + return; + } + for (const auto& channel : state) { + if (channel.enabled && !transmit(window, device, signal_command(channel))) { + return; + } + } + const auto frequency_command = "FREQ " + std::to_string(std::llround(frequency_hz * 1000.0)); + if (transmit(window, device, frequency_command)) { + window->set_status_message("All enabled analog values validated and transmitted."); + window->set_status_error(false); + } + return; + } + + if (action == "import-scl") { + window->set_status_message("SCL import is the next native controller integration seam."); + window->set_status_error(false); + return; + } + + if (action == "config" || action == "ramp" || action == "sequence") { + window->set_status_message("Quick Manual is the implemented fast workflow; advanced module integration remains staged."); + window->set_status_error(false); + } + }); + + ui->on_cell_commit_requested( + [ui_weak, &device, &state, &frequency_hz](const slint::SharedString& channel_value, + int column, + float requested_value, + bool force_commit) -> bool { + (void)force_commit; + const auto locked = ui_weak.lock(); + if (!locked) { + return false; + } + const auto& window = *locked; + const std::string channel_id{std::string_view(channel_value)}; + const auto index = channel_index(channel_id); + if (!index) { + window->set_status_message("Rejected signal edit: unknown channel."); + window->set_status_error(true); + return false; + } + + auto& channel = state[*index]; + if (column == 2) { + if (!std::isfinite(requested_value) || requested_value < 1.0F || requested_value > 1000.0F) { + window->set_status_message("Invalid frequency. Enter a value from 1 to 1000 Hz."); + window->set_status_error(true); + window->set_frequency_hz(static_cast(frequency_hz)); + return false; + } + frequency_hz = requested_value; + window->set_frequency_hz(requested_value); + sync_visuals(window, state, frequency_hz); + } else { + const double magnitude = column == 0 ? requested_value : channel.magnitude; + const double phase = column == 1 ? requested_value : channel.phase_degrees; + if (!valid_signal_value(channel, magnitude, phase)) { + const auto range = column == 0 + ? (channel.current ? "0 to 10000 A RMS" : "0 to 1000000 V RMS") + : "-360 to 360 degrees"; + window->set_status_message(("Invalid " + channel.id + " value. Allowed range: " + range + ".").c_str()); + window->set_status_error(true); + sync_editor_values(window, state); + return false; + } + channel.magnitude = magnitude; + channel.phase_degrees = phase; + sync_visuals(window, state, frequency_hz); + } + + if (!window->get_connected()) { + window->set_status_message((channel.id + " validated; preview updated and staged offline.").c_str()); + window->set_status_error(false); + return true; + } + + if (!window->get_live_apply_enabled()) { + window->set_status_message((channel.id + " validated and staged. Use Apply all to transmit.").c_str()); + window->set_status_error(false); + return true; + } + + const auto command = column == 2 + ? "FREQ " + std::to_string(std::llround(frequency_hz * 1000.0)) + : signal_command(channel); + if (transmit(window, device, command)) { + window->set_status_message((channel.id + " validated and live-applied to the next coherent generation.").c_str()); + } + return true; + }); + + ui->on_channel_enabled_requested([ui_weak, &device, &state, &frequency_hz](const slint::SharedString& channel_value, + bool enabled) { + const auto locked = ui_weak.lock(); + if (!locked) { + return; + } + const auto& window = *locked; + const std::string channel_id{std::string_view(channel_value)}; + const auto index = channel_index(channel_id); + if (!index) { + window->set_status_message("Rejected channel toggle: unknown channel."); + window->set_status_error(true); + return; + } + state[*index].enabled = enabled; + sync_visuals(window, state, frequency_hz); + const auto command = "ENABLE " + channel_id + " " + (enabled ? "1" : "0"); + if (window->get_connected() && window->get_live_apply_enabled()) { + transmit(window, device, command); + } + window->set_status_message((channel_id + (enabled ? " enabled." : " disabled.")).c_str()); + window->set_status_error(false); + }); + + ui->run(); + return 0; +} diff --git a/apps/smv_injector_slint/src/serial_device.cpp b/apps/smv_injector_slint/src/serial_device.cpp new file mode 100644 index 000000000..7cbbc0e04 --- /dev/null +++ b/apps/smv_injector_slint/src/serial_device.cpp @@ -0,0 +1,165 @@ +#include "serial_device.hpp" + +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include + +namespace { + +std::string windows_error(std::string operation) +{ + const auto code = GetLastError(); + return operation + " failed (Windows error " + std::to_string(code) + ")"; +} + +HANDLE native_handle(void* handle) +{ + return static_cast(handle); +} + +} // namespace +#endif + +SerialDevice::~SerialDevice() +{ + close(); +} + +bool SerialDevice::open(std::string port_name, std::uint32_t baud_rate) +{ + std::scoped_lock lock(mutex_); + close_unlocked(); + +#ifdef _WIN32 + port_name.erase( + std::remove_if(port_name.begin(), port_name.end(), [](unsigned char c) { return c == ' ' || c == '\t'; }), + port_name.end()); + if (port_name.empty()) { + last_error_ = "Enter a serial port such as COM3"; + return false; + } + + if (!port_name.starts_with("\\\\.\\")) { + port_name = "\\\\.\\" + port_name; + } + + const auto handle = CreateFileA(port_name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) { + last_error_ = windows_error("Opening " + port_name); + return false; + } + + DCB settings{}; + settings.DCBlength = sizeof(settings); + if (!GetCommState(handle, &settings)) { + last_error_ = windows_error("Reading serial settings"); + CloseHandle(handle); + return false; + } + + settings.BaudRate = baud_rate; + settings.ByteSize = 8; + settings.Parity = NOPARITY; + settings.StopBits = ONESTOPBIT; + settings.fBinary = TRUE; + settings.fDtrControl = DTR_CONTROL_ENABLE; + settings.fRtsControl = RTS_CONTROL_ENABLE; + settings.fOutxCtsFlow = FALSE; + settings.fOutxDsrFlow = FALSE; + settings.fOutX = FALSE; + settings.fInX = FALSE; + + COMMTIMEOUTS timeouts{}; + timeouts.ReadIntervalTimeout = 20; + timeouts.ReadTotalTimeoutConstant = 50; + timeouts.ReadTotalTimeoutMultiplier = 0; + timeouts.WriteTotalTimeoutConstant = 250; + timeouts.WriteTotalTimeoutMultiplier = 0; + + if (!SetCommState(handle, &settings) || !SetCommTimeouts(handle, &timeouts) + || !SetupComm(handle, 4096, 4096)) { + last_error_ = windows_error("Configuring serial port"); + CloseHandle(handle); + return false; + } + + PurgeComm(handle, PURGE_RXABORT | PURGE_RXCLEAR | PURGE_TXABORT | PURGE_TXCLEAR); + handle_ = handle; + last_error_.clear(); + return true; +#else + (void)port_name; + (void)baud_rate; + last_error_ = "Native serial control is currently available on Windows"; + return false; +#endif +} + +void SerialDevice::close() +{ + std::scoped_lock lock(mutex_); + close_unlocked(); +} + +void SerialDevice::close_unlocked() +{ +#ifdef _WIN32 + if (handle_ != nullptr && native_handle(handle_) != INVALID_HANDLE_VALUE) { + CloseHandle(native_handle(handle_)); + } +#endif + handle_ = nullptr; +} + +bool SerialDevice::is_open() const +{ + std::scoped_lock lock(mutex_); + return handle_ != nullptr; +} + +bool SerialDevice::send_line(std::string_view command) +{ + std::scoped_lock lock(mutex_); +#ifdef _WIN32 + if (handle_ == nullptr) { + last_error_ = "No serial device is connected"; + return false; + } + + std::string payload(command); + if (payload.empty() || payload.back() != '\n') { + payload += "\r\n"; + } + + std::size_t written_total = 0; + while (written_total < payload.size()) { + DWORD written = 0; + const auto remaining = static_cast(payload.size() - written_total); + if (!WriteFile(native_handle(handle_), payload.data() + written_total, remaining, &written, nullptr)) { + last_error_ = windows_error("Writing serial command"); + return false; + } + if (written == 0) { + last_error_ = "Serial write completed without sending data"; + return false; + } + written_total += written; + } + + last_error_.clear(); + return true; +#else + (void)command; + last_error_ = "Native serial control is currently available on Windows"; + return false; +#endif +} + +std::string SerialDevice::last_error() const +{ + std::scoped_lock lock(mutex_); + return last_error_; +} diff --git a/apps/smv_injector_slint/src/serial_device.hpp b/apps/smv_injector_slint/src/serial_device.hpp new file mode 100644 index 000000000..b72c40015 --- /dev/null +++ b/apps/smv_injector_slint/src/serial_device.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +class SerialDevice final { +public: + SerialDevice() = default; + ~SerialDevice(); + + SerialDevice(const SerialDevice&) = delete; + SerialDevice& operator=(const SerialDevice&) = delete; + + bool open(std::string port_name, std::uint32_t baud_rate = 115200); + void close(); + [[nodiscard]] bool is_open() const; + bool send_line(std::string_view command); + [[nodiscard]] std::string last_error() const; + +private: + void close_unlocked(); + + mutable std::mutex mutex_; + void* handle_ = nullptr; + std::string last_error_; +}; diff --git a/apps/smv_injector_slint/ui/app-window.slint b/apps/smv_injector_slint/ui/app-window.slint new file mode 100644 index 000000000..9ccceb6fb --- /dev/null +++ b/apps/smv_injector_slint/ui/app-window.slint @@ -0,0 +1,832 @@ +global Theme { + out property window: #f2f6fa; + out property surface: #ffffff; + out property surface-soft: #f8fafc; + out property ink: #132238; + out property ink-soft: #60718a; + out property line: #d8e2ee; + out property line-strong: #c6d4e4; + out property blue: #2563eb; + out property cyan: #14b8d4; + out property green: #0d9b68; + out property red: #e4556b; + out property phase-a: #ef3340; + out property phase-b: #e78300; + out property phase-c: #2563eb; + out property neutral: #64748b; +} + +component RibbonAction inherits Rectangle { + in property icon; + in property text; + in property hint; + in property active: false; + in property primary: false; + in property danger: false; + in property success: false; + in property enabled: true; + callback clicked; + + width: 76px; + height: 64px; + border-radius: 10px; + border-width: 1px; + border-color: !root.enabled ? #e1e7ed + : root.success ? #54c99a + : root.primary ? #6aa4ff + : root.danger ? #f1a3b1 + : root.active ? #8fdcff + : touch.has-hover ? #bdd0e4 + : #d9e4ef; + background: !root.enabled ? #f5f7f9 + : touch.pressed ? (root.success ? #087f56 : root.primary ? #1f56c5 : root.danger ? #cf4057 : #eaf2fa) + : root.success ? @linear-gradient(180deg, #45d39b, #0d9b68) + : root.primary ? @linear-gradient(180deg, #55a3ff, #2563eb) + : root.danger ? @linear-gradient(180deg, #ffd8df, #f49bad) + : root.active ? #eefaff + : touch.has-hover ? #f7fbff + : Theme.surface; + drop-shadow-blur: root.primary || root.danger || root.success ? 7px : 4px; + drop-shadow-offset-y: 2px; + drop-shadow-color: root.success ? #0d9b6838 : root.primary ? #2563eb38 : root.danger ? #d94e6530 : #14304a18; + animate background { duration: 100ms; } + + VerticalLayout { + padding-top: 7px; + padding-bottom: 6px; + spacing: 2px; + Image { + source: root.icon; + width: 23px; + height: 23px; + image-fit: contain; + colorize: !root.enabled ? #a9b4bf : (root.primary || root.danger || root.success) ? #ffffff : root.active ? #1676c6 : #384c64; + } + Text { + text: root.text; + color: !root.enabled ? #9aa7b4 : (root.primary || root.danger || root.success) ? #ffffff : Theme.ink; + font-size: 10px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + text: root.hint; + color: !root.enabled ? #b5bec7 : (root.primary || root.danger || root.success) ? #eaf2ff : Theme.ink-soft; + font-size: 7px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + touch := TouchArea { + enabled: root.enabled; + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +component Toggle inherits Rectangle { + in-out property checked: true; + callback toggled(bool); + width: 34px; + height: 18px; + border-radius: 9px; + background: root.checked ? Theme.green : #c6d1dc; + animate background { duration: 110ms; } + Rectangle { + x: root.checked ? parent.width - self.width - 3px : 3px; + y: 3px; + width: 12px; + height: 12px; + border-radius: 6px; + background: #ffffff; + drop-shadow-blur: 2px; + drop-shadow-offset-y: 1px; + drop-shadow-color: #00000030; + animate x { duration: 120ms; easing: ease-in-out; } + } + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.checked = !root.checked; root.toggled(root.checked); } + } +} + +component PanelHeader inherits Rectangle { + in property title; + in property meta; + height: 31px; + background: @linear-gradient(90deg, Theme.cyan, Theme.blue); + HorizontalLayout { + padding-left: 11px; + padding-right: 10px; + Text { text: root.title; color: #ffffff; font-size: 11px; font-weight: 600; vertical-alignment: center; horizontal-stretch: 1; } + Text { text: root.meta; color: #e7f4ff; font-size: 8px; vertical-alignment: center; } + } +} + +component DockTab inherits Rectangle { + in property label; + in property active: false; + callback selected; + width: 126px; + height: 25px; + background: root.active ? #ffffff : touch.has-hover ? #eef4fa : #00000000; + border-width: root.active ? 1px : 0px; + border-color: Theme.line-strong; + Text { + text: root.label; + color: root.active ? Theme.ink : Theme.ink-soft; + font-size: 8px; + font-weight: root.active ? 600 : 500; + horizontal-alignment: center; + vertical-alignment: center; + } + touch := TouchArea { mouse-cursor: pointer; clicked => { root.selected(); } } +} + +component MiniField inherits Rectangle { + in property label; + in property value; + height: 28px; + background: #00000000; + HorizontalLayout { + spacing: 7px; + Text { text: root.label; color: Theme.ink-soft; font-size: 9px; min-width: 76px; vertical-alignment: center; } + Rectangle { + horizontal-stretch: 1; + height: 27px; + border-radius: 5px; + border-width: 1px; + border-color: Theme.line-strong; + background: #fbfdff; + Text { text: root.value; color: Theme.ink; font-size: 10px; font-weight: 500; x: 8px; width: parent.width - 16px; vertical-alignment: center; } + } + } +} + +component EnableCell inherits Rectangle { + in-out property checked: true; + callback toggled(bool); + width: 34px; + height: 34px; + background: touch.has-hover ? #edf5ff : #00000000; + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 15px; + height: 15px; + border-radius: 3px; + border-width: 1px; + border-color: root.checked ? Theme.blue : #a8b7c6; + background: root.checked ? Theme.blue : #ffffff; + Image { + visible: root.checked; + x: 2px; y: 2px; width: 11px; height: 11px; + source: @image-url("../assets/lucide/check.svg"); + image-fit: contain; + colorize: #ffffff; + } + } + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.checked = !root.checked; root.toggled(root.checked); } + } +} + +component NumericCell inherits Rectangle { + in-out property value; + in property row-index; + in property column; + in property unit; + callback commit(int, int, float, bool) -> bool; + callback navigate(int, int); + private property invalid: false; + private property focused: editor.has-focus; + + height: 34px; + border-width: 1px; + border-color: root.invalid ? Theme.red : root.focused ? Theme.blue : #00000000; + background: root.invalid ? #fff1f3 : root.focused ? #f5f9ff : #00000000; + + public function focus-editor() { + editor.focus(); + editor.select-all(); + } + + function commit-and-move(next-row: int, next-column: int) { + if (root.commit(root.row-index, root.column, root.value, true)) { + root.invalid = false; + root.navigate(next-row, next-column); + } else { + root.invalid = true; + editor.focus(); + editor.select-all(); + } + } + + changed focused => { + if (root.focused) { editor.select-all(); } + } + + delayed-preview := Timer { + interval: 140ms; + running: false; + triggered => { + self.running = false; + root.invalid = !root.commit(root.row-index, root.column, root.value, false); + } + } + + HorizontalLayout { + padding-left: 3px; + padding-right: 4px; + spacing: 2px; + editor := TextInput { + horizontal-stretch: 1; + height: 28px; + text: root.value; + color: Theme.ink; + selection-foreground-color: #ffffff; + selection-background-color: Theme.blue; + font-size: 10px; + font-weight: 500; + horizontal-alignment: right; + vertical-alignment: center; + single-line: true; + input-type: decimal; + edited => { + if (self.text != "" && self.text != "-" && self.text != "+" && self.text != ".") { + root.value = self.text.to-float(); + delayed-preview.running = false; + delayed-preview.running = true; + } + } + accepted => { + if (self.text != "") { root.value = self.text.to-float(); } + root.commit-and-move(min(7, root.row-index + 1), root.column); + } + key-pressed(event) => { + if (event.text == Key.UpArrow) { + root.commit-and-move(max(0, root.row-index - 1), root.column); + return accept; + } + if (event.text == Key.DownArrow) { + root.commit-and-move(min(7, root.row-index + 1), root.column); + return accept; + } + if (event.text == Key.LeftArrow) { + root.commit-and-move(root.row-index, max(0, root.column - 1)); + return accept; + } + if (event.text == Key.RightArrow) { + root.commit-and-move(root.row-index, min(2, root.column + 1)); + return accept; + } + return reject; + } + } + Text { text: root.unit; color: Theme.ink-soft; font-size: 8px; min-width: root.unit == "" ? 0px : 18px; vertical-alignment: center; } + } +} + +component SignalGridRow inherits Rectangle { + in property row-index; + in property channel; + in property signal; + in property signal-color; + in-out property enabled: true; + in-out property magnitude; + in-out property phase; + in-out property frequency; + in property magnitude-unit; + in property alternate: false; + callback commit(string, int, int, float, bool) -> bool; + callback navigate(int, int); + callback enabled-changed(string, bool); + + height: 36px; + background: root.alternate ? #f7faff : Theme.surface; + border-width: 1px; + border-color: #e4ebf3; + + public function focus-column(column: int) { + if (column == 0) { magnitude-cell.focus-editor(); } + else if (column == 1) { phase-cell.focus-editor(); } + else { frequency-cell.focus-editor(); } + } + + HorizontalLayout { + spacing: 0px; + EnableCell { checked <=> root.enabled; toggled(value) => { root.enabled-changed(root.channel, value); } } + Rectangle { + width: 91px; + background: #00000000; + HorizontalLayout { + padding-left: 8px; + spacing: 7px; + Rectangle { width: 3px; height: 19px; border-radius: 2px; background: root.signal-color; } + Text { text: root.signal; color: root.enabled ? Theme.ink : #98a5b3; font-size: 10px; font-weight: 600; vertical-alignment: center; } + } + } + magnitude-cell := NumericCell { horizontal-stretch: 1; min-width: 91px; row-index: root.row-index; column: 0; value <=> root.magnitude; unit: root.magnitude-unit; commit(r, c, value, force) => { root.commit(root.channel, r, c, value, force) } navigate(r, c) => { root.navigate(r, c); } } + phase-cell := NumericCell { width: 91px; row-index: root.row-index; column: 1; value <=> root.phase; unit: "deg"; commit(r, c, value, force) => { root.commit(root.channel, r, c, value, force) } navigate(r, c) => { root.navigate(r, c); } } + frequency-cell := NumericCell { width: 89px; row-index: root.row-index; column: 2; value <=> root.frequency; unit: "Hz"; commit(r, c, value, force) => { root.commit(root.channel, r, c, value, force) } navigate(r, c) => { root.navigate(r, c); } } + } +} + +component LegendRow inherits Rectangle { + in property label; + in property value; + in property swatch; + height: 18px; + background: #00000000; + HorizontalLayout { + spacing: 6px; + Rectangle { width: 13px; height: 2px; border-radius: 1px; background: root.swatch; } + Text { text: root.label; color: Theme.ink-soft; font-size: 8px; horizontal-stretch: 1; vertical-alignment: center; } + Text { text: root.value; color: Theme.ink; font-size: 8px; font-weight: 500; vertical-alignment: center; horizontal-alignment: right; } + } +} + +component PhasorPanel inherits Rectangle { + in property running; + in property ia-path; + in property ib-path; + in property ic-path; + in property ua-path; + in property ub-path; + in property uc-path; + in property ia; + in property ib; + in property ic; + in property ua; + in property ub; + in property uc; + border-radius: 9px; + border-width: 1px; + border-color: Theme.line; + background: Theme.surface; + clip: true; + drop-shadow-blur: 5px; + drop-shadow-offset-y: 2px; + drop-shadow-color: #10243a18; + VerticalLayout { + spacing: 0px; + PanelHeader { title: "Phasor View"; meta: root.running ? "INJECTION LIVE" : "Effective SV outputs"; } + Rectangle { + vertical-stretch: 1; + background: #fcfdff; + plot := Rectangle { + width: min(parent.width - 24px, parent.height - 20px); + height: self.width; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: #00000000; + Path { + commands: "M 50 5 A 45 45 0 1 0 50 95 A 45 45 0 1 0 50 5 M 50 18 A 32 32 0 1 0 50 82 A 32 32 0 1 0 50 18 M 50 31 A 19 19 0 1 0 50 69 A 19 19 0 1 0 50 31 M 5 50 L 95 50 M 50 5 L 50 95 M 18.2 18.2 L 81.8 81.8 M 81.8 18.2 L 18.2 81.8"; + stroke: #d6e0eb; + stroke-width: 0.7px; + viewbox-width: 100; + viewbox-height: 100; + } + Path { commands: root.ia-path; stroke: Theme.phase-a; stroke-width: 2px; viewbox-width: 100; viewbox-height: 100; } + Path { commands: root.ib-path; stroke: Theme.phase-b; stroke-width: 2px; viewbox-width: 100; viewbox-height: 100; } + Path { commands: root.ic-path; stroke: Theme.phase-c; stroke-width: 2px; viewbox-width: 100; viewbox-height: 100; } + Path { commands: root.ua-path; stroke: Theme.phase-a; stroke-width: 1.25px; opacity: 0.58; viewbox-width: 100; viewbox-height: 100; } + Path { commands: root.ub-path; stroke: Theme.phase-b; stroke-width: 1.25px; opacity: 0.58; viewbox-width: 100; viewbox-height: 100; } + Path { commands: root.uc-path; stroke: Theme.phase-c; stroke-width: 1.25px; opacity: 0.58; viewbox-width: 100; viewbox-height: 100; } + Rectangle { x: parent.width / 2 - 2px; y: parent.height / 2 - 2px; width: 4px; height: 4px; border-radius: 2px; background: #334155; } + Text { x: parent.width - 23px; y: parent.height / 2 - 15px; text: "0"; color: Theme.ink-soft; font-size: 8px; } + Text { x: parent.width / 2 + 4px; y: 6px; text: "+90"; color: Theme.ink-soft; font-size: 8px; } + Text { x: 5px; y: parent.height / 2 - 15px; text: "180"; color: Theme.ink-soft; font-size: 8px; } + Text { x: parent.width / 2 + 4px; y: parent.height - 18px; text: "-90"; color: Theme.ink-soft; font-size: 8px; } + } + } + Rectangle { + height: 76px; + background: #f8fbff; + border-width: 1px; + border-color: Theme.line; + HorizontalLayout { + padding: 8px; + spacing: 14px; + VerticalLayout { + spacing: 1px; + Text { text: "VOLTAGE RMS"; color: Theme.ink-soft; font-size: 7px; font-weight: 600; } + LegendRow { label: "L1-E"; value: root.ua + " V"; swatch: Theme.phase-a; } + LegendRow { label: "L2-E"; value: root.ub + " V"; swatch: Theme.phase-b; } + LegendRow { label: "L3-E"; value: root.uc + " V"; swatch: Theme.phase-c; } + } + VerticalLayout { + spacing: 1px; + Text { text: "CURRENT RMS"; color: Theme.ink-soft; font-size: 7px; font-weight: 600; } + LegendRow { label: "L1"; value: root.ia + " A"; swatch: Theme.phase-a; } + LegendRow { label: "L2"; value: root.ib + " A"; swatch: Theme.phase-b; } + LegendRow { label: "L3"; value: root.ic + " A"; swatch: Theme.phase-c; } + } + } + } + } +} + +component WaveformPanel inherits Rectangle { + in property running; + in property ia-path; + in property ib-path; + in property ic-path; + in property ua-path; + in property ub-path; + in property uc-path; + in property frequency; + border-radius: 9px; + border-width: 1px; + border-color: Theme.line; + background: Theme.surface; + clip: true; + drop-shadow-blur: 5px; + drop-shadow-offset-y: 2px; + drop-shadow-color: #10243a18; + VerticalLayout { + spacing: 0px; + PanelHeader { title: "Waveform Monitor"; meta: root.running ? "LIVE / " + root.frequency + " Hz" : root.frequency + " Hz / 40 ms"; } + Rectangle { + vertical-stretch: 1; + background: #fcfdff; + Path { + x: 28px; y: 18px; width: parent.width - 44px; height: parent.height - 36px; + commands: "M 0 25 L 200 25 M 0 50 L 200 50 M 0 75 L 200 75 M 50 0 L 50 100 M 100 0 L 100 100 M 150 0 L 150 100 M 200 0 L 200 100"; + stroke: #dfe7f0; + stroke-width: 0.6px; + viewbox-width: 200; + viewbox-height: 100; + } + Path { x: 28px; y: 18px; width: parent.width - 44px; height: parent.height - 36px; commands: root.ua-path; stroke: Theme.phase-a; stroke-width: 1.6px; viewbox-width: 200; viewbox-height: 100; } + Path { x: 28px; y: 18px; width: parent.width - 44px; height: parent.height - 36px; commands: root.ub-path; stroke: Theme.phase-b; stroke-width: 1.6px; viewbox-width: 200; viewbox-height: 100; } + Path { x: 28px; y: 18px; width: parent.width - 44px; height: parent.height - 36px; commands: root.uc-path; stroke: Theme.phase-c; stroke-width: 1.6px; viewbox-width: 200; viewbox-height: 100; } + Path { x: 28px; y: 18px; width: parent.width - 44px; height: parent.height - 36px; commands: root.ia-path; stroke: Theme.phase-a; stroke-width: 1.6px; viewbox-width: 200; viewbox-height: 100; } + Path { x: 28px; y: 18px; width: parent.width - 44px; height: parent.height - 36px; commands: root.ib-path; stroke: Theme.phase-b; stroke-width: 1.6px; viewbox-width: 200; viewbox-height: 100; } + Path { x: 28px; y: 18px; width: parent.width - 44px; height: parent.height - 36px; commands: root.ic-path; stroke: Theme.phase-c; stroke-width: 1.6px; viewbox-width: 200; viewbox-height: 100; } + Text { x: 8px; y: parent.height * 0.21; text: "V"; color: Theme.ink-soft; font-size: 9px; } + Text { x: 8px; y: parent.height * 0.70; text: "A"; color: Theme.ink-soft; font-size: 9px; } + Text { x: 34px; y: 22px; text: "Voltage"; color: Theme.ink-soft; font-size: 8px; } + Text { x: 34px; y: parent.height / 2 + 22px; text: "Current"; color: Theme.ink-soft; font-size: 8px; } + } + Rectangle { + height: 76px; + background: #f8fbff; + border-width: 1px; + border-color: Theme.line; + HorizontalLayout { + padding-left: 11px; + padding-right: 11px; + spacing: 12px; + Text { text: "L1"; color: Theme.phase-a; font-size: 9px; font-weight: 600; vertical-alignment: center; } + Text { text: "L2"; color: Theme.phase-b; font-size: 9px; font-weight: 600; vertical-alignment: center; } + Text { text: "L3"; color: Theme.phase-c; font-size: 9px; font-weight: 600; vertical-alignment: center; } + Rectangle { horizontal-stretch: 1; background: #00000000; } + VerticalLayout { + spacing: 3px; + Text { text: "PUBLISH MONITOR"; color: Theme.ink-soft; font-size: 7px; font-weight: 600; horizontal-alignment: right; } + Text { text: "4800 fps | Class A"; color: Theme.ink; font-size: 9px; font-weight: 500; horizontal-alignment: right; } + Text { text: "Missed 0 | TX failures 0"; color: Theme.green; font-size: 8px; horizontal-alignment: right; } + } + } + } + } +} + +export component AppWindow inherits Window { + title: "ARStack61850 - SMV Injection Studio"; + preferred-width: 1480px; + preferred-height: 900px; + min-width: 1280px; + min-height: 800px; + background: Theme.window; + default-font-family: "Segoe UI Variable"; + + callback action-requested(string); + callback cell-commit-requested(string, int, float, bool) -> bool; + callback channel-enabled-requested(string, bool); + + in-out property connected: false; + in-out property running: false; + in-out property run-elapsed-seconds: 0; + in-out property live-apply-enabled: true; + in-out property bottom-dock-expanded: true; + in-out property bottom-dock-tab: 0; + in-out property serial-port: "COM3"; + in-out property status-message: "Ready. Arrow keys move between numeric cells; Enter validates and commits."; + in-out property status-error: false; + in-out property last-command: "No command transmitted"; + in-out property tx-sequence: 0; + in-out property frequency-hz: 50; + + in-out property ia-enabled: true; + in-out property ib-enabled: true; + in-out property ic-enabled: true; + in-out property in-enabled: false; + in-out property ua-enabled: true; + in-out property ub-enabled: true; + in-out property uc-enabled: true; + in-out property un-enabled: false; + in-out property ia-magnitude: 1; + in-out property ib-magnitude: 1; + in-out property ic-magnitude: 1; + in-out property in-magnitude: 0; + in-out property ua-magnitude: 57.74; + in-out property ub-magnitude: 57.74; + in-out property uc-magnitude: 57.74; + in-out property un-magnitude: 0; + in-out property ia-phase: 0; + in-out property ib-phase: -120; + in-out property ic-phase: 120; + in-out property in-phase: 0; + in-out property ua-phase: 0; + in-out property ub-phase: -120; + in-out property uc-phase: 120; + in-out property un-phase: 0; + + in-out property ia-wave-path; + in-out property ib-wave-path; + in-out property ic-wave-path; + in-out property ua-wave-path; + in-out property ub-wave-path; + in-out property uc-wave-path; + in-out property ia-phasor-path; + in-out property ib-phasor-path; + in-out property ic-phasor-path; + in-out property ua-phasor-path; + in-out property ub-phasor-path; + in-out property uc-phasor-path; + + run-clock := Timer { + interval: 1s; + running: root.running; + triggered => { root.run-elapsed-seconds += 1; } + } + + public function focus-cell(row: int, column: int) { + if (row == 0) { ua-row.focus-column(column); } + else if (row == 1) { ub-row.focus-column(column); } + else if (row == 2) { uc-row.focus-column(column); } + else if (row == 3) { un-row.focus-column(column); } + else if (row == 4) { ia-row.focus-column(column); } + else if (row == 5) { ib-row.focus-column(column); } + else if (row == 6) { ic-row.focus-column(column); } + else { in-row.focus-column(column); } + } + + VerticalLayout { + spacing: 0px; + Rectangle { + height: 32px; + background: #ffffff; + border-width: 1px; + border-color: Theme.line; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 8px; + Rectangle { width: 20px; height: 20px; border-radius: 4px; background: @linear-gradient(135deg, Theme.cyan, Theme.blue); Text { text: "AR"; color: #ffffff; font-size: 8px; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; } } + Text { text: "ARStack61850"; color: Theme.ink; font-size: 11px; font-weight: 600; vertical-alignment: center; } + Text { text: "SMV Injection Studio"; color: Theme.ink-soft; font-size: 9px; vertical-alignment: center; horizontal-stretch: 1; } + Rectangle { width: 7px; height: 7px; border-radius: 4px; background: root.connected ? Theme.green : #9aa8b6; } + Text { text: root.running ? "INJECTION LIVE" : root.connected ? "DEVICE READY" : "OFFLINE"; color: root.running ? Theme.green : Theme.ink-soft; font-size: 9px; font-weight: 600; vertical-alignment: center; } + } + } + + Rectangle { + height: 88px; + background: #fbfdff; + border-width: 1px; + border-color: Theme.line; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + padding-top: 10px; + padding-bottom: 10px; + spacing: 7px; + RibbonAction { icon: @image-url("../assets/lucide/settings.svg"); text: "Config"; hint: "Profile"; clicked => { root.action-requested("config"); } } + RibbonAction { icon: @image-url("../assets/lucide/orbit.svg"); text: "Manual"; hint: "Quick set"; active: true; clicked => { root.action-requested("manual"); } } + RibbonAction { icon: @image-url("../assets/lucide/chart-no-axes-combined.svg"); text: "Ramp"; hint: "Transition"; clicked => { root.action-requested("ramp"); } } + RibbonAction { icon: @image-url("../assets/lucide/list-tree.svg"); text: "Sequence"; hint: "States"; clicked => { root.action-requested("sequence"); } } + RibbonAction { icon: @image-url("../assets/lucide/undo-2.svg"); text: "Prefault"; hint: "Nominal"; clicked => { root.action-requested("prefault"); } } + RibbonAction { icon: @image-url("../assets/lucide/circle-slash-2.svg"); text: "Zero"; hint: "All output"; clicked => { root.action-requested("zero-output"); } } + RibbonAction { icon: @image-url("../assets/lucide/panels-top-left.svg"); text: "Views"; hint: "Dock panels"; active: root.bottom-dock-expanded; clicked => { root.bottom-dock-expanded = !root.bottom-dock-expanded; } } + Rectangle { width: 1px; background: Theme.line; } + RibbonAction { icon: @image-url("../assets/lucide/file-play.svg"); text: "Import SCL"; hint: "Compile"; clicked => { root.action-requested("import-scl"); } } + RibbonAction { icon: @image-url("../assets/lucide/circle-check-big.svg"); text: "Apply all"; hint: "Validate"; clicked => { root.action-requested("apply-all"); } } + Rectangle { width: 1px; background: Theme.line; } + RibbonAction { icon: @image-url("../assets/lucide/plug-zap.svg"); text: root.connected ? "Disconnect" : "Connect"; hint: root.serial-port; active: root.connected; clicked => { root.action-requested(root.connected ? "disconnect-device" : "connect-device"); } } + RibbonAction { icon: @image-url("../assets/lucide/play.svg"); text: root.running ? "Running" : "Start"; hint: root.running ? root.run-elapsed-seconds + " s" : "Publisher"; primary: !root.running; active: root.running; enabled: root.connected; clicked => { if (!root.running) { root.action-requested("start-injection"); } } } + RibbonAction { icon: @image-url("../assets/lucide/square.svg"); text: "Stop"; hint: "Safe stop"; danger: true; enabled: root.connected && root.running; clicked => { root.action-requested("stop-injection"); } } + Rectangle { horizontal-stretch: 1; background: #00000000; } + Rectangle { + width: 246px; + border-radius: 9px; + border-width: 1px; + border-color: Theme.line; + background: Theme.surface; + VerticalLayout { + padding: 8px; + spacing: 5px; + HorizontalLayout { + Text { text: "IED / MU PUBLISHER"; color: Theme.ink-soft; font-size: 7px; font-weight: 600; horizontal-stretch: 1; vertical-alignment: center; } + Text { text: "4800 fps"; color: Theme.green; font-size: 8px; font-weight: 600; vertical-alignment: center; } + } + HorizontalLayout { + spacing: 6px; + Rectangle { + width: 62px; height: 27px; border-radius: 5px; border-width: 1px; border-color: Theme.line-strong; background: #fbfdff; + TextInput { x: 7px; y: 2px; width: parent.width - 14px; height: parent.height - 4px; text <=> root.serial-port; color: Theme.ink; selection-foreground-color: #ffffff; selection-background-color: Theme.blue; font-size: 10px; font-weight: 500; horizontal-alignment: center; vertical-alignment: center; single-line: true; } + } + Rectangle { + horizontal-stretch: 1; height: 27px; border-radius: 5px; border-width: 1px; border-color: Theme.line-strong; background: #fbfdff; + Text { x: 8px; width: parent.width - 16px; text: "MU01 / MSVCB01"; color: Theme.ink; font-size: 9px; vertical-alignment: center; overflow: elide; } + } + } + } + } + } + } + + HorizontalLayout { + vertical-stretch: 1; + padding-left: 10px; + padding-right: 10px; + padding-top: 10px; + padding-bottom: 10px; + spacing: 9px; + + Rectangle { + horizontal-stretch: 34; + min-width: 400px; + border-radius: 9px; + border-width: 1px; + border-color: Theme.line; + background: Theme.surface; + clip: true; + drop-shadow-blur: 5px; + drop-shadow-offset-y: 2px; + drop-shadow-color: #10243a18; + VerticalLayout { + spacing: 0px; + PanelHeader { title: "Test View: Quick Manual"; meta: root.running ? "INJECTION LIVE" : root.live-apply-enabled ? "LIVE APPLY" : "STAGED"; } + Rectangle { + height: 121px; + background: Theme.surface; + VerticalLayout { + padding: 10px; + spacing: 5px; + HorizontalLayout { + spacing: 12px; + MiniField { label: "Set mode"; value: "Direct"; horizontal-stretch: 1; } + MiniField { label: "Sample rate"; value: "4800 fps"; horizontal-stretch: 1; } + } + HorizontalLayout { + spacing: 12px; + MiniField { label: "SV quality"; value: "Good quality"; horizontal-stretch: 1; } + MiniField { label: "Nominal"; value: root.frequency-hz + " Hz"; horizontal-stretch: 1; } + } + HorizontalLayout { + spacing: 7px; + Toggle { checked <=> root.live-apply-enabled; toggled(value) => { root.status-message = value ? "Live Apply enabled. Valid edits transmit after 140 ms." : "Live Apply paused. Use Apply all to transmit staged values."; } } + Text { text: "Live Apply"; color: Theme.ink; font-size: 9px; font-weight: 600; vertical-alignment: center; } + Text { text: "Validation engine active"; color: Theme.green; font-size: 8px; vertical-alignment: center; horizontal-stretch: 1; horizontal-alignment: right; } + } + } + } + Rectangle { + height: 29px; + background: #405f86; + HorizontalLayout { + spacing: 0px; + Text { text: "ON"; width: 34px; color: #ffffff; font-size: 8px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; } + Text { text: "SIGNAL"; width: 91px; color: #ffffff; font-size: 8px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; } + Text { text: "VALUE"; horizontal-stretch: 1; min-width: 91px; color: #ffffff; font-size: 8px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; } + Text { text: "ANGLE"; width: 91px; color: #ffffff; font-size: 8px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; } + Text { text: "FREQ"; width: 89px; color: #ffffff; font-size: 8px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; } + } + } + ua-row := SignalGridRow { row-index: 0; channel: "UA"; signal: "V L1-E"; signal-color: Theme.phase-a; enabled <=> root.ua-enabled; magnitude <=> root.ua-magnitude; phase <=> root.ua-phase; frequency <=> root.frequency-hz; magnitude-unit: "V"; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + ub-row := SignalGridRow { row-index: 1; channel: "UB"; signal: "V L2-E"; signal-color: Theme.phase-b; enabled <=> root.ub-enabled; magnitude <=> root.ub-magnitude; phase <=> root.ub-phase; frequency <=> root.frequency-hz; magnitude-unit: "V"; alternate: true; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + uc-row := SignalGridRow { row-index: 2; channel: "UC"; signal: "V L3-E"; signal-color: Theme.phase-c; enabled <=> root.uc-enabled; magnitude <=> root.uc-magnitude; phase <=> root.uc-phase; frequency <=> root.frequency-hz; magnitude-unit: "V"; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + un-row := SignalGridRow { row-index: 3; channel: "UN"; signal: "V N"; signal-color: Theme.neutral; enabled <=> root.un-enabled; magnitude <=> root.un-magnitude; phase <=> root.un-phase; frequency <=> root.frequency-hz; magnitude-unit: "V"; alternate: true; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + ia-row := SignalGridRow { row-index: 4; channel: "IA"; signal: "I L1"; signal-color: Theme.phase-a; enabled <=> root.ia-enabled; magnitude <=> root.ia-magnitude; phase <=> root.ia-phase; frequency <=> root.frequency-hz; magnitude-unit: "A"; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + ib-row := SignalGridRow { row-index: 5; channel: "IB"; signal: "I L2"; signal-color: Theme.phase-b; enabled <=> root.ib-enabled; magnitude <=> root.ib-magnitude; phase <=> root.ib-phase; frequency <=> root.frequency-hz; magnitude-unit: "A"; alternate: true; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + ic-row := SignalGridRow { row-index: 6; channel: "IC"; signal: "I L3"; signal-color: Theme.phase-c; enabled <=> root.ic-enabled; magnitude <=> root.ic-magnitude; phase <=> root.ic-phase; frequency <=> root.frequency-hz; magnitude-unit: "A"; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + in-row := SignalGridRow { row-index: 7; channel: "IN"; signal: "I N"; signal-color: Theme.neutral; enabled <=> root.in-enabled; magnitude <=> root.in-magnitude; phase <=> root.in-phase; frequency <=> root.frequency-hz; magnitude-unit: "A"; alternate: true; commit(ch, r, c, value, force) => { root.cell-commit-requested(ch, c, value, force) } navigate(r, c) => { root.focus-cell(r, c); } enabled-changed(ch, value) => { root.channel-enabled-requested(ch, value); } } + Rectangle { + vertical-stretch: 1; + background: Theme.surface; + VerticalLayout { + padding: 11px; + spacing: 8px; + Rectangle { height: 1px; background: Theme.line; } + Text { text: "FAST WORKFLOW"; color: Theme.ink-soft; font-size: 7px; font-weight: 600; } + Text { text: "Arrow keys move | Enter commits | Focus selects the full value"; color: Theme.ink; font-size: 9px; } + Text { text: "Invalid values stay selected and are rejected before device transmit."; color: Theme.green; font-size: 8px; } + Rectangle { vertical-stretch: 1; background: #00000000; } + HorizontalLayout { + Text { text: "Last TX"; color: Theme.ink-soft; font-size: 8px; vertical-alignment: center; } + Text { text: root.last-command; color: Theme.ink; font-size: 8px; font-weight: 500; horizontal-stretch: 1; horizontal-alignment: right; vertical-alignment: center; overflow: elide; } + } + } + } + } + } + + PhasorPanel { + horizontal-stretch: 33; + running: root.running; + ia-path: root.ia-phasor-path; ib-path: root.ib-phasor-path; ic-path: root.ic-phasor-path; + ua-path: root.ua-phasor-path; ub-path: root.ub-phasor-path; uc-path: root.uc-phasor-path; + ia: root.ia-magnitude; ib: root.ib-magnitude; ic: root.ic-magnitude; + ua: root.ua-magnitude; ub: root.ub-magnitude; uc: root.uc-magnitude; + } + + WaveformPanel { + horizontal-stretch: 33; + running: root.running; + frequency: root.frequency-hz; + ia-path: root.ia-wave-path; ib-path: root.ib-wave-path; ic-path: root.ic-wave-path; + ua-path: root.ua-wave-path; ub-path: root.ub-wave-path; uc-path: root.uc-wave-path; + } + } + + Rectangle { + height: root.bottom-dock-expanded ? 108px : 29px; + background: #f7f9fc; + border-width: 1px; + border-color: Theme.line-strong; + animate height { duration: 120ms; easing: ease-in-out; } + VerticalLayout { + spacing: 0px; + Rectangle { + height: 28px; + background: #e9eff6; + HorizontalLayout { + padding-left: 6px; + padding-right: 8px; + spacing: 3px; + DockTab { label: "Status History"; active: root.bottom-dock-tab == 0; selected => { root.bottom-dock-tab = 0; root.bottom-dock-expanded = true; } } + DockTab { label: "Overload Monitor"; active: root.bottom-dock-tab == 1; selected => { root.bottom-dock-tab = 1; root.bottom-dock-expanded = true; } } + Rectangle { horizontal-stretch: 1; background: #00000000; } + Text { text: root.bottom-dock-expanded ? "COLLAPSE" : "EXPAND"; color: Theme.ink-soft; font-size: 7px; font-weight: 600; vertical-alignment: center; } + Rectangle { + width: 26px; + background: #00000000; + Image { source: @image-url("../assets/lucide/chevron-down.svg"); width: 15px; height: 15px; x: 5px; y: 6px; image-fit: contain; colorize: Theme.ink-soft; transform-rotation: root.bottom-dock-expanded ? 180deg : 0deg; animate transform-rotation { duration: 120ms; } } + TouchArea { mouse-cursor: pointer; clicked => { root.bottom-dock-expanded = !root.bottom-dock-expanded; } } + } + } + } + if root.bottom-dock-expanded: Rectangle { + vertical-stretch: 1; + background: Theme.surface; + if root.bottom-dock-tab == 0: HorizontalLayout { + padding-left: 11px; + padding-right: 11px; + spacing: 10px; + Text { text: root.running ? "Injection session active" : "Ready for injection"; color: root.running ? Theme.green : Theme.ink; font-size: 9px; font-weight: 600; vertical-alignment: center; } + Rectangle { width: 1px; height: 32px; background: Theme.line; } + Text { text: root.status-message; color: root.status-error ? #bd3048 : Theme.ink-soft; font-size: 8px; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; } + Text { text: "Last TX " + root.last-command; color: Theme.ink-soft; font-size: 8px; vertical-alignment: center; horizontal-alignment: right; } + } + if root.bottom-dock-tab == 1: HorizontalLayout { + padding-left: 16px; + padding-right: 16px; + spacing: 25px; + for phase[index] in ["V L1-E", "V L2-E", "V L3-E", "I L1", "I L2", "I L3"]: VerticalLayout { + alignment: center; + spacing: 4px; + Rectangle { width: 11px; height: 11px; border-radius: 6px; background: Theme.green; border-width: 1px; border-color: #087d55; } + Text { text: phase; color: Theme.ink-soft; font-size: 8px; horizontal-alignment: center; } + } + Rectangle { horizontal-stretch: 1; background: #00000000; } + Text { text: "6 channels within configured limits"; color: Theme.green; font-size: 8px; font-weight: 600; vertical-alignment: center; } + } + } + } + } + + Rectangle { + height: 26px; + background: #ffffff; + border-width: 1px; + border-color: Theme.line; + HorizontalLayout { + padding-left: 11px; + padding-right: 11px; + spacing: 9px; + Rectangle { width: 7px; height: 7px; border-radius: 4px; background: root.status-error ? Theme.red : root.connected ? Theme.green : #96a5b4; } + Text { text: root.status-message; color: root.status-error ? #bd3048 : Theme.ink-soft; font-size: 8px; font-weight: root.status-error ? 600 : 400; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; } + Text { text: "TX " + root.tx-sequence; color: Theme.ink-soft; font-size: 8px; vertical-alignment: center; } + Text { text: root.running ? "RUN " + root.run-elapsed-seconds + " s" : "RUN --"; color: root.running ? Theme.green : Theme.ink-soft; font-size: 8px; font-weight: 600; vertical-alignment: center; } + Text { text: "Publisher " + (root.running ? "RUNNING" : "STOPPED"); color: root.running ? Theme.green : Theme.ink-soft; font-size: 8px; font-weight: 600; vertical-alignment: center; } + Text { text: "Slint 1.17.1"; color: #9aa7b4; font-size: 8px; vertical-alignment: center; } + } + } + } +}