From 373b3d596e62b5c91e1d4389481d6c3f1598aa27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:12:45 +0700 Subject: [PATCH 001/129] feat: add coherent live SV signal state --- .../sampled_values/live_signal_state.hpp | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 include/ariec61850/sampled_values/live_signal_state.hpp diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp new file mode 100644 index 000000000..218fa4827 --- /dev/null +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +constexpr std::size_t kLiveSignalChannelCount = 8U; +constexpr std::size_t kLiveSineLutSize = 4096U; + +struct SvLiveChannelState final { + // RMS magnitude in raw wire counts. Engineering-unit conversion belongs + // to the host/profile layer so the realtime engine remains profile-neutral. + std::int32_t rms_counts{}; + std::int32_t phase_millidegrees{}; + std::uint32_t quality{}; + bool enabled{true}; + + friend bool operator==(const SvLiveChannelState&, const SvLiveChannelState&) = default; +}; + +struct SvLiveSignalState final { + // Fundamental signal frequency in millihertz. This is deliberately + // independent of the SV publisher event rate. + std::uint32_t frequency_millihz{50000U}; + std::array channels{}; + std::uint64_t generation{}; + + friend bool operator==(const SvLiveSignalState&, const SvLiveSignalState&) = default; +}; + +class SvLiveSignalBank final { +public: + SvLiveSignalBank() noexcept { + banks_[0] = default_state(); + banks_[1] = banks_[0]; + } + + // One control-plane writer is expected. Readers can run concurrently at + // realtime priority. The sequence guard makes a copied snapshot coherent + // even if the writer publishes multiple generations rapidly. + [[nodiscard]] SvLiveSignalState snapshot() const noexcept { + SvLiveSignalState copy{}; + while (true) { + const auto sequence_before = sequence_.load(std::memory_order_acquire); + if ((sequence_before & 1U) != 0U) { + continue; + } + const auto index = active_index_.load(std::memory_order_acquire); + copy = banks_[index]; + const auto sequence_after = sequence_.load(std::memory_order_acquire); + if (sequence_before == sequence_after && (sequence_after & 1U) == 0U) { + return copy; + } + } + } + + [[nodiscard]] std::uint32_t active_index() const noexcept { + return active_index_.load(std::memory_order_acquire); + } + + [[nodiscard]] bool publish(const SvLiveSignalState& requested) noexcept { + if (!validate(requested)) { + return false; + } + + sequence_.fetch_add(1U, std::memory_order_acq_rel); // writer active: odd + const auto current = active_index_.load(std::memory_order_relaxed); + const auto next = current ^ 1U; + banks_[next] = requested; + banks_[next].generation = banks_[current].generation + 1U; + active_index_.store(next, std::memory_order_release); + sequence_.fetch_add(1U, std::memory_order_release); // stable: even + return true; + } + + [[nodiscard]] static bool validate(const SvLiveSignalState& state) noexcept { + if (state.frequency_millihz < 1000U || state.frequency_millihz > 1000000U) { + return false; + } + for (const auto& channel : state.channels) { + if (channel.rms_counts == std::numeric_limits::min()) { + return false; + } + if (channel.phase_millidegrees < -360000000 || + channel.phase_millidegrees > 360000000) { + return false; + } + } + return true; + } + + [[nodiscard]] static SvLiveSignalState default_state() noexcept { + SvLiveSignalState state; + state.channels[0] = {1000, 0, 0U, true}; + state.channels[1] = {1000, -120000, 0U, true}; + state.channels[2] = {1000, 120000, 0U, true}; + state.channels[3] = {0, 0, 0U, true}; + state.channels[4] = {5774, 0, 0U, true}; + state.channels[5] = {5774, -120000, 0U, true}; + state.channels[6] = {5774, 120000, 0U, true}; + state.channels[7] = {0, 0, 0U, true}; + return state; + } + +private: + std::array banks_{}; + mutable std::atomic sequence_{0U}; + std::atomic active_index_{0U}; +}; + +class SvFixedPointSineEngine final { +public: + using SampleRow = std::array; + + SvFixedPointSineEngine() noexcept { + build_lut(); + } + + void reset_phase(const std::uint32_t phase = 0U) noexcept { + phase_accumulator_ = phase; + } + + void advance( + const SvLiveSignalState& state, + const std::uint32_t publisher_rate_hz, + const std::uint32_t slots) noexcept { + const std::uint64_t delta = + static_cast(phase_step_for(state.frequency_millihz, publisher_rate_hz)) * + static_cast(slots); + phase_accumulator_ += static_cast(delta); + } + + [[nodiscard]] std::uint32_t phase_accumulator() const noexcept { + return phase_accumulator_; + } + + [[nodiscard]] SampleRow next( + const SvLiveSignalState& state, + const std::uint32_t publisher_rate_hz) noexcept { + SampleRow row{}; + if (publisher_rate_hz == 0U) { + return row; + } + + const auto phase_step = phase_step_for(state.frequency_millihz, publisher_rate_hz); + const auto base_phase = phase_accumulator_; + + for (std::size_t index = 0U; index < row.size(); ++index) { + const auto& channel = state.channels[index]; + if (!channel.enabled || channel.rms_counts == 0) { + row[index] = 0; + continue; + } + + const auto offset = phase_from_millidegrees(channel.phase_millidegrees); + const std::uint32_t phase = base_phase + offset; + const auto lut_index = static_cast(phase >> (32U - 12U)); + const std::int64_t sine_q30 = sine_lut_[lut_index & (kLiveSineLutSize - 1U)]; + const std::int64_t peak_counts = rms_to_peak_counts(channel.rms_counts); + const std::int64_t value = (sine_q30 * peak_counts) >> 30U; + row[index] = clamp_i32(value); + } + + phase_accumulator_ += phase_step; + return row; + } + + [[nodiscard]] static std::uint32_t phase_step_for( + const std::uint32_t frequency_millihz, + const std::uint32_t publisher_rate_hz) noexcept { + if (publisher_rate_hz == 0U) { + return 0U; + } + constexpr std::uint64_t full_turn = (std::uint64_t{1} << 32U); + const std::uint64_t denominator = + static_cast(publisher_rate_hz) * 1000ULL; + const std::uint64_t numerator = + full_turn * static_cast(frequency_millihz); + return static_cast((numerator + denominator / 2ULL) / denominator); + } + + [[nodiscard]] static std::uint32_t phase_from_millidegrees( + const std::int32_t millidegrees) noexcept { + constexpr std::int64_t full_turn_md = 360000LL; + constexpr std::uint64_t full_turn = (std::uint64_t{1} << 32U); + std::int64_t normalized = static_cast(millidegrees) % full_turn_md; + if (normalized < 0) { + normalized += full_turn_md; + } + return static_cast( + (static_cast(normalized) * full_turn) / + static_cast(full_turn_md)); + } + +private: + std::array sine_lut_{}; + std::uint32_t phase_accumulator_{}; + + void build_lut() noexcept { + constexpr double pi = 3.141592653589793238462643383279502884; + constexpr double q30 = static_cast(std::uint64_t{1} << 30U); + for (std::size_t index = 0U; index < sine_lut_.size(); ++index) { + const double angle = 2.0 * pi * static_cast(index) / + static_cast(sine_lut_.size()); + sine_lut_[index] = static_cast(std::llround(std::sin(angle) * q30)); + } + } + + [[nodiscard]] static std::int64_t rms_to_peak_counts(const std::int32_t rms) noexcept { + constexpr std::int64_t sqrt2_q30 = 1518500250LL; + return (static_cast(rms) * sqrt2_q30) >> 30U; + } + + [[nodiscard]] static std::int32_t clamp_i32(const std::int64_t value) noexcept { + if (value > std::numeric_limits::max()) { + return std::numeric_limits::max(); + } + if (value < std::numeric_limits::min()) { + return std::numeric_limits::min(); + } + return static_cast(value); + } +}; + +[[nodiscard]] inline std::optional live_channel_index( + const std::string_view name) noexcept { + constexpr std::array names{ + "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; + for (std::size_t index = 0U; index < names.size(); ++index) { + if (name == names[index]) { + return index; + } + } + return std::nullopt; +} + +} // namespace ar::iec61850::sampled_values From 1b3d451854e8faabed5638dfce6c9603463ff5e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:13:03 +0700 Subject: [PATCH 002/129] test: cover live SV signal generations --- tests/test_live_signal.cpp | 131 +++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/test_live_signal.cpp diff --git a/tests/test_live_signal.cpp b/tests/test_live_signal.cpp new file mode 100644 index 000000000..0f353d30e --- /dev/null +++ b/tests/test_live_signal.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/live_signal_state.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +void bank_commits_complete_generations() { + using namespace ar::iec61850::sampled_values; + + SvLiveSignalBank bank; + const auto before = bank.snapshot(); + CHECK(before.generation == 0U); + CHECK(before.channels[0].rms_counts == 1000); + CHECK(before.channels[1].phase_millidegrees == -120000); + + auto next = before; + next.frequency_millihz = 60000U; + next.channels[0].rms_counts = 2500; + next.channels[4].rms_counts = 12000; + next.channels[4].phase_millidegrees = 15000; + + CHECK(bank.publish(next)); + const auto after = bank.snapshot(); + CHECK(after.generation == 1U); + CHECK(after.frequency_millihz == 60000U); + CHECK(after.channels[0].rms_counts == 2500); + CHECK(after.channels[4].rms_counts == 12000); + CHECK(after.channels[4].phase_millidegrees == 15000); + + auto invalid = after; + invalid.frequency_millihz = 0U; + CHECK(!bank.publish(invalid)); + CHECK(bank.snapshot() == after); +} + +void fixed_point_engine_preserves_frequency_and_phase() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.frequency_millihz = 50000U; + + const auto step = SvFixedPointSineEngine::phase_step_for(50000U, 4000U); + const auto accumulated = static_cast( + static_cast(step) * 80ULL); + CHECK(accumulated < 80U || accumulated > 0xFFFFFFB0U); + + std::int32_t max_a{}; + std::int32_t min_a{}; + for (std::size_t sample = 0U; sample < 80U; ++sample) { + const auto row = engine.next(state, 4000U); + max_a = std::max(max_a, row[0]); + min_a = std::min(min_a, row[0]); + } + CHECK(max_a > 1300); + CHECK(max_a < 1500); + CHECK(min_a < -1300); + CHECK(min_a > -1500); + + const auto phase_before = engine.phase_accumulator(); + state.channels[0].rms_counts = 2000; + static_cast(engine.next(state, 4000U)); + CHECK(engine.phase_accumulator() != phase_before); + + const auto phase_before_skip = engine.phase_accumulator(); + engine.advance(state, 4000U, 3U); + CHECK(engine.phase_accumulator() != phase_before_skip); + + CHECK(SvFixedPointSineEngine::phase_from_millidegrees(0) == 0U); + const auto phase_120 = SvFixedPointSineEngine::phase_from_millidegrees(120000); + const auto phase_minus_120 = SvFixedPointSineEngine::phase_from_millidegrees(-120000); + CHECK(phase_120 != 0U); + CHECK(phase_minus_120 != 0U); + CHECK(phase_120 != phase_minus_120); +} + +void independent_channels_change_without_stream_restart() { + using namespace ar::iec61850::sampled_values; + + SvLiveSignalBank bank; + SvFixedPointSineEngine engine; + + const auto first_generation = bank.snapshot().generation; + for (std::size_t sample = 0U; sample < 17U; ++sample) { + const auto active = bank.snapshot(); + static_cast(engine.next(active, 4000U)); + } + const auto phase_before_update = engine.phase_accumulator(); + + auto updated = bank.snapshot(); + updated.channels[0].rms_counts = 3500; + updated.channels[0].phase_millidegrees = 30000; + updated.channels[6].rms_counts = 9000; + CHECK(bank.publish(updated)); + + const auto active = bank.snapshot(); + CHECK(active.generation == first_generation + 1U); + const auto row = engine.next(active, 4000U); + CHECK(engine.phase_accumulator() != phase_before_update); + CHECK(row[0] != 0); + CHECK(row[6] != 0); +} + +} // namespace + +int main() { + try { + bank_commits_complete_generations(); + fixed_point_engine_preserves_frequency_and_phase(); + independent_channels_change_without_stream_restart(); + std::cout << "[PASS] live Sampled Values signal state\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "[FAIL] live Sampled Values signal state: " << error.what() << '\n'; + return 1; + } +} From 01f16d95cdfb1ff132a4b1d2e4728811def4560f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:13:14 +0700 Subject: [PATCH 003/129] feat: add embedded live SV control interface --- .../main/live_control.hpp | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/live_control.hpp diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp new file mode 100644 index 000000000..b8523323d --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/sampled_values/live_signal_state.hpp" + +#include + +namespace ar::esp32p4::smv { + +void live_control_initialize( + std::int32_t current_rms_counts, + std::int32_t voltage_rms_counts) noexcept; + +[[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState +live_signal_snapshot() noexcept; + +[[nodiscard]] bool live_tx_running() noexcept; +[[nodiscard]] bool take_start_request() noexcept; + +void live_control_task(void*) noexcept; + +} // namespace ar::esp32p4::smv From 6ae45e3c171a68ea1003f70225046637fb1b1d5a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:13:42 +0700 Subject: [PATCH 004/129] feat: add bench live SV control task --- .../main/live_control.cpp | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/live_control.cpp diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp new file mode 100644 index 000000000..d2d54a7d1 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "live_control.hpp" + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +using ar::iec61850::sampled_values::SvLiveSignalBank; +using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::sampled_values::live_channel_index; + +constexpr char kTag[] = "ar_smv_ctrl"; +constexpr std::array kChannelNames{ + "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; + +SvLiveSignalBank g_signal_bank; +std::atomic g_running{false}; +std::atomic g_start_request{false}; + +void uppercase_ascii(char* text) noexcept { + if (text == nullptr) { + return; + } + for (; *text != '\0'; ++text) { + *text = static_cast(std::toupper(static_cast(*text))); + } +} + +bool parse_i32(const char* text, std::int32_t& value) noexcept { + if (text == nullptr || *text == '\0') { + return false; + } + errno = 0; + char* end{}; + const long parsed = std::strtol(text, &end, 0); + if (errno != 0 || end == text || *end != '\0' || + parsed < std::numeric_limits::min() || + parsed > std::numeric_limits::max()) { + return false; + } + value = static_cast(parsed); + return true; +} + +bool parse_u32(const char* text, std::uint32_t& value) noexcept { + if (text == nullptr || *text == '\0' || *text == '-') { + return false; + } + errno = 0; + char* end{}; + const unsigned long parsed = std::strtoul(text, &end, 0); + if (errno != 0 || end == text || *end != '\0' || + parsed > std::numeric_limits::max()) { + return false; + } + value = static_cast(parsed); + return true; +} + +void print_help() noexcept { + ESP_LOGI(kTag, "Live control commands (bench serial transport):"); + ESP_LOGI(kTag, " START | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " FREQ "); + ESP_LOGI(kTag, " SET [quality]"); + ESP_LOGI(kTag, " ENABLE <0|1>"); + ESP_LOGI(kTag, " QUALITY "); + ESP_LOGI(kTag, "SET/FREQ/QUALITY/ENABLE commit coherently; stream identity is unchanged."); +} + +void print_state() noexcept { + const auto state = g_signal_bank.snapshot(); + ESP_LOGI(kTag, + "state=%s generation=%llu signal_frequency=%u mHz", + g_running.load(std::memory_order_acquire) ? "RUNNING" : "STOPPED", + static_cast(state.generation), + static_cast(state.frequency_millihz)); + for (std::size_t index = 0U; index < state.channels.size(); ++index) { + const auto& channel = state.channels[index]; + ESP_LOGI(kTag, + "%.*s rms_counts=%ld phase_mdeg=%ld quality=0x%08lX enabled=%u", + static_cast(kChannelNames[index].size()), + kChannelNames[index].data(), + static_cast(channel.rms_counts), + static_cast(channel.phase_millidegrees), + static_cast(channel.quality), + channel.enabled ? 1U : 0U); + } +} + +bool publish_state(const SvLiveSignalState& state) noexcept { + if (!g_signal_bank.publish(state)) { + ESP_LOGE(kTag, "Rejected live signal update: value validation failed"); + return false; + } + const auto active = g_signal_bank.snapshot(); + ESP_LOGI(kTag, + "Live signal generation %llu committed", + static_cast(active.generation)); + return true; +} + +void handle_line(char* line) noexcept { + char* save{}; + char* command = strtok_r(line, " \t\r\n", &save); + if (command == nullptr) { + return; + } + uppercase_ascii(command); + + if (std::strcmp(command, "START") == 0) { + g_start_request.store(true, std::memory_order_release); + g_running.store(true, std::memory_order_release); + ESP_LOGI(kTag, "START accepted: phase and smpCnt reset at the next sample boundary"); + return; + } + if (std::strcmp(command, "STOP") == 0) { + g_running.store(false, std::memory_order_release); + ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); + return; + } + if (std::strcmp(command, "SHOW") == 0) { + print_state(); + return; + } + if (std::strcmp(command, "HELP") == 0) { + print_help(); + return; + } + if (std::strcmp(command, "ZERO") == 0) { + auto state = g_signal_bank.snapshot(); + for (auto& channel : state.channels) { + channel.rms_counts = 0; + } + static_cast(publish_state(state)); + return; + } + if (std::strcmp(command, "FREQ") == 0) { + char* value_text = strtok_r(nullptr, " \t\r\n", &save); + std::uint32_t frequency{}; + if (!parse_u32(value_text, frequency)) { + ESP_LOGE(kTag, "Usage: FREQ "); + return; + } + auto state = g_signal_bank.snapshot(); + state.frequency_millihz = frequency; + static_cast(publish_state(state)); + return; + } + + if (std::strcmp(command, "SET") == 0 || + std::strcmp(command, "ENABLE") == 0 || + std::strcmp(command, "QUALITY") == 0) { + char* channel_text = strtok_r(nullptr, " \t\r\n", &save); + if (channel_text == nullptr) { + ESP_LOGE(kTag, "Channel name is required"); + return; + } + uppercase_ascii(channel_text); + const auto channel_index = live_channel_index(channel_text); + if (!channel_index.has_value()) { + ESP_LOGE(kTag, "Unknown channel '%s'", channel_text); + return; + } + + auto state = g_signal_bank.snapshot(); + auto& channel = state.channels[*channel_index]; + + if (std::strcmp(command, "SET") == 0) { + char* rms_text = strtok_r(nullptr, " \t\r\n", &save); + char* phase_text = strtok_r(nullptr, " \t\r\n", &save); + char* quality_text = strtok_r(nullptr, " \t\r\n", &save); + std::int32_t rms{}; + std::int32_t phase{}; + if (!parse_i32(rms_text, rms) || !parse_i32(phase_text, phase)) { + ESP_LOGE(kTag, + "Usage: SET [quality]"); + return; + } + channel.rms_counts = rms; + channel.phase_millidegrees = phase; + if (quality_text != nullptr) { + std::uint32_t quality{}; + if (!parse_u32(quality_text, quality)) { + ESP_LOGE(kTag, "Invalid quality value '%s'", quality_text); + return; + } + channel.quality = quality; + } + static_cast(publish_state(state)); + return; + } + + if (std::strcmp(command, "ENABLE") == 0) { + char* enabled_text = strtok_r(nullptr, " \t\r\n", &save); + std::uint32_t enabled{}; + if (!parse_u32(enabled_text, enabled) || enabled > 1U) { + ESP_LOGE(kTag, "Usage: ENABLE <0|1>"); + return; + } + channel.enabled = enabled != 0U; + static_cast(publish_state(state)); + return; + } + + char* quality_text = strtok_r(nullptr, " \t\r\n", &save); + std::uint32_t quality{}; + if (!parse_u32(quality_text, quality)) { + ESP_LOGE(kTag, "Usage: QUALITY "); + return; + } + channel.quality = quality; + static_cast(publish_state(state)); + return; + } + + ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); +} + +} // namespace + +void live_control_initialize( + const std::int32_t current_rms_counts, + const std::int32_t voltage_rms_counts) noexcept { + auto initial = g_signal_bank.snapshot(); + for (std::size_t index = 0U; index < 3U; ++index) { + initial.channels[index].rms_counts = current_rms_counts; + } + initial.channels[3].rms_counts = 0; + for (std::size_t index = 4U; index < 7U; ++index) { + initial.channels[index].rms_counts = voltage_rms_counts; + } + initial.channels[7].rms_counts = 0; + static_cast(g_signal_bank.publish(initial)); + g_running.store(false, std::memory_order_release); + g_start_request.store(false, std::memory_order_release); +} + +SvLiveSignalState live_signal_snapshot() noexcept { + return g_signal_bank.snapshot(); +} + +bool live_tx_running() noexcept { + return g_running.load(std::memory_order_acquire); +} + +bool take_start_request() noexcept { + return g_start_request.exchange(false, std::memory_order_acq_rel); +} + +void live_control_task(void*) noexcept { + std::setvbuf(stdin, nullptr, _IONBF, 0); + print_help(); + print_state(); + + std::array line{}; + while (true) { + if (std::fgets(line.data(), static_cast(line.size()), stdin) == nullptr) { + std::clearerr(stdin); + vTaskDelay(pdMS_TO_TICKS(20)); + continue; + } + handle_line(line.data()); + } +} + +} // namespace ar::esp32p4::smv From 9cb345e7d75d1f4dcb9129898c2173bd13cf9703 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:15:33 +0700 Subject: [PATCH 005/129] build: include live SV control task --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 1bd39c6ee..c36fda4bd 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -2,6 +2,7 @@ idf_component_register( SRCS "app_main.cpp" "ethernet_port.c" + "live_control.cpp" INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) From 6709dbe42d92bf47d07e2e03798510fe8b4de778 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:16:47 +0700 Subject: [PATCH 006/129] feat: make ESP32-P4 SV values live-updatable while running --- .../esp32p4_smv_injector/main/app_main.cpp | 195 +++++++++--------- 1 file changed, 97 insertions(+), 98 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index a65bfa7b6..f12130ab3 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -4,9 +4,11 @@ #include "ariec61850/sampled_values/asdu.hpp" #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/live_signal_state.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" +#include "live_control.hpp" #include "esp_attr.h" #include "esp_err.h" #include "esp_eth.h" @@ -20,7 +22,6 @@ #include #include -#include #include #include #include @@ -30,38 +31,41 @@ namespace { +using ar::esp32p4::smv::live_control_initialize; +using ar::esp32p4::smv::live_control_task; +using ar::esp32p4::smv::live_signal_snapshot; +using ar::esp32p4::smv::live_tx_running; +using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; using ar::iec61850::sampled_values::SampledValueAsdu; using ar::iec61850::sampled_values::SampledValuesFrame; using ar::iec61850::sampled_values::SampledValuesFrameCodec; using ar::iec61850::sampled_values::SampledValuesPdu; +using ar::iec61850::sampled_values::SvFixedPointSineEngine; +using ar::iec61850::sampled_values::SvLiveSignalState; -constexpr char kTag[] = "ar_smv_p2"; +constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; -// Canonical stream identity is intentionally aligned with -// samples/scl/01_SV_Stream_4I+4V_(9-2LE).scd stream #1. +// Canonical development identity remains fixed until the compiled-device +// profile work moves these fields behind the validated publisher profile. constexpr std::uint16_t kCanonicalAppId = 0x4000U; constexpr char kCanonicalSvId[] = "MU01_SV1"; constexpr std::array kSvMulticastMac{ 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; -// The broadcast path exists only because the currently tested host capture -// path does not reliably expose the standards-facing multicast/VLAN stream. -// Give it a separate identity so analyzers never merge canonical and mirror -// samples into one sequence. +// The broadcast path is a separate diagnostic stream and never changes the +// canonical identity or sequence semantics. constexpr std::uint16_t kMirrorAppId = 0x4F01U; constexpr char kMirrorSvId[] = "AR_DIAG_SV1"; constexpr std::uint16_t kSampleCountWrap = 4000U; constexpr std::uint32_t kSampleRateHz = 4000U; -constexpr std::uint32_t kSamplesPerCycle = 80U; constexpr std::int64_t kSamplePeriodUs = 250; constexpr std::uint16_t kSampleModeSamplesPerSecond = 1U; constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; -constexpr double kPi = 3.14159265358979323846; EventGroupHandle_t g_link_events = nullptr; portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED; @@ -77,6 +81,7 @@ struct TimingStats final { std::int32_t lateness_min_us{std::numeric_limits::max()}; std::int32_t lateness_max_us{std::numeric_limits::min()}; std::uint16_t last_sample_count{}; + std::uint64_t last_signal_generation{}; }; TimingStats g_interval_stats{}; @@ -87,8 +92,7 @@ struct PacketTemplate final { std::size_t sample_payload_offset{std::numeric_limits::max()}; }; -using WaveformRow = std::array; -using WaveformTable = std::array; +using WaveformRow = SvFixedPointSineEngine::SampleRow; void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, void*) { switch (event_id) { @@ -127,8 +131,8 @@ void write_i32_be(std::uint8_t* destination, const std::int32_t value) noexcept write_u32_be(destination, static_cast(value)); } -std::vector make_9_2le_shape_payload() { - // Dataset order from the SCL: Ia, Ib, Ic, In, Ua, Ub, Uc, Un. +std::vector make_4i4v_payload() { + // Current development layout: Ia, Ib, Ic, In, Ua, Ub, Uc, Un. // Each value is INT32 followed by UINT32 Quality. return std::vector(8U * 8U, 0U); } @@ -139,23 +143,16 @@ SampledValuesFrame make_runtime_frame( const bool diagnostic_mirror) { SampledValueAsdu asdu; asdu.sv_id = diagnostic_mirror ? kMirrorSvId : kCanonicalSvId; - - // The supplied SCL has SmvOpts dataSet=false, so the canonical ASDU does - // not carry the dataset-reference field. asdu.data_set_reference.clear(); - // Fixed-width placeholders. The generic BER writer is minimal-width, while - // the realtime hot path must patch smpCnt/confRev in place without changing - // packet length. + // Fixed-width placeholders let the realtime task patch fields in place + // without BER rebuilds or packet-length changes. asdu.sample_count = 0x0100U; asdu.configuration_revision = 0x01000000U; asdu.reference_time.reset(); - asdu.sample_synchronization = 0U; // truthful: no measured PTP/1PPS lock. + asdu.sample_synchronization = 0U; if (diagnostic_mirror) { - // Raw process-bus reconstruction needs these fields when SCL is not - // bound. Keep them on the diagnostic stream only; canonical output - // remains aligned with the supplied SCL SmvOpts sampleRate=false. asdu.sample_rate = static_cast(kSampleRateHz); asdu.sample_mode = kSampleModeSamplesPerSecond; } else { @@ -163,7 +160,7 @@ SampledValuesFrame make_runtime_frame( asdu.sample_mode.reset(); } - asdu.sample_payload = make_9_2le_shape_payload(); + asdu.sample_payload = make_4i4v_payload(); SampledValuesFrame frame; frame.destination = MacAddress{std::span{kSvMulticastMac}}; @@ -226,53 +223,17 @@ bool build_packet_template( return true; } -WaveformTable make_waveform_table() { - WaveformTable table{}; - - // 9-2LE-shaped integer scaling retained on the publisher side: - // current 1 mA/count, voltage 10 mV/count. - const double current_peak_counts = - static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA) * std::sqrt(2.0); - const double voltage_peak_counts = - (static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV) * std::sqrt(2.0)) / 10.0; - - for (std::size_t sample = 0U; sample < table.size(); ++sample) { - const double angle_a = (2.0 * kPi * static_cast(sample)) / - static_cast(kSamplesPerCycle); - const double angle_b = angle_a - (2.0 * kPi / 3.0); - const double angle_c = angle_a + (2.0 * kPi / 3.0); - - const auto ia = static_cast( - std::lround(current_peak_counts * std::sin(angle_a))); - const auto ib = static_cast( - std::lround(current_peak_counts * std::sin(angle_b))); - const auto ic = static_cast( - std::lround(current_peak_counts * std::sin(angle_c))); - const auto in = static_cast(ia + ib + ic); - - const auto ua = static_cast( - std::lround(voltage_peak_counts * std::sin(angle_a))); - const auto ub = static_cast( - std::lround(voltage_peak_counts * std::sin(angle_b))); - const auto uc = static_cast( - std::lround(voltage_peak_counts * std::sin(angle_c))); - const auto un = static_cast(ua + ub + uc); - - table[sample] = {ia, ib, ic, in, ua, ub, uc, un}; - } - - return table; -} - void patch_packet( PacketTemplate& packet, const std::uint16_t sample_count, - const WaveformRow& values) noexcept { + const WaveformRow& values, + const SvLiveSignalState& signal_state) noexcept { write_u16_be(packet.bytes.data() + packet.sample_count_offset, sample_count); for (std::size_t channel = 0U; channel < values.size(); ++channel) { - write_i32_be(packet.bytes.data() + packet.sample_payload_offset + channel * 8U, - values[channel]); + const auto base = packet.sample_payload_offset + channel * 8U; + write_i32_be(packet.bytes.data() + base, values[channel]); + write_u32_be(packet.bytes.data() + base + 4U, signal_state.channels[channel].quality); } } @@ -290,7 +251,7 @@ std::array make_probe( frame[12] = static_cast((kDiagnosticEtherType >> 8U) & 0xFFU); frame[13] = static_cast(kDiagnosticEtherType & 0xFFU); - constexpr char payload[] = "ARSTACK-P2-CAPTURE-PROBE"; + constexpr char payload[] = "ARSTACK-P3-LIVE-CONTROL-PROBE"; for (std::size_t index = 0U; index < sizeof(payload) - 1U && 14U + index < frame.size(); ++index) { @@ -319,6 +280,7 @@ void merge_stats(TimingStats& local) noexcept { g_interval_stats.lateness_max_us = std::max(g_interval_stats.lateness_max_us, local.lateness_max_us); g_interval_stats.last_sample_count = local.last_sample_count; + g_interval_stats.last_signal_generation = local.last_signal_generation; portEXIT_CRITICAL(&g_stats_mux); local = TimingStats{}; @@ -328,7 +290,8 @@ void update_local_timing( TimingStats& local, const std::int64_t lateness_us, const std::uint64_t missed_slots, - const std::uint16_t sample_count) noexcept { + const std::uint16_t sample_count, + const std::uint64_t signal_generation) noexcept { const auto bounded_lateness = static_cast(std::clamp( lateness_us, std::numeric_limits::min(), @@ -340,6 +303,7 @@ void update_local_timing( local.lateness_min_us = std::min(local.lateness_min_us, bounded_lateness); local.lateness_max_us = std::max(local.lateness_max_us, bounded_lateness); local.last_sample_count = sample_count; + local.last_signal_generation = signal_generation; } bool IRAM_ATTR sample_alarm_callback( @@ -380,7 +344,7 @@ void publisher_task(void* argument) { #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ const auto eth_handle = static_cast(argument); - ESP_LOGW(kTag, "LIVE P2 TX ENABLED: isolated lab network only"); + ESP_LOGW(kTag, "LIVE SV TX ENABLED: isolated lab network only"); ESP_LOGI(kTag, "Waiting for Ethernet link..."); xEventGroupWaitBits(g_link_events, kLinkUpBit, pdFALSE, pdTRUE, portMAX_DELAY); @@ -405,62 +369,54 @@ void publisher_task(void* argument) { false, false, canonical)) { - ESP_LOGE(kTag, "Cannot start P2 publisher: canonical template build failed"); + ESP_LOGE(kTag, "Cannot start publisher: canonical template build failed"); vTaskDelete(nullptr); return; } #if CONFIG_AR_SMV_BROADCAST_MIRROR if (!build_packet_template(source_mac, false, true, true, mirror)) { - ESP_LOGE(kTag, "Cannot start P2 publisher: broadcast mirror template build failed"); + ESP_LOGE(kTag, "Cannot start publisher: diagnostic mirror template build failed"); vTaskDelete(nullptr); return; } #endif - const auto waveform = make_waveform_table(); - #if CONFIG_AR_SMV_DIAGNOSTIC_PROBES const auto probe_broadcast = make_probe(source_mac, false); const auto probe_multicast = make_probe(source_mac, true); #endif + const auto initial_signal = live_signal_snapshot(); ESP_LOGI(kTag, - "P2 waveform: 50 Hz, 80 samples/cycle, %u fps, period=%lld us, canonical smpCnt wrap=%u", + "Live signal engine ready: publisher=%u fps, signal=%u mHz, generation=%llu; type START to transmit", static_cast(kSampleRateHz), - static_cast(kSamplePeriodUs), - static_cast(kSampleCountWrap)); + static_cast(initial_signal.frequency_millihz), + static_cast(initial_signal.generation)); ESP_LOGI(kTag, - "Waveform RMS: I=%d mA/phase, U=%d mV phase-neutral; dataset order Ia Ib Ic In Ua Ub Uc Un", - CONFIG_AR_SMV_CURRENT_RMS_MA, - CONFIG_AR_SMV_VOLTAGE_RMS_MV); - ESP_LOGI(kTag, - "SCL canonical: svID=%s dst=01:0C:CD:04:00:01 APPID=0x%04X %s confRev=1 smpSynch=0", + "Development profile: svID=%s APPID=0x%04X canonical smpCnt wrap=%u %s", kCanonicalSvId, static_cast(kCanonicalAppId), + static_cast(kSampleCountWrap), #if CONFIG_AR_SMV_CANONICAL_VLAN "VLAN PCP4/VID0" #else "untagged" #endif ); -#if CONFIG_AR_SMV_BROADCAST_MIRROR - ESP_LOGW(kTag, - "LAB mirror: svID=%s APPID=0x%04X untagged broadcast; explicit smpRate=4000 smpMod=SmpPerSec; independent 16-bit smpCnt", - kMirrorSvId, - static_cast(kMirrorAppId)); -#endif ESP_LOGI(kTag, - "Fixed templates: canonical len=%u smpCnt@%u seqData@%u", + "Fixed template: canonical len=%u smpCnt@%u seqData@%u", static_cast(canonical.bytes.size()), static_cast(canonical.sample_count_offset), static_cast(canonical.sample_payload_offset)); + static SvFixedPointSineEngine signal_engine; + vTaskDelay(pdMS_TO_TICKS(100)); const gptimer_handle_t sample_clock = start_sample_clock(xTaskGetCurrentTaskHandle()); (void)sample_clock; ESP_LOGI(kTag, - "P2 sample clock: GPTimer 1 MHz, periodic alarm every %lld us; publisher blocks between alarms", + "Sample clock: GPTimer 1 MHz, periodic alarm every %lld us; publisher blocks between alarms", static_cast(kSamplePeriodUs)); std::uint16_t canonical_sample_count = 0U; @@ -475,6 +431,19 @@ void publisher_task(void* argument) { continue; } + if (take_start_request()) { + canonical_sample_count = 0U; + mirror_sample_count = 0U; + signal_engine.reset_phase(); + schedule_anchored = false; + local = TimingStats{}; + } + + if (!live_tx_running()) { + schedule_anchored = false; + continue; + } + const std::int64_t actual_wake_us = esp_timer_get_time(); std::uint64_t missed_slots = 0U; @@ -486,6 +455,8 @@ void publisher_task(void* argument) { static_cast(pending_ticks) * kSamplePeriodUs; } + const auto signal_state = live_signal_snapshot(); + if (pending_ticks > 1U) { missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( @@ -494,9 +465,14 @@ void publisher_task(void* argument) { mirror_sample_count = static_cast( static_cast(mirror_sample_count) + static_cast(missed_slots)); + signal_engine.advance( + signal_state, + kSampleRateHz, + static_cast(missed_slots)); } const std::int64_t lateness_us = actual_wake_us - expected_wake_us; + const auto row = signal_engine.next(signal_state, kSampleRateHz); if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { ++local.canonical_fail; @@ -504,8 +480,7 @@ void publisher_task(void* argument) { ++local.mirror_fail; #endif } else { - const auto& row = waveform[canonical_sample_count % kSamplesPerCycle]; - patch_packet(canonical, canonical_sample_count, row); + patch_packet(canonical, canonical_sample_count, row, signal_state); const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); @@ -516,7 +491,7 @@ void publisher_task(void* argument) { } #if CONFIG_AR_SMV_BROADCAST_MIRROR - patch_packet(mirror, mirror_sample_count, row); + patch_packet(mirror, mirror_sample_count, row, signal_state); const esp_err_t mirror_result = esp_eth_transmit(eth_handle, mirror.bytes.data(), mirror.bytes.size()); if (mirror_result == ESP_OK) { @@ -540,7 +515,12 @@ void publisher_task(void* argument) { #endif } - update_local_timing(local, lateness_us, missed_slots, canonical_sample_count); + update_local_timing( + local, + lateness_us, + missed_slots, + canonical_sample_count, + signal_state.generation); canonical_sample_count = static_cast( (canonical_sample_count + 1U) % kSampleCountWrap); @@ -552,7 +532,7 @@ void publisher_task(void* argument) { } #else (void)argument; - ESP_LOGW(kTag, "P2 realtime publisher is disabled in menuconfig"); + ESP_LOGW(kTag, "Realtime publisher is disabled in menuconfig"); vTaskDelete(nullptr); #endif } @@ -575,7 +555,7 @@ void telemetry_task(void*) { snapshot.lateness_sum_us / static_cast(snapshot.samples); ESP_LOGI(kTag, - "P2 timing: samples=%llu (~%llu fps) MC ok=%llu fail=%llu mirror ok=%llu fail=%llu missed=%llu wake_late_us[min/mean/max]=%ld/%lld/%ld canonical_smpCnt=%u", + "timing: samples=%llu (~%llu fps) MC ok=%llu fail=%llu mirror ok=%llu fail=%llu missed=%llu wake_late_us[min/mean/max]=%ld/%lld/%ld smpCnt=%u signal_gen=%llu", static_cast(snapshot.samples), static_cast(snapshot.samples), static_cast(snapshot.canonical_ok), @@ -586,7 +566,8 @@ void telemetry_task(void*) { static_cast(snapshot.lateness_min_us), static_cast(mean_lateness_us), static_cast(snapshot.lateness_max_us), - static_cast(snapshot.last_sample_count)); + static_cast(snapshot.last_sample_count), + static_cast(snapshot.last_signal_generation)); } } @@ -605,13 +586,31 @@ extern "C" void app_main(void) { esp_eth_handle_t eth_handle = ar_esp32p4_eth_init(); if (eth_handle == nullptr) { - ESP_LOGE(kTag, "ESP32-P4-ETH bring-up failed"); + ESP_LOGE(kTag, "ESP32-P4 Ethernet bring-up failed"); return; } ESP_ERROR_CHECK(esp_eth_start(eth_handle)); #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ + // Existing menuconfig values seed the first live generation. Current + // development wire scaling is 1 mA/count for I and 10 mV/count for U. + live_control_initialize( + static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA), + static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV / 10)); + + if (xTaskCreatePinnedToCore( + &live_control_task, + "ar_smv_control", + 6144, + nullptr, + 1, + nullptr, + 0) != pdPASS) { + ESP_LOGE(kTag, "Failed to create live control task on CPU0"); + return; + } + if (xTaskCreatePinnedToCore( &publisher_task, "ar_smv_publisher", @@ -636,6 +635,6 @@ extern "C" void app_main(void) { return; } #else - ESP_LOGW(kTag, "Live/P2 transmission disabled; Ethernet bring-up only"); + ESP_LOGW(kTag, "Live transmission disabled; Ethernet bring-up only"); #endif } From c4c5e29d83098ed4762db45ec44aff1d25c401e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:17:06 +0700 Subject: [PATCH 007/129] docs: define live SV injection workflow --- docs/LIVE_INJECTION_WORKFLOW.md | 129 ++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/LIVE_INJECTION_WORKFLOW.md diff --git a/docs/LIVE_INJECTION_WORKFLOW.md b/docs/LIVE_INJECTION_WORKFLOW.md new file mode 100644 index 000000000..300a16637 --- /dev/null +++ b/docs/LIVE_INJECTION_WORKFLOW.md @@ -0,0 +1,129 @@ +# Live Sampled Values Injection Workflow + +ARStack61850 targets an instrument workflow in which engineering intent is imported once, validated into a deterministic runtime profile, and signal values can then be changed while the publisher is running without rebuilding firmware or changing the canonical stream identity. + +## User workflow + +```text +Import SCL / CID / SCD / IID + | + v +Select and validate an SV stream + | + v +Resolve Class-A publisher profile + | + v +Configure initial signal values + | + v +Deploy profile to dedicated hardware + | + v +START + | + +---- live value / phase / frequency changes + | + +---- quality/state changes + | + +---- scenario transitions + | + v +STOP +``` + +The desktop/host side is the engineering and control plane. The ESP32-P4 is the deterministic realtime execution plane. + +## Separation of immutable and mutable state + +A running stream has two categories of state. + +### Immutable while armed/running + +These values define the stream/profile and are not edited in the per-sample hot path: + +- destination MAC; +- APPID; +- VLAN / PCP; +- svID and DataSet field-presence policy; +- confRev; +- nofASDU; +- sample-rate / sample-mode policy; +- validated sample-counter policy; +- ordered wire layout and payload widths; +- optional ASDU field policy. + +Changing an immutable property requires STOP -> validate/rebuild template -> ARM/START. This prevents half-updated frames. + +### Mutable while running + +These values are intentionally live: + +- per-channel magnitude or raw wire-count target; +- per-channel phase; +- signal frequency where supported by the active signal model; +- quality values; +- scenario/fault state; +- waveform source selector and transition state. + +A mutable update must become visible atomically at a sample boundary. The realtime task never parses text/XML, allocates memory, takes a host-facing mutex, or rebuilds BER structures. + +## Runtime state machine + +```text +EMPTY + -> PROFILE_VALIDATED + -> ARMED + -> RUNNING + -> STOPPED + +RUNNING --live signal update--> RUNNING +RUNNING --profile change------> reject; STOP required +``` + +`START` begins deterministic publication from a validated/armed profile. `STOP` stops canonical publication without destroying the profile. Live signal updates are accepted during RUNNING and committed as one coherent generation. + +## Coherent live update model + +The host/control task writes a complete next signal state into an inactive bank and then publishes a generation/index atomically. The realtime publisher copies one coherent generation at the start of a sample and uses only that snapshot for the whole payload. + +```text +control task realtime publisher +------------ ------------------ +copy active state +edit inactive bank +validate values +publish generation ----atomic----> copy coherent generation + synthesize one sample + patch prebuilt frame + transmit +``` + +This prevents mixed-channel updates such as Ia from a new command while Ib/Ic still belong to an older command. + +## Signal synthesis + +The first live engine uses a fixed-point phase accumulator plus a precomputed sine lookup table. Floating-point initialization is outside the realtime sample path; the sample hot path uses bounded integer work. + +A continuous phase accumulator allows magnitude, phase and frequency changes without restarting `smpCnt`. Frequency changes alter the next phase increment while preserving phase continuity. If realtime notifications are missed, both sample-count state and signal phase advance by the missed-slot count rather than replaying stale samples. + +The first bench control surface uses the serial console for direct hardware testing. It is a development transport only. The product control plane will use a versioned bounded command protocol and the same live-state semantics. + +## Safety and interoperability + +- canonical profile identity is never changed by a live signal-value command; +- profile changes require explicit stop/re-arm; +- `smpSynch` remains evidence-driven and is never promoted by a user value edit; +- SCL/profile scaling and semantic mapping stay host/profile responsibilities; raw wire-count control remains available for exact interoperability testing; +- unsupported channel semantics are rejected rather than guessed; +- diagnostic streams remain separate from canonical output. + +## Acceptance path + +1. Load a vendor-neutral SCL regression profile and resolve it to Class A. +2. Configure initial 4I+4V values. +3. Start the ESP32-P4 publisher and verify canonical identity/layout. +4. Change Ia/Uc/phase/frequency while RUNNING. +5. Verify the next coherent generation is visible on wire without sequence reset, malformed payload, or mixed-generation channels. +6. Verify STOP suppresses canonical TX and START resumes according to the runtime policy. +7. Repeat for 4000 and 4800 events/s profile families once compiled-device profiles are integrated. From 0555d756035ff61ac40e5c19d8755861990ef6ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:17:17 +0700 Subject: [PATCH 008/129] docs: record live injection state machine --- docs/LIVE_INJECTION_STATE_MACHINE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/LIVE_INJECTION_STATE_MACHINE.md diff --git a/docs/LIVE_INJECTION_STATE_MACHINE.md b/docs/LIVE_INJECTION_STATE_MACHINE.md new file mode 100644 index 000000000..063caeedd --- /dev/null +++ b/docs/LIVE_INJECTION_STATE_MACHINE.md @@ -0,0 +1,24 @@ +# Live injection state machine + +The deterministic publisher separates profile lifecycle from live signal lifecycle. + +```text +NO_PROFILE + | + v +PROFILE_VALIDATED + | + v +ARMED + | + +---- START ----> RUNNING + | | + | +---- live signal generation N -> N+1 + | +---- live signal generation N+1 -> N+2 + | | + | +---- STOP ----> STOPPED + | | + +--------------------------------------+ +``` + +Profile identity/layout changes are rejected while RUNNING. Live signal-state changes are allowed and committed coherently at a sample boundary. A development serial transport is currently used to prove this behavior; it will later be replaced by the versioned host/device control protocol without changing the realtime state semantics. From f558fd21430c431c52f1c25448798a4d3e15df3b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:17:30 +0700 Subject: [PATCH 009/129] docs: add bench live control command reference --- .../main/live_control_commands.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/live_control_commands.md diff --git a/embedded/esp32p4_smv_injector/main/live_control_commands.md b/embedded/esp32p4_smv_injector/main/live_control_commands.md new file mode 100644 index 000000000..4910fa2ce --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/live_control_commands.md @@ -0,0 +1,40 @@ +# Bench live-control commands + +This serial command surface exists only to validate the realtime live-update semantics before the versioned host/device control protocol and desktop UI are added. + +```text +HELP +SHOW +START +STOP +ZERO +FREQ +SET [quality] +ENABLE <0|1> +QUALITY +``` + +Examples: + +```text +SET IA 1000 0 +SET IB 1000 -120000 +SET IC 1000 120000 +SET UA 5774 0 +SET UB 5774 -120000 +SET UC 5774 120000 +FREQ 50000 +START + +# while RUNNING +SET IA 5000 0 +SET UC 10000 90000 +FREQ 49950 +QUALITY IA 0x00000000 + +STOP +``` + +`rms_wire_counts` is intentionally profile-neutral. Engineering-unit conversion belongs to the resolved host profile. For the current development stream, the existing bench mapping is 1 mA/count for current values and 10 mV/count for voltage values. + +Live updates are copied into a coherent generation and become visible to the realtime publisher at a sample boundary. They do not modify stream identity, reset `smpCnt`, rebuild BER, or claim synchronization. `START` requests a clean phase/sample-counter restart; profile identity changes require STOP/re-arm in the future compiled-profile runtime. From 626a09719015591dee0da6e62b3d11ebfca1200d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:18:20 +0700 Subject: [PATCH 010/129] test: add live SV signal runtime regression target --- CMakeLists.txt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index bf5ebc12d..6bb8ebd45 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -155,6 +155,13 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_${test_name}_tests COMMAND ariec61850_${test_name}_tests) endforeach() + add_executable(ariec61850_live_signal_tests tests/test_live_signal.cpp) + target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_live_signal_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_live_signal_tests) + ariec61850_apply_sanitizers(ariec61850_live_signal_tests) + add_test(NAME ariec61850_live_signal_tests COMMAND ariec61850_live_signal_tests) + add_executable(ariec61850_goose_runtime_tests tests/test_goose_runtime.cpp) target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_goose_runtime_tests PRIVATE cxx_std_20) @@ -321,4 +328,4 @@ if(ARIEC61850_BUILD_FUZZERS) ariec61850_add_fuzzer(ariec61850_fuzz_association fuzz/fuzz_association.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_mms_services fuzz/fuzz_mms_services.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_reporting fuzz/fuzz_reporting.cpp) -endif() +endif() \ No newline at end of file From c716380de185f4d3cbb64b4eb4ff27143b3be0c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:20:23 +0700 Subject: [PATCH 011/129] merge: preserve current main build targets with live SV regression --- CMakeLists.txt | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6bb8ebd45..5815733c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(ariec61850_core src/mms/data_codec.cpp src/mms/pdu.cpp src/mms/services.cpp + src/mms/file_service.cpp src/mms/dynamic_data_set.cpp src/mms/dynamic_report_planner.cpp src/mms/invoke_router.cpp @@ -133,6 +134,12 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_pagination_probe PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_pagination_probe) ariec61850_apply_sanitizers(ariec61850_pagination_probe) + + add_executable(ariec61850_file_transfer tools/file_transfer.cpp) + target_link_libraries(ariec61850_file_transfer PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_file_transfer PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_file_transfer) + ariec61850_apply_sanitizers(ariec61850_file_transfer) endif() if(ARIEC61850_BUILD_TESTS) @@ -217,6 +224,15 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_mms_services_tests) add_test(NAME ariec61850_mms_services_tests COMMAND ariec61850_mms_services_tests) + add_executable(ariec61850_mms_file_service_tests tests/test_mms_file_service.cpp) + target_link_libraries(ariec61850_mms_file_service_tests PRIVATE ARIEC61850::core) + target_compile_definitions(ariec61850_mms_file_service_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_mms_file_service_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_file_service_tests) + ariec61850_apply_sanitizers(ariec61850_mms_file_service_tests) + add_test(NAME ariec61850_mms_file_service_tests COMMAND ariec61850_mms_file_service_tests) + add_executable(ariec61850_dynamic_data_set_tests tests/test_dynamic_data_set.cpp) target_link_libraries(ariec61850_dynamic_data_set_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_dynamic_data_set_tests PRIVATE cxx_std_20) @@ -298,6 +314,9 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_pagination_probe_help COMMAND ariec61850_pagination_probe --help) + add_test( + NAME ariec61850_file_transfer_help + COMMAND ariec61850_file_transfer --help) endif() endif() @@ -327,5 +346,6 @@ if(ARIEC61850_BUILD_FUZZERS) ariec61850_add_fuzzer(ariec61850_fuzz_transport fuzz/fuzz_transport.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_association fuzz/fuzz_association.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_mms_services fuzz/fuzz_mms_services.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_mms_file_service fuzz/fuzz_mms_file_service.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_reporting fuzz/fuzz_reporting.cpp) endif() \ No newline at end of file From af32f5d6249b34c06a3d8a49af9afc1ce8be2342 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:24:29 +0700 Subject: [PATCH 012/129] fix: make live SV snapshots data-race-free with triple buffering --- .../sampled_values/live_signal_state.hpp | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp index 218fa4827..37ca82617 100644 --- a/include/ariec61850/sampled_values/live_signal_state.hpp +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -42,24 +42,27 @@ class SvLiveSignalBank final { SvLiveSignalBank() noexcept { banks_[0] = default_state(); banks_[1] = banks_[0]; + banks_[2] = banks_[0]; } - // One control-plane writer is expected. Readers can run concurrently at - // realtime priority. The sequence guard makes a copied snapshot coherent - // even if the writer publishes multiple generations rapidly. + // One control-plane writer and any number of serialized realtime snapshots + // are expected. The reader advertises the bank it is copying before the + // copy begins. With three banks, the writer can always select a bank that + // is neither active nor being read, so no non-atomic object is read and + // written concurrently. [[nodiscard]] SvLiveSignalState snapshot() const noexcept { - SvLiveSignalState copy{}; while (true) { - const auto sequence_before = sequence_.load(std::memory_order_acquire); - if ((sequence_before & 1U) != 0U) { - continue; - } const auto index = active_index_.load(std::memory_order_acquire); - copy = banks_[index]; - const auto sequence_after = sequence_.load(std::memory_order_acquire); - if (sequence_before == sequence_after && (sequence_after & 1U) == 0U) { - return copy; + reader_index_.store(index, std::memory_order_release); + + if (active_index_.load(std::memory_order_acquire) != index) { + reader_index_.store(kNoReader, std::memory_order_release); + continue; } + + const auto copy = banks_[index]; + reader_index_.store(kNoReader, std::memory_order_release); + return copy; } } @@ -72,13 +75,23 @@ class SvLiveSignalBank final { return false; } - sequence_.fetch_add(1U, std::memory_order_acq_rel); // writer active: odd - const auto current = active_index_.load(std::memory_order_relaxed); - const auto next = current ^ 1U; + const auto current = active_index_.load(std::memory_order_acquire); + const auto reader = reader_index_.load(std::memory_order_acquire); + + std::uint32_t next = kNoReader; + for (std::uint32_t candidate = 0U; candidate < banks_.size(); ++candidate) { + if (candidate != current && candidate != reader) { + next = candidate; + break; + } + } + if (next == kNoReader) { + return false; + } + banks_[next] = requested; banks_[next].generation = banks_[current].generation + 1U; active_index_.store(next, std::memory_order_release); - sequence_.fetch_add(1U, std::memory_order_release); // stable: even return true; } @@ -112,8 +125,9 @@ class SvLiveSignalBank final { } private: - std::array banks_{}; - mutable std::atomic sequence_{0U}; + static constexpr std::uint32_t kNoReader = 3U; + std::array banks_{}; + mutable std::atomic reader_index_{kNoReader}; std::atomic active_index_{0U}; }; From 3606f62efd9f208ea5658efdbea5044cf6d4cf5c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:27:41 +0700 Subject: [PATCH 013/129] Bind BRCB delivery to owner and defer commit --- .../ariec61850/mms/static_brcb_connection.hpp | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/include/ariec61850/mms/static_brcb_connection.hpp b/include/ariec61850/mms/static_brcb_connection.hpp index 2bf50ee43..406cdef1b 100644 --- a/include/ariec61850/mms/static_brcb_connection.hpp +++ b/include/ariec61850/mms/static_brcb_connection.hpp @@ -2,6 +2,7 @@ #pragma once #include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_brcb_control.hpp" #include "ariec61850/mms/static_brcb_runtime.hpp" #include @@ -16,6 +17,7 @@ enum class MmsStaticBrcbConnectionStatus : std::uint8_t { reporting_disabled, response_ready, not_established, + access_denied, response_buffer_too_small, workspace_too_small, frame_encode_failed, @@ -39,15 +41,32 @@ struct MmsStaticBrcbConnectionResult final { class MmsStaticBrcbConnection final { public: - // Deliver at most one already-buffered report while reporting is enabled. - // The queue front is removed only after a complete TPKT image is staged - // successfully. Capacity errors, disabled reporting and disconnected - // sessions preserve the exact same EntryID for retry. + // Stage at most one already-buffered report for the live BRCB owner. + // Reporting must be enabled through `control`, and the established MMS + // connection's association/Owner identity must exactly match the current + // live reservation. Foreign associations are denied without exposing or + // advancing retained report state. + // + // A successful poll only stages a complete TPKT image. It deliberately does + // NOT advance the BRCB delivery cursor. The transport adapter must call + // commit_sent() only after the complete staged frame has been accepted by + // the transport. Capacity errors, denied access, disabled reporting, + // disconnected sessions and send failures therefore preserve the same + // EntryID for retry/reconnect. [[nodiscard]] static MmsStaticBrcbConnectionResult poll( const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, MmsStaticBrcbRuntime& reports, + std::uint64_t now_ms, std::span response, std::span workspace) noexcept; + + // Second phase of report delivery. Call only after every byte of the staged + // response was accepted by the transport. The staged EntryID acts as the + // bounded delivery token; stale/replayed tokens are rejected by the runtime. + [[nodiscard]] static MmsStaticBrcbStatus commit_sent( + MmsStaticBrcbRuntime& reports, + const MmsStaticBrcbConnectionResult& staged) noexcept; }; } // namespace ar::iec61850::mms From 7b927ef3c68bfafd0c1115d0c8af36ccce3d6a6f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:28:05 +0700 Subject: [PATCH 014/129] Enforce owner-bound two-phase BRCB delivery --- src/mms/static_brcb_connection.cpp | 36 ++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp index e5a82557a..c2842a99d 100644 --- a/src/mms/static_brcb_connection.cpp +++ b/src/mms/static_brcb_connection.cpp @@ -42,21 +42,40 @@ namespace { return result; } +[[nodiscard]] bool same_owner( + const std::span left, + const std::span right) noexcept { + return left.size() == right.size() && !left.empty() && + std::equal(left.begin(), left.end(), right.begin()); +} + } // namespace MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, MmsStaticBrcbRuntime& reports, + const std::uint64_t now_ms, const std::span response, const std::span workspace) noexcept { if (connection.state() != MmsStaticConnectionState::established || connection.mms_presentation_context_id() == 0U) { return make_result(MmsStaticBrcbConnectionStatus::not_established); } - if (!reports.enabled()) { + + const auto control_state = control.state(now_ms); + if (!control_state.report_enabled) { return make_result(MmsStaticBrcbConnectionStatus::reporting_disabled); } + const auto access = connection.access_context(); + if (!control.valid() || !control_state.reserved || !control_state.owner_connected || + control_state.association_id == 0U || + access.association_id != control_state.association_id || + !same_owner(access.owner, control_state.owner)) { + return make_result(MmsStaticBrcbConnectionStatus::access_denied); + } + MmsStaticBrcbEntryView entry; if (!reports.front(entry)) { return make_result(MmsStaticBrcbConnectionStatus::no_report_available); @@ -115,12 +134,6 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( } auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry); - const auto committed = reports.commit_delivery(entry.entry_id); - if (committed != MmsStaticBrcbStatus::ok) { - result.status = MmsStaticBrcbConnectionStatus::stale_entry; - return result; - } - std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); result.bytes_written = tpkt.bytes_written; result.required_response_bytes = final_required; @@ -128,4 +141,13 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( return result; } +MmsStaticBrcbStatus MmsStaticBrcbConnection::commit_sent( + MmsStaticBrcbRuntime& reports, + const MmsStaticBrcbConnectionResult& staged) noexcept { + if (!staged.response_ready()) { + return MmsStaticBrcbStatus::stale_plan; + } + return reports.commit_delivery(staged.entry_id); +} + } // namespace ar::iec61850::mms From 233dc79150a005736362fd0bef05ef0fbcd95cd8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:29:21 +0700 Subject: [PATCH 015/129] Prove owner-bound two-phase BRCB delivery --- ...mms_brcb_connection_hard_profile_smoke.cpp | 146 +++++++++++++----- 1 file changed, 110 insertions(+), 36 deletions(-) diff --git a/embedded/mms_brcb_connection_hard_profile_smoke.cpp b/embedded/mms_brcb_connection_hard_profile_smoke.cpp index b3ce88e09..577d7e7ba 100644 --- a/embedded/mms_brcb_connection_hard_profile_smoke.cpp +++ b/embedded/mms_brcb_connection_hard_profile_smoke.cpp @@ -20,6 +20,8 @@ using namespace ar::iec61850; constexpr std::array kBooleanType{0x83U, 0x00U}; constexpr std::array kReportTime{ 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; +constexpr std::array kOwnerA{0xAAU, 0x01U}; +constexpr std::array kOwnerB{0xBBU, 0x01U}; constexpr std::array kAssociationRequest{ 0x0DU,0xB6U,0x05U,0x06U,0x13U,0x01U,0x00U,0x16U,0x01U,0x02U,0x14U,0x02U, @@ -53,6 +55,28 @@ constexpr std::array kAssociationRequest{ return {wire::EncodeStatus::ok, required, required}; } +[[nodiscard]] mms::MmsStaticBrcbClientIdentity identity( + const std::uint64_t association, + const std::array& owner) noexcept { + mms::MmsStaticBrcbClientIdentity result; + result.association_id = association; + result.owner[0] = owner[0]; + result.owner[1] = owner[1]; + result.owner_size = owner.size(); + return result; +} + +[[nodiscard]] mms::MmsStaticConnectionPolicy connection_policy( + const std::uint64_t association, + const std::array& owner) noexcept { + mms::MmsStaticConnectionPolicy result; + result.association_id = association; + result.owner[0] = owner[0]; + result.owner[1] = owner[1]; + result.owner_size = owner.size(); + return result; +} + [[nodiscard]] bool establish( mms::MmsStaticConnectionRuntime& connection, std::span request, @@ -136,7 +160,14 @@ int main() { mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; const mms::MmsStaticDataSetTable data_set_table{data_sets}; const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; - mms::MmsStaticConnectionRuntime connection{dispatcher}; + + const auto owner_a1 = identity(101U, kOwnerA); + const auto owner_a2 = identity(102U, kOwnerA); + const auto foreign_b = identity(201U, kOwnerB); + mms::MmsStaticConnectionRuntime connection{ + dispatcher, connection_policy(owner_a1.association_id, kOwnerA)}; + mms::MmsStaticConnectionRuntime foreign_connection{ + dispatcher, connection_policy(foreign_b.association_id, kOwnerB)}; const mms::MmsStaticBrcbDefinition definition{ "LD0", "LLN0$BR$Events", "LD0/LLN0$BR$Events", @@ -147,10 +178,16 @@ int main() { mms::MmsStaticBrcbPendingState pending{}; mms::MmsStaticBrcbRuntime reports{ definition, pending, slots, object_table, data_set_table}; - if (!reports.initialize() || reports.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + if (!reports.initialize()) { + return 1; + } + mms::MmsStaticBrcbControl control{reports}; + if (control.reserve(owner_a1, 5U, 100U) != mms::MmsStaticBrcbControlStatus::ok || + control.set_report_enabled(owner_a1, true, 100U) != + mms::MmsStaticBrcbControlStatus::ok || reports.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 100U) != mms::MmsStaticBrcbStatus::ok) { - return 1; + return 2; } mms::MmsStaticBrcbCapturePlan plan; @@ -159,101 +196,138 @@ int main() { if (!reports.next_due(100U, plan) || !reports.capture(plan, kReportTime, staging, value_workspace).success() || reports.queue_size() != 1U) { - return 2; + return 3; } - // Mutate the live backend after capture. Delivery must retain the old true snapshot. value = false; std::array request{}; std::array response{}; std::array workspace{}; std::array scratch{}; auto delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 100U, response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || reports.queue_size() != 1U) { - return 3; + return 4; + } + if (!establish(connection, request, response, workspace, scratch) || + !establish(foreign_connection, request, response, workspace, scratch)) { + return 5; + } + + // A foreign established association must not see or consume the owner's entry. + delivery = mms::MmsStaticBrcbConnection::poll( + foreign_connection, control, reports, 100U, response, workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::access_denied || + reports.queue_size() != 1U) { + return 6; } - if (!establish(connection, request, response, workspace, scratch)) return 4; mms::MmsStaticBrcbEntryView held_entry; if (!reports.front(held_entry) || held_entry.entry_id.size() != 8U || held_entry.entry_id[7U] != 1U || - reports.set_enabled(false) != mms::MmsStaticBrcbStatus::ok) { - return 5; + control.set_report_enabled(owner_a1, false, 100U) != + mms::MmsStaticBrcbControlStatus::ok) { + return 7; } delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 100U, response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::reporting_disabled || reports.queue_size() != 1U || !reports.front(held_entry) || held_entry.entry_id[7U] != 1U || - reports.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { - return 6; + control.set_report_enabled(owner_a1, true, 100U) != + mms::MmsStaticBrcbControlStatus::ok) { + return 8; } std::array tiny_response{}; delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, tiny_response, workspace); + connection, control, reports, 100U, tiny_response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::response_buffer_too_small || delivery.required_response_bytes <= tiny_response.size() || - delivery.entry_id[7] != 1U || reports.queue_size() != 1U) { - return 7; + delivery.entry_id[7U] != 1U || reports.queue_size() != 1U) { + return 9; } std::array tiny_workspace{}; delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, tiny_workspace); + connection, control, reports, 100U, response, tiny_workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::workspace_too_small || delivery.required_workspace_bytes <= tiny_workspace.size() || - delivery.entry_id[7] != 1U || reports.queue_size() != 1U) { - return 8; + delivery.entry_id[7U] != 1U || reports.queue_size() != 1U) { + return 10; } + // Staging is retry-safe: no delivery cursor movement until commit_sent(). delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 100U, response, workspace); if (!delivery.response_ready() || delivery.bytes_written == 0U || - delivery.entry_id[7] != 1U || delivery.sequence_number != 1U || - delivery.buffer_overflow || reports.queue_size() != 0U) { - return 9; + delivery.entry_id[7U] != 1U || delivery.sequence_number != 1U || + delivery.buffer_overflow || reports.queue_size() != 1U) { + return 11; + } + const auto retry = mms::MmsStaticBrcbConnection::poll( + connection, control, reports, 100U, response, workspace); + if (!retry.response_ready() || retry.entry_id != delivery.entry_id || + reports.queue_size() != 1U) { + return 12; } mms::MmsInformationReportView decoded; if (!decode_report_frame( - std::span{response}.first(delivery.bytes_written), decoded) || + std::span{response}.first(retry.bytes_written), decoded) || decoded.item_count != 12U) { - return 10; + return 13; } mms::MmsReadAccessResultView item; bool historical_value = false; if (!decoded.try_item(10U, item) || !decode_boolean(item, historical_value) || !historical_value) { - return 11; + return 14; + } + if (mms::MmsStaticBrcbConnection::commit_sent(reports, retry) != + mms::MmsStaticBrcbStatus::ok || + reports.queue_size() != 0U) { + return 15; } - // Queue another historical entry, then drop the association. The queue must survive reset. + // Queue another historical entry, close A1, then reclaim with the same stable + // Owner on association A2. The retained entry must remain retryable. if (reports.notify(0U, mms::MmsStaticBrcbEventReason::data_update, 200U) != mms::MmsStaticBrcbStatus::ok || !reports.next_due(200U, plan) || !reports.capture(plan, kReportTime, staging, value_workspace).success() || reports.queue_size() != 1U) { - return 12; + return 16; } connection.reset(); + control.on_association_closed(owner_a1.association_id, 200U); delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 200U, response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || - reports.queue_size() != 1U) { - return 13; + reports.queue_size() != 1U || + control.reserve(owner_a2, 5U, 201U) != mms::MmsStaticBrcbControlStatus::ok || + control.set_report_enabled(owner_a2, true, 201U) != + mms::MmsStaticBrcbControlStatus::ok) { + return 17; + } + + mms::MmsStaticConnectionRuntime reconnect{ + dispatcher, connection_policy(owner_a2.association_id, kOwnerA)}; + if (!establish(reconnect, request, response, workspace, scratch)) { + return 18; } - if (!establish(connection, request, response, workspace, scratch)) return 14; delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); - if (!delivery.response_ready() || delivery.entry_id[7] != 2U || + reconnect, control, reports, 201U, response, workspace); + if (!delivery.response_ready() || delivery.entry_id[7U] != 2U || + reports.queue_size() != 1U || + mms::MmsStaticBrcbConnection::commit_sent(reports, delivery) != + mms::MmsStaticBrcbStatus::ok || reports.queue_size() != 0U || mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace).status != + reconnect, control, reports, 201U, response, workspace).status != mms::MmsStaticBrcbConnectionStatus::no_report_available) { - return 15; + return 19; } return 0; From 8f2a0139cbbae865eb144d3b7955150271762bfa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:29:50 +0700 Subject: [PATCH 016/129] Close MMS runtime on every terminal server session path --- src/mms/static_server_session.cpp | 38 ++++++++++++++++++------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/mms/static_server_session.cpp b/src/mms/static_server_session.cpp index b65d079a9..324085051 100644 --- a/src/mms/static_server_session.cpp +++ b/src/mms/static_server_session.cpp @@ -43,10 +43,23 @@ void MmsStaticServerSession::reset() noexcept { } MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { + const auto terminate = [this]( + const MmsStaticServerSessionStatus status, + const MmsStaticConnectionStatus connection_status, + const std::size_t bytes_received = 0U, + const std::size_t bytes_sent = 0U) noexcept { + // All terminal transport/session outcomes are association teardown + // boundaries. MmsStaticConnectionRuntime guards its lifecycle callback, + // so this remains exactly-once even when process_tcp_window() already + // observed a COTP disconnect before the session reaches this helper. + runtime_.close_transport(); + terminal_ = true; + return make_result(status, connection_status, bytes_received, bytes_sent); + }; + if (!buffers_.valid() || stream_.send_callback == nullptr || stream_.receive_callback == nullptr) { - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::invalid_configuration, MmsStaticConnectionStatus::backend_failure); } @@ -71,15 +84,13 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { MmsStaticConnectionStatus::response_ready); } if (sent.status == embedded::IoStatus::closed) { - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::peer_closed, MmsStaticConnectionStatus::closed); } if (!sent.success() || sent.transferred == 0U || sent.transferred > remaining.size()) { - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::transport_error, MmsStaticConnectionStatus::backend_failure); } @@ -122,8 +133,7 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { connection.status); case MmsStaticConnectionStatus::closed: consume_input(connection.consumed_bytes); - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::peer_closed, connection.status); case MmsStaticConnectionStatus::need_more: @@ -133,16 +143,14 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { case MmsStaticConnectionStatus::response_buffer_too_small: case MmsStaticConnectionStatus::workspace_too_small: case MmsStaticConnectionStatus::backend_failure: - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::protocol_error, connection.status); } } if (receive_size_ == buffers_.receive.size()) { - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::receive_buffer_full, MmsStaticConnectionStatus::need_more); } @@ -160,15 +168,13 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { MmsStaticConnectionStatus::need_more); } if (received.status == embedded::IoStatus::closed) { - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::peer_closed, MmsStaticConnectionStatus::closed); } if (!received.success() || received.transferred == 0U || received.transferred > destination.size()) { - terminal_ = true; - return make_result( + return terminate( MmsStaticServerSessionStatus::transport_error, MmsStaticConnectionStatus::backend_failure); } From 22812f78aaa9c8a40f7f4a981bfba330b45067a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:30:46 +0700 Subject: [PATCH 017/129] Prove terminal server session closes established association --- tests/test_mms_static_server_session.cpp | 140 ++++++++++++++++++++++- 1 file changed, 138 insertions(+), 2 deletions(-) diff --git a/tests/test_mms_static_server_session.cpp b/tests/test_mms_static_server_session.cpp index f30185f14..a39a07592 100644 --- a/tests/test_mms_static_server_session.cpp +++ b/tests/test_mms_static_server_session.cpp @@ -15,6 +15,24 @@ namespace { using namespace ar::iec61850; +constexpr std::array kAssociationRequest{ + 0x0DU,0xB6U,0x05U,0x06U,0x13U,0x01U,0x00U,0x16U,0x01U,0x02U,0x14U,0x02U, + 0x00U,0x02U,0x33U,0x02U,0x00U,0x01U,0x34U,0x02U,0x00U,0x01U,0xC1U,0xA0U, + 0x31U,0x81U,0x9DU,0xA0U,0x03U,0x80U,0x01U,0x01U,0xA2U,0x81U,0x95U,0x81U, + 0x04U,0x00U,0x00U,0x00U,0x01U,0x82U,0x04U,0x00U,0x00U,0x00U,0x01U,0xA4U, + 0x23U,0x30U,0x0FU,0x02U,0x01U,0x01U,0x06U,0x04U,0x52U,0x01U,0x00U,0x01U, + 0x30U,0x04U,0x06U,0x02U,0x51U,0x01U,0x30U,0x10U,0x02U,0x01U,0x03U,0x06U, + 0x05U,0x28U,0xCAU,0x22U,0x02U,0x01U,0x30U,0x04U,0x06U,0x02U,0x51U,0x01U, + 0x61U,0x62U,0x30U,0x60U,0x02U,0x01U,0x01U,0xA0U,0x5BU,0x60U,0x59U,0xA1U, + 0x07U,0x06U,0x05U,0x28U,0xCAU,0x22U,0x02U,0x03U,0xA2U,0x07U,0x06U,0x05U, + 0x29U,0x01U,0x87U,0x67U,0x01U,0xA3U,0x03U,0x02U,0x01U,0x0CU,0xA6U,0x06U, + 0x06U,0x04U,0x29U,0x01U,0x87U,0x67U,0xA7U,0x03U,0x02U,0x01U,0x0CU,0xBEU, + 0x33U,0x28U,0x31U,0x06U,0x02U,0x51U,0x01U,0x02U,0x01U,0x03U,0xA0U,0x28U, + 0xA8U,0x26U,0x80U,0x03U,0x00U,0xFDU,0xE8U,0x81U,0x01U,0x0AU,0x82U,0x01U, + 0x0AU,0x83U,0x01U,0x05U,0xA4U,0x16U,0x80U,0x01U,0x01U,0x81U,0x03U,0x05U, + 0xF1U,0x00U,0x82U,0x0CU,0x03U,0xEEU,0x1CU,0x00U,0x00U,0x04U,0x08U,0x00U, + 0x00U,0x79U,0xEFU,0x18U}; + struct FakeTcp final { std::span inbound; std::span outbound; @@ -82,6 +100,67 @@ struct FakeTcp final { return {wire::EncodeStatus::ok, required, required}; } +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& runtime, + std::span request, + std::span response, + std::span workspace, + std::span scratch) noexcept { + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{ + osi::CotpSpanCodec::destination_tsap_parameter, + destination_tsap}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) return false; + const auto cr_tpkt = osi::TpktSpanCodec::encode_into( + scratch.first(cr.bytes_written), request); + if (!cr_tpkt.success()) return false; + auto result = runtime.process_tcp_window( + request.first(cr_tpkt.bytes_written), response, workspace); + if (!result.response_ready() || + runtime.state() != mms::MmsStaticConnectionState::awaiting_association) { + return false; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into(kAssociationRequest, scratch); + if (!cotp.success()) return false; + const auto association = osi::TpktSpanCodec::encode_into( + scratch.first(cotp.bytes_written), request); + if (!association.success()) return false; + result = runtime.process_tcp_window( + request.first(association.bytes_written), response, workspace); + return result.response_ready() && runtime.association_active() && + runtime.state() == mms::MmsStaticConnectionState::established; +} + +struct LifecycleSink final { + std::size_t calls{}; + std::uint64_t association_id{}; + std::uint64_t now_ms{}; +}; + +[[nodiscard]] std::uint64_t read_now(const void* context) noexcept { + return context == nullptr ? 0U : *static_cast(context); +} + +void association_closed( + void* context, + const std::uint64_t association_id, + const std::uint64_t now_ms) noexcept { + auto* sink = static_cast(context); + if (sink == nullptr) return; + ++sink->calls; + sink->association_id = association_id; + sink->now_ms = now_ms; +} + [[nodiscard]] bool fragmented_connection_request_is_pumped() noexcept { constexpr std::array boolean_type{0x83U, 0x00U}; const bool value = true; @@ -188,7 +267,7 @@ struct FakeTcp final { return first.status == mms::MmsStaticServerSessionStatus::progressed && second.status == mms::MmsStaticServerSessionStatus::receive_buffer_full && - second.terminal(); + second.terminal() && runtime.state() == mms::MmsStaticConnectionState::closed; } [[nodiscard]] bool peer_close_is_reported() noexcept { @@ -213,7 +292,61 @@ struct FakeTcp final { {receive, response, workspace}}; const auto result = session.poll_once(); return result.status == mms::MmsStaticServerSessionStatus::peer_closed && - result.terminal(); + result.terminal() && runtime.state() == mms::MmsStaticConnectionState::closed; +} + +[[nodiscard]] bool established_peer_close_notifies_once() noexcept { + const bool value = true; + constexpr std::array boolean_type{0x83U, 0x00U}; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "X", boolean_type, read_boolean, &value}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + + std::uint64_t now = 9'000U; + LifecycleSink sink; + mms::MmsStaticConnectionPolicy policy; + policy.association_id = 77U; + policy.owner[0] = 0xA1U; + policy.owner_size = 1U; + policy.now_ms = read_now; + policy.now_context = &now; + policy.association_closed = association_closed; + policy.association_closed_context = &sink; + mms::MmsStaticConnectionRuntime runtime{dispatcher, policy}; + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + if (!establish(runtime, request, response, workspace, scratch)) { + return false; + } + + std::array empty_inbound{}; + std::array outbound{}; + FakeTcp fake{std::span{empty_inbound}.first(0U), outbound}; + const embedded::TcpByteStream stream{&fake, fake_send, fake_receive}; + std::array receive{}; + std::array session_response{}; + std::array session_workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, session_response, session_workspace}}; + + const auto closed = session.poll_once(); + if (closed.status != mms::MmsStaticServerSessionStatus::peer_closed || + !closed.terminal() || runtime.association_active() || + runtime.state() != mms::MmsStaticConnectionState::closed || + sink.calls != 1U || sink.association_id != 77U || sink.now_ms != now) { + return false; + } + + static_cast(session.poll_once()); + runtime.close_transport(); + return sink.calls == 1U; } } // namespace @@ -228,5 +361,8 @@ int main() { if (!peer_close_is_reported()) { return 3; } + if (!established_peer_close_notifies_once()) { + return 4; + } return 0; } From 76563ab01a39651e13ebbeff835de42d694b0e6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:32:14 +0700 Subject: [PATCH 018/129] Recover BRCB checkpoint from surviving A/B bank --- src/mms/static_brcb_checkpoint_store.cpp | 84 ++++++++++++++++-------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/src/mms/static_brcb_checkpoint_store.cpp b/src/mms/static_brcb_checkpoint_store.cpp index cf9e4ccf5..0afc180f2 100644 --- a/src/mms/static_brcb_checkpoint_store.cpp +++ b/src/mms/static_brcb_checkpoint_store.cpp @@ -347,41 +347,71 @@ MmsStaticBrcbCheckpointResult MmsStaticBrcbCheckpointStore::restore( const auto first = inspect_bank(backend_, 0U, bank_bytes_); const auto second = inspect_bank(backend_, 1U, bank_bytes_); - if (first.io_error || second.io_error) { - result.status = MmsStaticBrcbCheckpointStatus::storage_failure; - return result; - } - if (!first.valid && !second.valid) { - result.status = MmsStaticBrcbCheckpointStatus::no_checkpoint; + const bool first_usable = first.valid && !first.io_error; + const bool second_usable = second.valid && !second.io_error; + if (!first_usable && !second_usable) { + result.status = first.io_error || second.io_error + ? MmsStaticBrcbCheckpointStatus::storage_failure + : MmsStaticBrcbCheckpointStatus::no_checkpoint; return result; } - std::size_t selected = 0U; - BankInfo info = first; - if (!first.valid || (second.valid && generation_newer(second.generation, first.generation))) { - selected = 1U; - info = second; - } - result.generation = info.generation; - result.bank_index = selected; - result.state_bytes = info.payload_bytes; - result.required_state_bytes = info.payload_bytes; - if (state_buffer.size() < info.payload_bytes) { - result.status = MmsStaticBrcbCheckpointStatus::state_buffer_too_small; - return result; + std::size_t primary_index = 0U; + BankInfo primary = first; + bool fallback_available = false; + std::size_t fallback_index = 0U; + BankInfo fallback{}; + if (first_usable && second_usable) { + if (generation_newer(second.generation, first.generation)) { + primary_index = 1U; + primary = second; + fallback_index = 0U; + fallback = first; + } else { + primary_index = 0U; + primary = first; + fallback_index = 1U; + fallback = second; + } + fallback_available = true; + } else if (second_usable) { + primary_index = 1U; + primary = second; } - const auto base = bank_offset(selected, bank_bytes_); - auto payload = state_buffer.first(info.payload_bytes); - if (!backend_.read( - backend_.context, - base + record_header_bytes, - payload) || - crc32(payload) != info.payload_crc) { - result.status = MmsStaticBrcbCheckpointStatus::storage_failure; + const auto load_bank = [this, state_buffer, &result]( + const std::size_t selected, + const BankInfo& info) noexcept { + result.generation = info.generation; + result.bank_index = selected; + result.state_bytes = info.payload_bytes; + result.required_state_bytes = info.payload_bytes; + if (state_buffer.size() < info.payload_bytes) { + return MmsStaticBrcbCheckpointStatus::state_buffer_too_small; + } + const auto base = bank_offset(selected, bank_bytes_); + auto payload = state_buffer.first(info.payload_bytes); + if (!backend_.read( + backend_.context, + base + record_header_bytes, + payload) || + crc32(payload) != info.payload_crc) { + return MmsStaticBrcbCheckpointStatus::storage_failure; + } + return MmsStaticBrcbCheckpointStatus::ok; + }; + + auto load_status = load_bank(primary_index, primary); + if (load_status == MmsStaticBrcbCheckpointStatus::storage_failure && + fallback_available) { + load_status = load_bank(fallback_index, fallback); + } + if (load_status != MmsStaticBrcbCheckpointStatus::ok) { + result.status = load_status; return result; } + const auto payload = state_buffer.first(result.state_bytes); const auto restored = MmsStaticBrcbStateCodec::restore(runtime, payload); result.state_status = restored.status; if (!restored.success()) { From 22eef1370b2ebf26eceffb155a9ceed4776871bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:32:52 +0700 Subject: [PATCH 019/129] Add one-bank BRCB checkpoint recovery regression --- ...checkpoint_fallback_hard_profile_smoke.cpp | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 embedded/mms_brcb_checkpoint_fallback_hard_profile_smoke.cpp diff --git a/embedded/mms_brcb_checkpoint_fallback_hard_profile_smoke.cpp b/embedded/mms_brcb_checkpoint_fallback_hard_profile_smoke.cpp new file mode 100644 index 000000000..4741e6002 --- /dev/null +++ b/embedded/mms_brcb_checkpoint_fallback_hard_profile_smoke.cpp @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_checkpoint_store.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::size_t kBankBytes = 4096U; +constexpr std::size_t kStorageBytes = kBankBytes * 2U; + +bool gValue = true; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +const std::array kObjects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &gValue, false}}; +const mms::MmsStaticObjectTable kObjectTable{kObjects}; +const std::array kMembers{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; +const std::array kDataSets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", kMembers, false}}; +const mms::MmsStaticDataSetTable kDataSetTable{kDataSets}; +const mms::MmsStaticBrcbDefinition kDefinition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Events", + "LD0", + "LLN0$Events", + 17U, + {0x7FU, 0x80U}, + 20U, + 0x70U}; + +struct RuntimeFixture final { + std::array slot0{}; + std::array slot1{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}, + mms::MmsStaticBrcbSlot{slot1}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime{ + kDefinition, pending, slots, kObjectTable, kDataSetTable}; +}; + +struct FakeStorage final { + std::array bytes{}; + bool fail_bank0_reads{}; + bool fail_bank1_reads{}; + + FakeStorage() noexcept { + std::fill(bytes.begin(), bytes.end(), std::uint8_t{0xFFU}); + } +}; + +[[nodiscard]] bool storage_read( + void* context, + const std::size_t offset, + const std::span destination) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || offset > storage->bytes.size() || + destination.size() > storage->bytes.size() - offset) { + return false; + } + if ((storage->fail_bank0_reads && offset < kBankBytes) || + (storage->fail_bank1_reads && offset >= kBankBytes)) { + return false; + } + std::copy_n( + storage->bytes.begin() + static_cast(offset), + destination.size(), + destination.begin()); + return true; +} + +[[nodiscard]] bool storage_write( + void* context, + const std::size_t offset, + const std::span source) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || offset > storage->bytes.size() || + source.size() > storage->bytes.size() - offset) { + return false; + } + std::copy( + source.begin(), + source.end(), + storage->bytes.begin() + static_cast(offset)); + return true; +} + +[[nodiscard]] bool storage_erase( + void* context, + const std::size_t offset, + const std::size_t bytes) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || offset > storage->bytes.size() || + bytes > storage->bytes.size() - offset) { + return false; + } + std::fill_n( + storage->bytes.begin() + static_cast(offset), + bytes, + std::uint8_t{0xFFU}); + return true; +} + +[[nodiscard]] bool storage_sync(void* context) noexcept { + return context != nullptr; +} + +} // namespace + +int main() { + FakeStorage storage; + const mms::MmsStaticBrcbStorageBackend backend{ + &storage, + storage.bytes.size(), + storage_read, + storage_write, + storage_erase, + storage_sync}; + const mms::MmsStaticBrcbCheckpointStore store{backend, kBankBytes}; + if (!store.valid()) { + return 1; + } + + RuntimeFixture source; + if (!source.runtime.initialize()) { + return 2; + } + std::array state_buffer{}; + const auto first = store.checkpoint(source.runtime, state_buffer); + const auto second = store.checkpoint(source.runtime, state_buffer); + if (!first.success() || first.generation != 1U || first.bank_index != 0U || + !second.success() || second.generation != 2U || second.bank_index != 1U) { + return 3; + } + + // Bank 0 is unreadable, but the newer committed bank 1 remains valid. + storage.fail_bank0_reads = true; + RuntimeFixture from_bank1; + if (!from_bank1.runtime.initialize()) { + return 4; + } + const auto bank1_restore = store.restore(from_bank1.runtime, state_buffer); + if (!bank1_restore.success() || bank1_restore.generation != 2U || + bank1_restore.bank_index != 1U) { + return 5; + } + + // Reverse the fault. The older bank 0 must remain a usable recovery point. + storage.fail_bank0_reads = false; + storage.fail_bank1_reads = true; + RuntimeFixture from_bank0; + if (!from_bank0.runtime.initialize()) { + return 6; + } + const auto bank0_restore = store.restore(from_bank0.runtime, state_buffer); + if (!bank0_restore.success() || bank0_restore.generation != 1U || + bank0_restore.bank_index != 0U) { + return 7; + } + + return 0; +} From 907f516e2afb73ef8ed98a913f36edc124fbfcab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:33:32 +0700 Subject: [PATCH 020/129] Extend BRCB hard profile safety regressions --- embedded/brcb_hard_profile/CMakeLists.txt | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt index 44a407ab3..ec5c08cf4 100644 --- a/embedded/brcb_hard_profile/CMakeLists.txt +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -108,6 +108,7 @@ add_executable(ariec61850_hard_mms_brcb_connection_smoke ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_control.cpp ${ARIEC61850_ROOT}/src/mms/static_brcb_connection.cpp) ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_connection_smoke) @@ -138,6 +139,20 @@ add_executable(ariec61850_hard_mms_brcb_persistence_smoke ${ARIEC61850_ROOT}/src/mms/static_brcb_checkpoint_store.cpp) ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_persistence_smoke) +add_executable(ariec61850_hard_mms_brcb_checkpoint_fallback_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_checkpoint_fallback_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_state_codec.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_checkpoint_store.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_checkpoint_fallback_smoke) + enable_testing() add_test(NAME ariec61850_hard_mms_brcb_smoke COMMAND ariec61850_hard_mms_brcb_smoke) add_test(NAME ariec61850_hard_mms_brcb_control_smoke COMMAND ariec61850_hard_mms_brcb_control_smoke) @@ -146,13 +161,14 @@ add_test(NAME ariec61850_hard_mms_brcb_lifecycle_smoke COMMAND ariec61850_hard_m add_test(NAME ariec61850_hard_mms_brcb_connection_smoke COMMAND ariec61850_hard_mms_brcb_connection_smoke) add_test(NAME ariec61850_hard_mms_brcb_recovery_v2_smoke COMMAND ariec61850_hard_mms_brcb_recovery_v2_smoke) add_test(NAME ariec61850_hard_mms_brcb_persistence_smoke COMMAND ariec61850_hard_mms_brcb_persistence_smoke) +add_test(NAME ariec61850_hard_mms_brcb_checkpoint_fallback_smoke COMMAND ariec61850_hard_mms_brcb_checkpoint_fallback_smoke) message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=ON") message(STATUS "arstack61850 BRCB hard profile: association ownership + ResvTms gate=ON") message(STATUS "arstack61850 BRCB hard profile: MMS RptEna/EntryID/PurgeBuf/ResvTms/Owner=ON") message(STATUS "arstack61850 BRCB hard profile: transport association lifecycle bridge=ON") message(STATUS "arstack61850 BRCB hard profile: retained-history recovery state v2 + v1 restore=ON") -message(STATUS "arstack61850 BRCB hard profile: retry-safe reconnect delivery=ON") -message(STATUS "arstack61850 BRCB hard profile: dual-bank checkpoint + reboot recovery oracle=ON") +message(STATUS "arstack61850 BRCB hard profile: retry-safe two-phase owner-bound delivery=ON") +message(STATUS "arstack61850 BRCB hard profile: dual-bank checkpoint + one-bank recovery=ON") message(STATUS "arstack61850 BRCB hard profile: platform non-volatile adapter=NOT_CLAIMED") message(STATUS "arstack61850 BRCB hard profile: exceptions/RTTI disabled=ON") From b39a6880980a73796a4dffcb76e14ff4d1894bc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:36:52 +0700 Subject: [PATCH 021/129] Isolate static MMS server from legacy core --- CMakeLists.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b34cdec79..2c2fa395b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -188,7 +188,7 @@ if(ARIEC61850_BUILD_TOOLS) add_executable(ariec61850_static_ied_server tools/static_ied_server.cpp) target_link_libraries( ariec61850_static_ied_server - PRIVATE ARIEC61850::mms_server_core ARIEC61850::core) + PRIVATE ARIEC61850::mms_server_core) if(WIN32) target_link_libraries(ariec61850_static_ied_server PRIVATE ws2_32) endif() @@ -404,7 +404,8 @@ if(ARIEC61850_BUILD_TESTS) mms_brcb_objects mms_brcb_lifecycle mms_brcb_persistence - mms_brcb_recovery_v2) + mms_brcb_recovery_v2 + mms_brcb_checkpoint_fallback) set(brcb_target "ariec61850_${brcb_smoke}_server_core_tests") add_executable( ${brcb_target} From 48efffd402049b98915336025edce87bb2b2d59b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:37:26 +0700 Subject: [PATCH 022/129] Ignore POSIX SIGPIPE in static server host process --- tools/static_ied_server_sigpipe.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tools/static_ied_server_sigpipe.cpp diff --git a/tools/static_ied_server_sigpipe.cpp b/tools/static_ied_server_sigpipe.cpp new file mode 100644 index 000000000..432c651f8 --- /dev/null +++ b/tools/static_ied_server_sigpipe.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +namespace { + +#if !defined(_WIN32) +struct PosixSigpipeGuard final { + PosixSigpipeGuard() noexcept { + std::signal(SIGPIPE, SIG_IGN); + } +}; + +[[maybe_unused]] const PosixSigpipeGuard kPosixSigpipeGuard{}; +#endif + +} // namespace From f47017c8cb64a3dd0f15ce3b6f54f78a2b9845bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:38:44 +0700 Subject: [PATCH 023/129] Wire host SIGPIPE guard into static server --- CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c2fa395b..0826ea697 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -185,7 +185,10 @@ if(ARIEC61850_BUILD_TOOLS) ariec61850_apply_warnings(ariec61850_file_transfer) ariec61850_apply_sanitizers(ariec61850_file_transfer) - add_executable(ariec61850_static_ied_server tools/static_ied_server.cpp) + add_executable( + ariec61850_static_ied_server + tools/static_ied_server.cpp + tools/static_ied_server_sigpipe.cpp) target_link_libraries( ariec61850_static_ied_server PRIVATE ARIEC61850::mms_server_core) From 79d6fb0c6f62725738a7f0abaac2e8c22b9980b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 08:40:37 +0700 Subject: [PATCH 024/129] Reauthorize owner on BRCB delivery commit --- include/ariec61850/mms/static_brcb_connection.hpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/mms/static_brcb_connection.hpp b/include/ariec61850/mms/static_brcb_connection.hpp index 406cdef1b..9ad1cf8dd 100644 --- a/include/ariec61850/mms/static_brcb_connection.hpp +++ b/include/ariec61850/mms/static_brcb_connection.hpp @@ -63,9 +63,14 @@ class MmsStaticBrcbConnection final { // Second phase of report delivery. Call only after every byte of the staged // response was accepted by the transport. The staged EntryID acts as the - // bounded delivery token; stale/replayed tokens are rejected by the runtime. - [[nodiscard]] static MmsStaticBrcbStatus commit_sent( + // bounded delivery token. Association/Owner authorization is checked again + // at commit time so a token staged by an earlier association cannot advance + // the cursor after ownership changes. + [[nodiscard]] static bool commit_sent( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, MmsStaticBrcbRuntime& reports, + std::uint64_t now_ms, const MmsStaticBrcbConnectionResult& staged) noexcept; }; From e5c63032d944e310737a4056358933056314d9d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 09:27:58 +0700 Subject: [PATCH 025/129] Harden ESP32-P4 bench serial command input --- .../main/live_control.cpp | 115 +++++++++++++++--- 1 file changed, 99 insertions(+), 16 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index d2d54a7d1..12a08ae56 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -26,6 +26,7 @@ using ar::iec61850::sampled_values::live_channel_index; constexpr char kTag[] = "ar_smv_ctrl"; constexpr std::array kChannelNames{ "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; +constexpr std::string_view kTokenDelimiters{" \t\r\n"}; SvLiveSignalBank g_signal_bank; std::atomic g_running{false}; @@ -40,6 +41,38 @@ void uppercase_ascii(char* text) noexcept { } } +// The bench console is a safety-sensitive control surface: random UART bytes, +// terminal escape sequences or UTF-8 fragments must never be normalized into a +// valid command. Accept printable 7-bit ASCII plus normal line whitespace only; +// reject the whole line otherwise. +bool is_safe_ascii_command_line(const char* line) noexcept { + if (line == nullptr) { + return false; + } + + bool has_payload = false; + for (const auto* cursor = reinterpret_cast(line); + *cursor != 0U; + ++cursor) { + const auto byte = *cursor; + if (byte == static_cast(' ') || + byte == static_cast('\t') || + byte == static_cast('\r') || + byte == static_cast('\n')) { + continue; + } + if (byte < 0x20U || byte > 0x7EU) { + return false; + } + has_payload = true; + } + return has_payload; +} + +bool has_extra_token(char** save) noexcept { + return strtok_r(nullptr, kTokenDelimiters.data(), save) != nullptr; +} + bool parse_i32(const char* text, std::int32_t& value) noexcept { if (text == nullptr || *text == '\0') { return false; @@ -115,32 +148,52 @@ bool publish_state(const SvLiveSignalState& state) noexcept { void handle_line(char* line) noexcept { char* save{}; - char* command = strtok_r(line, " \t\r\n", &save); + char* command = strtok_r(line, kTokenDelimiters.data(), &save); if (command == nullptr) { return; } uppercase_ascii(command); if (std::strcmp(command, "START") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: START"); + return; + } g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); ESP_LOGI(kTag, "START accepted: phase and smpCnt reset at the next sample boundary"); return; } if (std::strcmp(command, "STOP") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: STOP"); + return; + } g_running.store(false, std::memory_order_release); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; } if (std::strcmp(command, "SHOW") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: SHOW"); + return; + } print_state(); return; } if (std::strcmp(command, "HELP") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: HELP"); + return; + } print_help(); return; } if (std::strcmp(command, "ZERO") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: ZERO"); + return; + } auto state = g_signal_bank.snapshot(); for (auto& channel : state.channels) { channel.rms_counts = 0; @@ -149,9 +202,9 @@ void handle_line(char* line) noexcept { return; } if (std::strcmp(command, "FREQ") == 0) { - char* value_text = strtok_r(nullptr, " \t\r\n", &save); + char* value_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t frequency{}; - if (!parse_u32(value_text, frequency)) { + if (!parse_u32(value_text, frequency) || has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: FREQ "); return; } @@ -164,7 +217,7 @@ void handle_line(char* line) noexcept { if (std::strcmp(command, "SET") == 0 || std::strcmp(command, "ENABLE") == 0 || std::strcmp(command, "QUALITY") == 0) { - char* channel_text = strtok_r(nullptr, " \t\r\n", &save); + char* channel_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (channel_text == nullptr) { ESP_LOGE(kTag, "Channel name is required"); return; @@ -180,9 +233,9 @@ void handle_line(char* line) noexcept { auto& channel = state.channels[*channel_index]; if (std::strcmp(command, "SET") == 0) { - char* rms_text = strtok_r(nullptr, " \t\r\n", &save); - char* phase_text = strtok_r(nullptr, " \t\r\n", &save); - char* quality_text = strtok_r(nullptr, " \t\r\n", &save); + char* rms_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* phase_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* quality_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::int32_t rms{}; std::int32_t phase{}; if (!parse_i32(rms_text, rms) || !parse_i32(phase_text, phase)) { @@ -190,14 +243,19 @@ void handle_line(char* line) noexcept { "Usage: SET [quality]"); return; } + std::uint32_t quality{}; + if (quality_text != nullptr && !parse_u32(quality_text, quality)) { + ESP_LOGE(kTag, "Invalid quality value '%s'", quality_text); + return; + } + if (has_extra_token(&save)) { + ESP_LOGE(kTag, + "Usage: SET [quality]"); + return; + } channel.rms_counts = rms; channel.phase_millidegrees = phase; if (quality_text != nullptr) { - std::uint32_t quality{}; - if (!parse_u32(quality_text, quality)) { - ESP_LOGE(kTag, "Invalid quality value '%s'", quality_text); - return; - } channel.quality = quality; } static_cast(publish_state(state)); @@ -205,9 +263,9 @@ void handle_line(char* line) noexcept { } if (std::strcmp(command, "ENABLE") == 0) { - char* enabled_text = strtok_r(nullptr, " \t\r\n", &save); + char* enabled_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t enabled{}; - if (!parse_u32(enabled_text, enabled) || enabled > 1U) { + if (!parse_u32(enabled_text, enabled) || enabled > 1U || has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: ENABLE <0|1>"); return; } @@ -216,9 +274,9 @@ void handle_line(char* line) noexcept { return; } - char* quality_text = strtok_r(nullptr, " \t\r\n", &save); + char* quality_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t quality{}; - if (!parse_u32(quality_text, quality)) { + if (!parse_u32(quality_text, quality) || has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: QUALITY "); return; } @@ -230,6 +288,13 @@ void handle_line(char* line) noexcept { ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); } +void discard_overlong_line_tail() noexcept { + int byte{}; + do { + byte = std::fgetc(stdin); + } while (byte != '\n' && byte != EOF); +} + } // namespace void live_control_initialize( @@ -268,11 +333,29 @@ void live_control_task(void*) noexcept { std::array line{}; while (true) { + line.fill('\0'); if (std::fgets(line.data(), static_cast(line.size()), stdin) == nullptr) { std::clearerr(stdin); vTaskDelay(pdMS_TO_TICKS(20)); continue; } + + const auto length = std::strlen(line.data()); + const bool complete_line = + length > 0U && (line[length - 1U] == '\n' || line[length - 1U] == '\r'); + if (!complete_line && length == line.size() - 1U) { + discard_overlong_line_tail(); + ESP_LOGW(kTag, "Discarded overlong bench command"); + continue; + } + + if (!is_safe_ascii_command_line(line.data())) { + // Startup UART noise and terminal escape/UTF-8 fragments are + // intentionally dropped without echoing their bytes back to logs. + ESP_LOGD(kTag, "Discarded non-ASCII/empty bench input"); + continue; + } + handle_line(line.data()); } } From fe7e24eaf2b465120f100caace65a92dc8c2be5e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 09:43:52 +0700 Subject: [PATCH 026/129] Fix interactive serial line assembly --- .../main/live_control.cpp | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 12a08ae56..88e1089d1 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -288,13 +288,6 @@ void handle_line(char* line) noexcept { ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); } -void discard_overlong_line_tail() noexcept { - int byte{}; - do { - byte = std::fgetc(stdin); - } while (byte != '\n' && byte != EOF); -} - } // namespace void live_control_initialize( @@ -330,33 +323,81 @@ void live_control_task(void*) noexcept { std::setvbuf(stdin, nullptr, _IONBF, 0); print_help(); print_state(); + ESP_LOGI(kTag, "Console ready: type a complete command, then press Enter."); std::array line{}; + std::size_t length = 0U; + bool discard_until_eol = false; + while (true) { - line.fill('\0'); - if (std::fgets(line.data(), static_cast(line.size()), stdin) == nullptr) { + const int input = std::fgetc(stdin); + if (input == EOF) { std::clearerr(stdin); - vTaskDelay(pdMS_TO_TICKS(20)); + vTaskDelay(pdMS_TO_TICKS(10)); continue; } - const auto length = std::strlen(line.data()); - const bool complete_line = - length > 0U && (line[length - 1U] == '\n' || line[length - 1U] == '\r'); - if (!complete_line && length == line.size() - 1U) { - discard_overlong_line_tail(); - ESP_LOGW(kTag, "Discarded overlong bench command"); + const auto byte = static_cast(input); + + // The ESP-IDF monitor forwards interactive input byte-by-byte. Assemble + // those bytes here and execute only after Enter; std::fgets() is not + // suitable for this VFS because a short UART read can look like a + // complete stdio record. + if (byte == static_cast('\r') || + byte == static_cast('\n')) { + if (discard_until_eol) { + discard_until_eol = false; + length = 0U; + line.fill('\0'); + ESP_LOGW(kTag, "Discarded invalid/overlong bench command"); + continue; + } + + if (length == 0U) { + // Ignore the second half of CRLF and empty Enter presses. + continue; + } + + line[length] = '\0'; + if (!is_safe_ascii_command_line(line.data())) { + ESP_LOGW(kTag, "Discarded invalid bench command"); + } else { + handle_line(line.data()); + } + length = 0U; + line.fill('\0'); + continue; + } + + if (discard_until_eol) { + continue; + } + + // Support normal terminal editing without treating backspace/delete as + // a command byte. + if (byte == 0x08U || byte == 0x7FU) { + if (length > 0U) { + --length; + line[length] = '\0'; + } + continue; + } + + const bool accepted = + byte == static_cast(' ') || + byte == static_cast('\t') || + (byte >= 0x21U && byte <= 0x7EU); + if (!accepted) { + discard_until_eol = true; continue; } - if (!is_safe_ascii_command_line(line.data())) { - // Startup UART noise and terminal escape/UTF-8 fragments are - // intentionally dropped without echoing their bytes back to logs. - ESP_LOGD(kTag, "Discarded non-ASCII/empty bench input"); + if (length + 1U >= line.size()) { + discard_until_eol = true; continue; } - handle_line(line.data()); + line[length++] = static_cast(byte); } } From dbc5cedc2da8484674990c736312b32d2853c77b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 09:53:04 +0700 Subject: [PATCH 027/129] Fix BRCB two-phase commit authorization signature --- src/mms/static_brcb_connection.cpp | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp index c2842a99d..912daf556 100644 --- a/src/mms/static_brcb_connection.cpp +++ b/src/mms/static_brcb_connection.cpp @@ -141,13 +141,31 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( return result; } -MmsStaticBrcbStatus MmsStaticBrcbConnection::commit_sent( +bool MmsStaticBrcbConnection::commit_sent( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, MmsStaticBrcbRuntime& reports, + const std::uint64_t now_ms, const MmsStaticBrcbConnectionResult& staged) noexcept { - if (!staged.response_ready()) { - return MmsStaticBrcbStatus::stale_plan; + if (!staged.response_ready() || + connection.state() != MmsStaticConnectionState::established || + connection.mms_presentation_context_id() == 0U || !control.valid()) { + return false; + } + + const auto control_state = control.state(now_ms); + if (!control_state.report_enabled || !control_state.reserved || + !control_state.owner_connected || control_state.association_id == 0U) { + return false; } - return reports.commit_delivery(staged.entry_id); + + const auto access = connection.access_context(); + if (access.association_id != control_state.association_id || + !same_owner(access.owner, control_state.owner)) { + return false; + } + + return reports.commit_delivery(staged.entry_id) == MmsStaticBrcbStatus::ok; } } // namespace ar::iec61850::mms From c753f665546ca80f2a262571dec0a74406e00c1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 09:54:59 +0700 Subject: [PATCH 028/129] Update BRCB delivery smoke for owner-bound commit API --- ...mms_brcb_connection_hard_profile_smoke.cpp | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/embedded/mms_brcb_connection_hard_profile_smoke.cpp b/embedded/mms_brcb_connection_hard_profile_smoke.cpp index 577d7e7ba..606f84232 100644 --- a/embedded/mms_brcb_connection_hard_profile_smoke.cpp +++ b/embedded/mms_brcb_connection_hard_profile_smoke.cpp @@ -285,11 +285,19 @@ int main() { !historical_value) { return 14; } - if (mms::MmsStaticBrcbConnection::commit_sent(reports, retry) != - mms::MmsStaticBrcbStatus::ok || - reports.queue_size() != 0U) { + + // A staged token is not transferable: a foreign association cannot commit + // an EntryID staged by the legitimate BRCB owner. + if (mms::MmsStaticBrcbConnection::commit_sent( + foreign_connection, control, reports, 100U, retry) || + reports.queue_size() != 1U) { return 15; } + if (!mms::MmsStaticBrcbConnection::commit_sent( + connection, control, reports, 100U, retry) || + reports.queue_size() != 0U) { + return 16; + } // Queue another historical entry, close A1, then reclaim with the same stable // Owner on association A2. The retained entry must remain retryable. @@ -298,7 +306,7 @@ int main() { !reports.next_due(200U, plan) || !reports.capture(plan, kReportTime, staging, value_workspace).success() || reports.queue_size() != 1U) { - return 16; + return 17; } connection.reset(); control.on_association_closed(owner_a1.association_id, 200U); @@ -309,25 +317,25 @@ int main() { control.reserve(owner_a2, 5U, 201U) != mms::MmsStaticBrcbControlStatus::ok || control.set_report_enabled(owner_a2, true, 201U) != mms::MmsStaticBrcbControlStatus::ok) { - return 17; + return 18; } mms::MmsStaticConnectionRuntime reconnect{ dispatcher, connection_policy(owner_a2.association_id, kOwnerA)}; if (!establish(reconnect, request, response, workspace, scratch)) { - return 18; + return 19; } delivery = mms::MmsStaticBrcbConnection::poll( reconnect, control, reports, 201U, response, workspace); if (!delivery.response_ready() || delivery.entry_id[7U] != 2U || reports.queue_size() != 1U || - mms::MmsStaticBrcbConnection::commit_sent(reports, delivery) != - mms::MmsStaticBrcbStatus::ok || + !mms::MmsStaticBrcbConnection::commit_sent( + reconnect, control, reports, 201U, delivery) || reports.queue_size() != 0U || mms::MmsStaticBrcbConnection::poll( reconnect, control, reports, 201U, response, workspace).status != mms::MmsStaticBrcbConnectionStatus::no_report_available) { - return 19; + return 20; } return 0; From 3ea48e426011e487d25cd8e0e768941646c78362 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:09:01 +0700 Subject: [PATCH 029/129] feat: add browser SMV injector control GUI shell --- apps/smv_injector_gui/index.html | 149 +++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 apps/smv_injector_gui/index.html diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html new file mode 100644 index 000000000..a53c1c79b --- /dev/null +++ b/apps/smv_injector_gui/index.html @@ -0,0 +1,149 @@ + + + + + + + ARStack61850 · SMV Injector + + + +
+
+
+ +
+
ARStack61850
+

SMV Injector

+
+
+ +
+
+ + Device offline +
+ +
+
+ +
+
+
+
+
+
Process bus stream
+

Active publisher profile

+
+ Development profile +
+ +
+
+ svID + MU01_SV1 +
+
+
APPID0x4000
+
Rate4000 fps
+
smpCntwrap 4000
+
VLANPCP 4 · VID 0
+
+
+ +
+ +
No engineering file loaded
+
+

Engineering files are loaded locally. Runtime profile deployment will connect to the host profile compiler; live signal control below already talks directly to the ESP32-P4 bench firmware.

+
+ +
+
+ +
+
Publisher state
+ STOPPED +
+
+ +
+
Observed rate—
+
Missed slots—
+
TX failures—
+
Signal generation—
+
+ +
+ + +
+
+
+ +
+
+
+
Signal frequency
+
Hz
+
+ +
+ +
+ + + + +
+
+ +
+
+
+
Live signal control
+

Current & voltage channels

+
+

Magnitude uses engineering units. Phase uses ordinary degrees. Conversion to wire counts and milli-degrees happens inside the GUI.

+
+
+
+ +
+
+
+
+
Vector preview
+

Phase relationships

+
+
+ +
+ +
+
+
+
Device evidence
+

Runtime log

+
+ +
+
Waiting for device connection…
+
+
+
+
+ +
+ + + From 2041e0d8d0d607fabd2b1c92b30b5cf49d70e1e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:09:59 +0700 Subject: [PATCH 030/129] feat: style SMV injector control GUI --- apps/smv_injector_gui/styles.css | 212 +++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 apps/smv_injector_gui/styles.css diff --git a/apps/smv_injector_gui/styles.css b/apps/smv_injector_gui/styles.css new file mode 100644 index 000000000..9b0cd2e37 --- /dev/null +++ b/apps/smv_injector_gui/styles.css @@ -0,0 +1,212 @@ +:root { + --bg: #071018; + --bg-soft: #0b1621; + --panel: rgba(14, 27, 39, 0.92); + --panel-2: rgba(19, 35, 49, 0.88); + --line: rgba(131, 161, 183, 0.16); + --line-strong: rgba(131, 161, 183, 0.28); + --text: #eef6fb; + --muted: #8fa6b7; + --cyan: #4fc7ff; + --blue: #5b8cff; + --green: #3ddc97; + --amber: #f2bb4c; + --red: #ff6b76; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); + --radius: 18px; +} + +* { box-sizing: border-box; } +html { min-width: 1120px; background: var(--bg); } +body { + margin: 0; + min-height: 100vh; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 10% 0%, rgba(79, 199, 255, 0.08), transparent 28rem), + radial-gradient(circle at 92% 8%, rgba(91, 140, 255, 0.08), transparent 30rem), + linear-gradient(180deg, #09131c 0%, #061018 100%); +} + +button, input { font: inherit; } +button { color: inherit; } + +.app-shell { min-height: 100vh; } +.topbar { + min-height: 84px; + padding: 18px 30px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 20; + border-bottom: 1px solid var(--line); + background: rgba(7, 16, 24, 0.88); + backdrop-filter: blur(18px); +} +.brand, .connection-strip, .panel-header, .run-state, .run-actions, +.toolbar-actions, .switch-row, .section-heading { display: flex; align-items: center; } +.brand { gap: 14px; } +.brand-mark { + width: 42px; height: 42px; border-radius: 12px; position: relative; + background: linear-gradient(145deg, rgba(79,199,255,.16), rgba(91,140,255,.10)); + border: 1px solid rgba(79,199,255,.28); + box-shadow: inset 0 0 24px rgba(79,199,255,.08); +} +.brand-mark span { + position: absolute; left: 10px; right: 10px; height: 2px; border-radius: 999px; + background: linear-gradient(90deg, transparent, var(--cyan), transparent); +} +.brand-mark span:nth-child(1){ top: 12px; transform: rotate(-24deg); } +.brand-mark span:nth-child(2){ top: 20px; } +.brand-mark span:nth-child(3){ top: 28px; transform: rotate(24deg); } +.brand h1, .panel h2, .section-heading h2 { margin: 2px 0 0; font-size: 20px; line-height: 1.15; } +.eyebrow { color: var(--muted); text-transform: uppercase; letter-spacing: .12em; font-size: 10px; font-weight: 750; } +.connection-strip { gap: 12px; } +.status-pill, .badge { + display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; + border-radius: 999px; border: 1px solid var(--line); background: rgba(255,255,255,.025); + color: var(--muted); font-size: 12px; font-weight: 650; +} +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #687985; box-shadow: 0 0 0 4px rgba(104,121,133,.10); } +.status-pill[data-state="online"] { color: #c8f7e5; border-color: rgba(61,220,151,.25); background: rgba(61,220,151,.08); } +.status-pill[data-state="online"] .status-dot { background: var(--green); box-shadow: 0 0 0 4px rgba(61,220,151,.12); } + +.workspace { padding: 24px 30px 40px; max-width: 1720px; margin: 0 auto; } +.hero-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(370px, .85fr); gap: 18px; } +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(17,31,44,.94), rgba(11,23,33,.94)); + box-shadow: var(--shadow); +} +.stream-panel, .run-panel, .phase-panel, .console-panel { padding: 20px; } +.panel-header { justify-content: space-between; gap: 16px; } +.panel-header.compact { min-height: 48px; } +.badge.neutral { color: #c5d6e2; } + +.stream-identity { + margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; + background: rgba(4, 12, 18, .35); +} +.identity-main { display: flex; align-items: baseline; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--line); } +.identity-label, .identity-grid span, .run-metrics span { color: var(--muted); font-size: 11px; } +.identity-main strong { font-size: 22px; letter-spacing: .02em; } +.identity-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding-top: 14px; } +.identity-grid div, .run-metrics div { display: grid; gap: 5px; } +.identity-grid strong, .run-metrics strong { font-size: 13px; font-weight: 700; } +.profile-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; } +.file-button { + cursor: pointer; min-height: 38px; display: inline-flex; align-items: center; padding: 0 13px; + border-radius: 10px; border: 1px solid rgba(79,199,255,.25); background: rgba(79,199,255,.08); + color: #cfefff; font-size: 12px; font-weight: 700; +} +.file-button input { display:none; } +.file-state { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.microcopy { margin: 12px 0 0; color: #7890a2; font-size: 11px; line-height: 1.55; } + +.run-panel { display: grid; grid-template-rows: auto 1fr auto; } +.run-state { gap: 12px; } +.run-icon { width: 12px; height: 12px; border-radius: 50%; background: #7f919e; box-shadow: 0 0 0 6px rgba(127,145,158,.08); } +.run-state[data-state="running"] .run-icon { background: var(--green); box-shadow: 0 0 0 6px rgba(61,220,151,.12), 0 0 24px rgba(61,220,151,.18); } +.run-state strong { display:block; margin-top: 3px; font-size: 28px; letter-spacing: .04em; } +.run-state[data-state="running"] strong { color: #bdf4dd; } +.run-metrics { align-self:center; display:grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 24px 0; } +.run-metrics div { min-height: 62px; padding: 12px; border:1px solid var(--line); border-radius: 12px; background: rgba(4,12,18,.28); } +.run-metrics strong { font-size: 18px; } +.run-actions { gap: 10px; } +.run-actions .btn { flex: 1; height: 44px; } + +.btn { + border: 1px solid transparent; border-radius: 10px; min-height: 38px; padding: 0 14px; + cursor: pointer; font-size: 12px; font-weight: 750; transition: transform .12s ease, opacity .12s ease, border-color .12s ease, background .12s ease; +} +.btn:hover:not(:disabled){ transform: translateY(-1px); } +.btn:active:not(:disabled){ transform: translateY(0); } +.btn:disabled{ opacity:.38; cursor:not-allowed; } +.btn.primary { background: linear-gradient(135deg, #237fb7, #315ed1); border-color: rgba(91,140,255,.34); } +.btn.secondary { background: rgba(255,255,255,.04); border-color: var(--line-strong); color: #d5e2eb; } +.btn.start { background: linear-gradient(135deg, #1c9e67, #157a66); border-color: rgba(61,220,151,.28); } +.btn.stop { background: rgba(255,107,118,.08); color: #ffc4c9; border-color: rgba(255,107,118,.26); } +.btn.ghost { background: transparent; border-color: var(--line); color: var(--muted); } +.btn.small { min-height:30px; padding:0 10px; } + +.control-toolbar { + margin-top: 18px; padding: 16px 18px; display:grid; grid-template-columns: minmax(440px,1fr) auto; gap: 22px; align-items:center; +} +.frequency-control { display:grid; grid-template-columns: 170px 1fr; gap:18px; align-items:center; } +.frequency-value { display:flex; align-items:baseline; gap:7px; margin-top:2px; } +.frequency-value input { + width:105px; border:0; outline:0; background:transparent; color:var(--text); font-size:24px; font-weight:760; padding:0; +} +.frequency-value span { color:var(--muted); font-size:12px; } +input[type="range"] { width:100%; accent-color: var(--cyan); cursor:pointer; } +.toolbar-actions { justify-content:flex-end; gap:9px; flex-wrap:wrap; } +.switch-row { gap:8px; color:#c9d8e2; font-size:12px; margin-right:4px; cursor:pointer; } +.switch-row input { display:none; } +.switch { width:36px; height:20px; border-radius:999px; background:#263845; border:1px solid var(--line-strong); position:relative; transition:.15s; } +.switch::after { content:""; position:absolute; top:3px; left:3px; width:12px; height:12px; border-radius:50%; background:#8193a0; transition:.15s; } +.switch-row input:checked + .switch { background:rgba(61,220,151,.20); border-color:rgba(61,220,151,.35); } +.switch-row input:checked + .switch::after { left:19px; background:var(--green); } + +.channels-section { margin-top: 26px; } +.section-heading { justify-content:space-between; gap:20px; margin-bottom:14px; } +.section-heading p { max-width:650px; margin:0; color:var(--muted); font-size:12px; line-height:1.55; text-align:right; } +.channel-grid { display:grid; grid-template-columns: repeat(4, minmax(240px,1fr)); gap:14px; } +.channel-card { + border:1px solid var(--line); border-radius:16px; padding:15px; + background:linear-gradient(180deg,rgba(17,31,44,.88),rgba(9,20,29,.88)); + box-shadow:0 14px 36px rgba(0,0,0,.14); transition:border-color .15s, transform .15s, opacity .15s; +} +.channel-card:hover { border-color:rgba(79,199,255,.22); transform:translateY(-1px); } +.channel-card[data-enabled="false"] { opacity:.55; } +.channel-head { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.channel-title { display:flex; align-items:center; gap:10px; } +.phase-dot { width:10px; height:10px; border-radius:50%; background:var(--phase-color); box-shadow:0 0 0 5px color-mix(in srgb, var(--phase-color) 14%, transparent); } +.channel-title strong { font-size:17px; } +.channel-title span { display:block; margin-top:2px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.1em; } +.enable-toggle { position:relative; width:34px; height:20px; } +.enable-toggle input { display:none; } +.enable-toggle span { position:absolute; inset:0; border-radius:999px; background:#243642; border:1px solid var(--line-strong); cursor:pointer; } +.enable-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:12px; height:12px; border-radius:50%; background:#748895; transition:.15s; } +.enable-toggle input:checked + span { background:rgba(61,220,151,.16); border-color:rgba(61,220,151,.28); } +.enable-toggle input:checked + span::after { left:17px; background:var(--green); } +.channel-fields { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:15px; } +.field { display:grid; gap:6px; } +.field label { color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.1em; } +.input-unit { position:relative; } +.input-unit input { + width:100%; height:42px; border-radius:10px; border:1px solid var(--line); outline:0; + background:rgba(3,10,15,.45); color:var(--text); padding:0 44px 0 11px; font-size:15px; font-weight:680; +} +.input-unit input:focus { border-color:rgba(79,199,255,.45); box-shadow:0 0 0 3px rgba(79,199,255,.07); } +.input-unit span { position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#71899a; font-size:10px; } +.phase-slider { margin-top:10px; } +.channel-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:12px; } +.wire-preview { color:#6f8798; font-size:10px; } +.channel-apply { min-height:32px; padding:0 10px; } + +.bottom-grid { margin-top:18px; display:grid; grid-template-columns:1.05fr .95fr; gap:18px; } +#phasorCanvas { width:100%; height:300px; display:block; margin-top:8px; border-radius:12px; background:rgba(3,10,15,.34); border:1px solid var(--line); } +#runtimeLog { + height:300px; overflow:auto; margin:8px 0 0; padding:14px; border:1px solid var(--line); border-radius:12px; + background:#050c12; color:#9ec3d6; font:11px/1.55 "Cascadia Mono", "SFMono-Regular", Consolas, monospace; white-space:pre-wrap; word-break:break-word; +} + +.toast { + position:fixed; right:24px; bottom:24px; z-index:40; min-width:240px; max-width:420px; + padding:12px 14px; border-radius:12px; border:1px solid rgba(79,199,255,.25); + background:rgba(12,27,39,.96); box-shadow:var(--shadow); color:#dff4ff; font-size:12px; + opacity:0; transform:translateY(10px); pointer-events:none; transition:.18s; +} +.toast.show { opacity:1; transform:translateY(0); } +.toast.error { border-color:rgba(255,107,118,.34); color:#ffd0d4; } + +@media (max-width: 1320px) { + .workspace { padding-left:20px; padding-right:20px; } + .channel-grid { grid-template-columns:repeat(2,1fr); } + .control-toolbar { grid-template-columns:1fr; } + .toolbar-actions { justify-content:flex-start; } +} From 424b88e43cb0370637addfef223782056a0f8e38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:11:06 +0700 Subject: [PATCH 031/129] feat: wire SMV GUI to serial live-control protocol --- apps/smv_injector_gui/app.js | 495 +++++++++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 apps/smv_injector_gui/app.js diff --git a/apps/smv_injector_gui/app.js b/apps/smv_injector_gui/app.js new file mode 100644 index 000000000..eeedfe5dd --- /dev/null +++ b/apps/smv_injector_gui/app.js @@ -0,0 +1,495 @@ +"use strict"; + +const CHANNELS = [ + { id: "IA", kind: "current", label: "Current A", magnitude: 1.0, phase: 0, color: "#ff5f6d" }, + { id: "IB", kind: "current", label: "Current B", magnitude: 1.0, phase: -120, color: "#f2bb4c" }, + { id: "IC", kind: "current", label: "Current C", magnitude: 1.0, phase: 120, color: "#42a5f5" }, + { id: "IN", kind: "current", label: "Current N", magnitude: 0.0, phase: 0, color: "#a88cff" }, + { id: "UA", kind: "voltage", label: "Voltage A", magnitude: 57.74, phase: 0, color: "#ff5f6d" }, + { id: "UB", kind: "voltage", label: "Voltage B", magnitude: 57.74, phase: -120, color: "#f2bb4c" }, + { id: "UC", kind: "voltage", label: "Voltage C", magnitude: 57.74, phase: 120, color: "#42a5f5" }, + { id: "UN", kind: "voltage", label: "Voltage N", magnitude: 0.0, phase: 0, color: "#a88cff" }, +]; + +const state = { + port: null, + reader: null, + readLoopActive: false, + connected: false, + running: false, + liveApply: true, + frequencyHz: 50, + channels: new Map(CHANNELS.map((channel) => [channel.id, { ...channel, enabled: true, quality: 0 }])), + sendChain: Promise.resolve(), +}; + +const els = {}; +let toastTimer = null; + +function $(id) { return document.getElementById(id); } + +function initElements() { + [ + "deviceStatus", "deviceStatusText", "connectButton", "runState", "runStateText", + "startButton", "stopButton", "fpsMetric", "missedMetric", "failMetric", + "generationMetric", "frequencyInput", "frequencySlider", "liveApplyToggle", + "balancedButton", "zeroButton", "applyAllButton", "channelGrid", "runtimeLog", + "clearLogButton", "phasorCanvas", "toast", "sclFile", "sclState" + ].forEach((id) => { els[id] = $(id); }); +} + +function wireCountFor(channel) { + if (channel.kind === "current") return Math.round(channel.magnitude * 1000); + return Math.round(channel.magnitude * 100); +} + +function phaseMdeg(channel) { return Math.round(channel.phase * 1000); } +function frequencyMhz() { return Math.round(state.frequencyHz * 1000); } + +function showToast(message, error = false) { + clearTimeout(toastTimer); + els.toast.textContent = message; + els.toast.classList.toggle("error", error); + els.toast.classList.add("show"); + toastTimer = setTimeout(() => els.toast.classList.remove("show"), 2400); +} + +function logLine(text, direction = "rx") { + const now = new Date(); + const stamp = now.toLocaleTimeString([], { hour12: false }); + const prefix = direction === "tx" ? "→" : direction === "ui" ? "•" : "←"; + if (els.runtimeLog.textContent === "Waiting for device connection…") els.runtimeLog.textContent = ""; + els.runtimeLog.textContent += `[${stamp}] ${prefix} ${text}\n`; + if (els.runtimeLog.textContent.length > 60000) { + els.runtimeLog.textContent = els.runtimeLog.textContent.slice(-48000); + } + els.runtimeLog.scrollTop = els.runtimeLog.scrollHeight; +} + +function setConnected(connected) { + state.connected = connected; + els.deviceStatus.dataset.state = connected ? "online" : "offline"; + els.deviceStatusText.textContent = connected ? "Device connected" : "Device offline"; + els.connectButton.textContent = connected ? "Disconnect" : "Connect device"; + els.startButton.disabled = !connected || state.running; + els.stopButton.disabled = !connected || !state.running; + els.zeroButton.disabled = !connected; + els.applyAllButton.disabled = !connected; + document.querySelectorAll(".channel-apply").forEach((button) => { button.disabled = !connected; }); +} + +function setRunning(running) { + state.running = running; + els.runState.dataset.state = running ? "running" : "stopped"; + els.runStateText.textContent = running ? "RUNNING" : "STOPPED"; + els.startButton.disabled = !state.connected || running; + els.stopButton.disabled = !state.connected || !running; +} + +function buildChannels() { + els.channelGrid.innerHTML = ""; + for (const source of CHANNELS) { + const channel = state.channels.get(source.id); + const unit = channel.kind === "current" ? "A RMS" : "V RMS"; + const magStep = channel.kind === "current" ? "0.001" : "0.01"; + const magMax = channel.kind === "current" ? "10000" : "1000000"; + + const card = document.createElement("article"); + card.className = "channel-card"; + card.dataset.channel = channel.id; + card.dataset.enabled = String(channel.enabled); + card.style.setProperty("--phase-color", channel.color); + card.innerHTML = ` +
+
+ +
${channel.id}${channel.label}
+
+ +
+
+
+ +
+ + ${unit} +
+
+
+ +
+ + deg +
+
+
+ + `; + + els.channelGrid.appendChild(card); + bindChannelCard(card, channel.id); + updateWirePreview(card, channel); + } +} + +function readCardValues(card, id) { + const channel = state.channels.get(id); + const magnitude = Number(card.querySelector(".magnitude-input").value); + const phase = Number(card.querySelector(".phase-input").value); + const enabled = card.querySelector(".channel-enable").checked; + if (!Number.isFinite(magnitude) || magnitude < 0 || !Number.isFinite(phase)) return false; + channel.magnitude = magnitude; + channel.phase = phase; + channel.enabled = enabled; + card.dataset.enabled = String(enabled); + updateWirePreview(card, channel); + drawPhasors(); + return true; +} + +function updateWirePreview(card, channel) { + card.querySelector(".wire-preview").textContent = `${wireCountFor(channel)} counts · ${phaseMdeg(channel)} mdeg`; +} + +function bindChannelCard(card, id) { + const magnitude = card.querySelector(".magnitude-input"); + const phase = card.querySelector(".phase-input"); + const slider = card.querySelector(".phase-slider"); + const enable = card.querySelector(".channel-enable"); + const apply = card.querySelector(".channel-apply"); + + const schedule = debounce(async () => { + if (!readCardValues(card, id)) return; + if (state.liveApply && state.connected) await applyChannel(id); + }, 130); + + magnitude.addEventListener("input", schedule); + phase.addEventListener("input", () => { + const value = Number(phase.value); + if (Number.isFinite(value) && value >= -180 && value <= 180) slider.value = String(value); + schedule(); + }); + slider.addEventListener("input", () => { + phase.value = Number(slider.value).toFixed(1).replace(/\.0$/, ""); + schedule(); + }); + enable.addEventListener("change", async () => { + if (!readCardValues(card, id)) return; + if (state.connected) await sendCommand(`ENABLE ${id} ${state.channels.get(id).enabled ? 1 : 0}`); + }); + apply.addEventListener("click", async () => { + if (!readCardValues(card, id)) return; + await applyChannel(id); + }); +} + +function debounce(fn, delay) { + let timer = null; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +async function sendCommand(command) { + if (!state.connected || !state.port?.writable) { + showToast("Connect the device first", true); + return false; + } + state.sendChain = state.sendChain.then(async () => { + const writer = state.port.writable.getWriter(); + try { + await writer.write(new TextEncoder().encode(`${command}\n`)); + logLine(command, "tx"); + } finally { + writer.releaseLock(); + } + }).catch((error) => { + logLine(`Serial write failed: ${error.message}`, "ui"); + showToast(`Serial write failed: ${error.message}`, true); + }); + await state.sendChain; + return true; +} + +async function applyChannel(id) { + const channel = state.channels.get(id); + return sendCommand(`SET ${id} ${wireCountFor(channel)} ${phaseMdeg(channel)} ${channel.quality >>> 0}`); +} + +async function applyAll() { + for (const source of CHANNELS) await applyChannel(source.id); + await sendCommand(`FREQ ${frequencyMhz()}`); + showToast("All live signal values applied"); +} + +async function connectSerial() { + if (!("serial" in navigator)) { + showToast("This browser does not expose the Web Serial API", true); + logLine("Web Serial API unavailable. Use a compatible browser on localhost.", "ui"); + return; + } + + try { + state.port = await navigator.serial.requestPort(); + await state.port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: "none", flowControl: "none" }); + setConnected(true); + logLine("Serial device connected at 115200 baud", "ui"); + startReadLoop(); + await sendCommand("SHOW"); + } catch (error) { + if (error.name !== "NotFoundError") { + showToast(`Connection failed: ${error.message}`, true); + logLine(`Connection failed: ${error.message}`, "ui"); + } + } +} + +async function disconnectSerial() { + try { + state.readLoopActive = false; + if (state.reader) { + try { await state.reader.cancel(); } catch (_) { /* no-op */ } + } + if (state.port) await state.port.close(); + } catch (error) { + logLine(`Disconnect warning: ${error.message}`, "ui"); + } finally { + state.reader = null; + state.port = null; + setConnected(false); + setRunning(false); + els.fpsMetric.textContent = "—"; + els.missedMetric.textContent = "—"; + els.failMetric.textContent = "—"; + showToast("Device disconnected"); + } +} + +async function startReadLoop() { + if (!state.port?.readable || state.readLoopActive) return; + state.readLoopActive = true; + const decoder = new TextDecoder(); + let pending = ""; + + while (state.readLoopActive && state.port?.readable) { + try { + state.reader = state.port.readable.getReader(); + while (state.readLoopActive) { + const { value, done } = await state.reader.read(); + if (done) break; + pending += decoder.decode(value, { stream: true }); + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ""; + for (const line of lines) processDeviceLine(line); + } + } catch (error) { + if (state.readLoopActive) logLine(`Serial read failed: ${error.message}`, "ui"); + } finally { + state.reader?.releaseLock(); + state.reader = null; + } + break; + } +} + +function processDeviceLine(raw) { + const line = raw.replace(/\x1b\[[0-9;]*m/g, "").trim(); + if (!line) return; + logLine(line, "rx"); + + if (line.includes("START accepted")) setRunning(true); + if (line.includes("STOP accepted")) setRunning(false); + + const stateMatch = line.match(/state=(RUNNING|STOPPED).*generation=(\d+).*signal_frequency=(\d+)\s*mHz/i); + if (stateMatch) { + setRunning(stateMatch[1].toUpperCase() === "RUNNING"); + els.generationMetric.textContent = stateMatch[2]; + const hz = Number(stateMatch[3]) / 1000; + if (Number.isFinite(hz)) setFrequencyUi(hz, false); + } + + const timing = line.match(/samples=(\d+)\s+\(~(\d+)\s+fps\).*?MC ok=(\d+) fail=(\d+).*?missed=(\d+).*?signal_gen=(\d+)/i); + if (timing) { + els.fpsMetric.textContent = `${timing[2]} fps`; + els.failMetric.textContent = timing[4]; + els.missedMetric.textContent = timing[5]; + els.generationMetric.textContent = timing[6]; + } + + const committed = line.match(/Live signal generation\s+(\d+)\s+committed/i); + if (committed) els.generationMetric.textContent = committed[1]; +} + +function setFrequencyUi(hz, transmit = true) { + state.frequencyHz = hz; + els.frequencyInput.value = hz.toFixed(3); + if (hz >= 45 && hz <= 65) els.frequencySlider.value = String(hz); + if (transmit && state.liveApply && state.connected) { + sendCommand(`FREQ ${frequencyMhz()}`); + } +} + +function setBalanced() { + const values = { + IA: [1.0, 0], IB: [1.0, -120], IC: [1.0, 120], IN: [0, 0], + UA: [57.74, 0], UB: [57.74, -120], UC: [57.74, 120], UN: [0, 0], + }; + for (const [id, [magnitude, phase]] of Object.entries(values)) { + const channel = state.channels.get(id); + channel.magnitude = magnitude; + channel.phase = phase; + channel.enabled = true; + } + buildChannels(); + drawPhasors(); + if (state.connected) applyAll(); +} + +async function zeroOutputs() { + for (const channel of state.channels.values()) channel.magnitude = 0; + buildChannels(); + drawPhasors(); + if (state.connected) await sendCommand("ZERO"); +} + +function drawPhasors() { + const canvas = els.phasorCanvas; + const ctx = canvas.getContext("2d"); + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.max(300, Math.floor(rect.width * dpr)); + const height = Math.max(220, Math.floor(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + ctx.clearRect(0, 0, width, height); + ctx.save(); + ctx.scale(dpr, dpr); + const w = width / dpr; + const h = height / dpr; + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(w, h) * 0.36; + + ctx.strokeStyle = "rgba(143,166,183,.16)"; + ctx.lineWidth = 1; + for (const factor of [0.33, 0.66, 1]) { + ctx.beginPath(); ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); ctx.stroke(); + } + ctx.beginPath(); ctx.moveTo(cx - radius - 20, cy); ctx.lineTo(cx + radius + 20, cy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx, cy - radius - 20); ctx.lineTo(cx, cy + radius + 20); ctx.stroke(); + + const groups = [ + ["IA", "IB", "IC"], + ["UA", "UB", "UC"], + ]; + groups.forEach((ids, groupIndex) => { + const maxMagnitude = Math.max(0.0001, ...ids.map((id) => state.channels.get(id).magnitude)); + ids.forEach((id) => { + const channel = state.channels.get(id); + if (!channel.enabled || channel.magnitude <= 0) return; + const angle = channel.phase * Math.PI / 180; + const visualScale = 0.38 + 0.62 * Math.min(1, channel.magnitude / maxMagnitude); + const length = radius * visualScale * (groupIndex === 0 ? 0.86 : 1); + const x = cx + Math.cos(angle) * length; + const y = cy - Math.sin(angle) * length; + ctx.strokeStyle = channel.color; + ctx.fillStyle = channel.color; + ctx.globalAlpha = groupIndex === 0 ? 0.75 : 1; + ctx.lineWidth = groupIndex === 0 ? 2 : 3; + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(x, y); ctx.stroke(); + const head = 8; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x - Math.cos(angle - 0.45) * head, y + Math.sin(angle - 0.45) * head); + ctx.lineTo(x - Math.cos(angle + 0.45) * head, y + Math.sin(angle + 0.45) * head); + ctx.closePath(); ctx.fill(); + ctx.font = "600 11px system-ui"; + ctx.fillText(id, x + 7, y - 6); + ctx.globalAlpha = 1; + }); + }); + + ctx.fillStyle = "rgba(238,246,251,.72)"; + ctx.font = "600 10px system-ui"; + ctx.fillText("0°", cx + radius + 24, cy + 3); + ctx.fillText("+90°", cx + 5, cy - radius - 18); + ctx.fillText("±180°", cx - radius - 52, cy + 3); + ctx.fillText("−90°", cx + 5, cy + radius + 22); + ctx.restore(); +} + +function bindUi() { + els.connectButton.addEventListener("click", () => state.connected ? disconnectSerial() : connectSerial()); + els.startButton.addEventListener("click", () => sendCommand("START")); + els.stopButton.addEventListener("click", () => sendCommand("STOP")); + els.zeroButton.addEventListener("click", zeroOutputs); + els.applyAllButton.addEventListener("click", applyAll); + els.balancedButton.addEventListener("click", setBalanced); + els.clearLogButton.addEventListener("click", () => { els.runtimeLog.textContent = ""; }); + + els.liveApplyToggle.addEventListener("change", () => { + state.liveApply = els.liveApplyToggle.checked; + showToast(state.liveApply ? "Live apply enabled" : "Live apply paused"); + }); + + const frequencyDebounced = debounce(() => { + const hz = Number(els.frequencyInput.value); + if (!Number.isFinite(hz) || hz < 1 || hz > 1000) return; + setFrequencyUi(hz, true); + }, 140); + els.frequencyInput.addEventListener("input", frequencyDebounced); + els.frequencySlider.addEventListener("input", () => { + const hz = Number(els.frequencySlider.value); + setFrequencyUi(hz, true); + }); + + els.sclFile.addEventListener("change", async () => { + const file = els.sclFile.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const parser = new DOMParser(); + const xml = parser.parseFromString(text, "application/xml"); + if (xml.querySelector("parsererror") || xml.documentElement.localName !== "SCL") { + throw new Error("File is not a well-formed SCL document"); + } + const streamCount = xml.getElementsByTagNameNS("*", "SampledValueControl").length; + els.sclState.textContent = `${file.name} · ${streamCount} SV stream${streamCount === 1 ? "" : "s"} detected`; + showToast("Engineering file loaded locally"); + } catch (error) { + els.sclState.textContent = "Engineering file rejected"; + showToast(error.message, true); + } + }); + + window.addEventListener("resize", debounce(drawPhasors, 80)); + window.addEventListener("beforeunload", () => { + if (state.port) disconnectSerial(); + }); +} + +function init() { + initElements(); + buildChannels(); + bindUi(); + setConnected(false); + setRunning(false); + setFrequencyUi(50, false); + requestAnimationFrame(drawPhasors); + + if (!("serial" in navigator)) { + els.deviceStatusText.textContent = "Web Serial unavailable"; + logLine("Open this localhost GUI in a browser that supports Web Serial.", "ui"); + } +} + +init(); From 44a22ece21e5569500740073567ee75f8084f3f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:11:23 +0700 Subject: [PATCH 032/129] feat: add one-click local GUI launcher --- apps/smv_injector_gui/run.cmd | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 apps/smv_injector_gui/run.cmd diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd new file mode 100644 index 000000000..c62e1dba6 --- /dev/null +++ b/apps/smv_injector_gui/run.cmd @@ -0,0 +1,21 @@ +@echo off +setlocal +set "APP_DIR=%~dp0" +set "PORT=8765" + +where python >nul 2>nul +if errorlevel 1 ( + echo Python was not found on PATH. + echo Run this launcher from the ESP-IDF shell or another shell with Python available. + pause + exit /b 1 +) + +echo ARStack61850 SMV Injector GUI +echo Local UI: http://127.0.0.1:%PORT%/ +echo. +echo IMPORTANT: close idf.py monitor first so the GUI can own the serial port. +echo Press Ctrl+C in this window to stop the local UI server. +echo. +start "" "http://127.0.0.1:%PORT%/" +python -m http.server %PORT% --bind 127.0.0.1 --directory "%APP_DIR%" From dd72e6253942009590db1def6a6d25b13dc76c0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:11:48 +0700 Subject: [PATCH 033/129] docs: document SMV injector GUI bench workflow --- apps/smv_injector_gui/README.md | 96 +++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/smv_injector_gui/README.md diff --git a/apps/smv_injector_gui/README.md b/apps/smv_injector_gui/README.md new file mode 100644 index 000000000..cf498575e --- /dev/null +++ b/apps/smv_injector_gui/README.md @@ -0,0 +1,96 @@ +# ARStack61850 SMV Injector GUI + +This is the first operator-facing control surface for the ESP32-P4 Sampled Values injector runtime. + +It intentionally replaces direct bench command entry with user-facing engineering controls while keeping the current firmware protocol unchanged underneath. + +## What works now + +- connect directly to the ESP32-P4 serial control port; +- START and STOP the deterministic publisher; +- edit Ia, Ib, Ic, In, Ua, Ub, Uc and Un while the stream is running; +- use engineering units instead of raw firmware units: + - current: A RMS -> current wire counts for the current development profile; + - voltage: V RMS -> voltage wire counts for the current development profile; + - phase: degrees -> milli-degrees; + - frequency: Hz -> millihertz; +- enable/disable channels; +- zero all outputs; +- restore a balanced three-phase bench state; +- live-apply values as the operator edits them, or pause live apply and use Apply/Apply all; +- display a local phasor preview; +- parse device telemetry for publisher rate, missed slots, failures and signal generation; +- load an SCL/CID/SCD/IID file locally and confirm that it is well-formed SCL plus count discovered `SampledValueControl` elements. + +The SCL file picker is deliberately not a second IEC 61850 semantic engine. Full engineering-file normalization and `SvPublisherProfile` compilation remain in the C++ host engine and will be connected to this GUI through the P3-A1 device-profile bridge. + +## Run + +1. Flash the current `feature/sv-live-control-runtime` compatible firmware (the GUI branch is based on that runtime). +2. Exit `idf.py monitor` with `Ctrl+]`. The serial port cannot be owned by the monitor and GUI at the same time. +3. From the repository root, run: + +```powershell +.\apps\smv_injector_gui\run.cmd +``` + +4. The launcher opens the local GUI and starts an HTTP server bound only to `127.0.0.1`. +5. Click **Connect device** and select the ESP32-P4 serial port. +6. Use **Start**, edit channel magnitude/phase/frequency, and observe the live stream. + +The browser must expose the Web Serial API. The GUI is served from localhost because device serial APIs require a secure local context. + +## Important unit boundary + +The firmware bench protocol still carries raw units. The GUI hides those details from the operator: + +```text +GUI current A RMS -> SET ... current_counts ... +GUI voltage V RMS -> SET ... voltage_counts ... +GUI phase deg -> SET ... phase_mdeg ... +GUI frequency Hz -> FREQ frequency_millihz +``` + +This means an operator can enter `-120` degrees and the GUI sends `-120000` milli-degrees. The earlier confusing direct-console behavior is no longer part of the normal operator workflow. + +The current A/V conversion is explicitly tied to the development profile that is still hard-coded in the firmware. When the compiled SCL profile bridge lands, the GUI must obtain engineering-unit scaling from the resolved profile rather than assume a universal scale. + +## Realtime rule + +The GUI is control plane only. It does not synthesize the 4 kHz waveform and does not become a timing dependency. + +```text +GUI / host + | + | control updates + v +ESP32-P4 live state bank + | + v +fixed-point signal engine + | + v +prebuilt SV packet template + | + v +realtime Ethernet publisher +``` + +Disconnecting or closing the GUI must not make the PC responsible for sample timing. The embedded publisher remains the realtime authority. + +## Next integration + +The next product step is to connect the C++ SCL profile compiler to this surface so the workflow becomes: + +```text +Import SCL/CID + -> select SV stream + -> validate normalized profile + -> deploy immutable stream identity/layout + -> edit live engineering values + -> Start + -> change values while running + -> Stop +``` + +Stream identity/layout changes remain a STOP -> validate -> arm operation. Magnitude, phase, frequency, quality and enabled state are live controls. From 07cd7fdafdbf2f407cd4a15c727e7835e2e0c921 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:12:16 +0700 Subject: [PATCH 034/129] ci: add SMV injector GUI smoke checks --- .github/workflows/smv-injector-gui.yml | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/smv-injector-gui.yml diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml new file mode 100644 index 000000000..f414f17ba --- /dev/null +++ b/.github/workflows/smv-injector-gui.yml @@ -0,0 +1,53 @@ +name: SMV Injector GUI + +on: + push: + paths: + - 'apps/smv_injector_gui/**' + - '.github/workflows/smv-injector-gui.yml' + pull_request: + paths: + - 'apps/smv_injector_gui/**' + - '.github/workflows/smv-injector-gui.yml' + +permissions: + contents: read + +jobs: + static-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: JavaScript syntax + run: node --check apps/smv_injector_gui/app.js + + - name: Required UI files + shell: bash + run: | + test -s apps/smv_injector_gui/index.html + test -s apps/smv_injector_gui/styles.css + test -s apps/smv_injector_gui/app.js + test -s apps/smv_injector_gui/run.cmd + grep -q 'SMV Injector' apps/smv_injector_gui/index.html + grep -q 'navigator.serial' apps/smv_injector_gui/app.js + + - name: Local-server smoke + shell: bash + run: | + python3 -m http.server 8765 --bind 127.0.0.1 --directory apps/smv_injector_gui >/tmp/smv-gui-http.log 2>&1 & + server_pid=$! + trap 'kill ${server_pid} 2>/dev/null || true' EXIT + for attempt in $(seq 1 20); do + if curl --fail --silent http://127.0.0.1:8765/ >/tmp/smv-gui-index.html; then + break + fi + sleep 0.25 + done + grep -q 'ARStack61850' /tmp/smv-gui-index.html From a9e4470e036acc795f99337a6361165f9cd351a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:16:52 +0700 Subject: [PATCH 035/129] fix: serve SMV GUI from launcher directory --- apps/smv_injector_gui/run.cmd | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index c62e1dba6..0b8f3b250 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -1,6 +1,5 @@ @echo off setlocal -set "APP_DIR=%~dp0" set "PORT=8765" where python >nul 2>nul @@ -11,11 +10,34 @@ if errorlevel 1 ( exit /b 1 ) +if not exist "%~dp0index.html" ( + echo ERROR: GUI index.html was not found next to run.cmd. + echo Expected: %~dp0index.html + pause + exit /b 1 +) + +rem Serve from the launcher directory itself. Do not pass %%~dp0 to +rem http.server --directory: %%~dp0 ends in a backslash on Windows and can be +rem mis-parsed by the child process command-line quoting, causing HTTP 404s. +pushd "%~dp0" +if errorlevel 1 ( + echo ERROR: could not enter GUI directory: %~dp0 + pause + exit /b 1 +) + echo ARStack61850 SMV Injector GUI echo Local UI: http://127.0.0.1:%PORT%/ +echo Serving from: %CD% echo. echo IMPORTANT: close idf.py monitor first so the GUI can own the serial port. echo Press Ctrl+C in this window to stop the local UI server. echo. + start "" "http://127.0.0.1:%PORT%/" -python -m http.server %PORT% --bind 127.0.0.1 --directory "%APP_DIR%" +python -m http.server %PORT% --bind 127.0.0.1 +set "SERVER_EXIT=%ERRORLEVEL%" + +popd +exit /b %SERVER_EXIT% From 040829cfecf995ff2a855d2621ccbae39649cc45 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:35:01 +0700 Subject: [PATCH 036/129] fix(gui): make injector workspace responsive --- apps/smv_injector_gui/responsive.css | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/smv_injector_gui/responsive.css diff --git a/apps/smv_injector_gui/responsive.css b/apps/smv_injector_gui/responsive.css new file mode 100644 index 000000000..20758e88a --- /dev/null +++ b/apps/smv_injector_gui/responsive.css @@ -0,0 +1,42 @@ +/* Responsive operator layout. Keep all primary controls visible on laptops and split-screen benches. */ +html { min-width: 0 !important; } +body { overflow-x: hidden; } +.app-shell { min-width: 0; } +.workspace { width: 100%; min-width: 0; } +.hero-grid, .control-toolbar, .bottom-grid, .channel-grid { min-width: 0; } + +@media (max-width: 1180px) { + .topbar { padding: 14px 18px; } + .workspace { padding: 18px 18px 32px; } + .hero-grid { grid-template-columns: 1fr; } + .run-panel { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; column-gap: 18px; } + .run-metrics { grid-column: 1 / -1; grid-row: 2; margin: 14px 0 0; grid-template-columns: repeat(4, minmax(0, 1fr)); } + .run-actions { align-self: center; } + .run-actions .btn { min-width: 120px; } + .control-toolbar { grid-template-columns: 1fr; } + .frequency-control { grid-template-columns: 150px minmax(0, 1fr); } + .toolbar-actions { justify-content: flex-start; } + .channel-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .bottom-grid { grid-template-columns: 1fr; } + .section-heading { align-items: flex-start; } + .section-heading p { text-align: left; max-width: 520px; } +} + +@media (max-width: 760px) { + .topbar { position: static; align-items: flex-start; gap: 12px; flex-wrap: wrap; } + .connection-strip { width: 100%; justify-content: space-between; } + .workspace { padding: 14px 12px 28px; } + .stream-panel, .run-panel, .phase-panel, .console-panel { padding: 16px; } + .panel-header { align-items: flex-start; } + .identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .profile-actions { align-items: flex-start; flex-direction: column; } + .file-state { max-width: 100%; white-space: normal; } + .run-panel { display: block; } + .run-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .run-actions { margin-top: 14px; } + .frequency-control { grid-template-columns: 1fr; } + .channel-grid { grid-template-columns: 1fr; } + .section-heading { display: block; } + .section-heading p { margin-top: 8px; } + .channel-fields { grid-template-columns: 1fr 1fr; } +} From 2835bfd0c10e737c532755afe47276257dacea5e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:35:28 +0700 Subject: [PATCH 037/129] fix(gui): keep connection controls visible on narrow windows --- apps/smv_injector_gui/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index a53c1c79b..9754aa900 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -6,6 +6,7 @@ ARStack61850 · SMV Injector +
From 263fc4ebc797deaae3188900525d6c91a95bfa14 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:52:20 +0700 Subject: [PATCH 038/129] feat: converge GUI with SCL-driven SV profile engine --- docs/SV_INTEROPERABILITY_ROADMAP.md | 226 ++++++++++++++ .../sampled_values/publisher_profile.hpp | 287 ++++++++++++++++++ .../sampled_values/rational_schedule.hpp | 71 +++++ include/ariec61850/scl/model.hpp | 16 + src/scl/parser_part_02.inc | 151 +++++++++ src/scl/parser_part_04.inc | 108 ++++++- src/scl/parser_part_05_02.inc | 13 +- src/scl/parser_part_05_04.inc | 14 +- .../fixtures/scl/sv-4800-structured-4i4v.scd | 71 +++++ tests/test_scl.cpp | 97 ++++++ 10 files changed, 1040 insertions(+), 14 deletions(-) create mode 100644 docs/SV_INTEROPERABILITY_ROADMAP.md create mode 100644 include/ariec61850/sampled_values/publisher_profile.hpp create mode 100644 include/ariec61850/sampled_values/rational_schedule.hpp create mode 100644 tests/fixtures/scl/sv-4800-structured-4i4v.scd diff --git a/docs/SV_INTEROPERABILITY_ROADMAP.md b/docs/SV_INTEROPERABILITY_ROADMAP.md new file mode 100644 index 000000000..9d8220261 --- /dev/null +++ b/docs/SV_INTEROPERABILITY_ROADMAP.md @@ -0,0 +1,226 @@ +# Sampled Values Interoperability Roadmap + +ARStack61850 should simulate and analyze Sampled Values streams from standards-described merging-unit configurations **without vendor-specific code paths**. + +The interoperability target is not a list of brands. The target is a growing, testable set of IEC 61850 / instrument-transformer profile semantics that can be compiled from engineering intent and verified against observed wire behavior. + +## North-star behavior + +```text +SCL / CID / SCD / IID optional lab capture + | | + v v +secure SCL parser observed wire fingerprint + | | + v | +DataSet + type resolver | + | | + +-- explicit FCDA leaf | + | | + +-- structured FCDA -> ordered leaf | + | | + +------------------+-------------------+ + v + profile/evidence resolver + | + v + SvPublisherProfile compiler + | + +-- L2 address / VLAN / APPID + +-- svID / confRev / nofASDU + +-- sample-rate and sample-mode semantics + +-- SmvOpts field-presence policy + +-- ordered wire leaf layout + +-- validated sample-counter policy + +-- synchronization policy inputs + | + v + versioned binary device profile + | + v + RTOS deterministic publisher + | + v + hardware TX timestamp evidence +``` + +XML parsing, string resolution, type-template traversal and capture fingerprinting belong on the host. The embedded realtime hot path receives an already validated, immutable profile and prebuilt packet layout. + +## Three truth domains + +ARStack must never collapse these into one source of truth: + +1. **Configured intent** — what SCL/CID/SCD/IID says should exist. +2. **Observed wire facts** — what an independent capture actually contains: field presence, identity, counter progression, cadence and other measurable behavior. +3. **Profile/standard rules** — semantics required by the applicable standards/profile family. + +A conflict is diagnostic evidence, not permission to silently overwrite one domain with another. The engineer or an explicit profile rule decides which behavior is deployed. + +This separation is essential for broad interoperability because an engineering file may not encode every runtime behavior, and a capture may reflect a different configuration revision than the available engineering file. + +## Tolerance contract: forgiving representation, strict semantics + +Real vendor SCL/CID files often carry different namespace prefixes, `Private` blocks, foreign-namespace metadata, lexical formatting and tool-specific ordering around the same IEC 61850 engineering intent. ARStack should understand those differences without weakening the semantic gates that protect deterministic publishing. + +The compatibility engine therefore follows this rule: + +```text +malformed / unsafe XML -> REJECT +well-formed SCL + unfamiliar metadata -> TOLERATE + QUARANTINE/DIAGNOSE +known semantic equivalent -> NORMALIZE + PROVENANCE +unknown mandatory semantic -> MISSING_CONTEXT / UNSUPPORTED +fully resolved runtime profile -> CLASS A / DEPLOYABLE +``` + +Tolerance applies only where equivalence can be established safely. The engine may normalize namespace prefixes, standard lexical equivalents, harmless whitespace, equivalent L2 text representations and known edition/profile differences. It must not make XML element names arbitrarily case-insensitive, invent missing references, infer unknown payload layouts or promote synchronization state merely to make a file parse. + +Vendor extensions are quarantined from the canonical model unless an explicit, validated adapter maps them to a known semantic. Unknown extension data is bounded, non-executable metadata with path/namespace/type provenance; it cannot silently override core IEC 61850 values. + +Each normalized field should eventually carry explainable provenance such as: + +- `exact_scl`; +- `standard_equivalent`; +- `tolerated_lexical`; +- `profile_rule`; +- `vendor_extension`; +- `observed_wire`; +- `user_confirmed`; +- `unresolved`. + +The practical product rule is: **parse broadly, explain normalization, deploy narrowly**. A CID may be accepted for inspection even when some semantics remain unresolved; the same profile is not device-deployable until every mandatory runtime field has passed the Class-A gates. + +## Interoperability rules + +1. **SCL is configured intent.** Do not replace SCL values with convenient defaults when the configuration provides an explicit value. +2. **Resolved type order is wire order.** FCDA order is preserved. When an FCDA points at a structured data object without `daName`, ARStack expands the matching functional-constraint leaves in `DataTypeTemplates` order. +3. **Unsupported means rejected, not guessed.** Unknown basic types, ambiguous DataSet bindings, incomplete address bindings and unresolved structures must produce explicit diagnostics. +4. **Optional fields are profile data.** `SmvOpts` is preserved so canonical ASDU field presence is not inferred from what a particular analyzer prefers. +5. **Timing is rational, not truncated.** A requested rate such as 4800 samples/s must not become a fixed 208 us period. The scheduler distributes integer timer ticks so long-term rate has no truncation drift. +6. **Counter policy is independently validated.** `SmpPerSec` can establish publisher rate, but it must not be treated as universal proof of the `smpCnt` wrap rule. A rate-sized modulus may be shown as a candidate for inspection; device deployment requires an applicable profile rule or observed-evidence confirmation. +7. **Synchronization is evidence-driven.** Configuration and packet shape cannot promote `smpSynch`; measured clock state controls synchronization claims. +8. **Diagnostic traffic is separate.** Bench mirrors/probes must never change canonical stream identity or semantics. +9. **Regression evidence is vendor-neutral.** Real lab configurations may inform anonymous fixtures, but proprietary configuration files, commercial names and product comparisons are not committed. +10. **Tolerance is bounded.** Representation quirks can be normalized when equivalence is known; unresolved mandatory semantics remain non-deployable. + +## Compatibility classes + +### Class A — deterministic publishable + +A stream can be compiled completely: + +- destination MAC and APPID resolved; +- VLAN binding either complete or absent; +- svID resolved; +- sample rate/mode understood; +- nofASDU understood; +- DataSet binding resolved; +- every transmitted leaf has a deterministic supported wire width; +- counter policy is validated, not merely guessed from rate; +- optional-field policy is known; +- any configured-vs-observed conflict required for deployment is resolved explicitly. + +Class A profiles can proceed to the device compiler/runtime. + +### Class B — valid configuration needing runtime/profile context + +The stream is structurally understood but deployment needs additional context. Examples include: + +- samples-per-period mode without nominal system frequency; +- an unresolved sample-counter wrap policy; +- a capture-derived runtime behavior that conflicts with an older engineering file; +- a profile family whose optional-field semantics require an explicit selection. + +Class B is not guessed. The host UI/CLI must request, resolve or validate the missing context explicitly before deployment. + +### Class C — unsupported or ambiguous + +Any unresolved type/layout, ambiguous binding, unknown mandatory profile rule or inconsistent configuration is rejected with a diagnostic. Class C streams are interoperability backlog items, not silent fallbacks. + +## P3-A0 — normalized host profile foundation + +Current goals: + +- preserve `SmvOpts` in the SCL model; +- expand structured FCDA definitions to ordered leaf entries; +- compile `SclSampledValuesStream` into `SvPublisherProfile`; +- derive publisher rate from supported sampling semantics without hard-coding 4000; +- keep sample-counter policy explicit and independently validated; +- provide drift-free rational timer scheduling; +- inspect arbitrary SCL/CID/SCD/IID files with a read-only CLI; +- maintain anonymous fixtures for 4000 and 4800 samples/s families; +- add bounded SCL dialect normalization and vendor-extension quarantine tracked by #40. + +The initial fixed-width compiler intentionally supports only leaf types whose wire size is explicit in ARStack. New types should be added from standards/profile evidence with regression tests rather than broad assumptions. + +## P3-A1 — compiled device profile + +Next: + +- define a versioned binary schema; +- include schema version, total length and integrity check; +- serialize only resolved runtime values, never XML; +- require a validated counter policy before a profile is device-deployable; +- validate on host and device; +- activate profiles atomically; +- prebuild packet templates and patch tables before realtime start; +- move destination MAC, APPID, VLAN, svID, confRev, nofASDU, sample rate, validated counter modulus and dataset layout out of embedded hard-coded constants; +- integrate `RationalTickSchedule` with the ESP32-P4 timer path for non-integer-microsecond rates. + +## P3-A2 — observed fingerprint + interoperability matrix + +Add a read-only capture fingerprint layer that can measure, per stream: + +- destination/source MAC and VLAN visibility; +- APPID and ASDU field presence; +- svID / confRev / nofASDU; +- sample-counter progression, wrap candidates, duplicates and gaps; +- observed frame cadence and rate distribution; +- payload length/layout consistency; +- synchronization values as **observed fields**, not synchronization proof. + +The fingerprint augments SCL; it never silently replaces SCL. Conflicts are surfaced as configured-vs-observed diagnostics. + +Grow coverage by anonymous standards/profile families: + +| Capability | Minimum regression evidence | +|---|---| +| 4000 samples/s | exact 250 us nominal schedule + validated counter policy | +| 4800 samples/s | drift-free rational schedule + validated counter policy | +| Explicit FCDA leaves | ordered primitive + quality layout | +| Structured FCDA | recursive type expansion filtered by FC | +| Optional ASDU fields | each supported `SmvOpts` combination encoded intentionally | +| Address variants | APPID, multicast MAC, tagged/untagged VLAN handling | +| Multiple streams | independent identity, counter and timing state | +| nofASDU variants | deterministic frame construction and counter semantics | +| Additional fixed leaf types | standard-derived width + golden-wire tests | +| Config vs capture conflict | explicit diagnostic, no silent override | +| Vendor metadata / dialect variance | equivalent canonical profile + normalization provenance | + +A profile family enters the supported matrix only when parser/profile tests, encoded-wire tests and physical interoperability evidence agree. + +## Relation to hardware timing work + +Profile correctness and timing correctness are separate gates that converge at the embedded publisher: + +```text +configured/profile truth timing truth +SCL -> SvPublisherProfile timer deadline + | | + +-----------+------------------+ + v + deterministic TX + | + v + EMAC HW timestamp + | + v + evidence report +``` + +A stream can be byte-correct but badly timed, or well timed but configured incorrectly. ARStack must be able to distinguish those failure modes. + +## Definition of success + +The injector should eventually let an engineer select a standards-described SV stream, validate it, optionally compare it with a lab capture fingerprint, bind signal values/scenarios, deploy it to dedicated hardware, and receive deterministic timing evidence without manually editing packet bytes or creating device-specific firmware builds. + +The analyzer should consume the same normalized profile/evidence model for expected-vs-observed checks, so injector and analyzer share one interpretation of stream identity, dataset order, validated counter policy, optional fields and timing expectations. \ No newline at end of file diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp new file mode 100644 index 000000000..f95597d6b --- /dev/null +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class SvSampleMode : std::uint8_t { + unknown, + samples_per_period, + samples_per_second, +}; + +enum class SvSampleCounterPolicy : std::uint8_t { + unresolved, + candidate_sample_rate_modulus, + explicit_modulus, +}; + +struct SvAsduOptions final { + bool element_present{}; + bool refresh_time{}; + bool sample_synchronized{}; + bool sample_rate{}; + bool data_set{}; + bool security{}; + bool synch_source_id{}; + + friend bool operator==(const SvAsduOptions&, const SvAsduOptions&) = default; +}; + +struct SvPublisherChannel final { + std::size_t index{}; + std::string signal_reference; + std::string cdc; + std::string basic_type; + bool is_quality{}; + bool is_timestamp{}; + std::uint16_t wire_width_bytes{}; + + friend bool operator==(const SvPublisherChannel&, const SvPublisherChannel&) = default; +}; + +struct SvPublisherProfileCompileContext final { + // SCL describes sampling semantics but does not universally establish + // every runtime sample-counter wrap policy. A standards/profile rule or + // independently observed evidence may validate a modulus explicitly. + std::optional sample_counter_modulus; +}; + +struct SvPublisherProfile final { + std::uint32_t schema_version{1U}; + std::string control_block_reference; + std::string sv_id; + std::string data_set_reference; + + std::array destination_mac{}; + std::uint16_t app_id{}; + bool vlan_present{}; + std::uint16_t vlan_id{}; + std::uint8_t vlan_priority{}; + + std::uint32_t configuration_revision{}; + std::uint32_t sample_rate_value{}; + SvSampleMode sample_mode{SvSampleMode::unknown}; + std::optional publisher_rate_hz; + std::uint16_t no_asdu{1U}; + SvSampleCounterPolicy sample_counter_policy{SvSampleCounterPolicy::unresolved}; + std::optional sample_counter_modulus; + SvAsduOptions asdu_options; + + std::vector channels; + std::size_t payload_size_bytes{}; + + friend bool operator==(const SvPublisherProfile&, const SvPublisherProfile&) = default; +}; + +struct SvPublisherProfileCompileResult final { + std::optional profile; + std::vector errors; + std::vector warnings; + + [[nodiscard]] bool ok() const noexcept { + return profile.has_value() && errors.empty(); + } +}; + +class SvPublisherProfileCompiler final { +public: + [[nodiscard]] static SvPublisherProfileCompileResult compile( + const scl::SclSampledValuesStream& stream, + const SvPublisherProfileCompileContext& context = {}) { + SvPublisherProfileCompileResult result; + SvPublisherProfile profile; + + profile.control_block_reference = stream.control_block_reference; + profile.sv_id = stream.sv_id.empty() ? stream.smv_id : stream.sv_id; + profile.data_set_reference = stream.data_set_reference; + profile.configuration_revision = stream.configuration_revision; + profile.sample_rate_value = stream.sample_rate; + profile.sample_mode = parse_sample_mode(stream.sample_mode); + profile.no_asdu = stream.no_asdu; + profile.asdu_options = compile_options(stream.smv_options); + + if (!stream.address.destination_mac.has_value()) { + result.errors.push_back("SV stream has no valid destination MAC address."); + } else { + profile.destination_mac = *stream.address.destination_mac; + } + if (!stream.address.app_id.has_value()) { + result.errors.push_back("SV stream has no valid APPID."); + } else { + profile.app_id = *stream.address.app_id; + } + + const bool has_vlan_id = stream.address.vlan_id.has_value(); + const bool has_vlan_priority = stream.address.vlan_priority.has_value(); + if (has_vlan_id != has_vlan_priority) { + result.errors.push_back( + "SV stream has an incomplete VLAN binding; VLAN ID and priority must either both be present or both be absent."); + } else if (has_vlan_id) { + profile.vlan_present = true; + profile.vlan_id = *stream.address.vlan_id; + profile.vlan_priority = *stream.address.vlan_priority; + if (profile.vlan_id > 4095U) { + result.errors.push_back("SV VLAN ID exceeds the 12-bit Ethernet VLAN range."); + } + if (profile.vlan_priority > 7U) { + result.errors.push_back("SV VLAN priority exceeds the 3-bit Ethernet PCP range."); + } + } + + if (profile.sv_id.empty()) { + result.errors.push_back("SV stream has no svID/smvID."); + } + if (profile.no_asdu == 0U) { + result.errors.push_back("SV nofASDU must be greater than zero."); + } + if (profile.sample_rate_value == 0U) { + result.errors.push_back("SV sample rate is zero or missing."); + } + + if (profile.sample_mode == SvSampleMode::samples_per_second) { + profile.publisher_rate_hz = profile.sample_rate_value; + } else if (profile.sample_mode == SvSampleMode::samples_per_period) { + result.warnings.push_back( + "SmpPerPeriod requires a nominal-system-frequency input before an absolute publisher rate can be scheduled."); + } else { + result.errors.push_back("SV sample mode is missing or unsupported."); + } + + if (context.sample_counter_modulus.has_value()) { + if (*context.sample_counter_modulus == 0U) { + result.errors.push_back("SV sample-counter modulus must be greater than zero."); + } else { + profile.sample_counter_policy = SvSampleCounterPolicy::explicit_modulus; + profile.sample_counter_modulus = context.sample_counter_modulus; + } + } else if ( + profile.sample_mode == SvSampleMode::samples_per_second && + profile.sample_rate_value > 0U && + profile.sample_rate_value <= std::numeric_limits::max()) { + // Several second-aligned interoperability families use a one-second + // sample-count cycle. Preserve that useful candidate for inspection, + // but mark it non-authoritative: device deployment must validate the + // counter policy from a profile rule or observed evidence first. + profile.sample_counter_policy = + SvSampleCounterPolicy::candidate_sample_rate_modulus; + profile.sample_counter_modulus = + static_cast(profile.sample_rate_value); + result.warnings.push_back( + "SV sample-counter modulus equals the SmpPerSec rate only as an unvalidated candidate; confirm it from the applicable profile rule or observed evidence before deployment."); + } else { + result.warnings.push_back( + "SV sample-counter wrap policy is unresolved; supply a validated profile rule or observed-evidence modulus before deployment."); + } + + if (stream.entries.empty()) { + result.errors.push_back("SV DataSet is empty or unresolved."); + } + + std::size_t payload_size{}; + for (const auto& entry : stream.entries) { + const auto width = wire_width(entry); + if (!width.has_value()) { + result.errors.push_back( + "Unsupported or unresolved SV leaf type for " + entry.signal_reference + + ": '" + entry.basic_type + "'."); + continue; + } + if (payload_size > std::numeric_limits::max() - *width) { + result.errors.push_back("SV payload size overflow."); + break; + } + payload_size += *width; + profile.channels.push_back({ + entry.index, + entry.signal_reference, + entry.cdc, + entry.basic_type, + entry.is_quality, + entry.is_timestamp, + *width, + }); + } + profile.payload_size_bytes = payload_size; + + if (result.errors.empty()) { + result.profile = std::move(profile); + } + return result; + } + +private: + [[nodiscard]] static std::string 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]] static SvSampleMode parse_sample_mode(const std::string_view text) { + const auto normalized = lower_copy(text); + if (normalized == "smppersec") { + return SvSampleMode::samples_per_second; + } + if (normalized == "smpperperiod") { + return SvSampleMode::samples_per_period; + } + return SvSampleMode::unknown; + } + + [[nodiscard]] static std::optional wire_width( + const scl::SclDataSetEntry& entry) { + if (entry.is_quality || lower_copy(entry.basic_type) == "quality") { + return static_cast(4U); + } + if (entry.is_timestamp || lower_copy(entry.basic_type) == "timestamp") { + return static_cast(8U); + } + + const auto type = lower_copy(entry.basic_type); + if (type == "boolean" || type == "int8" || type == "int8u") { + return static_cast(1U); + } + if (type == "int16" || type == "int16u") { + return static_cast(2U); + } + if (type == "int32" || type == "int32u" || type == "float32") { + return static_cast(4U); + } + if (type == "int64" || type == "int64u" || type == "float64") { + return static_cast(8U); + } + return std::nullopt; + } + + [[nodiscard]] static SvAsduOptions compile_options( + const scl::SclSmvOptions& options) { + return { + options.element_present, + options.refresh_time, + options.sample_synchronized, + options.sample_rate, + options.data_set, + options.security, + options.synch_source_id, + }; + } +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/rational_schedule.hpp b/include/ariec61850/sampled_values/rational_schedule.hpp new file mode 100644 index 000000000..aa31610cc --- /dev/null +++ b/include/ariec61850/sampled_values/rational_schedule.hpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace ar::iec61850::sampled_values { + +class RationalTickSchedule final { +public: + RationalTickSchedule( + const std::uint64_t timer_frequency_hz, + const std::uint32_t event_rate_hz) + : timer_frequency_hz_(timer_frequency_hz), + event_rate_hz_(event_rate_hz) { + if (timer_frequency_hz_ == 0U || event_rate_hz_ == 0U) { + throw std::invalid_argument( + "Timer frequency and event rate must both be non-zero."); + } + if (timer_frequency_hz_ < event_rate_hz_) { + throw std::invalid_argument( + "Timer frequency must be at least as high as the event rate."); + } + + base_interval_ticks_ = timer_frequency_hz_ / event_rate_hz_; + remainder_ticks_numerator_ = static_cast( + timer_frequency_hz_ % event_rate_hz_); + } + + [[nodiscard]] std::uint64_t next_interval_ticks() noexcept { + auto interval = base_interval_ticks_; + const auto accumulated = + static_cast(remainder_accumulator_) + + remainder_ticks_numerator_; + if (accumulated >= event_rate_hz_) { + ++interval; + remainder_accumulator_ = static_cast( + accumulated - event_rate_hz_); + } else { + remainder_accumulator_ = static_cast(accumulated); + } + return interval; + } + + void reset() noexcept { + remainder_accumulator_ = 0U; + } + + [[nodiscard]] std::uint64_t timer_frequency_hz() const noexcept { + return timer_frequency_hz_; + } + [[nodiscard]] std::uint32_t event_rate_hz() const noexcept { + return event_rate_hz_; + } + [[nodiscard]] std::uint64_t base_interval_ticks() const noexcept { + return base_interval_ticks_; + } + [[nodiscard]] std::uint32_t remainder_ticks_numerator() const noexcept { + return remainder_ticks_numerator_; + } + +private: + std::uint64_t timer_frequency_hz_{}; + std::uint32_t event_rate_hz_{}; + std::uint64_t base_interval_ticks_{}; + std::uint32_t remainder_ticks_numerator_{}; + std::uint32_t remainder_accumulator_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/scl/model.hpp b/include/ariec61850/scl/model.hpp index 5b7d3a8ce..b1ecdd523 100644 --- a/include/ariec61850/scl/model.hpp +++ b/include/ariec61850/scl/model.hpp @@ -94,12 +94,28 @@ struct SclGooseStream final : SclProcessBusStream { friend bool operator==(const SclGooseStream&, const SclGooseStream&) = default; }; +struct SclSmvOptions final { + // Preserve both element presence and the normalized boolean attributes. + // This lets later profile compilation distinguish an omitted SmvOpts + // element from an explicitly present element whose options are all false. + bool element_present{}; + bool refresh_time{}; + bool sample_synchronized{}; + bool sample_rate{}; + bool data_set{}; + bool security{}; + bool synch_source_id{}; + + friend bool operator==(const SclSmvOptions&, const SclSmvOptions&) = default; +}; + struct SclSampledValuesStream final : SclProcessBusStream { std::string sv_id; std::string smv_id; std::uint16_t sample_rate{}; std::string sample_mode; std::uint16_t no_asdu{1U}; + SclSmvOptions smv_options; friend bool operator==(const SclSampledValuesStream&, const SclSampledValuesStream&) = default; }; diff --git a/src/scl/parser_part_02.inc b/src/scl/parser_part_02.inc index a334881ef..7c3e3875a 100644 --- a/src/scl/parser_part_02.inc +++ b/src/scl/parser_part_02.inc @@ -203,6 +203,11 @@ public: std::string enum_type; }; + struct ExpandedAttribute final { + std::string da_name; + Resolution resolution; + }; + explicit SclTypeIndex(const XmlNode& root) { for (const auto* ied : direct_children(root, "IED")) { add_first(ieds_, attribute(ied, "name"), ied); @@ -223,3 +228,149 @@ public: } } } + + [[nodiscard]] std::vector expand_data_object( + const std::string& ied_name, + const std::string& ld_inst, + const std::string& prefix, + const std::string& ln_class, + const std::string& ln_inst, + const std::string& do_name, + const std::string& functional_constraint) const { + std::vector expanded; + const auto* logical_node = find_logical_node( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst); + if (logical_node == nullptr) { + return expanded; + } + + const auto* logical_node_type = find( + logical_node_types_, attribute(logical_node, "lnType")); + const auto do_segments = split_name(do_name); + if (logical_node_type == nullptr || do_segments.empty()) { + return expanded; + } + + const XmlNode* data_object{}; + for (const auto* candidate : direct_children(*logical_node_type, "DO")) { + if (same(attribute(candidate, "name"), do_segments.front())) { + data_object = candidate; + break; + } + } + if (data_object == nullptr) { + return expanded; + } + + auto current_type_id = attribute(data_object, "type"); + const XmlNode* data_object_type = find(data_object_types_, current_type_id); + if (data_object_type == nullptr) { + return expanded; + } + + for (std::size_t index = 1U; index < do_segments.size(); ++index) { + const XmlNode* structured_data_object{}; + for (const auto* candidate : direct_children(*data_object_type, "SDO")) { + if (same(attribute(candidate, "name"), do_segments[index])) { + structured_data_object = candidate; + break; + } + } + if (structured_data_object == nullptr) { + return {}; + } + current_type_id = attribute(structured_data_object, "type"); + data_object_type = find(data_object_types_, current_type_id); + if (data_object_type == nullptr) { + return {}; + } + } + + const auto cdc = attribute(data_object_type, "cdc"); + const auto append_leaf = [&expanded, &cdc]( + const std::string& name, + const std::string& fc, + const XmlNode& node, + const bool resolved) { + const auto basic_type = attribute(&node, "bType"); + const auto type_id = attribute(&node, "type"); + std::string enum_type; + if (same(basic_type, "Enum")) { + enum_type = type_id; + } + expanded.push_back({ + name, + {resolved, fc, cdc, basic_type, type_id, enum_type}, + }); + }; + + const auto append_bdas = [this, &expanded, &cdc]( + auto&& self, + const XmlNode& container, + const std::string& path, + const std::string& fc) -> void { + for (const auto* bda : direct_children(container, "BDA")) { + const auto name = attribute(bda, "name"); + if (name.empty()) { + continue; + } + const auto child_path = path.empty() ? name : path + "." + name; + const auto basic_type = attribute(bda, "bType"); + const auto type_id = attribute(bda, "type"); + if (same(basic_type, "Struct")) { + const auto* nested = find(data_attribute_types_, type_id); + if (nested == nullptr) { + expanded.push_back({ + child_path, + {false, fc, cdc, basic_type, type_id, {}}, + }); + } else { + self(self, *nested, child_path, fc); + } + continue; + } + std::string enum_type; + if (same(basic_type, "Enum") && + enum_type_ids_.contains(normalized_key(type_id))) { + enum_type = type_id; + } + expanded.push_back({ + child_path, + {true, fc, cdc, basic_type, type_id, enum_type}, + }); + } + }; + + for (const auto* data_attribute : direct_children(*data_object_type, "DA")) { + const auto attribute_fc = attribute(data_attribute, "fc"); + if (!functional_constraint.empty() && + !same(attribute_fc, functional_constraint)) { + continue; + } + const auto resolved_fc = functional_constraint.empty() + ? attribute_fc + : functional_constraint; + const auto name = attribute(data_attribute, "name"); + if (name.empty()) { + continue; + } + const auto basic_type = attribute(data_attribute, "bType"); + if (same(basic_type, "Struct")) { + const auto type_id = attribute(data_attribute, "type"); + const auto* attribute_type = find(data_attribute_types_, type_id); + if (attribute_type == nullptr) { + append_leaf(name, resolved_fc, *data_attribute, false); + } else { + append_bdas(append_bdas, *attribute_type, name, resolved_fc); + } + continue; + } + append_leaf(name, resolved_fc, *data_attribute, true); + } + + return expanded; + } \ No newline at end of file diff --git a/src/scl/parser_part_04.inc b/src/scl/parser_part_04.inc index 423bf94dc..27da4a265 100644 --- a/src/scl/parser_part_04.inc +++ b/src/scl/parser_part_04.inc @@ -25,6 +25,102 @@ }; } +std::vector build_data_set_entries( + const std::string& fallback_ied_name, + const XmlNode& fcda, + const std::size_t first_index, + const SclTypeIndex& type_index, + std::vector& warnings) { + const auto explicit_da_name = attribute(&fcda, "daName"); + if (!explicit_da_name.empty()) { + return {build_data_set_entry( + fallback_ied_name, + fcda, + first_index, + type_index, + warnings)}; + } + + auto ied_name = attribute(&fcda, "iedName"); + if (ied_name.empty()) { + ied_name = fallback_ied_name; + } + const auto ld_inst = attribute(&fcda, "ldInst"); + const auto prefix = attribute(&fcda, "prefix"); + const auto ln_class = attribute(&fcda, "lnClass"); + const auto ln_inst = attribute(&fcda, "lnInst"); + const auto do_name = attribute(&fcda, "doName"); + const auto functional_constraint = attribute(&fcda, "fc"); + + const auto expanded = type_index.expand_data_object( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + functional_constraint); + if (expanded.empty()) { + const auto reference = build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + {}, + functional_constraint); + warnings.push_back( + "Unable to expand structured FCDA " + reference + + "; preserving the unresolved object entry."); + return {build_data_set_entry( + fallback_ied_name, + fcda, + first_index, + type_index, + warnings)}; + } + + std::vector result; + result.reserve(expanded.size()); + auto index = first_index; + for (const auto& leaf : expanded) { + const auto signal_reference = build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + leaf.da_name, + functional_constraint); + if (!leaf.resolution.resolved) { + warnings.push_back("Type unresolved for " + signal_reference + "."); + } + result.push_back({ + index++, + signal_reference, + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + leaf.da_name, + functional_constraint.empty() + ? leaf.resolution.functional_constraint + : functional_constraint, + leaf.resolution.cdc, + leaf.resolution.basic_type, + leaf.resolution.type_id, + leaf.resolution.enum_type, + is_quality_attribute(leaf.da_name), + is_timestamp_attribute(leaf.da_name), + }); + } + return result; +} + SclStreamAddress build_address(const XmlNode& owner) { std::unordered_map values; for (const auto* parameter : descendants(owner, "P")) { @@ -202,12 +298,16 @@ SclDocument SclParser::parse( std::size_t entry_index = 1U; for (const auto* fcda : direct_children(*data_set_node, "FCDA")) { - data_set.entries.push_back(build_data_set_entry( + auto entries = build_data_set_entries( ied_name, *fcda, - entry_index++, + entry_index, type_index, - document.warnings)); + document.warnings); + entry_index += entries.size(); + for (auto& entry : entries) { + data_set.entries.push_back(std::move(entry)); + } } document.data_sets.push_back(std::move(data_set)); } @@ -222,4 +322,4 @@ SclDocument SclParser::parse( index); } - std::unordered_map address_index; + std::unordered_map address_index; \ No newline at end of file diff --git a/src/scl/parser_part_05_02.inc b/src/scl/parser_part_05_02.inc index f862ac952..b2795ad2c 100644 --- a/src/scl/parser_part_05_02.inc +++ b/src/scl/parser_part_05_02.inc @@ -11,6 +11,17 @@ stream.no_asdu = std::max( 1U, ushort_attribute(*control, "nofASDU")); + if (const auto* options = first_child(*control, "SmvOpts"); options != nullptr) { + stream.smv_options.element_present = true; + stream.smv_options.refresh_time = bool_attribute(*options, "refreshTime"); + stream.smv_options.sample_synchronized = + bool_attribute(*options, "sampleSynchronized"); + stream.smv_options.sample_rate = bool_attribute(*options, "sampleRate"); + stream.smv_options.data_set = bool_attribute(*options, "dataSet"); + stream.smv_options.security = bool_attribute(*options, "security"); + stream.smv_options.synch_source_id = + bool_attribute(*options, "synchSourceId"); + } document.sampled_values_streams.push_back(std::move(stream)); } } @@ -42,4 +53,4 @@ report.control_block_reference = ied_name + ld_inst + "/" + logical_node_path + (buffered ? "$BR$" : "$RP$") + report.name; report.buffered = buffered; - report.indexed = !same(attribute(control, "indexed"), "false"); + report.indexed = !same(attribute(control, "indexed"), "false"); \ No newline at end of file diff --git a/src/scl/parser_part_05_04.inc b/src/scl/parser_part_05_04.inc index 6c5f89586..d65f63cf2 100644 --- a/src/scl/parser_part_05_04.inc +++ b/src/scl/parser_part_05_04.inc @@ -1,17 +1,13 @@ detect_address_conflicts("SV", document.sampled_values_streams); + // A focused engineering file may intentionally describe only one service + // family (for example, only Sampled Values). Absence of unrelated GOOSE, + // SV, or ReportControl blocks is therefore not a parser warning. Callers + // that require a specific service should validate that requirement in + // their own profile/compiler layer. if (document.data_sets.empty()) { document.warnings.push_back("No DataSet element was found. Semantic mapping will be limited."); } - if (document.goose_streams.empty()) { - document.warnings.push_back("No GSEControl stream was found."); - } - if (document.sampled_values_streams.empty()) { - document.warnings.push_back("No SampledValueControl stream was found."); - } - if (document.report_controls.empty()) { - document.warnings.push_back("No ReportControl block was found."); - } return document; } diff --git a/tests/fixtures/scl/sv-4800-structured-4i4v.scd b/tests/fixtures/scl/sv-4800-structured-4i4v.scd new file mode 100644 index 000000000..299688306 --- /dev/null +++ b/tests/fixtures/scl/sv-4800-structured-4i4v.scd @@ -0,0 +1,71 @@ + + +
+ + + + +
+

0

+

4

+

01-0C-CD-04-00-00

+

4001

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index a501fcc12..770ac8346 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -1,13 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/publisher_profile.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" #include "ariec61850/scl/dataset_reference.hpp" #include "ariec61850/scl/parser.hpp" #include +#include #include #include #include #include +#include #include #include #include @@ -137,6 +141,98 @@ void parser_extracts_multiple_sampled_values_streams_and_conflicts() { })); } +void parser_compiles_structured_4800_sv_profile_without_drift() { + using namespace ar::iec61850::sampled_values; + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); + CHECK(document.sampled_values_streams.size() == 1U); + CHECK(document.data_sets.size() == 1U); + CHECK(document.warnings.empty()); + + const auto& stream = document.sampled_values_streams.front(); + CHECK(stream.address.destination_mac_text == "01:0C:CD:04:00:00"); + CHECK(stream.address.app_id == std::optional{ + static_cast(0x4001U)}); + CHECK(stream.address.vlan_id == std::optional{ + static_cast(0U)}); + CHECK(stream.address.vlan_priority == std::optional{ + static_cast(4U)}); + CHECK(stream.configuration_revision == 100U); + CHECK(stream.sample_rate == 4800U); + CHECK(stream.sample_mode == "SmpPerSec"); + CHECK(stream.no_asdu == 1U); + CHECK(stream.smv_options.element_present); + CHECK(!stream.smv_options.refresh_time); + CHECK(!stream.smv_options.sample_synchronized); + CHECK(!stream.smv_options.sample_rate); + CHECK(!stream.smv_options.data_set); + CHECK(!stream.smv_options.security); + CHECK(!stream.smv_options.synch_source_id); + CHECK(stream.entries.size() == 16U); + + for (std::size_t channel = 0U; channel < 8U; ++channel) { + const auto& value = stream.entries[channel * 2U]; + const auto& quality = stream.entries[channel * 2U + 1U]; + CHECK(value.da_name == "instMag.i"); + CHECK(value.cdc == "SAV"); + CHECK(value.basic_type == "INT32"); + CHECK(!value.is_quality); + CHECK(quality.da_name == "q"); + CHECK(quality.cdc == "SAV"); + CHECK(quality.basic_type == "Quality"); + CHECK(quality.is_quality); + } + + SvPublisherProfileCompileContext context; + context.sample_counter_modulus = static_cast(4800U); + const auto compiled = SvPublisherProfileCompiler::compile(stream, context); + CHECK(compiled.ok()); + CHECK(compiled.profile.has_value()); + const auto& profile = *compiled.profile; + CHECK(profile.app_id == 0x4001U); + CHECK(profile.vlan_present); + CHECK(profile.vlan_id == 0U); + CHECK(profile.vlan_priority == 4U); + CHECK(profile.configuration_revision == 100U); + CHECK(profile.sample_mode == SvSampleMode::samples_per_second); + CHECK(profile.publisher_rate_hz == std::optional{4800U}); + CHECK(profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus); + CHECK(profile.sample_counter_modulus == std::optional{ + static_cast(4800U)}); + CHECK(profile.channels.size() == 16U); + CHECK(profile.payload_size_bytes == 64U); + CHECK(profile.asdu_options.element_present); + + RationalTickSchedule schedule_4800{1'000'000U, 4800U}; + std::uint64_t total_ticks{}; + std::uint32_t intervals_208{}; + std::uint32_t intervals_209{}; + for (std::uint32_t sample = 0U; sample < 4800U; ++sample) { + const auto interval = schedule_4800.next_interval_ticks(); + total_ticks += interval; + if (interval == 208U) { + ++intervals_208; + } else if (interval == 209U) { + ++intervals_209; + } else { + CHECK(false); + } + } + CHECK(total_ticks == 1'000'000U); + CHECK(intervals_208 == 3200U); + CHECK(intervals_209 == 1600U); + + RationalTickSchedule schedule_4000{1'000'000U, 4000U}; + total_ticks = 0U; + for (std::uint32_t sample = 0U; sample < 4000U; ++sample) { + const auto interval = schedule_4000.next_interval_ticks(); + CHECK(interval == 250U); + total_ticks += interval; + } + CHECK(total_ticks == 1'000'000U); +} + void dataset_reference_resolver_accepts_canonical_and_local_forms() { using namespace ar::iec61850::scl; @@ -263,6 +359,7 @@ int main() { const std::vector>> tests{ {"SCL minimal station", parser_extracts_minimal_station_semantics}, {"SCL multi-stream", parser_extracts_multiple_sampled_values_streams_and_conflicts}, + {"SCL structured 4800 SV profile", parser_compiles_structured_4800_sv_profile_without_drift}, {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms}, {"SCL conflicts and warnings", parser_detects_duplicate_ieds_and_missing_dataset_references}, {"SCL edition detection", parser_detects_editions_from_root_metadata}, From 216af6aa48f77372ad168de084731d56b21007f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:56:30 +0700 Subject: [PATCH 039/129] feat: add SCL-driven SV profile inspection tool --- tools/smv_profile_inspect.cpp | 280 ++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 tools/smv_profile_inspect.cpp diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp new file mode 100644 index 000000000..e739cd23e --- /dev/null +++ b/tools/smv_profile_inspect.cpp @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/publisher_profile.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#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; +using ar::iec61850::scl::SclDocument; +using ar::iec61850::scl::SclEdition; + +std::string json_escape(std::string_view text) { + std::ostringstream out; + for (const unsigned char ch : text) { + switch (ch) { + case '"': out << "\\\""; break; + case '\\': out << "\\\\"; break; + case '\b': out << "\\b"; break; + case '\f': out << "\\f"; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: + if (ch < 0x20U) { + out << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(ch) << std::dec << std::setfill(' '); + } else { + out << static_cast(ch); + } + } + } + return out.str(); +} + +void quoted(std::ostream& out, std::string_view value) { + out << '"' << json_escape(value) << '"'; +} + +template +void string_array(std::ostream& out, const Range& values) { + out << '['; + bool first = true; + for (const auto& value : values) { + if (!first) out << ','; + first = false; + quoted(out, value); + } + out << ']'; +} + +std::string edition_name(const SclEdition edition) { + switch (edition) { + case SclEdition::edition1: return "1"; + case SclEdition::edition2: return "2"; + case SclEdition::edition21: return "2.1"; + default: return "unknown"; + } +} + +std::string sample_mode_name(const SvSampleMode mode) { + switch (mode) { + case SvSampleMode::samples_per_second: return "SmpPerSec"; + case SvSampleMode::samples_per_period: return "SmpPerPeriod"; + default: return "unknown"; + } +} + +std::string counter_policy_name(const SvSampleCounterPolicy policy) { + switch (policy) { + case SvSampleCounterPolicy::explicit_modulus: return "explicit"; + case SvSampleCounterPolicy::candidate_sample_rate_modulus: return "candidate-rate"; + default: return "unresolved"; + } +} + +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) { + 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 << ','; + out << "\"controlBlockReference\":"; quoted(out, p.control_block_reference); out << ','; + out << "\"svID\":"; quoted(out, p.sv_id); out << ','; + out << "\"dataSetReference\":"; quoted(out, p.data_set_reference); out << ','; + out << "\"destinationMac\":"; quoted(out, mac_text(p.destination_mac)); out << ','; + out << "\"appID\":" << p.app_id << ','; + out << "\"vlanPresent\":" << (p.vlan_present ? "true" : "false") << ','; + out << "\"vlanID\":" << p.vlan_id << ','; + out << "\"vlanPriority\":" << static_cast(p.vlan_priority) << ','; + out << "\"confRev\":" << p.configuration_revision << ','; + out << "\"sampleRate\":" << p.sample_rate_value << ','; + out << "\"sampleMode\":"; quoted(out, sample_mode_name(p.sample_mode)); out << ','; + out << "\"publisherRateHz\":"; + if (p.publisher_rate_hz) out << *p.publisher_rate_hz; else out << "null"; + out << ','; + out << "\"nofASDU\":" << p.no_asdu << ','; + out << "\"counterPolicy\":"; quoted(out, counter_policy_name(p.sample_counter_policy)); out << ','; + out << "\"counterModulus\":"; + if (p.sample_counter_modulus) out << *p.sample_counter_modulus; else out << "null"; + out << ','; + out << "\"payloadBytes\":" << p.payload_size_bytes << ','; + out << "\"asduOptions\":{"; + out << "\"elementPresent\":" << (p.asdu_options.element_present ? "true" : "false") << ','; + out << "\"refreshTime\":" << (p.asdu_options.refresh_time ? "true" : "false") << ','; + out << "\"sampleSynchronized\":" << (p.asdu_options.sample_synchronized ? "true" : "false") << ','; + out << "\"sampleRate\":" << (p.asdu_options.sample_rate ? "true" : "false") << ','; + out << "\"dataSet\":" << (p.asdu_options.data_set ? "true" : "false") << ','; + 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) { + if (i != 0U) out << ','; + const auto& channel = p.channels[i]; + out << '{'; + out << "\"index\":" << channel.index << ','; + out << "\"signalReference\":"; quoted(out, channel.signal_reference); out << ','; + out << "\"cdc\":"; quoted(out, channel.cdc); out << ','; + out << "\"basicType\":"; quoted(out, channel.basic_type); out << ','; + out << "\"quality\":" << (channel.is_quality ? "true" : "false") << ','; + out << "\"timestamp\":" << (channel.is_timestamp ? "true" : "false") << ','; + out << "\"wireWidth\":" << channel.wire_width_bytes; + out << '}'; + } + out << "]}"; +} + +void emit_document( + std::ostream& out, + const SclDocument& document, + const std::optional counter_modulus) { + out << '{'; + out << "\"schemaVersion\":1,"; + out << "\"source\":"; quoted(out, document.source_name); out << ','; + out << "\"edition\":"; quoted(out, edition_name(document.edition)); out << ','; + 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) { + if (i != 0U) out << ','; + const auto& conflict = document.conflicts[i]; + out << '{'; + out << "\"kind\":"; quoted(out, conflict.kind); out << ','; + out << "\"key\":"; quoted(out, conflict.key); out << ','; + out << "\"description\":"; quoted(out, conflict.description); + out << '}'; + } + out << "],\"streams\":["; + + for (std::size_t index = 0; 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"; + 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"; + } + + out << '{'; + out << "\"index\":" << index << ','; + out << "\"ied\":"; quoted(out, stream.ied_name); out << ','; + out << "\"control\":"; quoted(out, stream.control_name); out << ','; + out << "\"controlBlockReference\":"; quoted(out, stream.control_block_reference); out << ','; + out << "\"compatibilityClass\":"; quoted(out, compatibility); out << ','; + out << "\"deviceSupport\":"; quoted(out, device_support); out << ','; + out << "\"errors\":"; string_array(out, compiled.errors); out << ','; + out << "\"warnings\":"; string_array(out, compiled.warnings); out << ','; + out << "\"profile\":"; + if (compiled.profile) emit_profile(out, *compiled.profile); else out << "null"; + out << '}'; + } + out << "]}"; +} + +std::optional parse_u16(std::string_view text) { + try { + const auto value = std::stoul(std::string{text}); + if (value == 0U || value > 65535U) return std::nullopt; + return static_cast(value); + } catch (...) { + return std::nullopt; + } +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "usage: ariec61850_smv_profile_inspect [--counter-modulus N]\n"; + return 2; + } + + std::optional counter_modulus; + for (int i = 2; i < argc; ++i) { + if (std::string_view{argv[i]} == "--counter-modulus" && i + 1 < argc) { + counter_modulus = parse_u16(argv[++i]); + if (!counter_modulus) { + std::cerr << "invalid counter modulus\n"; + return 2; + } + } else { + std::cerr << "unknown argument\n"; + return 2; + } + } + + try { + 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; + } catch (const std::exception& error) { + std::cout << "{\"schemaVersion\":1,\"fatalError\":"; + quoted(std::cout, error.what()); + std::cout << "}\n"; + return 1; + } +} From f072855ad1caade4f60e9f5231bda9ad874c94d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:56:53 +0700 Subject: [PATCH 040/129] build(gui): add focused host profile compiler target --- apps/smv_injector_gui/host/CMakeLists.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/smv_injector_gui/host/CMakeLists.txt diff --git a/apps/smv_injector_gui/host/CMakeLists.txt b/apps/smv_injector_gui/host/CMakeLists.txt new file mode 100644 index 000000000..529f7b7f7 --- /dev/null +++ b/apps/smv_injector_gui/host/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.20) +project(ARStackSmvGuiHost LANGUAGES CXX) + +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) + +add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../../.. arstack-core) + +add_executable(ariec61850_smv_profile_inspect + ${CMAKE_CURRENT_LIST_DIR}/../../../tools/smv_profile_inspect.cpp) +target_link_libraries(ariec61850_smv_profile_inspect PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_smv_profile_inspect PRIVATE cxx_std_20) From bc09fe213381cb72591a566a30f60f2c4faa495c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:57:18 +0700 Subject: [PATCH 041/129] feat(gui): serve smart SCL profile inspection locally --- apps/smv_injector_gui/host_server.py | 135 +++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 apps/smv_injector_gui/host_server.py diff --git a/apps/smv_injector_gui/host_server.py b/apps/smv_injector_gui/host_server.py new file mode 100644 index 000000000..656f18059 --- /dev/null +++ b/apps/smv_injector_gui/host_server.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import tempfile +import urllib.parse +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +APP_DIR = pathlib.Path(__file__).resolve().parent +MAX_SCL_BYTES = 16 * 1024 * 1024 + + +class Handler(SimpleHTTPRequestHandler): + server_version = "ARStackSmvGui/1" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(APP_DIR), **kwargs) + + def end_headers(self) -> None: + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Cross-Origin-Resource-Policy", "same-origin") + super().end_headers() + + def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path != "/api/scl/inspect": + self.send_error(404) + return + + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self.send_error(400, "Invalid Content-Length") + return + if length <= 0 or length > MAX_SCL_BYTES: + self.send_error(413, "Engineering file size is invalid") + return + + body = self.rfile.read(length) + query = urllib.parse.parse_qs(parsed.query) + counter = query.get("counterModulus", [None])[0] + if counter is not None: + try: + value = int(counter, 10) + if value <= 0 or value > 65535: + raise ValueError + except ValueError: + self._json(400, {"fatalError": "Counter modulus must be 1..65535."}) + return + + suffix = pathlib.Path(self.headers.get("X-File-Name", "engineering.scl")).suffix + if not suffix or len(suffix) > 12: + suffix = ".scl" + + temp_path = None + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle: + handle.write(body) + temp_path = pathlib.Path(handle.name) + + command = [str(self.server.profile_tool), str(temp_path)] + if counter is not None: + command += ["--counter-modulus", counter] + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=12, + check=False, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + payload_text = completed.stdout.strip() + if not payload_text: + raise RuntimeError(completed.stderr.strip() or "Profile compiler returned no output.") + payload = json.loads(payload_text) + status = 200 if completed.returncode == 0 else 422 + self._json(status, payload) + except subprocess.TimeoutExpired: + self._json(504, {"fatalError": "SCL profile compilation timed out."}) + except Exception as error: # local control-plane boundary + self._json(500, {"fatalError": str(error)}) + finally: + if temp_path is not None: + try: + temp_path.unlink(missing_ok=True) + except OSError: + pass + + def _json(self, status: int, payload: dict) -> None: + encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, fmt: str, *args) -> None: + print(f"{self.address_string()} - {fmt % args}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--profile-tool", required=True) + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + + tool = pathlib.Path(args.profile_tool).resolve() + if not tool.is_file(): + raise SystemExit(f"Profile compiler not found: {tool}") + + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + server.profile_tool = tool + print("ARStack61850 SMV Injector GUI") + print(f"Local UI: http://127.0.0.1:{args.port}/") + print(f"Smart SCL engine: {tool}") + print("IMPORTANT: close idf.py monitor first so the GUI can own the serial port.") + print("Press Ctrl+C to stop the local control-plane server.") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 5db08d90b961bd5b5cba918c5a74d931bec17b83 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:57:53 +0700 Subject: [PATCH 042/129] feat(gui): launch smart SCL profile backend --- apps/smv_injector_gui/run.cmd | 52 +++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index 0b8f3b250..992496e12 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -1,6 +1,10 @@ @echo off setlocal set "PORT=8765" +set "APP_DIR=%~dp0" +for %%I in ("%APP_DIR%..\..\..") do set "REPO_ROOT=%%~fI" +set "BUILD_DIR=%REPO_ROOT%\build-smv-gui" +set "PROFILE_TOOL=%BUILD_DIR%\ariec61850_smv_profile_inspect.exe" where python >nul 2>nul if errorlevel 1 ( @@ -10,34 +14,54 @@ if errorlevel 1 ( exit /b 1 ) -if not exist "%~dp0index.html" ( - echo ERROR: GUI index.html was not found next to run.cmd. - echo Expected: %~dp0index.html +where cmake >nul 2>nul +if errorlevel 1 ( + echo CMake was not found on PATH. + echo Run this launcher from the ESP-IDF shell. pause exit /b 1 ) -rem Serve from the launcher directory itself. Do not pass %%~dp0 to -rem http.server --directory: %%~dp0 ends in a backslash on Windows and can be -rem mis-parsed by the child process command-line quoting, causing HTTP 404s. -pushd "%~dp0" +where ninja >nul 2>nul if errorlevel 1 ( - echo ERROR: could not enter GUI directory: %~dp0 + echo Ninja was not found on PATH. + echo Run this launcher from the ESP-IDF shell. + pause + exit /b 1 +) + +if not exist "%APP_DIR%index.html" ( + echo ERROR: GUI index.html was not found next to run.cmd. pause exit /b 1 ) echo ARStack61850 SMV Injector GUI +echo Building smart SCL profile engine... +cmake -S "%APP_DIR%host" -B "%BUILD_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release +if errorlevel 1 goto :build_failed +cmake --build "%BUILD_DIR%" --target ariec61850_smv_profile_inspect +if errorlevel 1 goto :build_failed + +if not exist "%PROFILE_TOOL%" ( + echo ERROR: profile compiler was not produced: %PROFILE_TOOL% + pause + exit /b 1 +) + echo Local UI: http://127.0.0.1:%PORT%/ -echo Serving from: %CD% +echo Smart SCL engine: %PROFILE_TOOL% echo. echo IMPORTANT: close idf.py monitor first so the GUI can own the serial port. -echo Press Ctrl+C in this window to stop the local UI server. +echo Press Ctrl+C in this window to stop the local control-plane server. echo. start "" "http://127.0.0.1:%PORT%/" -python -m http.server %PORT% --bind 127.0.0.1 -set "SERVER_EXIT=%ERRORLEVEL%" +python "%APP_DIR%host_server.py" --profile-tool "%PROFILE_TOOL%" --port %PORT% +exit /b %ERRORLEVEL% -popd -exit /b %SERVER_EXIT% +:build_failed +echo. +echo ERROR: smart SCL profile engine build failed. +pause +exit /b 1 From 926f9c8834c88860b0f69e0afa4c201a4643940a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:59:09 +0700 Subject: [PATCH 043/129] feat(embedded): add immutable runtime SV profile state --- .../main/runtime_profile.hpp | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/runtime_profile.hpp diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp new file mode 100644 index 000000000..b42ba1eda --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace ar::esp32p4::smv { + +struct RuntimePublisherProfile final { + std::uint32_t schema_version{1U}; + std::array destination_mac{0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + std::uint16_t app_id{0x4000U}; + bool vlan_present{true}; + std::uint16_t vlan_id{0U}; + std::uint8_t vlan_priority{4U}; + std::uint32_t configuration_revision{1U}; + std::uint32_t publisher_rate_hz{4000U}; + std::uint16_t sample_counter_modulus{4000U}; + std::uint16_t no_asdu{1U}; + bool include_data_set{false}; + bool include_sample_rate{false}; + std::array sv_id{}; + std::array data_set_reference{}; + std::uint64_t generation{1U}; +}; + +void runtime_profile_initialize() noexcept; +[[nodiscard]] RuntimePublisherProfile runtime_profile_snapshot() noexcept; +[[nodiscard]] bool runtime_profile_commit(const RuntimePublisherProfile& requested) noexcept; +[[nodiscard]] bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept; + +} // namespace ar::esp32p4::smv From 334db43cddf4588f68b50703070e152ec5405ea0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:59:27 +0700 Subject: [PATCH 044/129] feat(embedded): implement runtime SV profile commit boundary --- .../main/runtime_profile.cpp | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/runtime_profile.cpp diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp new file mode 100644 index 000000000..2eb2dc12e --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "runtime_profile.hpp" + +#include "freertos/FreeRTOS.h" + +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +portMUX_TYPE g_profile_mux = portMUX_INITIALIZER_UNLOCKED; +RuntimePublisherProfile g_profile{}; + +void copy_text(std::array& destination, const char* source) noexcept { + destination.fill('\0'); + if (source == nullptr) return; + std::strncpy(destination.data(), source, destination.size() - 1U); +} + +} // namespace + +void runtime_profile_initialize() noexcept { + RuntimePublisherProfile initial; + copy_text(initial.sv_id, "MU01_SV1"); + initial.data_set_reference.fill('\0'); + initial.generation = 1U; + portENTER_CRITICAL(&g_profile_mux); + g_profile = initial; + portEXIT_CRITICAL(&g_profile_mux); +} + +RuntimePublisherProfile runtime_profile_snapshot() noexcept { + RuntimePublisherProfile snapshot; + portENTER_CRITICAL(&g_profile_mux); + snapshot = g_profile; + portEXIT_CRITICAL(&g_profile_mux); + return snapshot; +} + +bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept { + if (profile.schema_version != 1U || profile.app_id == 0U || + profile.publisher_rate_hz == 0U || profile.publisher_rate_hz > 65535U || + profile.sample_counter_modulus == 0U || profile.no_asdu != 1U || + profile.sv_id[0] == '\0') { + return false; + } + if (profile.vlan_present && (profile.vlan_id > 4095U || profile.vlan_priority > 7U)) { + return false; + } + if (profile.include_data_set && profile.data_set_reference[0] == '\0') { + return false; + } + return true; +} + +bool runtime_profile_commit(const RuntimePublisherProfile& requested) noexcept { + if (!runtime_profile_validate(requested)) return false; + portENTER_CRITICAL(&g_profile_mux); + auto next = requested; + next.generation = g_profile.generation + 1U; + g_profile = next; + portEXIT_CRITICAL(&g_profile_mux); + return true; +} + +} // namespace ar::esp32p4::smv From f2baaab07c84a9c7dc426862cdbc4af66e9e73e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:59:49 +0700 Subject: [PATCH 045/129] build(embedded): include runtime SV profile state --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index c36fda4bd..c27cc8050 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -3,6 +3,7 @@ idf_component_register( "app_main.cpp" "ethernet_port.c" "live_control.cpp" + "runtime_profile.cpp" INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) From 5f997b398784110551f5fcee64784c785c205945 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:00:16 +0700 Subject: [PATCH 046/129] feat(embedded): add machine profile control interface --- .../esp32p4_smv_injector/main/profile_control.hpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/profile_control.hpp diff --git a/embedded/esp32p4_smv_injector/main/profile_control.hpp b/embedded/esp32p4_smv_injector/main/profile_control.hpp new file mode 100644 index 000000000..542546268 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/profile_control.hpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +namespace ar::esp32p4::smv { + +// Handles the machine-facing PROFILE subcommands used by the local GUI. +// The operator-facing surface remains the GUI; this text transport is a +// bounded development control protocol, not a user workflow. +void handle_profile_command(char* arguments) noexcept; + +} // namespace ar::esp32p4::smv From ab4c1fd54553356b856102567a9c057c9f2ebefd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:00:53 +0700 Subject: [PATCH 047/129] feat(embedded): stage and commit validated SV profiles --- .../main/profile_control.cpp | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/profile_control.cpp diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp new file mode 100644 index 000000000..bd8db6c6f --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "profile_control.hpp" + +#include "live_control.hpp" +#include "runtime_profile.hpp" + +#include "esp_log.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +constexpr char kTag[] = "ar_smv_profile"; +constexpr char kDelimiters[] = " \t\r\n"; +RuntimePublisherProfile g_staging{}; +bool g_staging_active = false; + +void uppercase_ascii(char* text) noexcept { + if (text == nullptr) return; + for (; *text != '\0'; ++text) { + *text = static_cast(std::toupper(static_cast(*text))); + } +} + +bool parse_u32(const char* text, std::uint32_t& value) noexcept { + if (text == nullptr || *text == '\0' || *text == '-') return false; + errno = 0; + char* end{}; + const unsigned long parsed = std::strtoul(text, &end, 0); + if (errno != 0 || end == text || *end != '\0' || + parsed > std::numeric_limits::max()) return false; + value = static_cast(parsed); + return true; +} + +int hex_nibble(const char ch) noexcept { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return 10 + ch - 'a'; + if (ch >= 'A' && ch <= 'F') return 10 + ch - 'A'; + return -1; +} + +template +bool decode_hex_text(const char* text, std::array& destination) noexcept { + destination.fill('\0'); + if (text == nullptr) return false; + if (std::strcmp(text, "-") == 0) return true; + const std::size_t length = std::strlen(text); + if (length == 0U || (length % 2U) != 0U || length / 2U >= N) return false; + for (std::size_t i = 0U; i < length; i += 2U) { + const int high = hex_nibble(text[i]); + const int low = hex_nibble(text[i + 1U]); + if (high < 0 || low < 0) return false; + const unsigned char byte = static_cast((high << 4) | low); + if (byte == 0U) return false; + destination[i / 2U] = static_cast(byte); + } + return true; +} + +bool decode_mac(const char* text, std::array& mac) noexcept { + if (text == nullptr || std::strlen(text) != 12U) return false; + for (std::size_t i = 0U; i < mac.size(); ++i) { + const int high = hex_nibble(text[i * 2U]); + const int low = hex_nibble(text[i * 2U + 1U]); + if (high < 0 || low < 0) return false; + mac[i] = static_cast((high << 4) | low); + } + return true; +} + +bool no_extra(char** save) noexcept { + return strtok_r(nullptr, kDelimiters, save) == nullptr; +} + +void print_profile(const RuntimePublisherProfile& profile) noexcept { + ESP_LOGI(kTag, + "PROFILE generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u dataSetField=%u sampleRateField=%u", + static_cast(profile.generation), + profile.sv_id.data(), + static_cast(profile.app_id), + static_cast(profile.publisher_rate_hz), + static_cast(profile.sample_counter_modulus), + static_cast(profile.configuration_revision), + profile.vlan_present ? 1U : 0U, + static_cast(profile.vlan_id), + static_cast(profile.vlan_priority), + profile.include_data_set ? 1U : 0U, + profile.include_sample_rate ? 1U : 0U); +} + +} // namespace + +void handle_profile_command(char* arguments) noexcept { + char* save{}; + char* subcommand = strtok_r(arguments, kDelimiters, &save); + if (subcommand == nullptr) { + ESP_LOGE(kTag, "PROFILE subcommand required"); + return; + } + uppercase_ascii(subcommand); + + if (std::strcmp(subcommand, "SHOW") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "Usage: PROFILE SHOW"); + return; + } + print_profile(runtime_profile_snapshot()); + return; + } + + if (live_tx_running()) { + ESP_LOGE(kTag, "PROFILE rejected: stop the publisher before changing stream identity/layout"); + return; + } + + if (std::strcmp(subcommand, "BEGIN") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "Usage: PROFILE BEGIN"); + return; + } + g_staging = runtime_profile_snapshot(); + g_staging_active = true; + ESP_LOGI(kTag, "PROFILE staging started"); + return; + } + + if (!g_staging_active) { + ESP_LOGE(kTag, "PROFILE staging is not active; send PROFILE BEGIN first"); + return; + } + + if (std::strcmp(subcommand, "ID") == 0) { + char* value = strtok_r(nullptr, kDelimiters, &save); + if (!decode_hex_text(value, g_staging.sv_id) || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE ID payload"); + return; + } + return; + } + + if (std::strcmp(subcommand, "DATASET") == 0) { + char* value = strtok_r(nullptr, kDelimiters, &save); + if (!decode_hex_text(value, g_staging.data_set_reference) || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE DATASET payload"); + return; + } + return; + } + + if (std::strcmp(subcommand, "L2") == 0) { + char* appid_text = strtok_r(nullptr, kDelimiters, &save); + char* mac_text = strtok_r(nullptr, kDelimiters, &save); + char* vlan_present_text = strtok_r(nullptr, kDelimiters, &save); + char* vlan_id_text = strtok_r(nullptr, kDelimiters, &save); + char* pcp_text = strtok_r(nullptr, kDelimiters, &save); + std::uint32_t appid{}; + std::uint32_t vlan_present{}; + std::uint32_t vlan_id{}; + std::uint32_t pcp{}; + std::array mac{}; + if (!parse_u32(appid_text, appid) || appid == 0U || appid > 65535U || + !decode_mac(mac_text, mac) || + !parse_u32(vlan_present_text, vlan_present) || vlan_present > 1U || + !parse_u32(vlan_id_text, vlan_id) || vlan_id > 4095U || + !parse_u32(pcp_text, pcp) || pcp > 7U || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE L2 payload"); + return; + } + g_staging.app_id = static_cast(appid); + g_staging.destination_mac = mac; + g_staging.vlan_present = vlan_present != 0U; + g_staging.vlan_id = static_cast(vlan_id); + g_staging.vlan_priority = static_cast(pcp); + return; + } + + if (std::strcmp(subcommand, "SV") == 0) { + char* confrev_text = strtok_r(nullptr, kDelimiters, &save); + char* rate_text = strtok_r(nullptr, kDelimiters, &save); + char* modulus_text = strtok_r(nullptr, kDelimiters, &save); + char* no_asdu_text = strtok_r(nullptr, kDelimiters, &save); + char* flags_text = strtok_r(nullptr, kDelimiters, &save); + std::uint32_t confrev{}; + std::uint32_t rate{}; + std::uint32_t modulus{}; + std::uint32_t no_asdu{}; + std::uint32_t flags{}; + if (!parse_u32(confrev_text, confrev) || + !parse_u32(rate_text, rate) || rate == 0U || rate > 65535U || + !parse_u32(modulus_text, modulus) || modulus == 0U || modulus > 65535U || + !parse_u32(no_asdu_text, no_asdu) || no_asdu != 1U || + !parse_u32(flags_text, flags) || flags > 3U || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE SV payload"); + return; + } + g_staging.configuration_revision = confrev; + g_staging.publisher_rate_hz = rate; + g_staging.sample_counter_modulus = static_cast(modulus); + g_staging.no_asdu = static_cast(no_asdu); + g_staging.include_data_set = (flags & 0x1U) != 0U; + g_staging.include_sample_rate = (flags & 0x2U) != 0U; + return; + } + + if (std::strcmp(subcommand, "COMMIT") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "Usage: PROFILE COMMIT"); + return; + } + if (!runtime_profile_commit(g_staging)) { + ESP_LOGE(kTag, "PROFILE commit rejected by runtime validation"); + return; + } + g_staging_active = false; + const auto active = runtime_profile_snapshot(); + ESP_LOGI(kTag, + "PROFILE committed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u", + static_cast(active.generation), + active.sv_id.data(), + static_cast(active.app_id), + static_cast(active.publisher_rate_hz), + static_cast(active.sample_counter_modulus)); + return; + } + + ESP_LOGE(kTag, "Unknown PROFILE subcommand"); +} + +} // namespace ar::esp32p4::smv From 23f3f1ab9eeb5f0b4b2e363b3cfca2cb6b8950c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:01:13 +0700 Subject: [PATCH 048/129] build(embedded): include profile control transport --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index c27cc8050..bd43d1526 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -3,6 +3,7 @@ idf_component_register( "app_main.cpp" "ethernet_port.c" "live_control.cpp" + "profile_control.cpp" "runtime_profile.cpp" INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) From d6681ab01b337f04d495c0a719e024b46d7e6a09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:01:36 +0700 Subject: [PATCH 049/129] feat(embedded): wake publisher on GUI control transitions --- embedded/esp32p4_smv_injector/main/live_control.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index b8523323d..d9aa717ab 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -3,6 +3,8 @@ #pragma once #include "ariec61850/sampled_values/live_signal_state.hpp" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include @@ -12,6 +14,8 @@ void live_control_initialize( std::int32_t current_rms_counts, std::int32_t voltage_rms_counts) noexcept; +void live_control_bind_publisher_task(TaskHandle_t task) noexcept; + [[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState live_signal_snapshot() noexcept; From 91689bd6064128c5570650fa1616df1c3357d285 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:02:18 +0700 Subject: [PATCH 050/129] feat(embedded): route GUI profile commands and publisher wakes --- .../main/live_control.cpp | 115 ++++++------------ 1 file changed, 40 insertions(+), 75 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 88e1089d1..dea956d9f 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "live_control.hpp" +#include "profile_control.hpp" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -31,29 +32,27 @@ constexpr std::string_view kTokenDelimiters{" \t\r\n"}; SvLiveSignalBank g_signal_bank; std::atomic g_running{false}; std::atomic g_start_request{false}; +std::atomic g_publisher_task{nullptr}; -void uppercase_ascii(char* text) noexcept { - if (text == nullptr) { - return; +void wake_publisher() noexcept { + const auto task = g_publisher_task.load(std::memory_order_acquire); + if (task != nullptr) { + xTaskNotifyGive(task); } +} + +void uppercase_ascii(char* text) noexcept { + if (text == nullptr) return; for (; *text != '\0'; ++text) { *text = static_cast(std::toupper(static_cast(*text))); } } -// The bench console is a safety-sensitive control surface: random UART bytes, -// terminal escape sequences or UTF-8 fragments must never be normalized into a -// valid command. Accept printable 7-bit ASCII plus normal line whitespace only; -// reject the whole line otherwise. bool is_safe_ascii_command_line(const char* line) noexcept { - if (line == nullptr) { - return false; - } - + if (line == nullptr) return false; bool has_payload = false; for (const auto* cursor = reinterpret_cast(line); - *cursor != 0U; - ++cursor) { + *cursor != 0U; ++cursor) { const auto byte = *cursor; if (byte == static_cast(' ') || byte == static_cast('\t') || @@ -61,9 +60,7 @@ bool is_safe_ascii_command_line(const char* line) noexcept { byte == static_cast('\n')) { continue; } - if (byte < 0x20U || byte > 0x7EU) { - return false; - } + if (byte < 0x20U || byte > 0x7EU) return false; has_payload = true; } return has_payload; @@ -74,32 +71,24 @@ bool has_extra_token(char** save) noexcept { } bool parse_i32(const char* text, std::int32_t& value) noexcept { - if (text == nullptr || *text == '\0') { - return false; - } + if (text == nullptr || *text == '\0') return false; errno = 0; char* end{}; const long parsed = std::strtol(text, &end, 0); if (errno != 0 || end == text || *end != '\0' || parsed < std::numeric_limits::min() || - parsed > std::numeric_limits::max()) { - return false; - } + parsed > std::numeric_limits::max()) return false; value = static_cast(parsed); return true; } bool parse_u32(const char* text, std::uint32_t& value) noexcept { - if (text == nullptr || *text == '\0' || *text == '-') { - return false; - } + if (text == nullptr || *text == '\0' || *text == '-') return false; errno = 0; char* end{}; const unsigned long parsed = std::strtoul(text, &end, 0); if (errno != 0 || end == text || *end != '\0' || - parsed > std::numeric_limits::max()) { - return false; - } + parsed > std::numeric_limits::max()) return false; value = static_cast(parsed); return true; } @@ -111,7 +100,7 @@ void print_help() noexcept { ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); ESP_LOGI(kTag, " QUALITY "); - ESP_LOGI(kTag, "SET/FREQ/QUALITY/ENABLE commit coherently; stream identity is unchanged."); + ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } void print_state() noexcept { @@ -140,8 +129,7 @@ bool publish_state(const SvLiveSignalState& state) noexcept { return false; } const auto active = g_signal_bank.snapshot(); - ESP_LOGI(kTag, - "Live signal generation %llu committed", + ESP_LOGI(kTag, "Live signal generation %llu committed", static_cast(active.generation)); return true; } @@ -149,9 +137,7 @@ bool publish_state(const SvLiveSignalState& state) noexcept { void handle_line(char* line) noexcept { char* save{}; char* command = strtok_r(line, kTokenDelimiters.data(), &save); - if (command == nullptr) { - return; - } + if (command == nullptr) return; uppercase_ascii(command); if (std::strcmp(command, "START") == 0) { @@ -161,7 +147,8 @@ void handle_line(char* line) noexcept { } g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); - ESP_LOGI(kTag, "START accepted: phase and smpCnt reset at the next sample boundary"); + wake_publisher(); + ESP_LOGI(kTag, "START accepted: profile armed, phase and smpCnt reset at first sample boundary"); return; } if (std::strcmp(command, "STOP") == 0) { @@ -170,6 +157,7 @@ void handle_line(char* line) noexcept { return; } g_running.store(false, std::memory_order_release); + wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; } @@ -189,15 +177,17 @@ void handle_line(char* line) noexcept { print_help(); return; } + if (std::strcmp(command, "PROFILE") == 0) { + handle_profile_command(save); + return; + } if (std::strcmp(command, "ZERO") == 0) { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: ZERO"); return; } auto state = g_signal_bank.snapshot(); - for (auto& channel : state.channels) { - channel.rms_counts = 0; - } + for (auto& channel : state.channels) channel.rms_counts = 0; static_cast(publish_state(state)); return; } @@ -239,8 +229,7 @@ void handle_line(char* line) noexcept { std::int32_t rms{}; std::int32_t phase{}; if (!parse_i32(rms_text, rms) || !parse_i32(phase_text, phase)) { - ESP_LOGE(kTag, - "Usage: SET [quality]"); + ESP_LOGE(kTag, "Usage: SET [quality]"); return; } std::uint32_t quality{}; @@ -249,15 +238,12 @@ void handle_line(char* line) noexcept { return; } if (has_extra_token(&save)) { - ESP_LOGE(kTag, - "Usage: SET [quality]"); + ESP_LOGE(kTag, "Usage: SET [quality]"); return; } channel.rms_counts = rms; channel.phase_millidegrees = phase; - if (quality_text != nullptr) { - channel.quality = quality; - } + if (quality_text != nullptr) channel.quality = quality; static_cast(publish_state(state)); return; } @@ -294,19 +280,19 @@ void live_control_initialize( const std::int32_t current_rms_counts, const std::int32_t voltage_rms_counts) noexcept { auto initial = g_signal_bank.snapshot(); - for (std::size_t index = 0U; index < 3U; ++index) { - initial.channels[index].rms_counts = current_rms_counts; - } + for (std::size_t index = 0U; index < 3U; ++index) initial.channels[index].rms_counts = current_rms_counts; initial.channels[3].rms_counts = 0; - for (std::size_t index = 4U; index < 7U; ++index) { - initial.channels[index].rms_counts = voltage_rms_counts; - } + for (std::size_t index = 4U; index < 7U; ++index) initial.channels[index].rms_counts = voltage_rms_counts; initial.channels[7].rms_counts = 0; static_cast(g_signal_bank.publish(initial)); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); } +void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { + g_publisher_task.store(task, std::memory_order_release); +} + SvLiveSignalState live_signal_snapshot() noexcept { return g_signal_bank.snapshot(); } @@ -338,11 +324,6 @@ void live_control_task(void*) noexcept { } const auto byte = static_cast(input); - - // The ESP-IDF monitor forwards interactive input byte-by-byte. Assemble - // those bytes here and execute only after Enter; std::fgets() is not - // suitable for this VFS because a short UART read can look like a - // complete stdio record. if (byte == static_cast('\r') || byte == static_cast('\n')) { if (discard_until_eol) { @@ -352,12 +333,7 @@ void live_control_task(void*) noexcept { ESP_LOGW(kTag, "Discarded invalid/overlong bench command"); continue; } - - if (length == 0U) { - // Ignore the second half of CRLF and empty Enter presses. - continue; - } - + if (length == 0U) continue; line[length] = '\0'; if (!is_safe_ascii_command_line(line.data())) { ESP_LOGW(kTag, "Discarded invalid bench command"); @@ -369,12 +345,7 @@ void live_control_task(void*) noexcept { continue; } - if (discard_until_eol) { - continue; - } - - // Support normal terminal editing without treating backspace/delete as - // a command byte. + if (discard_until_eol) continue; if (byte == 0x08U || byte == 0x7FU) { if (length > 0U) { --length; @@ -387,16 +358,10 @@ void live_control_task(void*) noexcept { byte == static_cast(' ') || byte == static_cast('\t') || (byte >= 0x21U && byte <= 0x7EU); - if (!accepted) { + if (!accepted || length + 1U >= line.size()) { discard_until_eol = true; continue; } - - if (length + 1U >= line.size()) { - discard_until_eol = true; - continue; - } - line[length++] = static_cast(byte); } } From 0c766f88598f845b1328727fd6030f896a22f31b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:03:50 +0700 Subject: [PATCH 051/129] feat(embedded): expose publisher fail-closed stop --- embedded/esp32p4_smv_injector/main/live_control.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index d9aa717ab..d80370d02 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -15,6 +15,7 @@ void live_control_initialize( std::int32_t voltage_rms_counts) noexcept; void live_control_bind_publisher_task(TaskHandle_t task) noexcept; +void live_control_force_stop() noexcept; [[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState live_signal_snapshot() noexcept; From 204261478386b39c570e5a7cdc146a7f25c304a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:05:26 +0700 Subject: [PATCH 052/129] feat(embedded): arm SCL-driven profiles with rational realtime scheduling --- .../esp32p4_smv_injector/main/app_main.cpp | 390 +++++++++--------- 1 file changed, 188 insertions(+), 202 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index f12130ab3..9fae74595 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -9,7 +9,7 @@ #include "driver/gptimer.h" #include "ethernet_port.h" #include "live_control.hpp" -#include "esp_attr.h" +#include "runtime_profile.hpp" #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -31,10 +31,15 @@ namespace { +using ar::esp32p4::smv::RuntimePublisherProfile; +using ar::esp32p4::smv::live_control_bind_publisher_task; using ar::esp32p4::smv::live_control_initialize; using ar::esp32p4::smv::live_control_task; using ar::esp32p4::smv::live_signal_snapshot; using ar::esp32p4::smv::live_tx_running; +using ar::esp32p4::smv::runtime_profile_initialize; +using ar::esp32p4::smv::runtime_profile_snapshot; +using ar::esp32p4::smv::runtime_profile_validate; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; @@ -47,25 +52,12 @@ using ar::iec61850::sampled_values::SvLiveSignalState; constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; - -// Canonical development identity remains fixed until the compiled-device -// profile work moves these fields behind the validated publisher profile. -constexpr std::uint16_t kCanonicalAppId = 0x4000U; -constexpr char kCanonicalSvId[] = "MU01_SV1"; -constexpr std::array kSvMulticastMac{ - 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; - -// The broadcast path is a separate diagnostic stream and never changes the -// canonical identity or sequence semantics. constexpr std::uint16_t kMirrorAppId = 0x4F01U; constexpr char kMirrorSvId[] = "AR_DIAG_SV1"; - -constexpr std::uint16_t kSampleCountWrap = 4000U; -constexpr std::uint32_t kSampleRateHz = 4000U; -constexpr std::int64_t kSamplePeriodUs = 250; constexpr std::uint16_t kSampleModeSamplesPerSecond = 1U; constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; +constexpr std::uint32_t kTimerResolutionHz = 1000000U; EventGroupHandle_t g_link_events = nullptr; portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED; @@ -94,6 +86,38 @@ struct PacketTemplate final { using WaveformRow = SvFixedPointSineEngine::SampleRow; +struct RationalScheduleCursor final { + std::uint32_t rate_hz{}; + std::uint32_t base_ticks{}; + std::uint32_t remainder{}; + std::uint32_t accumulator{}; + + void reset(const std::uint32_t rate) noexcept { + rate_hz = rate; + base_ticks = kTimerResolutionHz / rate_hz; + remainder = kTimerResolutionHz % rate_hz; + accumulator = 0U; + } + + [[nodiscard]] std::uint32_t next_ticks() noexcept { + std::uint32_t ticks = base_ticks; + accumulator += remainder; + if (accumulator >= rate_hz) { + accumulator -= rate_hz; + ++ticks; + } + return ticks; + } +}; + +struct SampleClockContext final { + TaskHandle_t publisher_task{}; + RationalScheduleCursor schedule{}; + gptimer_alarm_config_t next_alarm{}; +}; + +SampleClockContext g_sample_clock_context{}; + void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, void*) { switch (event_id) { case ETHERNET_EVENT_CONNECTED: @@ -132,45 +156,43 @@ void write_i32_be(std::uint8_t* destination, const std::int32_t value) noexcept } std::vector make_4i4v_payload() { - // Current development layout: Ia, Ib, Ic, In, Ua, Ub, Uc, Un. - // Each value is INT32 followed by UINT32 Quality. return std::vector(8U * 8U, 0U); } SampledValuesFrame make_runtime_frame( const std::array& source_mac, - const bool use_vlan, + const RuntimePublisherProfile& profile, const bool diagnostic_mirror) { SampledValueAsdu asdu; - asdu.sv_id = diagnostic_mirror ? kMirrorSvId : kCanonicalSvId; - asdu.data_set_reference.clear(); + asdu.sv_id = diagnostic_mirror ? kMirrorSvId : profile.sv_id.data(); + asdu.data_set_reference = + (!diagnostic_mirror && profile.include_data_set) ? profile.data_set_reference.data() : ""; - // Fixed-width placeholders let the realtime task patch fields in place - // without BER rebuilds or packet-length changes. + // Keep BER widths fixed so the realtime path only patches bytes in place. asdu.sample_count = 0x0100U; asdu.configuration_revision = 0x01000000U; asdu.reference_time.reset(); + // No disciplined PTP clock exists yet, so never claim synchronization. asdu.sample_synchronization = 0U; - if (diagnostic_mirror) { - asdu.sample_rate = static_cast(kSampleRateHz); + if (diagnostic_mirror || profile.include_sample_rate) { + asdu.sample_rate = static_cast(profile.publisher_rate_hz); asdu.sample_mode = kSampleModeSamplesPerSecond; } else { asdu.sample_rate.reset(); asdu.sample_mode.reset(); } - asdu.sample_payload = make_4i4v_payload(); SampledValuesFrame frame; - frame.destination = MacAddress{std::span{kSvMulticastMac}}; + frame.destination = MacAddress{std::span{profile.destination_mac}}; frame.source = MacAddress{std::span{source_mac}}; - if (use_vlan) { - frame.vlan = VlanTag{4U, 0U}; + if (!diagnostic_mirror && profile.vlan_present) { + frame.vlan = VlanTag{profile.vlan_priority, profile.vlan_id}; } else { frame.vlan.reset(); } - frame.app_id = diagnostic_mirror ? kMirrorAppId : kCanonicalAppId; + frame.app_id = diagnostic_mirror ? kMirrorAppId : profile.app_id; frame.reserved1 = 0U; frame.reserved2 = 0U; frame.pdu = SampledValuesPdu{{std::move(asdu)}}; @@ -193,13 +215,13 @@ bool find_fixed_field( bool build_packet_template( const std::array& source_mac, - const bool use_vlan, + const RuntimePublisherProfile& profile, const bool broadcast_destination, const bool diagnostic_mirror, PacketTemplate& packet) { packet = {}; packet.bytes = SampledValuesFrameCodec::encode( - make_runtime_frame(source_mac, use_vlan, diagnostic_mirror)); + make_runtime_frame(source_mac, profile, diagnostic_mirror)); if (broadcast_destination) { std::fill_n(packet.bytes.begin(), 6U, std::uint8_t{0xFFU}); @@ -212,14 +234,13 @@ bool build_packet_template( ESP_LOGE(kTag, "Failed to locate fixed SV patch fields in encoded template"); return false; } - if (packet.sample_payload_offset + 64U > packet.bytes.size()) { ESP_LOGE(kTag, "SV payload patch region exceeds encoded frame"); return false; } write_u16_be(packet.bytes.data() + packet.sample_count_offset, 0U); - write_u32_be(packet.bytes.data() + conf_rev_offset, 1U); + write_u32_be(packet.bytes.data() + conf_rev_offset, profile.configuration_revision); return true; } @@ -229,7 +250,6 @@ void patch_packet( const WaveformRow& values, const SvLiveSignalState& signal_state) noexcept { write_u16_be(packet.bytes.data() + packet.sample_count_offset, sample_count); - for (std::size_t channel = 0U; channel < values.size(); ++channel) { const auto base = packet.sample_payload_offset + channel * 8U; write_i32_be(packet.bytes.data() + base, values[channel]); @@ -240,33 +260,26 @@ void patch_packet( #if CONFIG_AR_SMV_DIAGNOSTIC_PROBES std::array make_probe( const std::array& source_mac, + const std::array& destination_mac, const bool multicast) { std::array frame{}; - for (std::size_t index = 0U; index < 6U; ++index) { - frame[index] = multicast ? kSvMulticastMac[index] : 0xFFU; + frame[index] = multicast ? destination_mac[index] : 0xFFU; frame[6U + index] = source_mac[index]; } - frame[12] = static_cast((kDiagnosticEtherType >> 8U) & 0xFFU); frame[13] = static_cast(kDiagnosticEtherType & 0xFFU); - - constexpr char payload[] = "ARSTACK-P3-LIVE-CONTROL-PROBE"; + constexpr char payload[] = "ARSTACK-P3-PROFILE-PROBE"; for (std::size_t index = 0U; - index < sizeof(payload) - 1U && 14U + index < frame.size(); - ++index) { + index < sizeof(payload) - 1U && 14U + index < frame.size(); ++index) { frame[14U + index] = static_cast(payload[index]); } - return frame; } #endif void merge_stats(TimingStats& local) noexcept { - if (local.samples == 0U) { - return; - } - + if (local.samples == 0U) return; portENTER_CRITICAL(&g_stats_mux); g_interval_stats.samples += local.samples; g_interval_stats.canonical_ok += local.canonical_ok; @@ -275,52 +288,66 @@ void merge_stats(TimingStats& local) noexcept { g_interval_stats.mirror_fail += local.mirror_fail; g_interval_stats.missed_slots += local.missed_slots; g_interval_stats.lateness_sum_us += local.lateness_sum_us; - g_interval_stats.lateness_min_us = - std::min(g_interval_stats.lateness_min_us, local.lateness_min_us); - g_interval_stats.lateness_max_us = - std::max(g_interval_stats.lateness_max_us, local.lateness_max_us); + g_interval_stats.lateness_min_us = std::min(g_interval_stats.lateness_min_us, local.lateness_min_us); + g_interval_stats.lateness_max_us = std::max(g_interval_stats.lateness_max_us, local.lateness_max_us); g_interval_stats.last_sample_count = local.last_sample_count; g_interval_stats.last_signal_generation = local.last_signal_generation; portEXIT_CRITICAL(&g_stats_mux); - local = TimingStats{}; } +void reset_interval_stats() noexcept { + portENTER_CRITICAL(&g_stats_mux); + g_interval_stats = TimingStats{}; + portEXIT_CRITICAL(&g_stats_mux); +} + void update_local_timing( TimingStats& local, const std::int64_t lateness_us, const std::uint64_t missed_slots, const std::uint16_t sample_count, const std::uint64_t signal_generation) noexcept { - const auto bounded_lateness = static_cast(std::clamp( + const auto bounded = static_cast(std::clamp( lateness_us, std::numeric_limits::min(), std::numeric_limits::max())); - ++local.samples; local.missed_slots += missed_slots; local.lateness_sum_us += lateness_us; - local.lateness_min_us = std::min(local.lateness_min_us, bounded_lateness); - local.lateness_max_us = std::max(local.lateness_max_us, bounded_lateness); + local.lateness_min_us = std::min(local.lateness_min_us, bounded); + local.lateness_max_us = std::max(local.lateness_max_us, bounded); local.last_sample_count = sample_count; local.last_signal_generation = signal_generation; } -bool IRAM_ATTR sample_alarm_callback( - gptimer_handle_t, - const gptimer_alarm_event_data_t*, +bool sample_alarm_callback( + gptimer_handle_t timer, + const gptimer_alarm_event_data_t* event, void* user_context) { - auto task = static_cast(user_context); + auto* context = static_cast(user_context); + const auto interval = context->schedule.next_ticks(); + context->next_alarm = {}; + context->next_alarm.alarm_count = event->alarm_value + interval; + context->next_alarm.flags.auto_reload_on_alarm = false; + static_cast(gptimer_set_alarm_action(timer, &context->next_alarm)); + BaseType_t high_priority_task_woken = pdFALSE; - vTaskNotifyGiveFromISR(task, &high_priority_task_woken); + vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); return high_priority_task_woken == pdTRUE; } -gptimer_handle_t start_sample_clock(TaskHandle_t publisher_task_handle) { +gptimer_handle_t start_sample_clock( + TaskHandle_t publisher_task_handle, + const std::uint32_t publisher_rate_hz) { + g_sample_clock_context = {}; + g_sample_clock_context.publisher_task = publisher_task_handle; + g_sample_clock_context.schedule.reset(publisher_rate_hz); + gptimer_config_t timer_config{}; timer_config.clk_src = GPTIMER_CLK_SRC_DEFAULT; timer_config.direction = GPTIMER_COUNT_UP; - timer_config.resolution_hz = 1000000U; + timer_config.resolution_hz = kTimerResolutionHz; gptimer_handle_t timer = nullptr; ESP_ERROR_CHECK(gptimer_new_timer(&timer_config, &timer)); @@ -328,21 +355,31 @@ gptimer_handle_t start_sample_clock(TaskHandle_t publisher_task_handle) { gptimer_event_callbacks_t callbacks{}; callbacks.on_alarm = &sample_alarm_callback; ESP_ERROR_CHECK(gptimer_register_event_callbacks( - timer, &callbacks, publisher_task_handle)); + timer, &callbacks, &g_sample_clock_context)); - gptimer_alarm_config_t alarm_config{}; - alarm_config.alarm_count = static_cast(kSamplePeriodUs); - alarm_config.reload_count = 0U; - alarm_config.flags.auto_reload_on_alarm = true; - ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &alarm_config)); + g_sample_clock_context.next_alarm = {}; + g_sample_clock_context.next_alarm.alarm_count = + g_sample_clock_context.schedule.next_ticks(); + g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; + ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); ESP_ERROR_CHECK(gptimer_enable(timer)); ESP_ERROR_CHECK(gptimer_start(timer)); return timer; } +void stop_sample_clock(gptimer_handle_t& timer) noexcept { + if (timer == nullptr) return; + static_cast(gptimer_stop(timer)); + static_cast(gptimer_disable(timer)); + static_cast(gptimer_del_timer(timer)); + timer = nullptr; +} + void publisher_task(void* argument) { #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ const auto eth_handle = static_cast(argument); + const auto task_handle = xTaskGetCurrentTaskHandle(); + live_control_bind_publisher_task(task_handle); ESP_LOGW(kTag, "LIVE SV TX ENABLED: isolated lab network only"); ESP_LOGI(kTag, "Waiting for Ethernet link..."); @@ -350,129 +387,123 @@ void publisher_task(void* argument) { std::array source_mac{}; ESP_ERROR_CHECK(esp_eth_ioctl(eth_handle, ETH_CMD_G_MAC_ADDR, source_mac.data())); - - ESP_LOGI(kTag, - "EMAC source MAC=%02X:%02X:%02X:%02X:%02X:%02X", + ESP_LOGI(kTag, "EMAC source MAC=%02X:%02X:%02X:%02X:%02X:%02X", source_mac[0], source_mac[1], source_mac[2], source_mac[3], source_mac[4], source_mac[5]); - PacketTemplate canonical; - PacketTemplate mirror; - - if (!build_packet_template( - source_mac, -#if CONFIG_AR_SMV_CANONICAL_VLAN - true, -#else - false, -#endif - false, - false, - canonical)) { - ESP_LOGE(kTag, "Cannot start publisher: canonical template build failed"); - vTaskDelete(nullptr); - return; - } - -#if CONFIG_AR_SMV_BROADCAST_MIRROR - if (!build_packet_template(source_mac, false, true, true, mirror)) { - ESP_LOGE(kTag, "Cannot start publisher: diagnostic mirror template build failed"); - vTaskDelete(nullptr); - return; - } -#endif - -#if CONFIG_AR_SMV_DIAGNOSTIC_PROBES - const auto probe_broadcast = make_probe(source_mac, false); - const auto probe_multicast = make_probe(source_mac, true); -#endif - const auto initial_signal = live_signal_snapshot(); + const auto initial_profile = runtime_profile_snapshot(); ESP_LOGI(kTag, - "Live signal engine ready: publisher=%u fps, signal=%u mHz, generation=%llu; type START to transmit", - static_cast(kSampleRateHz), + "Live signal engine ready: profile=%s rate=%lu fps signal=%u mHz generation=%llu; use GUI START", + initial_profile.sv_id.data(), + static_cast(initial_profile.publisher_rate_hz), static_cast(initial_signal.frequency_millihz), static_cast(initial_signal.generation)); - ESP_LOGI(kTag, - "Development profile: svID=%s APPID=0x%04X canonical smpCnt wrap=%u %s", - kCanonicalSvId, - static_cast(kCanonicalAppId), - static_cast(kSampleCountWrap), -#if CONFIG_AR_SMV_CANONICAL_VLAN - "VLAN PCP4/VID0" -#else - "untagged" -#endif - ); - ESP_LOGI(kTag, - "Fixed template: canonical len=%u smpCnt@%u seqData@%u", - static_cast(canonical.bytes.size()), - static_cast(canonical.sample_count_offset), - static_cast(canonical.sample_payload_offset)); + PacketTemplate canonical; + PacketTemplate mirror; + RuntimePublisherProfile active_profile = initial_profile; static SvFixedPointSineEngine signal_engine; - - vTaskDelay(pdMS_TO_TICKS(100)); - const gptimer_handle_t sample_clock = start_sample_clock(xTaskGetCurrentTaskHandle()); - (void)sample_clock; - ESP_LOGI(kTag, - "Sample clock: GPTimer 1 MHz, periodic alarm every %lld us; publisher blocks between alarms", - static_cast(kSamplePeriodUs)); - + gptimer_handle_t sample_clock = nullptr; + RationalScheduleCursor expected_schedule{}; std::uint16_t canonical_sample_count = 0U; std::uint16_t mirror_sample_count = 0U; TimingStats local{}; bool schedule_anchored = false; std::int64_t expected_wake_us = 0; +#if CONFIG_AR_SMV_DIAGNOSTIC_PROBES + std::array probe_broadcast{}; + std::array probe_multicast{}; +#endif + while (true) { const std::uint32_t pending_ticks = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - if (pending_ticks == 0U) { - continue; - } + if (pending_ticks == 0U) continue; if (take_start_request()) { + stop_sample_clock(sample_clock); + active_profile = runtime_profile_snapshot(); + if (!runtime_profile_validate(active_profile)) { + ESP_LOGE(kTag, "START rejected: active runtime profile is invalid"); + continue; + } + if (!build_packet_template(source_mac, active_profile, false, false, canonical)) { + ESP_LOGE(kTag, "START rejected: canonical template build failed"); + continue; + } +#if CONFIG_AR_SMV_BROADCAST_MIRROR + if (!build_packet_template(source_mac, active_profile, true, true, mirror)) { + ESP_LOGE(kTag, "START rejected: diagnostic mirror template build failed"); + continue; + } +#endif +#if CONFIG_AR_SMV_DIAGNOSTIC_PROBES + probe_broadcast = make_probe(source_mac, active_profile.destination_mac, false); + probe_multicast = make_probe(source_mac, active_profile.destination_mac, true); +#endif canonical_sample_count = 0U; mirror_sample_count = 0U; signal_engine.reset_phase(); - schedule_anchored = false; local = TimingStats{}; + reset_interval_stats(); + schedule_anchored = false; + expected_schedule.reset(active_profile.publisher_rate_hz); + static_cast(expected_schedule.next_ticks()); // first alarm interval + sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); + + ESP_LOGI(kTag, + "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", + static_cast(active_profile.generation), + active_profile.sv_id.data(), + static_cast(active_profile.app_id), + static_cast(active_profile.publisher_rate_hz), + static_cast(active_profile.sample_counter_modulus), + static_cast(active_profile.configuration_revision), + active_profile.vlan_present ? 1U : 0U, + static_cast(active_profile.vlan_id), + static_cast(active_profile.vlan_priority), + static_cast(canonical.bytes.size())); + continue; } if (!live_tx_running()) { + stop_sample_clock(sample_clock); schedule_anchored = false; continue; } + if (sample_clock == nullptr) continue; const std::int64_t actual_wake_us = esp_timer_get_time(); std::uint64_t missed_slots = 0U; - if (!schedule_anchored) { expected_wake_us = actual_wake_us; schedule_anchored = true; } else { - expected_wake_us += - static_cast(pending_ticks) * kSamplePeriodUs; + std::uint64_t elapsed_ticks = 0U; + for (std::uint32_t i = 0U; i < pending_ticks; ++i) { + elapsed_ticks += expected_schedule.next_ticks(); + } + expected_wake_us += static_cast(elapsed_ticks); } const auto signal_state = live_signal_snapshot(); - if (pending_ticks > 1U) { missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( (static_cast(canonical_sample_count) + missed_slots) % - kSampleCountWrap); + active_profile.sample_counter_modulus); mirror_sample_count = static_cast( static_cast(mirror_sample_count) + static_cast(missed_slots)); signal_engine.advance( signal_state, - kSampleRateHz, + active_profile.publisher_rate_hz, static_cast(missed_slots)); } const std::int64_t lateness_us = actual_wake_us - expected_wake_us; - const auto row = signal_engine.next(signal_state, kSampleRateHz); + const auto row = signal_engine.next(signal_state, active_profile.publisher_rate_hz); if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { ++local.canonical_fail; @@ -481,54 +512,35 @@ void publisher_task(void* argument) { #endif } else { patch_packet(canonical, canonical_sample_count, row, signal_state); - const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); - if (canonical_result == ESP_OK) { - ++local.canonical_ok; - } else { - ++local.canonical_fail; - } + if (canonical_result == ESP_OK) ++local.canonical_ok; + else ++local.canonical_fail; #if CONFIG_AR_SMV_BROADCAST_MIRROR patch_packet(mirror, mirror_sample_count, row, signal_state); const esp_err_t mirror_result = esp_eth_transmit(eth_handle, mirror.bytes.data(), mirror.bytes.size()); - if (mirror_result == ESP_OK) { - ++local.mirror_ok; - } else { - ++local.mirror_fail; - } + if (mirror_result == ESP_OK) ++local.mirror_ok; + else ++local.mirror_fail; #endif #if CONFIG_AR_SMV_DIAGNOSTIC_PROBES - if ((canonical_sample_count % kSampleCountWrap) == 0U) { - (void)esp_eth_transmit( - eth_handle, - const_cast(probe_broadcast.data()), - probe_broadcast.size()); - (void)esp_eth_transmit( - eth_handle, - const_cast(probe_multicast.data()), - probe_multicast.size()); + if (canonical_sample_count == 0U) { + static_cast(esp_eth_transmit( + eth_handle, probe_broadcast.data(), probe_broadcast.size())); + static_cast(esp_eth_transmit( + eth_handle, probe_multicast.data(), probe_multicast.size())); } #endif } update_local_timing( - local, - lateness_us, - missed_slots, - canonical_sample_count, - signal_state.generation); - + local, lateness_us, missed_slots, canonical_sample_count, signal_state.generation); canonical_sample_count = static_cast( - (canonical_sample_count + 1U) % kSampleCountWrap); + (canonical_sample_count + 1U) % active_profile.sample_counter_modulus); mirror_sample_count = static_cast(mirror_sample_count + 1U); - - if (local.samples >= kStatsMergeEvery) { - merge_stats(local); - } + if (local.samples >= kStatsMergeEvery) merge_stats(local); } #else (void)argument; @@ -540,20 +552,15 @@ void publisher_task(void* argument) { void telemetry_task(void*) { while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); - TimingStats snapshot{}; portENTER_CRITICAL(&g_stats_mux); snapshot = g_interval_stats; g_interval_stats = TimingStats{}; portEXIT_CRITICAL(&g_stats_mux); - - if (snapshot.samples == 0U) { - continue; - } + if (snapshot.samples == 0U) continue; const std::int64_t mean_lateness_us = snapshot.lateness_sum_us / static_cast(snapshot.samples); - ESP_LOGI(kTag, "timing: samples=%llu (~%llu fps) MC ok=%llu fail=%llu mirror ok=%llu fail=%llu missed=%llu wake_late_us[min/mean/max]=%ld/%lld/%ld smpCnt=%u signal_gen=%llu", static_cast(snapshot.samples), @@ -589,48 +596,27 @@ extern "C" void app_main(void) { ESP_LOGE(kTag, "ESP32-P4 Ethernet bring-up failed"); return; } - ESP_ERROR_CHECK(esp_eth_start(eth_handle)); #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ - // Existing menuconfig values seed the first live generation. Current - // development wire scaling is 1 mA/count for I and 10 mV/count for U. + runtime_profile_initialize(); live_control_initialize( static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA), static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV / 10)); if (xTaskCreatePinnedToCore( - &live_control_task, - "ar_smv_control", - 6144, - nullptr, - 1, - nullptr, - 0) != pdPASS) { + &live_control_task, "ar_smv_control", 7168, nullptr, 1, nullptr, 0) != pdPASS) { ESP_LOGE(kTag, "Failed to create live control task on CPU0"); return; } - if (xTaskCreatePinnedToCore( - &publisher_task, - "ar_smv_publisher", - 8192, - eth_handle, - configMAX_PRIORITIES - 2, - nullptr, - 1) != pdPASS) { + &publisher_task, "ar_smv_publisher", 9216, eth_handle, + configMAX_PRIORITIES - 2, nullptr, 1) != pdPASS) { ESP_LOGE(kTag, "Failed to create realtime publisher task on CPU1"); return; } - if (xTaskCreatePinnedToCore( - &telemetry_task, - "ar_smv_telemetry", - 4096, - nullptr, - 2, - nullptr, - 0) != pdPASS) { + &telemetry_task, "ar_smv_telemetry", 4096, nullptr, 2, nullptr, 0) != pdPASS) { ESP_LOGE(kTag, "Failed to create telemetry task on CPU0"); return; } From 006e89f68e1cb5520f223ab6a6a619b8bb5ee63a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:06:50 +0700 Subject: [PATCH 053/129] feat(gui): connect SCL compiler to profile deployment workflow --- apps/smv_injector_gui/profile_bridge.js | 321 ++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 apps/smv_injector_gui/profile_bridge.js diff --git a/apps/smv_injector_gui/profile_bridge.js b/apps/smv_injector_gui/profile_bridge.js new file mode 100644 index 000000000..e3de88b4b --- /dev/null +++ b/apps/smv_injector_gui/profile_bridge.js @@ -0,0 +1,321 @@ +"use strict"; + +const profileBridge = { + file: null, + inspection: null, + selectedIndex: 0, + deployed: false, + deploying: false, + currentCountsPerAmp: 1000, + voltageCountsPerVolt: 100, +}; + +function utf8Hex(text) { + const bytes = new TextEncoder().encode(text ?? ""); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function macHex(text) { + return String(text ?? "").replace(/[^0-9a-f]/gi, "").toUpperCase(); +} + +function selectedCompiledStream() { + return profileBridge.inspection?.streams?.find((item) => item.index === profileBridge.selectedIndex) ?? null; +} + +function installProfileUi() { + const streamPanel = document.querySelector(".stream-panel"); + const microcopy = streamPanel?.querySelector(".microcopy"); + if (!streamPanel || !microcopy) return; + + microcopy.textContent = "Import engineering data through the C++ IEC 61850 engine. Representation differences may be normalized, but unresolved semantics block deployment."; + + const bridge = document.createElement("section"); + bridge.className = "profile-bridge-card"; + bridge.innerHTML = ` +
+
+
Engineering profile compiler
+ No SCL profile selected +
+ — +
+
+ +
Destination—
+
APPID—
+
Sample rate—
+
Payload—
+
Device support—
+
+ + +
Import an engineering file to compile its SV streams.
+
+ Development profile remains armed + +
`; + microcopy.after(bridge); + + $("streamSelect").addEventListener("change", () => { + profileBridge.selectedIndex = Number($("streamSelect").value); + profileBridge.deployed = false; + renderProfileSelection(); + }); + $("validateProfileButton").addEventListener("click", validateCounterPolicy); + $("deployProfileButton").addEventListener("click", deploySelectedProfile); + $("currentScale").addEventListener("change", updateScaling); + $("voltageScale").addEventListener("change", updateScaling); + + els.sclFile.addEventListener("change", async () => { + const file = els.sclFile.files?.[0]; + if (!file) return; + profileBridge.file = file; + profileBridge.deployed = false; + await inspectEngineeringFile(null); + }); + + // Replace the development-only conversion with explicit profile-level test scaling. + wireCountFor = function(channel) { + const scale = channel.kind === "current" + ? profileBridge.currentCountsPerAmp + : profileBridge.voltageCountsPerVolt; + return Math.round(channel.magnitude * scale); + }; + + const baseSetConnected = setConnected; + setConnected = function(connected) { + baseSetConnected(connected); + refreshDeployAvailability(); + }; + const baseSetRunning = setRunning; + setRunning = function(running) { + baseSetRunning(running); + refreshDeployAvailability(); + }; + const baseProcessDeviceLine = processDeviceLine; + processDeviceLine = function(raw) { + baseProcessDeviceLine(raw); + const line = raw.replace(/\x1b\[[0-9;]*m/g, "").trim(); + const committed = line.match(/PROFILE committed generation=(\d+)\s+svID=(\S+)\s+APPID=0x([0-9A-Fa-f]+)\s+rate=(\d+)\s+wrap=(\d+)/); + if (committed && profileBridge.deploying) { + profileBridge.deploying = false; + profileBridge.deployed = true; + applySelectedProfileToActiveCard(); + $("deployState").textContent = `Profile armed · generation ${committed[1]}`; + showToast("SCL profile deployed and armed"); + refreshDeployAvailability(); + } + const armed = line.match(/PROFILE armed generation=(\d+)\s+svID=(\S+)\s+APPID=0x([0-9A-Fa-f]+)\s+rate=(\d+)\s+wrap=(\d+)/); + if (armed) { + $("deployState").textContent = `Running profile · generation ${armed[1]}`; + } + if (line.includes("PROFILE commit rejected") || line.includes("PROFILE rejected")) { + profileBridge.deploying = false; + showToast("Device rejected the profile", true); + refreshDeployAvailability(); + } + }; +} + +function updateScaling() { + const current = Number($("currentScale").value); + const voltage = Number($("voltageScale").value); + if (Number.isFinite(current) && current > 0) profileBridge.currentCountsPerAmp = current; + if (Number.isFinite(voltage) && voltage > 0) profileBridge.voltageCountsPerVolt = voltage; + buildChannels(); + drawPhasors(); +} + +async function inspectEngineeringFile(counterModulus) { + const file = profileBridge.file; + if (!file) return; + els.sclState.textContent = `${file.name} · compiling…`; + $("bridgeMessages").textContent = "Running smart IEC 61850 profile compiler…"; + try { + const query = counterModulus ? `?counterModulus=${encodeURIComponent(counterModulus)}` : ""; + const response = await fetch(`/api/scl/inspect${query}`, { + method: "POST", + headers: { "Content-Type": "application/xml", "X-File-Name": file.name }, + body: await file.arrayBuffer(), + }); + const payload = await response.json(); + if (payload.fatalError) throw new Error(payload.fatalError); + profileBridge.inspection = payload; + if (!payload.streams?.length) throw new Error("No Sampled Values streams were resolved by the IEC 61850 engine."); + if (!payload.streams.some((stream) => stream.index === profileBridge.selectedIndex)) { + profileBridge.selectedIndex = payload.streams[0].index; + } + populateStreamSelector(); + els.sclState.textContent = `${file.name} · ${payload.streams.length} compiled SV stream${payload.streams.length === 1 ? "" : "s"}`; + renderProfileSelection(); + showToast("Engineering file compiled by the IEC 61850 engine"); + } catch (error) { + profileBridge.inspection = null; + els.sclState.textContent = `${file.name} · rejected`; + $("bridgeMessages").textContent = error.message; + $("compatBadge").textContent = "BLOCKED"; + $("compatBadge").dataset.class = "C"; + showToast(error.message, true); + refreshDeployAvailability(); + } +} + +function populateStreamSelector() { + const select = $("streamSelect"); + select.innerHTML = ""; + for (const stream of profileBridge.inspection.streams) { + const option = document.createElement("option"); + option.value = String(stream.index); + option.textContent = `${stream.ied || "IED"} · ${stream.control || stream.profile?.svID || `SV ${stream.index + 1}`}`; + option.selected = stream.index === profileBridge.selectedIndex; + select.appendChild(option); + } + select.disabled = false; +} + +function renderProfileSelection() { + const stream = selectedCompiledStream(); + const p = stream?.profile; + if (!stream || !p) { + $("bridgeTitle").textContent = "Stream cannot be compiled"; + $("bridgeMessages").textContent = [...(stream?.errors ?? []), ...(stream?.warnings ?? [])].join(" · ") || "Unresolved profile"; + $("compatBadge").textContent = "CLASS C"; + $("compatBadge").dataset.class = "C"; + $("counterConfirm").hidden = true; + $("scalingRow").hidden = true; + refreshDeployAvailability(); + return; + } + + $("bridgeTitle").textContent = p.svID || stream.controlBlockReference || "SV profile"; + $("compatBadge").textContent = `CLASS ${stream.compatibilityClass}`; + $("compatBadge").dataset.class = stream.compatibilityClass; + $("bridgeMac").textContent = p.destinationMac; + $("bridgeAppid").textContent = `0x${Number(p.appID).toString(16).toUpperCase().padStart(4, "0")}`; + $("bridgeRate").textContent = p.publisherRateHz ? `${p.publisherRateHz} fps` : `${p.sampleRate} ${p.sampleMode}`; + $("bridgePayload").textContent = `${p.payloadBytes} B · ${p.channels.length} leaves`; + $("bridgeSupport").textContent = stream.deviceSupport; + $("counterConfirm").hidden = stream.compatibilityClass === "A"; + $("scalingRow").hidden = false; + if (stream.compatibilityClass !== "A" && p.counterModulus) { + $("counterModulus").value = String(p.counterModulus); + $("counterConfirmed").checked = false; + } + const messages = [...(stream.errors ?? []), ...(stream.warnings ?? [])]; + if (profileBridge.inspection.warnings?.length) messages.push(...profileBridge.inspection.warnings); + if (profileBridge.inspection.conflicts?.length) { + messages.push(...profileBridge.inspection.conflicts.map((c) => `Conflict: ${c.description}`)); + } + $("bridgeMessages").textContent = messages.join(" · ") || "Profile semantics resolved."; + $("deployState").textContent = profileBridge.deployed ? "Profile armed on device" : "Not deployed"; + refreshDeployAvailability(); +} + +async function validateCounterPolicy() { + const value = Number($("counterModulus").value); + if (!$("counterConfirmed").checked) { + showToast("Confirm the counter cycle before validation", true); + return; + } + if (!Number.isInteger(value) || value <= 0 || value > 65535) { + showToast("Counter modulus must be 1..65535", true); + return; + } + await inspectEngineeringFile(value); +} + +function refreshDeployAvailability() { + const button = $("deployProfileButton"); + if (!button) return; + const stream = selectedCompiledStream(); + const ready = Boolean( + stream?.profile && + stream.compatibilityClass === "A" && + stream.deviceSupport === "ready" && + state.connected && !state.running && !profileBridge.deploying + ); + button.disabled = !ready; + if (profileBridge.file && !profileBridge.deployed) { + els.startButton.disabled = true; + } else if (state.connected && !state.running) { + els.startButton.disabled = false; + } +} + +async function deploySelectedProfile() { + const stream = selectedCompiledStream(); + const p = stream?.profile; + if (!p || stream.compatibilityClass !== "A" || stream.deviceSupport !== "ready") { + showToast("Profile is not deployable yet", true); + return; + } + if (state.running) { + showToast("Stop the publisher before changing profile identity/layout", true); + return; + } + + const idHex = utf8Hex(p.svID); + const dataSetHex = p.asduOptions?.dataSet ? utf8Hex(p.dataSetReference) : "-"; + if (!idHex || idHex.length > 180 || dataSetHex.length > 170) { + showToast("Profile identifier is too long for the current device bridge", true); + return; + } + const mac = macHex(p.destinationMac); + if (mac.length !== 12) { + showToast("Invalid destination MAC in compiled profile", true); + return; + } + let flags = 0; + if (p.asduOptions?.dataSet) flags |= 1; + if (p.asduOptions?.sampleRate) flags |= 2; + + profileBridge.deploying = true; + profileBridge.deployed = false; + $("deployState").textContent = "Deploying validated profile…"; + refreshDeployAvailability(); + + const commands = [ + "PROFILE BEGIN", + `PROFILE ID ${idHex}`, + `PROFILE DATASET ${dataSetHex}`, + `PROFILE L2 ${p.appID} ${mac} ${p.vlanPresent ? 1 : 0} ${p.vlanID || 0} ${p.vlanPriority || 0}`, + `PROFILE SV ${p.confRev} ${p.publisherRateHz} ${p.counterModulus} ${p.nofASDU} ${flags}`, + "PROFILE COMMIT", + "PROFILE SHOW", + ]; + for (const command of commands) { + const sent = await sendCommand(command); + if (!sent) { + profileBridge.deploying = false; + refreshDeployAvailability(); + return; + } + } +} + +function applySelectedProfileToActiveCard() { + const stream = selectedCompiledStream(); + const p = stream?.profile; + if (!p) return; + $("profileBadge").textContent = "SCL profile armed"; + $("svIdValue").textContent = p.svID; + $("appIdValue").textContent = `0x${Number(p.appID).toString(16).toUpperCase().padStart(4, "0")}`; + $("sampleRateValue").textContent = `${p.publisherRateHz} fps`; + $("counterValue").textContent = `wrap ${p.counterModulus}`; + $("vlanValue").textContent = p.vlanPresent ? `PCP ${p.vlanPriority} · VID ${p.vlanID}` : "untagged"; +} + +installProfileUi(); From 55030c04754a5e6ced00d92ed12771a54cb41b86 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:07:12 +0700 Subject: [PATCH 054/129] style(gui): add SCL profile compiler and deploy states --- apps/smv_injector_gui/profile_bridge.css | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/smv_injector_gui/profile_bridge.css diff --git a/apps/smv_injector_gui/profile_bridge.css b/apps/smv_injector_gui/profile_bridge.css new file mode 100644 index 000000000..fba5cad40 --- /dev/null +++ b/apps/smv_injector_gui/profile_bridge.css @@ -0,0 +1,67 @@ +.profile-bridge-card { + margin-top: 16px; + padding: 15px; + border: 1px solid rgba(79,199,255,.18); + border-radius: 14px; + background: rgba(4,12,18,.34); +} +.bridge-heading, .bridge-actions, .counter-confirm, .scaling-row { + display: flex; + align-items: center; +} +.bridge-heading { justify-content: space-between; gap: 14px; } +.bridge-heading strong { display:block; margin-top:3px; font-size:14px; } +.compat-badge { + min-width: 66px; padding: 6px 10px; border-radius: 999px; text-align:center; + font-size:10px; font-weight:800; letter-spacing:.06em; border:1px solid var(--line); + color:var(--muted); background:rgba(255,255,255,.025); +} +.compat-badge[data-class="A"] { color:#bdf4dd; border-color:rgba(61,220,151,.3); background:rgba(61,220,151,.09); } +.compat-badge[data-class="B"] { color:#ffe2a2; border-color:rgba(242,187,76,.3); background:rgba(242,187,76,.08); } +.compat-badge[data-class="C"] { color:#ffc4c9; border-color:rgba(255,107,118,.3); background:rgba(255,107,118,.08); } +.bridge-grid { + margin-top:13px; display:grid; grid-template-columns:1.5fr repeat(5, minmax(95px,1fr)); gap:8px; +} +.bridge-field { + min-height:58px; padding:9px 10px; border:1px solid var(--line); border-radius:10px; + background:rgba(3,10,15,.35); overflow:hidden; +} +.bridge-field span { display:block; color:var(--muted); font-size:9px; text-transform:uppercase; letter-spacing:.09em; } +.bridge-field strong { display:block; margin-top:5px; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.bridge-field select { + width:100%; margin-top:5px; border:0; outline:0; color:var(--text); background:#0a1721; + font-size:11px; font-weight:650; +} +.counter-confirm { + margin-top:10px; gap:10px; flex-wrap:wrap; padding:10px; border-radius:10px; + border:1px solid rgba(242,187,76,.18); background:rgba(242,187,76,.045); +} +.counter-confirm > div { min-width:220px; flex:1; } +.counter-confirm span { display:block; color:#e8c979; font-size:10px; font-weight:750; } +.counter-confirm strong { display:block; margin-top:3px; color:var(--muted); font-size:9px; font-weight:500; } +.counter-confirm input[type="number"] { + width:100px; height:34px; border:1px solid var(--line); border-radius:8px; background:#071119; color:var(--text); padding:0 8px; +} +.counter-confirm label { color:#cbd8e1; font-size:10px; } +.scaling-row { + margin-top:10px; gap:10px; flex-wrap:wrap; padding:9px 10px; border:1px solid var(--line); border-radius:10px; +} +.scaling-note { min-width:230px; flex:1; color:var(--muted); font-size:9px; line-height:1.4; } +.scaling-row label { display:flex; align-items:center; gap:5px; color:#cbd8e1; font-size:9px; } +.scaling-row input { width:78px; height:30px; border:1px solid var(--line); border-radius:7px; background:#071119; color:var(--text); padding:0 7px; } +.scaling-row span { color:var(--muted); } +.bridge-messages { + margin-top:10px; color:#8199aa; font-size:9px; line-height:1.45; max-height:58px; overflow:auto; +} +.bridge-actions { justify-content:space-between; gap:10px; margin-top:10px; } +.bridge-actions span { color:var(--muted); font-size:10px; } +.bridge-actions .btn { min-width:150px; } + +@media (max-width: 1360px) { + .bridge-grid { grid-template-columns: repeat(3, minmax(120px,1fr)); } + .bridge-stream { grid-column: span 2; } +} +@media (max-width: 900px) { + .bridge-grid { grid-template-columns: 1fr 1fr; } + .bridge-stream { grid-column: 1 / -1; } +} From 5549459fa0f444772d6b2bfd475eb343434722d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:07:37 +0700 Subject: [PATCH 055/129] feat(gui): load smart SCL profile bridge --- apps/smv_injector_gui/index.html | 88 ++++++-------------------------- 1 file changed, 16 insertions(+), 72 deletions(-) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 9754aa900..1c2362f13 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -7,25 +7,17 @@ ARStack61850 · SMV Injector +
- -
-
ARStack61850
-

SMV Injector

-
+ +
ARStack61850

SMV Injector

-
-
- - Device offline -
+
Device offline
@@ -34,18 +26,11 @@

SMV Injector

-
-
Process bus stream
-

Active publisher profile

-
+
Process bus stream

Active publisher profile

Development profile
-
-
- svID - MU01_SV1 -
+
svIDMU01_SV1
APPID0x4000
Rate4000 fps
@@ -53,55 +38,32 @@

Active publisher profile

VLANPCP 4 · VID 0
-
- +
No engineering file loaded
-

Engineering files are loaded locally. Runtime profile deployment will connect to the host profile compiler; live signal control below already talks directly to the ESP32-P4 bench firmware.

+

Engineering files are compiled by the local IEC 61850 profile engine before deployment.

-
- -
-
Publisher state
- STOPPED -
-
- +
Publisher state
STOPPED
Observed rate—
Missed slots—
TX failures—
Signal generation—
- -
- - -
+
-
-
Signal frequency
-
Hz
-
+
Signal frequency
Hz
-
- + @@ -109,42 +71,24 @@

Active publisher profile

-
-
-
Live signal control
-

Current & voltage channels

-
-

Magnitude uses engineering units. Phase uses ordinary degrees. Conversion to wire counts and milli-degrees happens inside the GUI.

-
+
Live signal control

Current & voltage channels

Magnitude uses engineering units. Phase uses ordinary degrees. Conversion to wire values remains outside the realtime publisher.

-
-
-
Vector preview
-

Phase relationships

-
-
+
Vector preview

Phase relationships

-
-
-
-
Device evidence
-

Runtime log

-
- -
+
Device evidence

Runtime log

Waiting for device connection…
-
+ From 18b090806b59d8902a15ed89fb9ccbc5bc5efe84 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:08:19 +0700 Subject: [PATCH 056/129] ci(gui): validate smart SCL profile bridge --- .github/workflows/smv-injector-gui.yml | 55 +++++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index f414f17ba..7f2c9b743 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -4,17 +4,27 @@ on: push: paths: - 'apps/smv_injector_gui/**' + - 'tools/smv_profile_inspect.cpp' + - 'include/ariec61850/sampled_values/publisher_profile.hpp' + - 'include/ariec61850/scl/**' + - 'src/scl/**' + - 'tests/fixtures/scl/**' - '.github/workflows/smv-injector-gui.yml' pull_request: paths: - 'apps/smv_injector_gui/**' + - 'tools/smv_profile_inspect.cpp' + - 'include/ariec61850/sampled_values/publisher_profile.hpp' + - 'include/ariec61850/scl/**' + - 'src/scl/**' + - 'tests/fixtures/scl/**' - '.github/workflows/smv-injector-gui.yml' permissions: contents: read jobs: - static-smoke: + profile-bridge-smoke: runs-on: ubuntu-latest steps: - name: Checkout @@ -26,28 +36,53 @@ jobs: node-version: '22' - name: JavaScript syntax - run: node --check apps/smv_injector_gui/app.js + run: | + node --check apps/smv_injector_gui/app.js + node --check apps/smv_injector_gui/profile_bridge.js - name: Required UI files shell: bash run: | test -s apps/smv_injector_gui/index.html test -s apps/smv_injector_gui/styles.css + test -s apps/smv_injector_gui/profile_bridge.css test -s apps/smv_injector_gui/app.js + test -s apps/smv_injector_gui/profile_bridge.js + test -s apps/smv_injector_gui/host_server.py test -s apps/smv_injector_gui/run.cmd - grep -q 'SMV Injector' apps/smv_injector_gui/index.html grep -q 'navigator.serial' apps/smv_injector_gui/app.js + grep -q '/api/scl/inspect' apps/smv_injector_gui/profile_bridge.js + + - name: Build smart SCL profile engine + shell: bash + run: | + cmake -S apps/smv_injector_gui/host -B build-smv-gui -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build build-smv-gui --target ariec61850_smv_profile_inspect + + - name: Profile compiler regression + shell: bash + run: | + fixture=tests/fixtures/scl/sv-4800-structured-4i4v.scd + build-smv-gui/ariec61850_smv_profile_inspect "$fixture" > /tmp/profile-b.json + grep -q '"compatibilityClass":"B"' /tmp/profile-b.json + grep -q '"publisherRateHz":4800' /tmp/profile-b.json + build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --counter-modulus 4800 > /tmp/profile-a.json + grep -q '"compatibilityClass":"A"' /tmp/profile-a.json + grep -q '"deviceSupport":"ready"' /tmp/profile-a.json - - name: Local-server smoke + - name: Local backend smoke shell: bash run: | - python3 -m http.server 8765 --bind 127.0.0.1 --directory apps/smv_injector_gui >/tmp/smv-gui-http.log 2>&1 & + python3 apps/smv_injector_gui/host_server.py --profile-tool build-smv-gui/ariec61850_smv_profile_inspect --port 8765 >/tmp/smv-gui-http.log 2>&1 & server_pid=$! trap 'kill ${server_pid} 2>/dev/null || true' EXIT - for attempt in $(seq 1 20); do - if curl --fail --silent http://127.0.0.1:8765/ >/tmp/smv-gui-index.html; then - break - fi - sleep 0.25 + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8765/ >/tmp/smv-gui-index.html; then break; fi + sleep 0.2 done grep -q 'ARStack61850' /tmp/smv-gui-index.html + curl --fail --silent -X POST \ + -H 'X-File-Name: fixture.scd' \ + --data-binary @tests/fixtures/scl/sv-4800-structured-4i4v.scd \ + 'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800' >/tmp/api.json + grep -q '"deviceSupport":"ready"' /tmp/api.json From ff66b64ba84c7245984923bd86040e4528ad34cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:10:17 +0700 Subject: [PATCH 057/129] docs(gui): document SCL compile and device deploy workflow --- apps/smv_injector_gui/README.md | 137 +++++++++++++++----------------- 1 file changed, 65 insertions(+), 72 deletions(-) diff --git a/apps/smv_injector_gui/README.md b/apps/smv_injector_gui/README.md index cf498575e..eec90e23f 100644 --- a/apps/smv_injector_gui/README.md +++ b/apps/smv_injector_gui/README.md @@ -1,96 +1,89 @@ # ARStack61850 SMV Injector GUI -This is the first operator-facing control surface for the ESP32-P4 Sampled Values injector runtime. - -It intentionally replaces direct bench command entry with user-facing engineering controls while keeping the current firmware protocol unchanged underneath. - -## What works now - -- connect directly to the ESP32-P4 serial control port; -- START and STOP the deterministic publisher; -- edit Ia, Ib, Ic, In, Ua, Ub, Uc and Un while the stream is running; -- use engineering units instead of raw firmware units: - - current: A RMS -> current wire counts for the current development profile; - - voltage: V RMS -> voltage wire counts for the current development profile; - - phase: degrees -> milli-degrees; - - frequency: Hz -> millihertz; -- enable/disable channels; -- zero all outputs; -- restore a balanced three-phase bench state; -- live-apply values as the operator edits them, or pause live apply and use Apply/Apply all; -- display a local phasor preview; -- parse device telemetry for publisher rate, missed slots, failures and signal generation; -- load an SCL/CID/SCD/IID file locally and confirm that it is well-formed SCL plus count discovered `SampledValueControl` elements. - -The SCL file picker is deliberately not a second IEC 61850 semantic engine. Full engineering-file normalization and `SvPublisherProfile` compilation remain in the C++ host engine and will be connected to this GUI through the P3-A1 device-profile bridge. +Operator-facing control plane for the ESP32-P4 Sampled Values injector. -## Run +The GUI is not a waveform generator. The PC compiles engineering intent and sends bounded control/profile updates; the ESP32-P4 remains the realtime publisher. -1. Flash the current `feature/sv-live-control-runtime` compatible firmware (the GUI branch is based on that runtime). -2. Exit `idf.py monitor` with `Ctrl+]`. The serial port cannot be owned by the monitor and GUI at the same time. -3. From the repository root, run: +## Current workflow -```powershell -.\apps\smv_injector_gui\run.cmd +```text +Import SCL / CID / SCD / IID + -> C++ SCL parser + -> normalized SvPublisherProfile + -> select SV stream + -> resolve warnings / counter policy + -> configure test scaling when SCL does not establish it + -> Deploy to device while STOPPED + -> Start + -> live I/U/phase/frequency/quality changes + -> Stop ``` -4. The launcher opens the local GUI and starts an HTTP server bound only to `127.0.0.1`. -5. Click **Connect device** and select the ESP32-P4 serial port. -6. Use **Start**, edit channel magnitude/phase/frequency, and observe the live stream. +The browser does not implement a second IEC 61850 semantic parser. `run.cmd` builds the focused C++ profile compiler and starts a localhost-only Python bridge. Uploaded engineering data is written to a temporary local file only for compilation and removed immediately after the request. -The browser must expose the Web Serial API. The GUI is served from localhost because device serial APIs require a secure local context. +## Supported device profile in this phase -## Important unit boundary +The host parser can inspect broader SCL SV structures, but the first ESP32-P4 deployment bridge intentionally accepts only the physically proven 4I+4V wire layout: -The firmware bench protocol still carries raw units. The GUI hides those details from the operator: +- one ASDU; +- `SmpPerSec` with an absolute publisher rate; +- explicit/validated sample-counter modulus; +- 16 ordered leaves as 8 `INT32 value + Quality` pairs; +- 64-byte sample payload; +- configurable destination MAC, APPID, VLAN/PCP, svID, confRev and supported ASDU field presence. -```text -GUI current A RMS -> SET ... current_counts ... -GUI voltage V RMS -> SET ... voltage_counts ... -GUI phase deg -> SET ... phase_mdeg ... -GUI frequency Hz -> FREQ frequency_millihz -``` +A structurally valid but unsupported layout remains visible in the GUI and is blocked from deployment. The firmware does not guess a payload layout. -This means an operator can enter `-120` degrees and the GUI sends `-120000` milli-degrees. The earlier confusing direct-console behavior is no longer part of the normal operator workflow. +## Counter-policy rule -The current A/V conversion is explicitly tied to the development profile that is still hard-coded in the firmware. When the compiled SCL profile bridge lands, the GUI must obtain engineering-unit scaling from the resolved profile rather than assume a universal scale. +SCL sampling rate does not universally prove the `smpCnt` wrap policy. When the compiler can only infer a rate-sized candidate, the GUI shows Class B and requires explicit confirmation from an applicable profile rule, independent wire evidence, or engineering knowledge before deployment becomes Class A. -## Realtime rule +## Engineering scaling -The GUI is control plane only. It does not synthesize the 4 kHz waveform and does not become a timing dependency. +Generic SCL does not always establish physical scaling. The GUI therefore exposes current and voltage counts-per-unit conversion for the active test profile instead of claiming a universal scale. This scaling affects only engineering-value conversion in the control plane; it does not change realtime scheduling. -```text -GUI / host - | - | control updates - v -ESP32-P4 live state bank - | - v -fixed-point signal engine - | - v -prebuilt SV packet template - | - v -realtime Ethernet publisher +## Run + +1. Flash firmware from the same `feature/smv-scl-profile-bridge` branch. +2. Exit `idf.py monitor` with `Ctrl+]`; the GUI and monitor cannot own the serial port simultaneously. +3. From the repository root run: + +```powershell +.\apps\smv_injector_gui\run.cmd ``` -Disconnecting or closing the GUI must not make the PC responsible for sample timing. The embedded publisher remains the realtime authority. +The launcher incrementally builds the smart SCL profile tool into `build-smv-gui`, starts the local control-plane server on `127.0.0.1:8765`, and opens the GUI. -## Next integration +4. Click **Connect device** and select the ESP32-P4 serial port. +5. Import an engineering file, select an SV stream, resolve any Class-B counter-policy requirement, then **Deploy to device** while STOPPED. +6. Press **Start**. Magnitude, phase, frequency, quality and enabled state remain live controls while running. -The next product step is to connect the C++ SCL profile compiler to this surface so the workflow becomes: +## Realtime boundary ```text -Import SCL/CID - -> select SV stream - -> validate normalized profile - -> deploy immutable stream identity/layout - -> edit live engineering values - -> Start - -> change values while running - -> Stop +engineering file + | + v +C++ profile compiler ---- GUI engineering values + | | + +-----------+---------------+ + | + v + ESP32-P4 control state + | + immutable wire profile + + mutable signal state + | + v + fixed-point signal engine + | + v + prebuilt SV packet template + | + v + realtime Ethernet publisher ``` -Stream identity/layout changes remain a STOP -> validate -> arm operation. Magnitude, phase, frequency, quality and enabled state are live controls. +Profile identity/layout changes require `STOP -> validate -> deploy -> arm`. Live signal values do not rebuild BER and do not restart `smpCnt`. + +The current rational GPTimer scheduler supports runtime publisher rates without rounding a fractional-microsecond period into a permanently wrong fixed interval; for example, 4800 events/s uses a repeating 208/208/209 microsecond schedule at the 1 MHz timer domain. From 366aa56c27007f92de6fe53d0bb8575536fa0f4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:14:27 +0700 Subject: [PATCH 058/129] docs: keep embedded roadmap vendor-neutral --- ESP32_EMBEDDED_ROADMAP.md | 165 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 ESP32_EMBEDDED_ROADMAP.md diff --git a/ESP32_EMBEDDED_ROADMAP.md b/ESP32_EMBEDDED_ROADMAP.md new file mode 100644 index 000000000..ff07db047 --- /dev/null +++ b/ESP32_EMBEDDED_ROADMAP.md @@ -0,0 +1,165 @@ +# ARStack61850 Embedded Runtime Roadmap + +## Product direction + +ARStack61850 is a portable IEC 61850 instrumentation stack with two complementary deployment classes: + +1. **Host / engineering** — Windows and Linux tools for SCL engineering, live discovery, evidence, diagnostics, waveform/scenario configuration, capture integration, and interoperability workflows. +2. **Embedded / realtime runtime** — bounded protocol execution on dedicated hardware for deterministic SV/GOOSE publication, capture, timing evidence, and future synchronized operation. + +The host is the engineering brain. Dedicated hardware is the realtime packet and timing authority. + +## Process-bus hardware principles + +The embedded process-bus path requires a real Ethernet Layer-2 interface. Wireless networking is not a substitute for canonical SV/GOOSE transmission. + +Reference platforms should provide, as applicable: + +- raw Ethernet frame transmission and reception; +- deterministic interrupt/DMA behavior; +- hardware-assisted timestamps where available; +- sufficient RAM for bounded packet/profile/capture buffers; +- a management/control path that does not become the sample-timing source; +- electrical isolation appropriate to the intended laboratory or field role. + +Board-specific pin maps, commercial product identities, and marketing specifications do not belong in the public architecture roadmap. Platform adapters keep those details outside the protocol core. + +## Non-negotiable architecture rules + +### Embedded core + +The embedded core may contain wire codecs, immutable compiled profiles, bounded state machines, and realtime signal/runtime primitives, but it must not depend on: + +- desktop socket APIs; +- filesystem or interactive CLI workflows; +- full XML/SCL parsing; +- PCAP/evidence report generation; +- desktop UI frameworks; +- host-only discovery and engineering workflows. + +Platform ownership is injected through small HAL/adaptor contracts. RTOS, MAC/PHY, DMA, timestamp, USB/management, and board details remain outside the IEC 61850 semantic core. + +### SCL/profile boundary + +XML and vendor-specific engineering-file dialect handling stay on the host: + +```text +SCL / CID / SCD / IID + | + v +smart tolerant host parser + | + v +validated immutable profile + | + v +embedded runtime +``` + +The host may normalize representation differences, preserve provenance, quarantine irrelevant extensions, and explain conflicts. Deployment remains fail-closed when mandatory wire semantics are unresolved. + +### Realtime boundary + +The realtime publisher must not parse XML, rebuild BER per sample, allocate on the hot path, or depend on Windows/Linux scheduling. + +Preferred execution model: + +```text +validated profile + mutable signal state + | + v + prebuilt packet template + | + v + deterministic scheduler + | + v + Ethernet MAC/DMA + | + v + timing evidence +``` + +Signal values may change coherently while running. Stream identity/layout changes require STOP -> validate -> arm. + +### Memory discipline + +Steady-state publisher/subscriber paths should use: + +- preallocated or caller-owned buffers; +- bounded capacities; +- no per-frame heap allocation; +- no unbounded queues; +- explicit overflow/drop accounting; +- fixed or bounded metadata structures; +- predictable CPU ownership. + +Host convenience models may use dynamic containers at configuration time as long as they do not leak allocation requirements into the realtime path. + +## Timing truth hierarchy + +Timing claims should progress through increasingly authoritative evidence: + +1. sample deadline; +2. realtime task wake; +3. MAC transmit/receive hardware timestamp where supported; +4. independent measurement-point timestamp. + +Host arrival time is useful diagnostic information but must not be treated as canonical wire timing evidence. + +## Synchronization direction + +A future synchronized runtime should separate: + +- wire protocol parsing; +- passive synchronization monitoring; +- applicable profile validation; +- hardware clock access; +- offset/delay estimation and servo; +- lock / acquiring / holdover states; +- sample-synchronization field policy; +- timestamp evidence. + +Presence of synchronization traffic alone is never proof of lock. + +## Process-bus capture direction + +The receive side should converge on the same realtime/evidence core used by the publisher: + +- promiscuous raw receive; +- hardware timestamps where available; +- bounded capture rings; +- sequence/gap/duplicate/out-of-order accounting; +- SCL expected-vs-observed comparison; +- waveform, RMS, frequency, and phasor reconstruction; +- triggers with bounded pre/post capture; +- evidence export handled by the host. + +## Hardware evolution + +The current single-port MCU-class reference platform is suitable for proving deterministic publication, receive instrumentation, control-plane separation, and timestamp architecture. + +A later precision platform should be specified from measured needs rather than assumptions. Potential requirements include: + +- multiple independent process-side physical paths; +- separate management path; +- copper and/or optical interfaces as required by deployment; +- timestamping close to the measurement point; +- disciplined oscillator/clock architecture; +- external switching, tapping, or programmable logic only where measured throughput/timing requirements justify it. + +## Delivery order + +1. deterministic SV publisher foundation; +2. hardware timing evidence; +3. smart SCL-to-profile compiler; +4. immutable device-profile deployment; +5. live engineering-value control through the GUI; +6. broad interoperability matrix and observed-wire resolver; +7. synchronized clock/profile support; +8. hardware-timestamped process-bus analyzer; +9. precision multi-port hardware when evidence requires it. + +## Public repository rule + +Public project-owned content remains vendor-neutral. Research findings from commercial implementations may inform engineering principles privately, but public architecture should contain standards, generic engineering patterns, reproducible measurements, and project-owned evidence rather than competitor/product references or UI imitation. From 1b37afa63ffc157b999de0cea21740877b880fe0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:14:39 +0700 Subject: [PATCH 059/129] docs: remove legacy vendor-specific embedded roadmap --- ESP32_WAVESHARE_ROADMAP.md | 385 ------------------------------------- 1 file changed, 385 deletions(-) delete mode 100644 ESP32_WAVESHARE_ROADMAP.md diff --git a/ESP32_WAVESHARE_ROADMAP.md b/ESP32_WAVESHARE_ROADMAP.md deleted file mode 100644 index 34ff38045..000000000 --- a/ESP32_WAVESHARE_ROADMAP.md +++ /dev/null @@ -1,385 +0,0 @@ -# arstack61850 — Public Alpha + Waveshare ESP32-S3 Roadmap - -## Product direction - -arstack61850 is not intended to become a line-by-line C++ clone of ARIEC61850. -ARIEC61850 remains the behavioral oracle. arstack61850 becomes the portable -industrial implementation with two supported deployment classes: - -1. **Host / engineering** — Windows and Linux tools, live MMS discovery, - evidence, diagnostics, SCL/PCAP/COMTRADE integration. -2. **Embedded / runtime** — bounded protocol engine suitable for ESP32-class - MCUs first and STM32/NXP-class targets later. - -The first reference MCU is the **Waveshare ESP32-S3-POE-ETH-8DI-8DO**. - -## Reference hardware facts - -Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. - -- ESP32-S3-WROOM-1U-N16R8: 16 MB Flash, 8 MB PSRAM. -- W5500 10/100 Ethernet controller over SPI. -- PoE module, IEEE 802.3af. -- Eight isolated digital inputs and eight isolated digital outputs. -- W5500 pins: INT=GPIO12, MOSI=13, MISO=14, SCLK=15, CS=16, RST=39. -- DI1..DI8: GPIO4..GPIO11. -- Digital outputs are exposed through TCA9554PWR, I2C address 0x20. -- Isolated RS485 and CAN are available for future gateway use. -- W5500 supports MACRAW at chip level. The preferred ESP-IDF W5500 driver - operates the device in MAC RAW mode and feeds the ESP software TCP/IP stack. - Raw SV/GOOSE and lwIP TCP/MMS therefore share one Ethernet interface instead - of arstack independently allocating W5500 hardware sockets. - -Raw Layer-2 SV/GOOSE is an Ethernet feature. Wi-Fi is not a substitute for the -reference process-bus path. - -## Non-negotiable architecture rules - -### Embedded core - -`ARIEC61850::embedded_core` may contain wire codecs and bounded runtime state, -but must not depend on: - -- WinSock or BSD/POSIX sockets; -- filesystem, iostream or CLI code; -- PCAP/evidence tooling; -- COMTRADE; -- full SCL parsing; -- live-discovery host workflows. - -Platform ownership is injected through small callback HAL contracts. ESP-IDF, -lwIP, FreeRTOS and W5500 details stay outside the protocol engine. - -### Memory discipline - -Steady-state publisher/subscriber/runtime paths should move toward: - -- caller-owned buffers; -- bounded capacities; -- no per-frame allocation; -- no RTTI; -- eventually no C++ exceptions in the MCU core. - -Existing `std::vector`/`std::string` data models may remain as configuration-time -containers during migration. They must not force allocation on the 4 kHz SV -steady-state encode/transmit path. - -### Compatibility - -Existing host APIs remain source-compatible where practical. Allocation-free -APIs are added underneath host convenience wrappers, so Windows/Linux users do -not lose ergonomics while the same codec becomes MCU-capable. - ---- - -# Delivery plan - -## Track A — Public Alpha - -### A0. Live read-only MMS foundation — CURRENT - -Acceptance: - -- TPKT/COTP/Session/Presentation/ACSE association works against live vendor IED. -- GetNameList domains, variables and DataSets works. -- GetVariableAccessAttributes works. -- Read works. -- DataSet and RCB inventories are preserved. -- Dynamic/unbound RCB state is represented without false failure. -- RCB planner/availability evidence is read-only. -- GCC, Clang and MSVC CI pass. -- ASan/UBSan and fuzz corpus pass. - -### A1. Public discovery/model alpha - -Goal: a third-party engineer can clone the repository and use it without -knowing the C# project. - -Deliverables: - -- stable `live_discover` CLI contract; -- documented JSON/model schema; -- deterministic structural fingerprint separated from runtime evidence; -- TypeTemplates + VariableTypeDiscoveries parity slice; -- same-IED C# versus C++ model comparison fixture; -- examples for association, read, DataSet and RCB inventory; -- README quick-start for Windows/Linux; -- explicit feature matrix: implemented / partial / host-only / embedded-ready. - -Acceptance: - -- clean configure/build/test from a fresh clone; -- at least three repeat live cycles on the current test IED/simulator; -- no diagnostic regression; -- reproducible JSON parity report checked into evidence. - -### A2. Public alpha release - -Goal: tag a release that is genuinely useful even before full ARIEC61850 parity. - -Required public surface: - -- MMS client association + confirmed read services; -- read-only live model discovery; -- DataSet/RCB inspection; -- GOOSE encode/decode/runtime foundation; -- SV encode/decode foundation; -- documented portability boundary; -- examples and CI status. - -Not release-blocking: - -- full MMS file service; -- TLS; -- all dynamic DataSet mutation workflows; -- complete ARIEC61850 host-tool parity; -- full IED server implementation. - ---- - -## Track E — ESP32 reference product - -### E0. Embedded architecture baseline — IN PROGRESS - -Deliverables: - -- standalone `embedded/CMakeLists.txt`; -- `ARIEC61850::embedded_core`; -- no-RTTI host-simulation CI; -- source-boundary audit; -- callback HAL for raw Ethernet, connected TCP and clocks; -- configurable ESP32 small-capacity profile; -- Waveshare board facts/profile. - -Exit gate: - -- GCC and Clang embedded host-simulation builds link every embedded source; -- host-only dependency audit is clean; -- legacy exception debt is measured and cannot silently expand. - -### E1. Allocation-bounded SV encoder — IN PROGRESS - -Goal: the 4 kHz publisher loop does not allocate a new frame buffer per sample. - -API direction: - -```cpp -std::optional SampledValuesFrameCodec::encoded_size(...) noexcept; -wire::EncodeResult SampledValuesFrameCodec::encode_into( - const SampledValuesFrame&, - std::span destination) noexcept; -``` - -Host `encode()` remains as a convenience wrapper. - -Acceptance: - -- bounded encoder is byte-identical to existing C#/C++ golden vectors; -- buffer-too-small returns required size and does not partially report success; -- same caller-owned Ethernet buffer can be reused continuously; -- timestamp serialization has a no-allocation path; -- sanitizer/fuzzer/host regression remains green. - -### E2. ESP-IDF W5500 Ethernet adapter - -Goal: map `embedded::RawEthernetPort` to the official ESP-IDF W5500 Ethernet -MACRAW path without leaking ESP-IDF/W5500 APIs into protocol code. - -Preferred architecture: - -```text -arstack raw SV/GOOSE ----> esp_eth raw Ethernet transmit ----+ - | -MMS TCP <---- lwIP / BSD socket API <---- esp_netif --------+--> ESP-IDF W5500 MACRAW -``` - -The ESP-IDF W5500 driver owns the chip-level MACRAW operation. arstack should -not independently program W5500 hardware sockets when using that driver. - -Responsibilities: - -- initialize the official ESP-IDF W5500 SPI Ethernet driver with board pins; -- attach the driver to `esp_netif`/lwIP for TCP/MMS; -- expose raw-frame transmit callback for GOOSE/SV; -- preserve destination/source MAC, VLAN tag and EtherType exactly; -- expose PHY/link state; -- instrument TX errors, would-block and frame counts. - -Acceptance on host/mock first: - -- adapter-facing policy can be unit-tested with fake transmit backend; -- protocol core does not include ESP-IDF/W5500 headers. - -Acceptance on board: - -- Ethernet obtains link and normal IP traffic works through lwIP; -- raw custom EtherType frame visible in Wireshark; -- VLAN-tagged frame visible without mutation; -- SV EtherType 0x88BA frame visible while normal TCP/IP remains usable. - -### E3. ESP32-S3 SV Publisher hardware proof - -Start with synthetic waveform; do not block on ADC hardware. - -Reference first profile: - -- 50 Hz system; -- 80 samples/cycle = 4,000 sample instants/s; -- one ASDU initially; -- fixed APPID and multicast destination; -- sample counter wraps according to configured rate; -- configuration revision fixed during a run; -- timestamp optional for the first throughput proof, then enabled. - -Acceptance: - -- sustained 4,000 frames/s target or explicitly documented multi-ASDU packing - alternative; -- no steady-state frame-buffer allocation; -- no sample-counter discontinuity generated by publisher logic; -- packet capture decodes as IEC 61850 SV; -- 10-minute soak first, then 1-hour soak; -- CPU usage, heap delta, minimum free heap and TX errors recorded. - -### E4. Board I/O HAL - -Goal: make the Waveshare hardware useful as a real substation I/O prototype. - -Inputs: - -- DI1..DI8 from GPIO4..GPIO11; -- debounce/filter policy separated from raw input state; -- timestamped state-change queue. - -Outputs: - -- TCA9554PWR adapter at I2C address 0x20; -- EXIO1..EXIO8 mapping verified against schematic/vendor demo before hard-coding; -- safe startup state: outputs off unless application explicitly restores state; -- command watchdog/failsafe hooks. - -Acceptance: - -- deterministic read of all eight DIs; -- deterministic set/reset of all eight DOs; -- power-cycle safe-state test; -- no IEC 61850 code knows GPIO/I2C details. - -### E5. Minimal MMS server foundation - -This is required for the board to become an actual IEC 61850 I/O IED. - -Implement a static, bounded server model first. Do not port desktop dynamic -model discovery into the MCU server. - -Minimum server services: - -1. association acceptor; -2. MMS Initiate negotiation; -3. GetNameList; -4. GetVariableAccessAttributes; -5. Read. - -Initial static model proposal: - -```text -ESP32S3IO -└── LD0 - ├── LLN0 - ├── LPHD1 - ├── GGIO1 digital inputs - └── GGIO2 digital outputs/status -``` - -Acceptance: - -- IEDScout can associate; -- IEDScout can browse the static model; -- eight DI values can be read; -- server remains bounded under malformed/fuzzed MMS requests. - -### E6. Simple IEC 61850 I/O IED - -Add controlled output operation only after read-only server browse/read is -stable. - -Sequence: - -- simple Write where standards/model permit; -- IEC 61850 control model required for the exposed DOs; -- interlock/check hooks; -- operation timeout; -- command audit/result status. - -Reporting sequence: - -- static DataSet; -- URCB first; -- data-change report for DI state; -- BRCB later, not a first-I/O-IED requirement. - -Acceptance: - -- IEDScout browse/read/control proof; -- input change generates expected report; -- output command changes physical DO and reflected model state; -- negative tests for invalid control/FC/reference. - -### E7. GOOSE I/O extension - -After raw MAC transport is proven by SV, GOOSE is a natural extension. - -Targets: - -- DI state as GOOSE publisher; -- GOOSE subscriber mapped to selected DO only through application safety policy; -- retransmission schedule and TAL supervision; -- VLAN priority validation. - -### E8. Industrialization / STM32 migration - -ESP32 is the reference accessibility target, not the final certification claim. -The same embedded core should later move to STM32H7/NXP-class hardware. - -Industrialization work: - -- `-fno-exceptions` embedded core; -- replace remaining hot-path dynamic containers with bounded storage/views; -- explicit memory budget report; -- deterministic scheduler/timing hooks; -- watchdog integration; -- stronger time synchronization strategy; -- long-duration soak and fault injection; -- multi-vendor interoperability campaign. - ---- - -# Priority rule - -Until Public Alpha is tagged: - -- approximately **70% effort** goes to public-useful host protocol parity, - documentation and real-IED evidence; -- approximately **30% effort** goes to embedded architecture and the SV hardware - path. - -Exception: embedded changes that prevent architectural debt (allocation-free -wire encoding, HAL boundaries, compile gates) are performed immediately before -new protocol features are ported. - -After Public Alpha: - -- finish E2/E3 hardware SV proof; -- then shift primary development toward E5/E6 MMS server + I/O IED. - -# Definition of success - -The project succeeds when the same protocol implementation can demonstrate all -of these without forks of the wire core: - -1. Windows/Linux engineering client can browse a real IED. -2. ESP32-S3 can publish standards-shaped SV frames over the ESP-IDF W5500 - MACRAW Ethernet interface. -3. ESP32-S3 can expose eight isolated inputs/outputs as a small IEC 61850 IED. -4. A later STM32/NXP port changes platform adapters and capacities, not protocol - semantics. From 94822e77350ddcabfa642cefc29cf33f6a92de43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:16:44 +0700 Subject: [PATCH 060/129] fix(embedded): fail closed when publisher arm fails --- embedded/esp32p4_smv_injector/main/live_control.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index dea956d9f..683397519 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -293,6 +293,12 @@ void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { g_publisher_task.store(task, std::memory_order_release); } +void live_control_force_stop() noexcept { + g_running.store(false, std::memory_order_release); + g_start_request.store(false, std::memory_order_release); + wake_publisher(); +} + SvLiveSignalState live_signal_snapshot() noexcept { return g_signal_bank.snapshot(); } From ff3688a95f85180af4f1bf070c1931eea0995b1e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:19:22 +0700 Subject: [PATCH 061/129] fix(gui): make C++ SCL engine the only import authority --- apps/smv_injector_gui/scl_bridge_bootstrap.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 apps/smv_injector_gui/scl_bridge_bootstrap.js diff --git a/apps/smv_injector_gui/scl_bridge_bootstrap.js b/apps/smv_injector_gui/scl_bridge_bootstrap.js new file mode 100644 index 000000000..ee2250c94 --- /dev/null +++ b/apps/smv_injector_gui/scl_bridge_bootstrap.js @@ -0,0 +1,12 @@ +"use strict"; + +// app.js predates the smart host profile compiler and installs a lightweight +// browser XML listener on the engineering-file input. Replace that input node +// before the profile bridge binds so vendor-dialect handling has exactly one +// semantic authority: the C++ SCL engine. This avoids a stricter/shallower DOM +// pre-check disagreeing with the tolerant normalized parser. +if (els.sclFile) { + const freshInput = els.sclFile.cloneNode(true); + els.sclFile.replaceWith(freshInput); + els.sclFile = freshInput; +} From 61ad066e524bc00b087b1ead3b3880e061f525cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:19:47 +0700 Subject: [PATCH 062/129] fix(gui): route SCL import only through smart profile engine --- apps/smv_injector_gui/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 1c2362f13..a0979df64 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -89,6 +89,7 @@
+ From 1377f05576db822430d3765c248b4f13be4bd266 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:20:09 +0700 Subject: [PATCH 063/129] ci(gui): cover smart SCL import bootstrap --- .github/workflows/smv-injector-gui.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 7f2c9b743..86b9a5215 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -38,6 +38,7 @@ jobs: - name: JavaScript syntax run: | node --check apps/smv_injector_gui/app.js + node --check apps/smv_injector_gui/scl_bridge_bootstrap.js node --check apps/smv_injector_gui/profile_bridge.js - name: Required UI files @@ -47,6 +48,7 @@ jobs: test -s apps/smv_injector_gui/styles.css test -s apps/smv_injector_gui/profile_bridge.css test -s apps/smv_injector_gui/app.js + test -s apps/smv_injector_gui/scl_bridge_bootstrap.js test -s apps/smv_injector_gui/profile_bridge.js test -s apps/smv_injector_gui/host_server.py test -s apps/smv_injector_gui/run.cmd From f54ddf8346da299048d2b6d7284a1baa4fd3dbfc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:47:19 +0700 Subject: [PATCH 064/129] fix(gui): select native Windows host compiler from ESP-IDF shell --- apps/smv_injector_gui/run.cmd | 101 ++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index 992496e12..fdf85eec4 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -1,8 +1,12 @@ @echo off -setlocal +setlocal EnableExtensions set "PORT=8765" +set "BUILD_ONLY=0" +if /I "%~1"=="--build-only" set "BUILD_ONLY=1" + set "APP_DIR=%~dp0" -for %%I in ("%APP_DIR%..\..\..") do set "REPO_ROOT=%%~fI" +rem apps\smv_injector_gui -> repository root is two levels up. +for %%I in ("%APP_DIR%..\..") do set "REPO_ROOT=%%~fI" set "BUILD_DIR=%REPO_ROOT%\build-smv-gui" set "PROFILE_TOOL=%BUILD_DIR%\ariec61850_smv_profile_inspect.exe" @@ -17,7 +21,7 @@ if errorlevel 1 ( where cmake >nul 2>nul if errorlevel 1 ( echo CMake was not found on PATH. - echo Run this launcher from the ESP-IDF shell. + echo Run this launcher from the ESP-IDF shell or another shell with CMake available. pause exit /b 1 ) @@ -25,7 +29,7 @@ if errorlevel 1 ( where ninja >nul 2>nul if errorlevel 1 ( echo Ninja was not found on PATH. - echo Run this launcher from the ESP-IDF shell. + echo Run this launcher from the ESP-IDF shell or another shell with Ninja available. pause exit /b 1 ) @@ -36,7 +40,24 @@ if not exist "%APP_DIR%index.html" ( exit /b 1 ) +call :prepare_host_toolchain +if errorlevel 1 goto :host_compiler_missing + +rem A previous configure may have captured ESP-IDF's cross clang. CMake caches +rem compiler identity, so discard only the small host-tool configure cache when +rem it points at an Espressif compiler. This never touches embedded sdkconfig. +if exist "%BUILD_DIR%\CMakeCache.txt" ( + findstr /I /C:"Espressif/tools/esp-clang" /C:"Espressif\tools\esp-clang" "%BUILD_DIR%\CMakeCache.txt" >nul 2>nul + if not errorlevel 1 ( + echo Removing stale ESP-IDF cross-compiler cache... + del /q "%BUILD_DIR%\CMakeCache.txt" >nul 2>nul + if exist "%BUILD_DIR%\CMakeFiles" rmdir /s /q "%BUILD_DIR%\CMakeFiles" + ) +) + echo ARStack61850 SMV Injector GUI +echo Repository: %REPO_ROOT% +echo Native host compiler: %HOST_COMPILER_LABEL% echo Building smart SCL profile engine... cmake -S "%APP_DIR%host" -B "%BUILD_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release if errorlevel 1 goto :build_failed @@ -49,6 +70,11 @@ if not exist "%PROFILE_TOOL%" ( exit /b 1 ) +if "%BUILD_ONLY%"=="1" ( + echo Smart SCL profile engine build PASS. + exit /b 0 +) + echo Local UI: http://127.0.0.1:%PORT%/ echo Smart SCL engine: %PROFILE_TOOL% echo. @@ -60,8 +86,75 @@ start "" "http://127.0.0.1:%PORT%/" python "%APP_DIR%host_server.py" --profile-tool "%PROFILE_TOOL%" --port %PORT% exit /b %ERRORLEVEL% +:prepare_host_toolchain +rem ESP-IDF PowerShell intentionally exports an embedded clang toolchain. That +rem compiler cannot link a native Windows host executable. Clear compiler hints +rem first, then deliberately select a native Windows compiler. +set "CC=" +set "CXX=" +set "AR=" +set "AS=" +set "LD=" +set "CFLAGS=" +set "CXXFLAGS=" +set "LDFLAGS=" +set "HOST_COMPILER_LABEL=" + +where cl.exe >nul 2>nul +if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC cl.exe" + exit /b 0 +) + +set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +if exist "%VSWHERE%" ( + set "VSINSTALL=" + for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%I" + if defined VSINSTALL ( + if exist "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" ( + call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul + where cl.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC x64 via Visual Studio Build Tools" + exit /b 0 + ) + ) + ) +) + +where g++.exe >nul 2>nul +if not errorlevel 1 ( + where gcc.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=gcc.exe" + set "CXX=g++.exe" + set "HOST_COMPILER_LABEL=MinGW GCC/G++" + exit /b 0 + ) +) + +exit /b 1 + +:host_compiler_missing +echo. +echo ERROR: no native Windows C++ compiler was found. +echo The ESP-IDF esp-clang compiler is an embedded cross compiler and cannot + echo link the Windows host-side SCL profile tool. +echo. +echo Install Microsoft Visual Studio Build Tools with the Desktop development + echo with C++ workload, then run this same command again. The launcher will + echo discover it automatically; do not change the ESP32-P4 toolchain. +echo. +pause +exit /b 1 + :build_failed echo. echo ERROR: smart SCL profile engine build failed. +echo Native compiler selected: %HOST_COMPILER_LABEL% pause exit /b 1 From 58a37db9e0d5e17b8191c0b31f0e72bd45c63fdb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:47:47 +0700 Subject: [PATCH 065/129] ci(gui): exercise Windows host-tool launcher --- .github/workflows/smv-injector-gui.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 86b9a5215..15b7c9419 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -88,3 +88,23 @@ jobs: --data-binary @tests/fixtures/scl/sv-4800-structured-4i4v.scd \ 'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800' >/tmp/api.json grep -q '"deviceSupport":"ready"' /tmp/api.json + + windows-launcher-smoke: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify native-host launcher + shell: cmd + run: apps\smv_injector_gui\run.cmd --build-only + + - name: Verify Windows profile compiler + shell: powershell + run: | + $tool = Join-Path $PWD 'build-smv-gui\ariec61850_smv_profile_inspect.exe' + if (-not (Test-Path $tool)) { throw "profile compiler missing: $tool" } + & $tool 'tests\fixtures\scl\sv-4800-structured-4i4v.scd' '--counter-modulus' '4800' | Tee-Object -FilePath profile-a.json + if (-not (Select-String -Path profile-a.json -SimpleMatch '"deviceSupport":"ready"')) { + throw 'Windows profile compiler did not produce a deployable Class-A fixture result' + } From 5a58cd83b66758acccb5fc6d147bf2ec1f3fe6a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:59:57 +0700 Subject: [PATCH 066/129] fix(gui): discover installed Visual Studio generically --- apps/smv_injector_gui/run.cmd | 86 +++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index fdf85eec4..458267cac 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -58,6 +58,7 @@ if exist "%BUILD_DIR%\CMakeCache.txt" ( echo ARStack61850 SMV Injector GUI echo Repository: %REPO_ROOT% echo Native host compiler: %HOST_COMPILER_LABEL% +if defined VSINSTALL_USED echo Visual Studio: %VSINSTALL_USED% echo Building smart SCL profile engine... cmake -S "%APP_DIR%host" -B "%BUILD_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release if errorlevel 1 goto :build_failed @@ -89,7 +90,7 @@ exit /b %ERRORLEVEL% :prepare_host_toolchain rem ESP-IDF PowerShell intentionally exports an embedded clang toolchain. That rem compiler cannot link a native Windows host executable. Clear compiler hints -rem first, then deliberately select a native Windows compiler. +rem first, then deliberately select any installed native Windows compiler. set "CC=" set "CXX=" set "AR=" @@ -99,31 +100,88 @@ set "CFLAGS=" set "CXXFLAGS=" set "LDFLAGS=" set "HOST_COMPILER_LABEL=" +set "VSINSTALL_USED=" +set "VISUAL_STUDIO_FOUND=" +set "VISUAL_STUDIO_PATH=" +rem If the current shell already has native MSVC configured, use it directly. where cl.exe >nul 2>nul if not errorlevel 1 ( set "CC=cl.exe" set "CXX=cl.exe" - set "HOST_COMPILER_LABEL=MSVC cl.exe" + set "HOST_COMPILER_LABEL=MSVC cl.exe from current environment" exit /b 0 ) +rem Discover Visual Studio by capability, not by product year. Include preview / +rem prerelease installations so newer Visual Studio releases work without any +rem launcher change. vswhere is installed by the Visual Studio Installer. set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" if exist "%VSWHERE%" ( set "VSINSTALL=" - for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%I" + for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%I" if defined VSINSTALL ( + set "VISUAL_STUDIO_FOUND=1" + set "VISUAL_STUDIO_PATH=%VSINSTALL%" if exist "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" ( call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul where cl.exe >nul 2>nul if not errorlevel 1 ( set "CC=cl.exe" set "CXX=cl.exe" - set "HOST_COMPILER_LABEL=MSVC x64 via Visual Studio Build Tools" + set "HOST_COMPILER_LABEL=MSVC x64 via installed Visual Studio" + set "VSINSTALL_USED=%VSINSTALL%" exit /b 0 ) ) ) + + rem If Visual Studio exists but the VC workload query did not match, remember + rem the installation so the error can ask to modify that existing install + rem instead of recommending another Visual Studio version. + if not defined VISUAL_STUDIO_FOUND ( + set "VSINSTALL=" + for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -prerelease -products * -property installationPath`) do set "VSINSTALL=%%I" + if defined VSINSTALL ( + set "VISUAL_STUDIO_FOUND=1" + set "VISUAL_STUDIO_PATH=%VSINSTALL%" + if exist "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" ( + call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul + where cl.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC x64 via installed Visual Studio" + set "VSINSTALL_USED=%VSINSTALL%" + exit /b 0 + ) + ) + ) + ) +) + +rem Final Visual Studio fallback if vswhere is unavailable: scan version-neutral +rem install roots, including future product-year folders. +for %%R in ("%ProgramFiles%\Microsoft Visual Studio" "%ProgramFiles(x86)%\Microsoft Visual Studio") do ( + if exist "%%~R" ( + for /d %%V in ("%%~R\*") do ( + for /d %%E in ("%%~fV\*") do ( + if exist "%%~fE\Common7\Tools\VsDevCmd.bat" ( + set "VISUAL_STUDIO_FOUND=1" + set "VISUAL_STUDIO_PATH=%%~fE" + call "%%~fE\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul + where cl.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC x64 via installed Visual Studio" + set "VSINSTALL_USED=%%~fE" + exit /b 0 + ) + ) + ) + ) + ) ) where g++.exe >nul 2>nul @@ -141,13 +199,24 @@ exit /b 1 :host_compiler_missing echo. -echo ERROR: no native Windows C++ compiler was found. +echo ERROR: no usable native Windows C++ compiler was found. echo The ESP-IDF esp-clang compiler is an embedded cross compiler and cannot echo link the Windows host-side SCL profile tool. echo. -echo Install Microsoft Visual Studio Build Tools with the Desktop development - echo with C++ workload, then run this same command again. The launcher will - echo discover it automatically; do not change the ESP32-P4 toolchain. +if defined VISUAL_STUDIO_FOUND ( + echo Visual Studio was detected at: + echo %VISUAL_STUDIO_PATH% + echo. + echo Keep that existing Visual Studio installation. Open Visual Studio Installer, + echo choose Modify for that installation, and enable Desktop development with C++ + echo ^(MSVC x64/x86 build tools plus a Windows SDK^). No specific Visual Studio + echo product year is required by ARStack61850. +) else ( + echo No Visual Studio installation with native C++ tools was detected. + echo Install or modify any supported Visual Studio edition with Desktop development + echo with C++, or provide a native MinGW GCC/G++ toolchain on PATH. ARStack61850 + echo does not require a specific Visual Studio product year. +) echo. pause exit /b 1 @@ -156,5 +225,6 @@ exit /b 1 echo. echo ERROR: smart SCL profile engine build failed. echo Native compiler selected: %HOST_COMPILER_LABEL% +if defined VSINSTALL_USED echo Visual Studio: %VSINSTALL_USED% pause exit /b 1 From 5780dafe3fb23b37cfa02f7aed57ff6c22a24b5e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:14:46 +0700 Subject: [PATCH 067/129] fix(gui): self-heal missing host build scaffold --- apps/smv_injector_gui/run.cmd | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index 458267cac..52c4d5354 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -40,6 +40,36 @@ if not exist "%APP_DIR%index.html" ( exit /b 1 ) +if not exist "%APP_DIR%host_server.py" ( + echo ERROR: GUI host_server.py is missing from the local working tree. + echo Restore the GUI files from the current branch, then run this launcher again: + echo git -C "%REPO_ROOT%" restore --source=HEAD -- apps/smv_injector_gui + pause + exit /b 1 +) + +rem The tiny host CMake scaffold is tracked in Git, but a locally deleted tracked +rem directory can survive a fast-forward pull. Self-heal this generated-style +rem scaffold so the operator never has to restore one internal build file by hand. +if not exist "%APP_DIR%host\CMakeLists.txt" ( + echo Repairing missing local host build scaffold... + if not exist "%APP_DIR%host" mkdir "%APP_DIR%host" >nul 2>nul + > "%APP_DIR%host\CMakeLists.txt" echo cmake_minimum_required^(VERSION 3.20^) + >> "%APP_DIR%host\CMakeLists.txt" echo project^(ARStackSmvGuiHost LANGUAGES CXX^) + >> "%APP_DIR%host\CMakeLists.txt" echo. + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo. + >> "%APP_DIR%host\CMakeLists.txt" echo add_subdirectory^(${CMAKE_CURRENT_LIST_DIR}/../../.. arstack-core^) + >> "%APP_DIR%host\CMakeLists.txt" echo. + >> "%APP_DIR%host\CMakeLists.txt" echo add_executable^(ariec61850_smv_profile_inspect + >> "%APP_DIR%host\CMakeLists.txt" echo ${CMAKE_CURRENT_LIST_DIR}/../../../tools/smv_profile_inspect.cpp^) + >> "%APP_DIR%host\CMakeLists.txt" echo target_link_libraries^(ariec61850_smv_profile_inspect PRIVATE ARIEC61850::core^) + >> "%APP_DIR%host\CMakeLists.txt" echo target_compile_features^(ariec61850_smv_profile_inspect PRIVATE cxx_std_20^) +) + call :prepare_host_toolchain if errorlevel 1 goto :host_compiler_missing From 9bd7451df1852cd0e25ad51f580927bf111b6c04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:15:21 +0700 Subject: [PATCH 068/129] test(gui): cover missing host scaffold recovery --- .github/workflows/smv-injector-gui.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 15b7c9419..1a96d3934 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -95,7 +95,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Verify native-host launcher + - name: Simulate locally deleted host scaffold + shell: powershell + run: Remove-Item 'apps\smv_injector_gui\host' -Recurse -Force + + - name: Verify native-host launcher and self-heal shell: cmd run: apps\smv_injector_gui\run.cmd --build-only From bcbd74997fe79dc087e7353382fd8786f0dee220 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:21:12 +0700 Subject: [PATCH 069/129] fix(gui): self-heal missing tracked runtime assets --- apps/smv_injector_gui/host_server.py | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/apps/smv_injector_gui/host_server.py b/apps/smv_injector_gui/host_server.py index 656f18059..e047e077d 100644 --- a/apps/smv_injector_gui/host_server.py +++ b/apps/smv_injector_gui/host_server.py @@ -13,7 +13,55 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer APP_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = APP_DIR.parent.parent MAX_SCL_BYTES = 16 * 1024 * 1024 +REQUIRED_RUNTIME_ASSETS = ( + "index.html", + "styles.css", + "responsive.css", + "app.js", + "scl_bridge_bootstrap.js", + "profile_bridge.js", + "profile_bridge.css", +) + + +def ensure_runtime_assets() -> None: + """Restore only missing tracked GUI assets from the current Git HEAD. + + A locally deleted tracked file can survive a fast-forward pull. The operator + should not have to discover that from a silent browser 404. Existing files + are never overwritten, so intentional local edits remain untouched. + """ + + missing = [name for name in REQUIRED_RUNTIME_ASSETS if not (APP_DIR / name).is_file()] + if not missing: + return + + git = "git.exe" if os.name == "nt" else "git" + paths = [f"apps/smv_injector_gui/{name}" for name in missing] + print(f"Repairing missing tracked GUI assets: {', '.join(missing)}") + try: + completed = subprocess.run( + [git, "-C", str(REPO_ROOT), "restore", "--source=HEAD", "--", *paths], + capture_output=True, + text=True, + timeout=15, + check=False, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise SystemExit(f"Unable to restore missing GUI assets: {error}") from error + + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() or "git restore failed" + raise SystemExit(f"Unable to restore missing GUI assets: {detail}") + + still_missing = [name for name in missing if not (APP_DIR / name).is_file()] + if still_missing: + raise SystemExit( + "Required GUI assets are still missing after restore: " + ", ".join(still_missing) + ) class Handler(SimpleHTTPRequestHandler): @@ -111,6 +159,8 @@ def main() -> int: parser.add_argument("--port", type=int, default=8765) args = parser.parse_args() + ensure_runtime_assets() + tool = pathlib.Path(args.profile_tool).resolve() if not tool.is_file(): raise SystemExit(f"Profile compiler not found: {tool}") From ffc79495591fa7bf76e7d33c367b8dded5a09104 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:48:46 +0700 Subject: [PATCH 070/129] docs: record SMV injector completion boundary and bench evidence --- docs/SMV_INJECTOR_STATUS.md | 146 ++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/SMV_INJECTOR_STATUS.md diff --git a/docs/SMV_INJECTOR_STATUS.md b/docs/SMV_INJECTOR_STATUS.md new file mode 100644 index 000000000..eeedfbc78 --- /dev/null +++ b/docs/SMV_INJECTOR_STATUS.md @@ -0,0 +1,146 @@ +# SMV Injector status and completion boundary + +This document records the current engineering status of the ESP32-P4 Sampled Values injector and defines what is considered complete versus what remains as validation, timing, analyzer and product-hardening work. + +## Status summary + +**SMV Injector functional phase: COMPLETE for the current supported runtime profile.** + +The completed functional path is: + +```text +Import SCL / CID / SCD / IID + -> C++ IEC 61850 SCL parser + -> normalized SvPublisherProfile + -> stream selection and Class A/B/C diagnostics + -> explicit sample-counter-policy validation when required + -> deploy immutable profile while STOPPED + -> ESP32-P4 arms the runtime profile + -> START + -> deterministic Sampled Values publication + -> live I/U/phase/frequency/quality updates while RUNNING + -> STOP +``` + +The current completion statement is intentionally narrower than a standards-conformance or production-readiness claim. + +## Physical bench evidence achieved + +The current branch has been exercised on an ESP32-P4 Ethernet board with a real engineering-file-derived 4800 fps profile. + +Observed results include: + +- engineering file compiled by the C++ SCL engine; +- one SV stream resolved and promoted to Class A after explicit counter-policy confirmation; +- immutable runtime profile deployed to the ESP32-P4; +- SCL-derived `svID`, APPID, VLAN/PCP intent, configuration revision, publisher rate and counter modulus displayed as the armed profile; +- runtime transitioned to 4800 fps; +- device telemetry reported approximately 4800 samples/s; +- device telemetry reported zero missed slots in the retained run; +- device telemetry reported zero canonical TX failures in the retained run; +- live 50 Hz three-phase waveform remained observable through the diagnostic capture path; +- diagnostic stream inherited the active profile rate and configuration revision, confirming that it was produced by the same active runtime profile; +- live engineering-value changes remain independent from immutable stream identity/layout. + +This is sufficient to call the **injector workflow itself functionally complete for the supported 4I+4V profile boundary**. + +## Current supported device-profile boundary + +The first deployable embedded profile is deliberately bounded: + +- one ASDU; +- `SmpPerSec` with an absolute publisher rate; +- explicit validated `smpCnt` modulus; +- eight logical channels ordered as `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- 16 ordered leaves as eight `INT32 value + Quality` pairs; +- 64-byte sample payload; +- configurable destination MAC, APPID, VLAN presence/VID/PCP, `svID`, `confRev` and supported ASDU field-presence options; +- rational GPTimer scheduling for rates that are not an integer number of microseconds, including 4800 fps; +- truthful `smpSynch=0` until measured synchronization exists. + +Structurally valid profiles outside this boundary may still be parsed and explained by the host compiler, but deployment must remain blocked rather than guessing an embedded payload layout. + +## Canonical stream versus diagnostic mirror + +The firmware currently transmits two distinct streams during bench validation: + +```text +canonical stream + SCL-derived multicast destination + SCL-derived APPID + SCL-derived svID + configured VLAN/PCP + standards-facing identity + + diagnostic mirror + broadcast destination + APPID 0x4F01 + svID AR_DIAG_SV1 + untagged + capture/debug identity only +``` + +The diagnostic mirror exists because the current host USB-Ethernet capture path does not reliably expose the canonical multicast/priority-tagged stream. The diagnostic identity is intentionally different so a tool cannot confuse the mirror with the configured process-bus stream. + +Seeing `AR_DIAG_SV1` in the current analyzer therefore does **not** mean the SCL profile failed to deploy. It means the analyzer is observing the lab mirror while the canonical SCL stream is transmitted in parallel. + +## What is not yet claimed + +The following remain outside the functional-complete statement: + +- trusted on-wire proof of canonical multicast destination, APPID, `svID` and VLAN/PCP on an independent raw capture path; +- formal IEC 61850-9-2, IEC 61869-9 or legacy profile conformance; +- hardware-TX timestamp statistics; +- IEC/IEEE 61850-9-3 PTP discipline and synchronized `smpSynch` behavior; +- universal engineering scaling inferred from arbitrary SCL files; +- arbitrary DataSet layouts and arbitrary basic-type combinations on the embedded target; +- multi-stream publication; +- fault/scenario scripting and COMTRADE playback; +- production packaging, signed desktop distribution and long-duration qualification; +- independent subscriber/relay interoperability matrix. + +These are follow-on engineering phases, not evidence that the current injector workflow is unfinished. + +## Next hardware phase: independent analyzer node + +A second ESP32-P4 Ethernet board is a suitable next validation node. + +The intended analyzer architecture is: + +```text +process-bus Ethernet + | + v +ESP32-P4 raw EMAC RX + | + +-- promiscuous / all-multicast receive + +-- preserve and parse IEEE 802.1Q tags + +-- classify EtherType 0x88BA + +-- decode SV APPID / ASDU / svID / smpCnt / confRev / quality + +-- calculate continuity and timing evidence + | + v +Observed-wire profile + | + v +GUI configured-vs-observed comparison +``` + +The analyzer can only inspect frames that physically reach its Ethernet port. In a switched process-bus lab, use a trusted TAP or managed-switch mirror/SPAN port that preserves VLAN tags. + +The goal is to remove the current Windows USB-NIC capture ambiguity and make configured-vs-observed evidence a first-class ARStack61850 capability. + +## Product interpretation + +The injector has crossed the boundary from a fixed bench waveform generator to an **SCL-driven IEC 61850 Sampled Values injection instrument** for the current supported profile. + +The next work should focus on verification depth and profile breadth rather than re-opening the already proven core workflow: + +```text +Configured engineering intent + -> deterministic injector + -> independent raw analyzer + -> configured-vs-observed evidence +``` + +No decoded packet or successful bench run by itself is treated as a formal conformance certificate. \ No newline at end of file From 8ea7ec6dbc79e85f1ec142bf5a4c81239cd5b73c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:49:16 +0700 Subject: [PATCH 071/129] docs: update GUI README with completed SCL-driven injector workflow --- apps/smv_injector_gui/README.md | 58 ++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/smv_injector_gui/README.md b/apps/smv_injector_gui/README.md index eec90e23f..ee5a42dd1 100644 --- a/apps/smv_injector_gui/README.md +++ b/apps/smv_injector_gui/README.md @@ -2,7 +2,9 @@ Operator-facing control plane for the ESP32-P4 Sampled Values injector. -The GUI is not a waveform generator. The PC compiles engineering intent and sends bounded control/profile updates; the ESP32-P4 remains the realtime publisher. +The GUI is not the realtime waveform generator. The PC compiles engineering intent and sends bounded profile/control updates; the ESP32-P4 remains the realtime publisher. + +For the current completion boundary and retained bench evidence, see [`../../docs/SMV_INJECTOR_STATUS.md`](../../docs/SMV_INJECTOR_STATUS.md). ## Current workflow @@ -11,15 +13,25 @@ Import SCL / CID / SCD / IID -> C++ SCL parser -> normalized SvPublisherProfile -> select SV stream - -> resolve warnings / counter policy - -> configure test scaling when SCL does not establish it + -> resolve Class A/B/C diagnostics + -> confirm sample-counter policy when required + -> configure explicit test scaling when SCL does not establish it -> Deploy to device while STOPPED + -> immutable profile armed on ESP32-P4 -> Start -> live I/U/phase/frequency/quality changes -> Stop ``` -The browser does not implement a second IEC 61850 semantic parser. `run.cmd` builds the focused C++ profile compiler and starts a localhost-only Python bridge. Uploaded engineering data is written to a temporary local file only for compilation and removed immediately after the request. +The browser does not implement a second IEC 61850 semantic parser. `run.cmd` builds the focused native C++ profile compiler and starts a localhost-only Python bridge. Engineering data is written to a temporary local file only for compilation and removed immediately after the request. + +## Functional status + +The SCL-driven injector workflow is physically proven for the current supported 4I+4V device profile. + +A real engineering-file-derived 4800 fps profile has been compiled, validated, deployed and run on the ESP32-P4 with retained device telemetry showing approximately 4800 samples/s, zero missed slots and zero canonical TX failures in the observed run. + +This is a functional-completion statement, not a claim of universal SCL support or formal IEC conformance. ## Supported device profile in this phase @@ -30,7 +42,8 @@ The host parser can inspect broader SCL SV structures, but the first ESP32-P4 de - explicit/validated sample-counter modulus; - 16 ordered leaves as 8 `INT32 value + Quality` pairs; - 64-byte sample payload; -- configurable destination MAC, APPID, VLAN/PCP, svID, confRev and supported ASDU field presence. +- channel order `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- configurable destination MAC, APPID, VLAN/PCP, `svID`, `confRev` and supported ASDU field presence. A structurally valid but unsupported layout remains visible in the GUI and is blocked from deployment. The firmware does not guess a payload layout. @@ -42,6 +55,22 @@ SCL sampling rate does not universally prove the `smpCnt` wrap policy. When the Generic SCL does not always establish physical scaling. The GUI therefore exposes current and voltage counts-per-unit conversion for the active test profile instead of claiming a universal scale. This scaling affects only engineering-value conversion in the control plane; it does not change realtime scheduling. +## Canonical stream and diagnostic visibility + +The active SCL profile is the canonical standards-facing stream identity. During the current bench phase, firmware also emits a separate untagged broadcast diagnostic mirror: + +```text +canonical + -> SCL-derived multicast destination / APPID / svID / VLAN + +diagnostic mirror + -> broadcast / APPID 0x4F01 / svID AR_DIAG_SV1 / untagged +``` + +The mirror exists because the currently tested host USB-Ethernet capture path does not reliably expose multicast/priority-tagged canonical traffic. If a raw analyzer shows `AR_DIAG_SV1`, it is seeing the diagnostic mirror, not evidence that the SCL profile failed to deploy. + +The diagnostic mirror follows the active publisher rate and configuration revision but intentionally keeps a different identity. + ## Run 1. Flash firmware from the same `feature/smv-scl-profile-bridge` branch. @@ -52,11 +81,18 @@ Generic SCL does not always establish physical scaling. The GUI therefore expose .\apps\smv_injector_gui\run.cmd ``` -The launcher incrementally builds the smart SCL profile tool into `build-smv-gui`, starts the local control-plane server on `127.0.0.1:8765`, and opens the GUI. +The launcher: + +- detects a native Windows C++ compiler by capability rather than Visual Studio product year; +- incrementally builds the smart SCL profile tool into `build-smv-gui`; +- starts the local control-plane server on `127.0.0.1:8765`; +- opens the GUI. 4. Click **Connect device** and select the ESP32-P4 serial port. -5. Import an engineering file, select an SV stream, resolve any Class-B counter-policy requirement, then **Deploy to device** while STOPPED. -6. Press **Start**. Magnitude, phase, frequency, quality and enabled state remain live controls while running. +5. Import an engineering file and select an SV stream. +6. Resolve any Class-B counter-policy requirement. +7. **Deploy to device** while STOPPED. +8. Press **Start**. Magnitude, phase, frequency, quality and enabled state remain live controls while running. ## Realtime boundary @@ -86,4 +122,8 @@ C++ profile compiler ---- GUI engineering values Profile identity/layout changes require `STOP -> validate -> deploy -> arm`. Live signal values do not rebuild BER and do not restart `smpCnt`. -The current rational GPTimer scheduler supports runtime publisher rates without rounding a fractional-microsecond period into a permanently wrong fixed interval; for example, 4800 events/s uses a repeating 208/208/209 microsecond schedule at the 1 MHz timer domain. +The rational GPTimer scheduler supports runtime publisher rates without permanently rounding fractional-microsecond periods. For example, 4800 events/s uses a repeating 208/208/209 microsecond schedule in the 1 MHz timer domain. + +## Next validation surface + +The preferred next hardware validation path is an independent raw Ethernet analyzer, potentially using a second ESP32-P4 board in promiscuous/all-multicast receive mode with application-layer VLAN parsing. That analyzer should compare configured SCL truth against observed canonical wire truth without relying on the current Windows USB-NIC capture path. \ No newline at end of file From 97751846fa910fa755bba2b6e5bc8b539c0d0890 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:50:04 +0700 Subject: [PATCH 072/129] docs: promote ESP32-P4 injector README to SCL-driven runtime status --- embedded/esp32p4_smv_injector/README.md | 261 +++++++++++++++--------- 1 file changed, 164 insertions(+), 97 deletions(-) diff --git a/embedded/esp32p4_smv_injector/README.md b/embedded/esp32p4_smv_injector/README.md index 7979e12c3..da4bbc9b8 100644 --- a/embedded/esp32p4_smv_injector/README.md +++ b/embedded/esp32p4_smv_injector/README.md @@ -1,68 +1,139 @@ -# ESP32-P4-ETH SMV Injector — P0/P1 + P2 50 Hz bring-up +# ESP32-P4 Ethernet Sampled Values Injector -This target brings up the current ESP32-P4 Ethernet development board using the internal EMAC + onboard RMII PHY and publishes IEC 61850 Sampled Values through the existing ARStack process-bus codec. +This target runs the ARStack61850 Sampled Values injector on the current ESP32-P4 Ethernet development board using the internal EMAC and onboard RMII PHY. -See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the standards-first SCL/profile/PTP direction after the current P2 bring-up. +The firmware has progressed beyond the original fixed 4000 fps bring-up. The current runtime accepts a bounded immutable SCL-compiled publisher profile from the host GUI, arms that profile while STOPPED, and publishes deterministic Sampled Values while live signal values remain mutable. + +See: + +- [`ARCHITECTURE.md`](ARCHITECTURE.md) for the standards-first system boundaries; +- [`../../docs/SMV_INJECTOR_STATUS.md`](../../docs/SMV_INJECTOR_STATUS.md) for the current completion statement and physical bench evidence. ## Current hardware result -P0/P1 and the first P2 realtime path have been proven on real hardware: +The current ESP32-P4 path has been proven on real hardware: + +- ESP32-P4 rev 1.3 boots and drives the onboard RMII PHY; +- raw Layer-2 TX reaches an independent host capture path; +- IEC 61850 Sampled Values are decoded from the generated `0x88BA` traffic; +- the development 4000 fps profile has sustained stable device telemetry with zero TX failures and zero missed timer notifications in retained runs; +- the SCL-driven runtime has physically transitioned to a real engineering-file-derived 4800 fps profile; +- the 4800 fps retained run reported approximately 4800 samples/s, zero canonical TX failures and zero missed slots; +- the active profile exposed SCL-derived `svID`, APPID, VLAN/PCP intent, configuration revision, publisher rate and counter modulus in the GUI; +- three-phase current/voltage waveforms remained observable through the diagnostic capture path; +- profile identity/layout is immutable while RUNNING, while magnitude, phase, frequency, quality and channel enable state remain live controls. + +This proves the injector workflow for the supported profile boundary. It does not by itself prove formal IEC conformance or trusted on-wire visibility of every canonical multicast/VLAN field. + +## Runtime profile model + +The embedded runtime currently accepts a bounded `RuntimePublisherProfile` containing: + +- destination MAC; +- APPID; +- VLAN present/VID/PCP; +- configuration revision; +- publisher rate; +- sample-counter modulus; +- `nofASDU`; +- supported ASDU field-presence flags; +- `svID`; +- DataSet reference when present; +- profile generation. + +The supported embedded DataSet layout is currently: + +```text +Ia, Ib, Ic, In, Ua, Ub, Uc, Un +``` + +with eight `INT32 sample + UINT32 quality` pairs for a 64-byte sample payload. + +Profiles that cannot be mapped safely to this bounded layout must be rejected rather than guessed. + +## Runtime state rule + +```text +STOPPED + | + +-- receive / validate immutable publisher profile + | + +-- COMMIT / ARM + | + v +RUNNING + | + +-- magnitude may change live + +-- phase may change live + +-- frequency may change live + +-- quality may change live + +-- channel enable may change live + | + +-- MAC / APPID / VLAN / svID / DataSet / rate / layout remain immutable + | + v +STOPPED +``` + +A structural profile change therefore requires `STOP -> validate -> deploy -> arm`. + +## Rational sample scheduler -- ESP32-P4 rev 1.3 boots and drives the onboard RMII PHY. -- Raw Layer-2 transmit reaches a host capture NIC. -- Independent packet capture recognizes the generated `0x88BA` payload as IEC 61850 Sampled Values. -- The GPTimer-driven publisher sustains about 4000 samples/s on the current bench with device-side TX failures and missed timer notifications at zero in the observed run. -- A raw process-bus analyzer reconstructs balanced 50 Hz three-phase voltage/current waveforms from the diagnostic stream and reports continuous sequence integrity. -- The tested USB-Ethernet host capture path reports roughly the correct average rate but shows large individual arrival-time excursions. Treat those timestamps as capture-path evidence, not device-TX timing evidence. -- The tested host capture path does not reliably expose the normal IEC 61850 multicast/priority-tagged stream. The firmware therefore keeps a lab-only untagged broadcast diagnostic stream so waveform/timing validation can continue without pretending that stream is the standards-facing output. +The publisher uses a 1 MHz GPTimer and a rational one-shot scheduling cursor instead of permanently rounding fractional-microsecond periods. -## P2 50 Hz publisher +Examples: -The default firmware enables the first realtime publisher foundation: +```text +4000 fps -> 250 us, 250 us, 250 us, ... + +4800 fps -> 208 us, 208 us, 209 us, ... +``` + +This preserves the requested long-term event rate in the timer domain. + +The realtime TX path prebuilds the SV frame template. Per sample it patches only bounded fixed-width fields such as `smpCnt`, sample values and quality; it does not perform XML parsing or rebuild the full BER structure in the hot loop. -- 50 Hz nominal frequency. -- 80 samples/cycle. -- 4000 frames/s. -- 250 us GPTimer period. -- canonical `smpCnt` wraps at 4000. -- prebuilt fixed-size frame templates; no BER encode or heap allocation in the hot TX loop. -- fixed 2-byte `smpCnt` and 4-byte `confRev` patch fields in the runtime template. -- balanced three-phase sine generator. -- dataset order `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`. -- each channel is `(INT32 sample, UINT32 quality)`. -- current payload scaling: 1 mA/count. -- voltage payload scaling: 10 mV/count. -- default phase current: 1.000 A RMS. -- default phase-to-neutral voltage: approximately 57.74 V RMS (100 V line-to-line). -- `smpSynch=0` remains truthful until a measured PTP/1PPS lock exists. +## Canonical stream and diagnostic mirror -### Standards-facing canonical stream +During bench validation the firmware transmits two distinct streams. -The current canonical stream is aligned with `01_SV_Stream_4I+4V_(9-2LE).scd` stream #1: +### Canonical stream -- destination `01:0C:CD:04:00:01`; -- APPID `0x4000`; -- `svID=MU01_SV1`; -- `confRev=1`; -- VLAN PCP 4 / VID 0 enabled by default; -- one ASDU; -- dataset-reference field omitted because the supplied SCL has `dataSet=false`; -- explicit `smpRate` / `smpMod` fields omitted because the supplied SCL has `sampleRate=false`. +The canonical stream uses the active runtime profile: -This is the interoperability target. The build still does **not** claim final strict 9-2LE/IEC 61869-9 conformance; exact profile rules, synchronization, canonical multicast/VLAN capture on a trusted path and relay interoperability remain acceptance gates. +- configured multicast destination MAC; +- configured APPID; +- configured VLAN/PCP when enabled; +- configured `svID`; +- configured `confRev`; +- configured publisher rate; +- configured `smpCnt` modulus; +- supported configured ASDU field-presence policy. -### Lab diagnostic stream +This is the standards-facing stream identity. -For the current host-capture limitation the firmware also publishes a separate diagnostic stream: +### Diagnostic mirror -- destination `FF:FF:FF:FF:FF:FF`; +The separate lab mirror currently uses: + +- broadcast destination `FF:FF:FF:FF:FF:FF`; - APPID `0x4F01`; - `svID=AR_DIAG_SV1`; -- untagged; -- explicit `smpRate=4000` and `smpMod=SmpPerSec` so a raw analyzer can reconstruct the waveform without SCL binding; -- independent normal 16-bit sample counter. +- untagged Ethernet; +- the same live waveform source; +- the active runtime publisher rate; +- the active configuration revision; +- an independent diagnostic sample counter. + +The tested Windows USB-Ethernet capture path reliably shows the broadcast mirror but does not reliably expose the canonical multicast/priority-tagged stream. The mirror therefore exists only as a visibility/debug aid. + +An analyzer showing `AR_DIAG_SV1` is observing the diagnostic mirror, not the configured canonical SCL identity. + +## Synchronization truth + +The current firmware keeps `smpSynch=0` because no measured PTP-disciplined synchronization has yet been established. -The diagnostic identity is intentionally different from the canonical stream so analyzers do not merge both sequences. This stream is a capture/debug aid, not a process-bus compliance target. +A publisher rate, stable waveform or software task timestamp must never be converted into a synchronization claim. ## Board wiring used @@ -82,82 +153,78 @@ The diagnostic identity is intentionally different from the canonical stream so ## Toolchain -Use ESP-IDF 5.5.x. If the board contains an early ESP32-P4 engineering sample, select the matching chip revision configuration before flashing. For a detected ESP32-P4 v1.x/v2.x board, enable the ESP-IDF pre-v3 chip-revision option and rebuild. Do not bypass a revision mismatch with `--force`. +Use ESP-IDF 5.5.x. -The currently tested board is ESP32-P4 rev 1.3, so its local `sdkconfig` must retain the pre-v3 revision setting. +The currently tested board is ESP32-P4 rev 1.3, so its local `sdkconfig` must retain the pre-v3 chip-revision setting required by that silicon revision. + +Do not use `fullclean`, delete a working `sdkconfig`, or change target/revision settings merely to repeat a normal build. ## Build and flash ```bash cd embedded/esp32p4_smv_injector idf.py build -idf.py -p flash monitor +idf.py -p flash +idf.py -p monitor ``` -For the tested bench the port is currently `COM3`. +For the current bench the serial port has been `COM3`. -After boot, expected serial output includes: +A current SCL-profile-bridge boot includes output similar to: ```text -P2 waveform: 50 Hz, 80 samples/cycle, 4000 fps, period=250 us -SCL canonical: svID=MU01_SV1 ... APPID=0x4000 ... -LAB mirror: svID=AR_DIAG_SV1 APPID=0x4F01 ... explicit smpRate=4000 smpMod=SmpPerSec -P2 timing: samples=... MC ok=... fail=... mirror ok=... fail=... missed=... wake_late_us[min/mean/max]=... +Live signal engine ready: profile=... rate=... fps ...; use GUI START +GUI profile deployment uses bounded PROFILE subcommands while STOPPED. ``` -## Capture / analyzer checks - -For the current host capture adapter, use the diagnostic stream first: - -```text -eth.type == 0x88ba -``` +After the GUI deploys a profile and START is requested, the publisher reports an armed profile and begins rate-specific telemetry. -Expected diagnostic behavior: +## Host control path -- roughly 4000 decoded SV frames/s; -- continuous 16-bit `smpCnt` progression; -- visible non-zero three-phase current and voltage samples; -- reconstructed 80 samples/cycle at 50 Hz; -- balanced A/B/C waveform and phasors separated by 120 degrees. - -The standards-facing multicast stream is sent in parallel. A trusted capture path should validate: +The normal operator path is the local GUI: ```text -eth.dst == 01:0c:cd:04:00:01 -vlan.etype == 0x88ba +Import SCL / CID + -> host C++ profile compiler + -> Class A/B/C diagnostics + -> Deploy while STOPPED + -> START + -> live value changes + -> STOP ``` -Do not use host USB-Ethernet arrival jitter as the final device timing metric. The next P2 timing gate is ESP32-P4 EMAC IEEE1588v2 hardware TX timestamps and/or an independent reference capture path. - -## Configuration +The serial command parser remains a low-level bench/fallback transport and is not the primary operator UX. -Run `idf.py menuconfig` and open **ARStack ESP32-P4 SMV Injector** to control: +## Remaining validation and hardening work -- live TX; -- P2 50 Hz realtime mode; -- canonical VLAN PCP4/VID0; -- lab-only untagged diagnostic stream; -- optional `0x88B5` capture probes; -- current RMS in mA; -- voltage RMS in mV. +The injector functional phase is complete for the current supported 4I+4V profile. Remaining work is validation depth and profile breadth: -Use an isolated bench network or a direct capture/TAP connection. Do not connect this development image to an operational substation process bus. +- trusted raw capture of the canonical multicast/VLAN stream; +- independent configured-vs-observed profile comparison; +- ESP32-P4 EMAC hardware TX timestamp evidence; +- hardware timing statistics over retained long-duration runs; +- IEC/IEEE 61850-9-3 PTP clock discipline before changing `smpSynch`; +- broader SCL DataSet/basic-type deployment support; +- engineering scaling derived from validated source information rather than generic assumptions; +- multi-stream publication; +- fault/scenario/COMTRADE workflows; +- relay/subscriber interoperability evidence; +- formal profile/conformance work using sourced normative rules and independent test evidence. -## Remaining P2 acceptance work +## Next hardware validation node -Before calling the publisher production-grade: +A second ESP32-P4 Ethernet board is a suitable candidate for an independent raw analyzer: -- validate 60 seconds of 4000 fps capture with zero unexplained `smpCnt` gaps; -- enable ESP32-P4 EMAC IEEE1588v2 hardware time and collect hardware TX timestamp evidence; -- calculate scheduled-vs-actual TX min/max/mean/P95/P99 timing error; -- validate canonical VLAN multicast on a trusted capture path; -- add 60 Hz / 4800 fps scheduling; -- add IEC/IEEE 61850-9-3 PTP synchronization before changing `smpSynch` from 0; -- move from hard-coded bench constants to an SCL/profile-compiled runtime configuration; -- validate exact IEC 61850-9-2 / IEC 61869-9 / legacy 9-2LE behavior against sourced profile rules and independent relay/tool testing. +```text +Ethernet RX + -> promiscuous / all-multicast receive + -> preserve and parse VLAN tags + -> classify 0x88BA + -> decode SV identity and ASDU + -> verify smpCnt continuity / rate / quality + -> compare observed wire facts to configured SCL facts +``` -Tracked work: +In a switched lab, the analyzer still requires the relevant frames to reach its physical port. Use a TAP or managed-switch mirror/SPAN configuration that preserves the traffic and VLAN tags. -- P2 timing evidence: issue #21 -- standards-first SCL/profile/PTP engine: issue #24 \ No newline at end of file +Use an isolated bench network. Do not connect this development image to an operational process bus. \ No newline at end of file From 3186756393d6f8b721b53c161a5478d97f2359d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:51:04 +0700 Subject: [PATCH 073/129] docs: update SMV architecture after SCL-driven injector acceptance --- embedded/esp32p4_smv_injector/ARCHITECTURE.md | 300 ++++++++++++------ 1 file changed, 208 insertions(+), 92 deletions(-) diff --git a/embedded/esp32p4_smv_injector/ARCHITECTURE.md b/embedded/esp32p4_smv_injector/ARCHITECTURE.md index e83b68204..2d403af5c 100644 --- a/embedded/esp32p4_smv_injector/ARCHITECTURE.md +++ b/embedded/esp32p4_smv_injector/ARCHITECTURE.md @@ -1,116 +1,180 @@ -# ESP32-P4 SV Injector — standards-first architecture +# ESP32-P4 Sampled Values Injector — standards-first architecture -This document defines the direction after P0/P1 hardware bring-up and P2 50 Hz realtime publishing proved that the current ESP32-P4 target can sustain a 4000-sample/s SV stream on the bench. +This document defines the architecture after the ESP32-P4 injector progressed from fixed-rate bring-up to a physically exercised SCL-driven runtime profile. + +The core injector workflow is now functionally complete for the current supported 4I+4V embedded profile. Remaining work is deliberately separated into validation depth, profile breadth, synchronization, analyzer and product-hardening phases. The design separates: - **wire facts** — what is actually observed or timestamped; - **configured expectations** — what SCL/profile configuration requires; -- **profile claims** — conclusions such as generic IEC 61850-9-2, IEC 61869-9, or legacy 9-2LE compatibility; +- **runtime state** — what immutable profile and mutable signal state the ESP32-P4 has actually armed; +- **profile claims** — conclusions such as generic IEC 61850-9-2, IEC 61869-9, or legacy profile compatibility; - **synchronization evidence** — what is actually known about PTP clock and `smpSynch` state. -A decoded SV packet is not by itself a conformance claim. +A decoded SV packet or successful bench run is not by itself a conformance claim. -## 1. Product split +## 1. Current product split ```text Host engineering application | - | SCD/CID/ICD/IID + waveform/fault scenario + | SCD/CID/ICD/IID + operator values v -SCL/profile compiler +C++ SCL/profile compiler |- stream address and identifiers |- SmvOpts field-presence policy - |- dataset order and element types - |- sample-rate / nofASDU / counter policy - |- scaling and quality policy - |- synchronization policy + |- DataSet order and element types + |- sample-rate / nofASDU / counter-policy candidates + |- compatibility Class A/B/C diagnostics v -Versioned compiled SvPublisherProfile +Validated immutable SvPublisherProfile | - | control plane + | bounded control plane v ESP32-P4 realtime runtime - |- validate immutable profile + |- validate / commit immutable profile while STOPPED |- prebuild packet templates - |- waveform/fault engine + |- fixed-point waveform engine |- deterministic SV publisher - |- PTP-disciplined hardware clock - |- truthful smpSynch + |- live magnitude / phase / frequency / quality state + |- truthful unsynchronized state until PTP evidence exists |- timing/evidence telemetry ``` XML/SCL parsing belongs on the host. It must never occur in the ESP32 realtime TX hot path. -## 2. Reuse ARStack core instead of creating parallel models +## 2. Proven end-to-end injector flow + +The current physically exercised flow is: + +```text +engineering file + -> C++ SCL parser + -> resolved SV stream + -> Class A after required counter-policy confirmation + -> Deploy while STOPPED + -> ESP32-P4 profile commit + -> START + -> 4800 fps runtime publication + -> live 50 Hz three-phase values +``` + +A retained real engineering-file-derived test reached approximately 4800 samples/s with zero canonical TX failures and zero missed slots in device telemetry. + +This establishes functional completion for the current embedded profile boundary, not universal SCL support or formal conformance. -ARStack already contains: +## 3. Reuse ARStack core instead of creating parallel models -- `ariec61850::scl::SclDocument` / `SclSampledValuesStream`; -- dataset entries with CDC/basic-type metadata; -- Ethernet/VLAN types; -- Sampled Values ASDU/PDU/frame codecs; -- sample-counter and stream-supervision utilities. +ARStack contains the shared SCL, Ethernet, SV and evidence primitives required by this architecture. The host compiler should remain the semantic authority rather than introducing a second browser or embedded SCL model. -The future publisher profile should be compiled from those models rather than duplicating SCL parsing inside the embedded application. +The browser is therefore an operator/control surface, not an IEC 61850 semantic parser. -## 3. Compiled SV profile +## 4. Compiled SV profile -The host should reduce SCL plus an explicitly selected interoperability profile to a compact immutable structure similar to: +The host reduces SCL plus explicit engineering resolution to an immutable profile containing the fields needed by the embedded runtime, including: ```text SvPublisherProfile - version - profileFamily + schemaVersion + controlBlockReference + svId + dataSetReference destinationMac appId - vlanId / vlanPriority - svId + vlanPresent / vlanId / vlanPriority confRev + sampleRate / sampleMode + publisherRateHz nofAsdu - fieldPresence - dataSet - refrTm - smpRate - smpMod - dataSetReference - samplingBasis - sampleRate - samplesPerCycle - sampleCounterPolicy - synchronizationPolicy - channels[] - semantic - basicType - byteOffset - scale - qualityPolicy + sampleCounterPolicy / modulus + ASDU field-presence policy + ordered channels + payloadSizeBytes ``` -Activation must fail closed when required bindings are unresolved or the binary profile fails version/length/checksum validation. +The first embedded bridge intentionally supports a bounded subset: + +- one ASDU; +- absolute `SmpPerSec` publisher rate; +- explicit validated sample-counter modulus; +- eight logical channels `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- 16 ordered leaves as eight `INT32 value + Quality` pairs; +- 64-byte sample payload. + +Activation fails closed when required bindings are unresolved or the profile cannot be mapped safely to this layout. -## 4. Profile claims +## 5. Runtime mutability law + +While RUNNING, profile identity/layout is immutable: + +```text +immutable while RUNNING + destination MAC + APPID + VLAN / PCP + svID + DataSet identity/layout + publisher rate + sample-counter policy + ASDU field-presence policy +``` + +Live signal state remains mutable: + +```text +mutable while RUNNING + current / voltage magnitude + phase + signal frequency + quality + channel enable state +``` + +Structural change therefore requires: + +```text +STOP -> validate -> deploy -> arm -> START +``` -Keep three product-facing families separate: +## 6. Canonical versus diagnostic traffic -1. **Generic IEC 61850-9-2 / SCL driven** — exact configuration follows the selected SCL stream. -2. **IEC 61869-9** — only after required constraints are sourced and validated. -3. **Legacy 9-2LE compatibility** — explicit compatibility mode, never silently inferred as the generic standard. +The standards-facing canonical stream and the capture workaround have separate identities. -Unknown or partially evidenced profile fields stay unknown. The software must not convert a heuristic into a compliance label. +### Canonical stream -## 5. Canonical vs diagnostic traffic +The canonical stream uses the active SCL-derived runtime profile: multicast destination, APPID, VLAN/PCP, `svID`, `confRev`, publisher rate and sample-counter policy. -The standards-facing canonical stream and any capture workaround must have separate identities. +### Diagnostic mirror -Current bench example: +The current lab mirror is intentionally different: -- canonical: SCL-derived multicast/VLAN stream; -- diagnostic mirror: untagged broadcast with its own APPID/svID and explicit sampling metadata for raw analyzer reconstruction. +- broadcast destination; +- APPID `0x4F01`; +- `svID=AR_DIAG_SV1`; +- untagged; +- same live waveform source; +- active publisher rate and configuration revision; +- separate diagnostic sample counter. -A diagnostic mirror is never evidence that the multicast/VLAN process-bus stream has passed interoperability testing. +The current Windows USB-Ethernet capture path reliably exposes the mirror but does not reliably expose the canonical multicast/priority-tagged stream. Therefore observing `AR_DIAG_SV1` is evidence of the diagnostic path only; it is not evidence that the SCL profile was ignored. -## 6. Timing hierarchy +The diagnostic mirror must never be relabelled with the canonical `svID`, because that would make a debug frame indistinguishable from the standards-facing stream. + +## 7. Rational scheduling + +The runtime uses a 1 MHz GPTimer with a rational one-shot schedule cursor. + +```text +4000 fps -> 250 / 250 / 250 ... us +4800 fps -> 208 / 208 / 209 ... us +``` + +This avoids permanently rounding a fractional-microsecond target into the wrong long-term publisher rate. + +The packet template is prebuilt when the profile is armed. The hot loop only patches bounded fixed-width fields and transmits the frame. + +## 8. Timing hierarchy Timing evidence has distinct layers and they must not be mixed: @@ -123,64 +187,116 @@ sample deadline -> host capture timestamp ``` -The current GPTimer/FreeRTOS telemetry proves scheduler continuity, but host USB-Ethernet arrival timestamps can be batched or perturbed and are not the source of truth for device TX jitter. +Current GPTimer/FreeRTOS telemetry proves scheduler continuity and device-side submission behavior. It does not establish final on-wire jitter. -P2 should therefore add ESP32-P4 EMAC IEEE1588v2 hardware timestamp evidence before making on-wire timing claims. +The next timing gate remains ESP32-P4 EMAC hardware TX timestamp evidence and/or an independent hardware analyzer. -## 7. PTP / IEC 61850-9-3 direction +## 9. PTP / IEC 61850-9-3 direction -Use an evidence-oriented layered design and move standards-facing synchronization onto hardware time: +Use an evidence-oriented layered design and move synchronization claims onto hardware time: 1. PTPv2 Layer-2 codec (`0x88F7`). 2. Passive monitor: observed sources, domain, message types, sequence integrity. -3. IEC/IEEE 61850-9-3 Power Profile validator. +3. Applicable power-profile validator. 4. ESP32-P4 EMAC IEEE1588v2 hardware clock. 5. Peer-delay / offset estimation and clock servo. 6. Lock / holdover / unlocked state machine. 7. SV `smpSynch` policy derived from measured state. 8. Hardware TX timestamp evidence correlated with `smpCnt`. -Software-generated lab PTP traffic must never be treated as proof of hardware synchronization. Traffic visibility, clock discipline and synchronization claims remain separate. - -## 8. `smpSynch` rule +Software-generated traffic or stable waveform output must never be treated as proof of synchronization. -Standards mode: +## 10. `smpSynch` rule ```text no valid synchronization evidence -> unsynchronized -measured local synchronization -> local according to selected profile -measured global/PTP synchronization-> global according to selected profile +measured synchronization -> encode only what the selected profile and evidence justify +``` + +The current injector therefore keeps `smpSynch=0`. + +## 11. Independent analyzer node + +The preferred next hardware phase is a second raw Ethernet analyzer node, potentially using another ESP32-P4 board: + +```text +Process-bus Ethernet + | + v +ESP32-P4 Analyzer + |- promiscuous / all-multicast raw RX + |- preserve / parse IEEE 802.1Q tags + |- classify SV EtherType 0x88BA + |- decode APPID / svID / smpCnt / confRev / quality / sample values + |- stream table and continuity statistics + |- timing evidence + | + v +Observed-wire profile + | + v +Configured-vs-observed comparison ``` -Compatibility overrides, if later offered for relay readability tests, must be visibly labelled as compatibility behavior and never treated as timing evidence. +Promiscuous mode cannot recover frames that a switch never sends to the analyzer port. A switched lab therefore requires a trusted TAP or mirror/SPAN port that preserves the relevant traffic and VLAN tags. + +This analyzer removes ambiguity introduced by host USB-NIC filtering or VLAN handling and enables direct comparison of configured SCL truth with observed wire truth. + +## 12. Profile claims + +Keep product-facing claims separate: -## 9. Current bench interpretation +1. **Generic SCL-driven Sampled Values** — exact configuration follows the selected engineering stream within the implemented runtime boundary. +2. **IEC 61869-9 profile support** — only after the applicable constraints are sourced and independently validated. +3. **Legacy profile compatibility** — explicit compatibility behavior only, never silently inferred as the generic standard. -The current 50 Hz build has demonstrated: +Unknown or partially evidenced profile fields stay unknown. A heuristic must not become a compliance label. -- approximately 4000 SV samples/s; -- continuous sequence on the diagnostic stream; -- balanced reconstructed 50 Hz three-phase waveforms; -- zero device-side TX failures and missed GPTimer notifications in observed stable runs; -- a host USB-Ethernet capture path that reports about the correct average throughput but large individual arrival-time excursions. +## 13. Current completion boundary -Therefore the next timing work is **hardware timestamp evidence**, not compensating the publisher against host capture jitter. +### Functionally complete -## 10. Acceptance gates +- host SCL/CID import; +- C++ semantic compilation; +- stream discovery; +- Class A/B/C diagnostics; +- explicit counter-policy confirmation; +- bounded immutable profile deployment; +- ESP32-P4 runtime profile arm; +- 4000 and 4800 fps runtime scheduling; +- live current/voltage magnitude, phase, frequency, quality and enable control; +- START/STOP state model; +- device telemetry for rate, TX failure, missed slots and generation; +- separate diagnostic mirror for capture-path troubleshooting. -A production-grade result requires independent gates for: +### Follow-on phases + +- trusted canonical multicast/VLAN on-wire proof; +- configured-vs-observed analyzer; +- hardware TX timing statistics; +- PTP-disciplined clock and synchronization evidence; +- broader embedded DataSet/type support; +- validated engineering scaling; +- multi-stream publication; +- fault/scenario and COMTRADE workflows; +- independent relay/subscriber interoperability; +- production packaging and qualification; +- formal conformance work. + +These follow-on gates do not reopen the already proven core injector workflow. + +## 14. Acceptance philosophy + +A production-grade result ultimately requires independent evidence for: - packet/framing correctness; - SCL/configuration match; -- dataset semantics and engineering scaling; +- DataSet semantics and engineering scaling; - canonical multicast/VLAN interoperability; -- sustained 4000/4800 sample/s integrity; +- sustained publisher integrity; - hardware TX timing statistics; - PTP synchronization quality and truthful `smpSynch`; -- relay/subscriber interoperability; -- evidence artifacts (PCAP + machine-readable timing/profile report). - -Tracked work: +- subscriber interoperability; +- retained evidence artifacts. -- P2 deterministic timing evidence: issue #21 -- standards-first SCL/profile/PTP architecture: issue #24 \ No newline at end of file +The project should continue to distinguish **functional completion**, **interoperability evidence**, and **formal conformance** rather than treating them as the same milestone. \ No newline at end of file From ba0d31f4a3744231ca598eaf12dff574264bfab4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:53:15 +0700 Subject: [PATCH 074/129] 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 075/129] 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 076/129] 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 077/129] 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 078/129] 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 079/129] 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 080/129] 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 081/129] 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 082/129] 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 083/129] 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 084/129] 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 085/129] 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 086/129] 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 087/129] 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 088/129] 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 089/129] 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 090/129] 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 091/129] 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 092/129] 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 093/129] 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 094/129] 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 7635fb7a3d2f124a91144313b65b373878188417 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:21:33 +0700 Subject: [PATCH 095/129] Restore current-main live signal test in merged CMake --- CMakeLists.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0826ea697..2e43e8868 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -220,6 +220,13 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_${test_name}_tests COMMAND ariec61850_${test_name}_tests) endforeach() + add_executable(ariec61850_live_signal_tests tests/test_live_signal.cpp) + target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_live_signal_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_live_signal_tests) + ariec61850_apply_sanitizers(ariec61850_live_signal_tests) + add_test(NAME ariec61850_live_signal_tests COMMAND ariec61850_live_signal_tests) + add_executable(ariec61850_goose_runtime_tests tests/test_goose_runtime.cpp) target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_goose_runtime_tests PRIVATE cxx_std_20) From 35fe7ab18b5b24ed7666dc45a843cf926bc9264f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:37:39 +0700 Subject: [PATCH 096/129] Track negotiated peer response limits --- include/ariec61850/mms/connection_runtime.hpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp index 85689d2b4..7579d60b1 100644 --- a/include/ariec61850/mms/connection_runtime.hpp +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -27,6 +27,7 @@ enum class MmsStaticConnectionStatus : std::uint8_t { application_rejected, response_buffer_too_small, workspace_too_small, + peer_limit_exceeded, backend_failure, closed, }; @@ -120,6 +121,16 @@ class MmsStaticConnectionRuntime final { return mms_presentation_context_id_; } + // Negotiated outbound limits. They are zero until the corresponding COTP + // and MMS association handshakes have completed successfully. + [[nodiscard]] constexpr std::size_t negotiated_tpdu_size_bytes() const noexcept { + return negotiated_tpdu_size_bytes_; + } + + [[nodiscard]] constexpr std::uint32_t negotiated_mms_pdu_size() const noexcept { + return negotiated_mms_pdu_size_; + } + [[nodiscard]] constexpr MmsStaticRequestAccessContext access_context() const noexcept { return policy_.access_context(); } @@ -135,8 +146,10 @@ class MmsStaticConnectionRuntime final { MmsStaticConnectionPolicy policy_{}; MmsStaticConnectionState state_{MmsStaticConnectionState::awaiting_cotp_connect}; std::uint32_t mms_presentation_context_id_{}; + std::size_t negotiated_tpdu_size_bytes_{}; + std::uint32_t negotiated_mms_pdu_size_{}; bool association_active_{}; bool association_close_notified_{}; }; -} // namespace ar::iec61850::mms +} // namespace ar::iec61850::mms \ No newline at end of file From 68b0189cd61753f42f43f91102bebcd7238515c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:39:21 +0700 Subject: [PATCH 097/129] Enforce negotiated COTP and MMS response limits --- src/mms/connection_runtime.cpp | 73 +++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index 523981ffd..28c571361 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -20,6 +20,9 @@ namespace ar::iec61850::mms { namespace { +// Must remain aligned with MmsPduSpanCodec::encode_default_initiate_response_into(). +constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; + [[nodiscard]] MmsStaticConnectionResult make_result( const MmsStaticConnectionStatus status, const MmsStaticConnectionState state, @@ -63,16 +66,54 @@ namespace { return true; } +[[nodiscard]] bool selected_tpdu_size_bytes( + const osi::CotpTpduView& request, + const std::uint8_t local_maximum_code, + std::size_t& selected_bytes) noexcept { + selected_bytes = 0U; + std::size_t local_bytes{}; + if (!osi::CotpSpanCodec::try_tpdu_size_bytes(local_maximum_code, local_bytes)) { + return false; + } + + std::span offered; + if (!request.try_parameter(osi::CotpSpanCodec::tpdu_size_parameter, offered)) { + selected_bytes = local_bytes; + return true; + } + if (offered.size() != 1U) { + return false; + } + + std::size_t peer_bytes{}; + if (!osi::CotpSpanCodec::try_tpdu_size_bytes(offered[0], peer_bytes)) { + return false; + } + selected_bytes = std::min(local_bytes, peer_bytes); + return true; +} + [[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response( const std::span session_or_presentation, const std::size_t consumed, const MmsStaticConnectionState state, + const std::size_t negotiated_tpdu_size_bytes, const std::span response, const std::span workspace) noexcept { // COTP Data adds three bytes and TPKT adds four bytes. Preflight both // caller-owned buffers so a capacity retry never advances connection state. + std::size_t cotp_required{}; + if (!add_overhead(session_or_presentation.size(), 3U, cotp_required)) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + if (negotiated_tpdu_size_bytes == 0U || + cotp_required > negotiated_tpdu_size_bytes) { + return make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, state, consumed); + } + std::size_t required{}; - if (!add_overhead(session_or_presentation.size(), 7U, required)) { + if (!add_overhead(cotp_required, osi::TpktSpanCodec::header_length, required)) { return make_result(MmsStaticConnectionStatus::backend_failure, state); } if (response.size() < required) { @@ -183,12 +224,16 @@ void MmsStaticConnectionRuntime::close_transport() noexcept { notify_association_closed(); state_ = MmsStaticConnectionState::closed; mms_presentation_context_id_ = 0U; + negotiated_tpdu_size_bytes_ = 0U; + negotiated_mms_pdu_size_ = 0U; } void MmsStaticConnectionRuntime::reset() noexcept { notify_association_closed(); state_ = MmsStaticConnectionState::awaiting_cotp_connect; mms_presentation_context_id_ = 0U; + negotiated_tpdu_size_bytes_ = 0U; + negotiated_mms_pdu_size_ = 0U; association_active_ = false; association_close_notified_ = false; } @@ -238,6 +283,16 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( peek.frame_bytes); } + std::size_t selected_tpdu_bytes{}; + if (!selected_tpdu_size_bytes( + cotp, policy_.maximum_tpdu_size_code, selected_tpdu_bytes)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + const auto cc = osi::CotpSpanCodec::encode_connection_confirm_from_request_into( cotp, policy_.cotp_source_reference, @@ -269,6 +324,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return make_result(MmsStaticConnectionStatus::backend_failure, state_); } + negotiated_tpdu_size_bytes_ = selected_tpdu_bytes; state_ = MmsStaticConnectionState::awaiting_association; return make_result( MmsStaticConnectionStatus::response_ready, @@ -307,6 +363,8 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( state_, peek.frame_bytes); } + const auto selected_mms_pdu_size = std::min( + initiate.maximum_mms_pdu_size, kServerMaximumMmsPduSize); const auto accept = acse::AcseSpanCodec::build_accept_response_into( association, workspace); @@ -322,12 +380,14 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( workspace.first(accept.bytes_written), peek.frame_bytes, MmsStaticConnectionState::established, + negotiated_tpdu_size_bytes_, response, workspace); if (!wrapped.response_ready()) { return wrapped; } mms_presentation_context_id_ = association.mms_presentation_context_id; + negotiated_mms_pdu_size_ = selected_mms_pdu_size; state_ = MmsStaticConnectionState::established; association_active_ = true; association_close_notified_ = false; @@ -411,6 +471,14 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return application_rejected(state_, peek.frame_bytes, application); } + if (negotiated_mms_pdu_size_ == 0U || + application.bytes_written > negotiated_mms_pdu_size_) { + return make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state_, + peek.frame_bytes); + } + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( response.first(application.bytes_written), workspace, @@ -431,6 +499,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( workspace.first(p_data.bytes_written), peek.frame_bytes, state_, + negotiated_tpdu_size_bytes_, response, workspace); if (wrapped.response_ready()) { @@ -441,4 +510,4 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return wrapped; } -} // namespace ar::iec61850::mms +} // namespace ar::iec61850::mms \ No newline at end of file From 988eae0e1230e68ad9213e82947b8e3699f58353 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:39:53 +0700 Subject: [PATCH 098/129] Fail closed on negotiated peer limit overflow --- src/mms/static_server_session.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mms/static_server_session.cpp b/src/mms/static_server_session.cpp index 324085051..5016d5d0a 100644 --- a/src/mms/static_server_session.cpp +++ b/src/mms/static_server_session.cpp @@ -142,6 +142,7 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { case MmsStaticConnectionStatus::protocol_violation: case MmsStaticConnectionStatus::response_buffer_too_small: case MmsStaticConnectionStatus::workspace_too_small: + case MmsStaticConnectionStatus::peer_limit_exceeded: case MmsStaticConnectionStatus::backend_failure: return terminate( MmsStaticServerSessionStatus::protocol_error, @@ -187,4 +188,4 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { 0U); } -} // namespace ar::iec61850::mms +} // namespace ar::iec61850::mms \ No newline at end of file From bd9a581b6f7b0a2fe807f09044ce766f0ee137b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:40:40 +0700 Subject: [PATCH 099/129] Expose BRCB peer-limit result --- include/ariec61850/mms/static_brcb_connection.hpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/include/ariec61850/mms/static_brcb_connection.hpp b/include/ariec61850/mms/static_brcb_connection.hpp index 9ad1cf8dd..ffea1368c 100644 --- a/include/ariec61850/mms/static_brcb_connection.hpp +++ b/include/ariec61850/mms/static_brcb_connection.hpp @@ -20,6 +20,7 @@ enum class MmsStaticBrcbConnectionStatus : std::uint8_t { access_denied, response_buffer_too_small, workspace_too_small, + peer_limit_exceeded, frame_encode_failed, stale_entry, }; @@ -50,9 +51,9 @@ class MmsStaticBrcbConnection final { // A successful poll only stages a complete TPKT image. It deliberately does // NOT advance the BRCB delivery cursor. The transport adapter must call // commit_sent() only after the complete staged frame has been accepted by - // the transport. Capacity errors, denied access, disabled reporting, - // disconnected sessions and send failures therefore preserve the same - // EntryID for retry/reconnect. + // the transport. Capacity errors, peer-limit rejection, denied access, + // disabled reporting, disconnected sessions and send failures therefore + // preserve the same EntryID for retry/reconnect. [[nodiscard]] static MmsStaticBrcbConnectionResult poll( const MmsStaticConnectionRuntime& connection, MmsStaticBrcbControl& control, @@ -74,4 +75,4 @@ class MmsStaticBrcbConnection final { const MmsStaticBrcbConnectionResult& staged) noexcept; }; -} // namespace ar::iec61850::mms +} // namespace ar::iec61850::mms \ No newline at end of file From a228dde1ad7e63bd7ba819e2620cdbb85f8a6f9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:41:12 +0700 Subject: [PATCH 100/129] Enforce negotiated limits on BRCB delivery --- src/mms/static_brcb_connection.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp index 912daf556..e7f3b9b66 100644 --- a/src/mms/static_brcb_connection.cpp +++ b/src/mms/static_brcb_connection.cpp @@ -81,6 +81,11 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( return make_result(MmsStaticBrcbConnectionStatus::no_report_available); } + const auto mms_limit = connection.negotiated_mms_pdu_size(); + if (mms_limit == 0U || entry.mms_pdu.size() > mms_limit) { + return make_result(MmsStaticBrcbConnectionStatus::peer_limit_exceeded, &entry); + } + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( entry.mms_pdu, workspace, @@ -101,8 +106,17 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( return result; } + std::size_t cotp_required{}; + if (!add_size(p_data.bytes_written, 3U, cotp_required)) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + const auto tpdu_limit = connection.negotiated_tpdu_size_bytes(); + if (tpdu_limit == 0U || cotp_required > tpdu_limit) { + return make_result(MmsStaticBrcbConnectionStatus::peer_limit_exceeded, &entry); + } + std::size_t final_required{}; - if (!add_size(p_data.bytes_written, 7U, final_required)) { + if (!add_size(cotp_required, osi::TpktSpanCodec::header_length, final_required)) { return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); } if (response.size() < final_required) { @@ -168,4 +182,4 @@ bool MmsStaticBrcbConnection::commit_sent( return reports.commit_delivery(staged.entry_id) == MmsStaticBrcbStatus::ok; } -} // namespace ar::iec61850::mms +} // namespace ar::iec61850::mms \ No newline at end of file From 5f8ead9b89b6cb25384e214b61a01dc59c13d6f6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:43:43 +0700 Subject: [PATCH 101/129] Add negotiated response limit regression --- ...s_negotiated_limits_hard_profile_smoke.cpp | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 embedded/mms_negotiated_limits_hard_profile_smoke.cpp diff --git a/embedded/mms_negotiated_limits_hard_profile_smoke.cpp b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp new file mode 100644 index 000000000..e090c6e81 --- /dev/null +++ b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_brcb_connection.hpp" +#include "ariec61850/mms/static_brcb_control.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kOctet80Type{0x89U, 0x01U, 0x50U}; +constexpr std::array kAssociationRequest{ + 0x0DU,0xB6U,0x05U,0x06U,0x13U,0x01U,0x00U,0x16U,0x01U,0x02U,0x14U,0x02U, + 0x00U,0x02U,0x33U,0x02U,0x00U,0x01U,0x34U,0x02U,0x00U,0x01U,0xC1U,0xA0U, + 0x31U,0x81U,0x9DU,0xA0U,0x03U,0x80U,0x01U,0x01U,0xA2U,0x81U,0x95U,0x81U, + 0x04U,0x00U,0x00U,0x00U,0x01U,0x82U,0x04U,0x00U,0x00U,0x00U,0x01U,0xA4U, + 0x23U,0x30U,0x0FU,0x02U,0x01U,0x01U,0x06U,0x04U,0x52U,0x01U,0x00U,0x01U, + 0x30U,0x04U,0x06U,0x02U,0x51U,0x01U,0x30U,0x10U,0x02U,0x01U,0x03U,0x06U, + 0x05U,0x28U,0xCAU,0x22U,0x02U,0x01U,0x30U,0x04U,0x06U,0x02U,0x51U,0x01U, + 0x61U,0x62U,0x30U,0x60U,0x02U,0x01U,0x01U,0xA0U,0x5BU,0x60U,0x59U,0xA1U, + 0x07U,0x06U,0x05U,0x28U,0xCAU,0x22U,0x02U,0x03U,0xA2U,0x07U,0x06U,0x05U, + 0x29U,0x01U,0x87U,0x67U,0x01U,0xA3U,0x03U,0x02U,0x01U,0x0CU,0xA6U,0x06U, + 0x06U,0x04U,0x29U,0x01U,0x87U,0x67U,0xA7U,0x03U,0x02U,0x01U,0x0CU,0xBEU, + 0x33U,0x28U,0x31U,0x06U,0x02U,0x51U,0x01U,0x02U,0x01U,0x03U,0xA0U,0x28U, + 0xA8U,0x26U,0x80U,0x03U,0x00U,0xFDU,0xE8U,0x81U,0x01U,0x0AU,0x82U,0x01U, + 0x0AU,0x83U,0x01U,0x05U,0xA4U,0x16U,0x80U,0x01U,0x01U,0x81U,0x03U,0x05U, + 0xF1U,0x00U,0x82U,0x0CU,0x03U,0xEEU,0x1CU,0x00U,0x00U,0x04U,0x08U,0x00U, + 0x00U,0x79U,0xEFU,0x18U}; + +constexpr std::array kReadRequest{ + 0xA0U,0x27U,0x02U,0x01U,0x0CU, + 0xA4U,0x22U,0xA1U,0x20U,0xA0U,0x1EU, + 0x30U,0x0DU,0xA0U,0x0BU,0xA1U,0x09U, + 0x1AU,0x03U,0x4CU,0x44U,0x30U, + 0x1AU,0x02U,0x52U,0x31U, + 0x30U,0x0DU,0xA0U,0x0BU,0xA1U,0x09U, + 0x1AU,0x03U,0x4CU,0x44U,0x30U, + 0x1AU,0x02U,0x4DU,0x31U}; + +[[nodiscard]] wire::EncodeResult read_large_octets( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t payload_bytes = 80U; + constexpr std::size_t required = payload_bytes + 2U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto& payload = *static_cast*>(context); + destination[0] = 0x89U; + destination[1] = static_cast(payload_bytes); + for (std::size_t index = 0U; index < payload.size(); ++index) { + destination[index + 2U] = payload[index]; + } + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool set_maximum_mms_pdu( + std::array& association, + const std::uint32_t value) noexcept { + if (value > 0x00FF'FFFFU) { + return false; + } + constexpr std::array marker{0x80U,0x03U,0x00U,0xFDU,0xE8U}; + for (std::size_t index = 0U; index + marker.size() <= association.size(); ++index) { + bool match = true; + for (std::size_t marker_index = 0U; marker_index < marker.size(); ++marker_index) { + if (association[index + marker_index] != marker[marker_index]) { + match = false; + break; + } + } + if (!match) { + continue; + } + association[index + 2U] = static_cast((value >> 16U) & 0xFFU); + association[index + 3U] = static_cast((value >> 8U) & 0xFFU); + association[index + 4U] = static_cast(value & 0xFFU); + return true; + } + return false; +} + +template +[[nodiscard]] wire::EncodeResult build_data_tpkt( + const std::span payload, + std::array& output, + std::array& scratch) noexcept { + const auto cotp = osi::CotpSpanCodec::encode_data_into(payload, scratch); + if (!cotp.success()) { + return cotp; + } + return osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), output); +} + +template +[[nodiscard]] wire::EncodeResult build_mms_tpkt( + const std::span mms_pdu, + std::array& output, + std::array& presentation, + std::array& scratch) noexcept { + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + mms_pdu, presentation, 3U, true); + if (!p_data.success()) { + return p_data; + } + return build_data_tpkt( + std::span{presentation}.first(p_data.bytes_written), + output, + scratch); +} + +[[nodiscard]] bool connect_cotp( + mms::MmsStaticConnectionRuntime& runtime, + const std::uint8_t tpdu_size_code, + std::span request, + std::span response, + std::span workspace, + std::span scratch) noexcept { + const std::array tpdu_size{tpdu_size_code}; + constexpr std::array source_tsap{0x00U,0x01U}; + constexpr std::array destination_tsap{0x00U,0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) { + return false; + } + const auto tpkt = osi::TpktSpanCodec::encode_into( + scratch.first(cr.bytes_written), request); + if (!tpkt.success()) { + return false; + } + const auto result = runtime.process_tcp_window( + request.first(tpkt.bytes_written), response, workspace); + std::size_t expected{}; + return osi::CotpSpanCodec::try_tpdu_size_bytes(tpdu_size_code, expected) && + result.response_ready() && + runtime.state() == mms::MmsStaticConnectionState::awaiting_association && + runtime.negotiated_tpdu_size_bytes() == expected; +} + +[[nodiscard]] mms::MmsStaticConnectionResult associate( + mms::MmsStaticConnectionRuntime& runtime, + const std::uint32_t maximum_mms_pdu, + std::span request, + std::span response, + std::span workspace, + std::span scratch) noexcept { + auto association = kAssociationRequest; + if (!set_maximum_mms_pdu(association, maximum_mms_pdu)) { + return {}; + } + const auto cotp = osi::CotpSpanCodec::encode_data_into(association, scratch); + if (!cotp.success()) { + return {}; + } + const auto tpkt = osi::TpktSpanCodec::encode_into( + scratch.first(cotp.bytes_written), request); + if (!tpkt.success()) { + return {}; + } + return runtime.process_tcp_window( + request.first(tpkt.bytes_written), response, workspace); +} + +} // namespace + +int main() { + std::array large_value{}; + for (std::size_t index = 0U; index < large_value.size(); ++index) { + large_value[index] = static_cast(index); + } + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kOctet80Type, read_large_octets, &large_value, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kOctet80Type, read_large_octets, &large_value, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; + if (!object_table.valid() || !data_set_table.valid_against(object_table)) { + return 1; + } + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + std::array presentation{}; + + // A 128-byte TPDU cannot carry the fixed association-accept TSDU as one DT + // TPDU. Until outbound COTP segmentation is implemented, reject it rather + // than violate the negotiated limit. + mms::MmsStaticConnectionRuntime tiny_tpdu{dispatcher}; + if (!connect_cotp(tiny_tpdu, 0x07U, request, response, workspace, scratch)) { + return 2; + } + const auto tiny_association = associate( + tiny_tpdu, 65'000U, request, response, workspace, scratch); + if (tiny_association.status != mms::MmsStaticConnectionStatus::peer_limit_exceeded || + tiny_association.bytes_written != 0U || + tiny_tpdu.negotiated_tpdu_size_bytes() != 128U || + tiny_tpdu.state() != mms::MmsStaticConnectionState::awaiting_association) { + return 3; + } + + mms::MmsStaticConnectionPolicy policy; + policy.association_id = 101U; + policy.owner[0] = 0xAAU; + policy.owner[1] = 0x01U; + policy.owner_size = 2U; + mms::MmsStaticConnectionRuntime runtime{dispatcher, policy}; + if (!connect_cotp(runtime, 0x09U, request, response, workspace, scratch)) { + return 4; + } + const auto association = associate(runtime, 64U, request, response, workspace, scratch); + if (!association.response_ready() || + runtime.state() != mms::MmsStaticConnectionState::established || + runtime.negotiated_tpdu_size_bytes() != 512U || + runtime.negotiated_mms_pdu_size() != 64U) { + return 5; + } + + // The two large Read results exceed the peer's 64-byte MMS PDU offer. No + // oversized confirmed response may be placed on the wire. + const auto read_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + if (!read_tpkt.success()) { + return 6; + } + const auto read_result = runtime.process_tcp_window( + request.first(read_tpkt.bytes_written), response, workspace); + if (read_result.status != mms::MmsStaticConnectionStatus::peer_limit_exceeded || + read_result.bytes_written != 0U || + read_result.consumed_bytes != read_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::established) { + return 7; + } + + // InformationReport has the same peer MMS/COTP bounds. Retain the entry and + // cursor when delivery is refused so it can be retried on a compatible peer. + const mms::MmsStaticBrcbDefinition definition{ + "LD0", "LLN0$BR$B1", "LD0/LLN0$BR$B1", + "LD0", "LLN0$Events", 1U, {0x7FU,0x80U}, 0U, 0x70U}; + std::array slot_storage{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot_storage}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime reports{ + definition, pending, slots, object_table, data_set_table}; + if (!reports.initialize()) { + return 8; + } + mms::MmsStaticBrcbControl control{reports}; + mms::MmsStaticBrcbClientIdentity client; + client.association_id = 101U; + client.owner[0] = 0xAAU; + client.owner[1] = 0x01U; + client.owner_size = 2U; + if (control.reserve(client, 0U, 100U) != mms::MmsStaticBrcbControlStatus::ok || + control.set_report_enabled(client, true, 100U) != + mms::MmsStaticBrcbControlStatus::ok || + reports.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 100U) != + mms::MmsStaticBrcbStatus::ok) { + return 9; + } + mms::MmsStaticBrcbCapturePlan plan; + std::array encode_buffer{}; + std::array capture_workspace{}; + constexpr std::array report_time{0U,0U,0x12U,0x34U,0U,0x01U}; + if (!reports.next_due(100U, plan) || + !reports.capture(plan, report_time, encode_buffer, capture_workspace).success() || + reports.queue_size() != 1U) { + return 10; + } + const auto report = mms::MmsStaticBrcbConnection::poll( + runtime, control, reports, 100U, response, workspace); + if (report.status != mms::MmsStaticBrcbConnectionStatus::peer_limit_exceeded || + report.bytes_written != 0U || reports.queue_size() != 1U) { + return 11; + } + + runtime.reset(); + if (runtime.negotiated_tpdu_size_bytes() != 0U || + runtime.negotiated_mms_pdu_size() != 0U) { + return 12; + } + return 0; +} From f4328d4cec41864494e3deed286a68fb29a8b37c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:47:02 +0700 Subject: [PATCH 102/129] Run negotiated response limit smoke in embedded profile --- embedded/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index be160f868..5052569c3 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -134,6 +134,7 @@ foreach(server_smoke IN ITEMS mms_write mms_dispatcher mms_connection_runtime + mms_negotiated_limits mms_data_set mms_brcb mms_brcb_control From 29e4996634ae945826cac4c8fe13d320a0621946 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:48:23 +0700 Subject: [PATCH 103/129] Run negotiated limit regression through public server core --- CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e43e8868..f1ea91083 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -415,7 +415,8 @@ if(ARIEC61850_BUILD_TESTS) mms_brcb_lifecycle mms_brcb_persistence mms_brcb_recovery_v2 - mms_brcb_checkpoint_fallback) + mms_brcb_checkpoint_fallback + mms_negotiated_limits) set(brcb_target "ariec61850_${brcb_smoke}_server_core_tests") add_executable( ${brcb_target} From 7c17778d1415605799c6e63e57c117bc8312547e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:49:10 +0700 Subject: [PATCH 104/129] Prove negotiated limits under BRCB hard profile --- embedded/brcb_hard_profile/CMakeLists.txt | 28 ++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt index ec5c08cf4..8945d93b4 100644 --- a/embedded/brcb_hard_profile/CMakeLists.txt +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -153,6 +153,30 @@ add_executable(ariec61850_hard_mms_brcb_checkpoint_fallback_smoke ${ARIEC61850_ROOT}/src/mms/static_brcb_checkpoint_store.cpp) ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_checkpoint_fallback_smoke) +add_executable(ariec61850_hard_mms_negotiated_limits_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_negotiated_limits_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_control.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_connection.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_negotiated_limits_smoke) + enable_testing() add_test(NAME ariec61850_hard_mms_brcb_smoke COMMAND ariec61850_hard_mms_brcb_smoke) add_test(NAME ariec61850_hard_mms_brcb_control_smoke COMMAND ariec61850_hard_mms_brcb_control_smoke) @@ -162,6 +186,7 @@ add_test(NAME ariec61850_hard_mms_brcb_connection_smoke COMMAND ariec61850_hard_ add_test(NAME ariec61850_hard_mms_brcb_recovery_v2_smoke COMMAND ariec61850_hard_mms_brcb_recovery_v2_smoke) add_test(NAME ariec61850_hard_mms_brcb_persistence_smoke COMMAND ariec61850_hard_mms_brcb_persistence_smoke) add_test(NAME ariec61850_hard_mms_brcb_checkpoint_fallback_smoke COMMAND ariec61850_hard_mms_brcb_checkpoint_fallback_smoke) +add_test(NAME ariec61850_hard_mms_negotiated_limits_smoke COMMAND ariec61850_hard_mms_negotiated_limits_smoke) message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=ON") message(STATUS "arstack61850 BRCB hard profile: association ownership + ResvTms gate=ON") @@ -169,6 +194,7 @@ message(STATUS "arstack61850 BRCB hard profile: MMS RptEna/EntryID/PurgeBuf/Resv message(STATUS "arstack61850 BRCB hard profile: transport association lifecycle bridge=ON") message(STATUS "arstack61850 BRCB hard profile: retained-history recovery state v2 + v1 restore=ON") message(STATUS "arstack61850 BRCB hard profile: retry-safe two-phase owner-bound delivery=ON") +message(STATUS "arstack61850 BRCB hard profile: negotiated COTP/MMS outbound bounds=ON") message(STATUS "arstack61850 BRCB hard profile: dual-bank checkpoint + one-bank recovery=ON") message(STATUS "arstack61850 BRCB hard profile: platform non-volatile adapter=NOT_CLAIMED") -message(STATUS "arstack61850 BRCB hard profile: exceptions/RTTI disabled=ON") +message(STATUS "arstack61850 BRCB hard profile: exceptions/RTTI disabled=ON") \ No newline at end of file From 40bcc3fd47940ef3018797d3f55a212b9f1c0fd4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:49:46 +0700 Subject: [PATCH 105/129] Fix static server smoke command and capability scope --- docs/STATIC_IED_SERVER.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/STATIC_IED_SERVER.md b/docs/STATIC_IED_SERVER.md index 9b45872e4..8d7064324 100644 --- a/docs/STATIC_IED_SERVER.md +++ b/docs/STATIC_IED_SERVER.md @@ -13,7 +13,7 @@ TCP listen/accept (WinSock or BSD sockets) -> static object/DataSet tables ``` -The included reference model is read-only: +The included desktop reference model is read-only: ```text ESP32S3IOLD0 @@ -24,19 +24,23 @@ ESP32S3IOLD0 ## Desktop smoke run -Build with `ARIEC61850_BUILD_TOOLS=ON`, then run: +Build with `ARIEC61850_BUILD_TOOLS=ON`, then run an unprivileged development +endpoint such as port 8102: ```powershell -.\ariec61850_static_ied_server.exe --bind 0.0.0.0 --port 8102 --di-mask 0x35 +.\ariec61850_static_ied_server.exe --port 8102 --digital-input-mask 0x35 --max-connections 1 ``` -Port 8102 is the unprivileged development default. Use `--port 102` for a -normal IEC 61850 lab endpoint when the operating system permits binding it. +The executable default is TCP port **102**, the standard IEC 61850 MMS port. +Use `--port 8102` when local permissions or another process prevent binding +port 102. `--digital-input-mask` defaults to `0x35`; `--max-connections 0` +means unlimited sequential accepts. Implemented server services in this slice: -- COTP connection accept; -- ACSE association accept and MMS Initiate response; +- COTP connection accept with negotiated TPDU-size bounds; +- ACSE association accept and MMS Initiate response with the peer MMS PDU limit + retained and enforced on outbound confirmed responses; - `GetNameList`; - `GetVariableAccessAttributes`; - `GetNamedVariableListAttributes` when a static DataSet table is supplied; @@ -44,6 +48,13 @@ Implemented server services in this slice: - bounded `Write` only for object entries that explicitly provide a write callback (the included desktop model provides none). +The public `ARIEC61850::mms_server_core` also contains the bounded BRCB R1-R3 +runtime, ownership/lifecycle objects, retained replay/recovery, and negotiated +outbound-limit enforcement for InformationReport delivery. The current +`ariec61850_static_ied_server` reference model does **not** yet instantiate or +schedule those BRCB objects, so a desktop IEDScout browse/read pass must not be +reported as live BRCB interoperability. + ## ESP32/lwIP integration boundary The protocol core does not include an ESP-IDF header or own a listening socket. @@ -61,5 +72,9 @@ maximum concurrent association count remain product-profile decisions. - Physical IEDScout browse/read acceptance is not yet recorded. - The reference values are startup constants; a board adapter still needs to bind the eight GGIO values to debounced input snapshots. -- Reporting, control, authentication, TLS, file services, and runtime SCL model - loading are outside this first read-only server slice. +- The desktop reference adapter does not yet expose BRCB reporting or control + objects even though the server core contains those bounded primitives. +- Authentication, TLS, file services, runtime SCL model loading, and outbound + COTP segmentation are not implemented in this reference adapter. Responses + that cannot fit the negotiated peer TPDU/MMS limits fail closed instead of + emitting an oversized frame. \ No newline at end of file From 42073d5bb62b4c18086a98f4d97187b28f624618 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 16:54:25 +0700 Subject: [PATCH 106/129] Fix negotiated limit smoke span view --- embedded/mms_negotiated_limits_hard_profile_smoke.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/embedded/mms_negotiated_limits_hard_profile_smoke.cpp b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp index e090c6e81..e6ab2f5bf 100644 --- a/embedded/mms_negotiated_limits_hard_profile_smoke.cpp +++ b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp @@ -252,7 +252,9 @@ int main() { return 6; } const auto read_result = runtime.process_tcp_window( - request.first(read_tpkt.bytes_written), response, workspace); + std::span{request}.first(read_tpkt.bytes_written), + response, + workspace); if (read_result.status != mms::MmsStaticConnectionStatus::peer_limit_exceeded || read_result.bytes_written != 0U || read_result.consumed_bytes != read_tpkt.bytes_written || From ec47fed4b24051159170b5ec9f48c8e5a590f693 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:18:27 +0700 Subject: [PATCH 107/129] Advertise only implemented MMS services --- src/acse/association_span.cpp | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/acse/association_span.cpp b/src/acse/association_span.cpp index c7f1ea4d4..988783582 100644 --- a/src/acse/association_span.cpp +++ b/src/acse/association_span.cpp @@ -30,6 +30,9 @@ constexpr std::array kDefaultMmsInitiateRequest{ 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; +// servicesSupportedCalled contains only the confirmed services implemented by +// MmsStaticApplicationDispatcher: GetNameList(1), Read(4), Write(5), +// GetVariableAccessAttributes(6), and GetNamedVariableListAttributes(12). constexpr std::array kDefaultMmsInitiateResponse{ 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, @@ -39,10 +42,12 @@ constexpr std::array kDefaultMmsInitiateResponse{ 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, - 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, - 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; -// Byte-identical to AcseAssociationCodec::encode_aare(default_accept_aare()). +// Byte-identical to the deterministic static AARE profile used by the bounded +// server. The embedded MMS Initiate response carries the same narrow service +// bitmap as kDefaultMmsInitiateResponse above. constexpr std::array kDefaultAcceptAare{ 0x61U, 0x4AU, 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, @@ -61,8 +66,8 @@ constexpr std::array kDefaultAcceptAare{ 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, - 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, - 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; [[nodiscard]] bool valid_oid( const std::span value, @@ -157,6 +162,19 @@ constexpr std::array kDefaultAcceptAare{ } external.indirect_reference = *value; saw_indirect = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_indirect) { + external = {}; + return false; + } + const auto value = asn1::BerSpanReader::read_uint32(field); + if (!value) { + external = {}; + return false; + } + external.indirect_reference = *value; + saw_indirect = true; } else if (field.tag_class == asn1::BerClass::context_specific && field.tag_number == 0 && field.constructed) { if (saw_encoding || field.value.size() > AcseSpanCodec::maximum_acse_bytes) { From b6fe0b4f39c12de77e82a502716afc3cde2c7948 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:19:31 +0700 Subject: [PATCH 108/129] Remove duplicated ACSE external decode branch --- src/acse/association_span.cpp | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/acse/association_span.cpp b/src/acse/association_span.cpp index 988783582..ee976a8f4 100644 --- a/src/acse/association_span.cpp +++ b/src/acse/association_span.cpp @@ -162,19 +162,6 @@ constexpr std::array kDefaultAcceptAare{ } external.indirect_reference = *value; saw_indirect = true; - } else if (field.tag_class == asn1::BerClass::universal && - field.tag_number == 2 && !field.constructed) { - if (saw_indirect) { - external = {}; - return false; - } - const auto value = asn1::BerSpanReader::read_uint32(field); - if (!value) { - external = {}; - return false; - } - external.indirect_reference = *value; - saw_indirect = true; } else if (field.tag_class == asn1::BerClass::context_specific && field.tag_number == 0 && field.constructed) { if (saw_encoding || field.value.size() > AcseSpanCodec::maximum_acse_bytes) { From 25831b485c6f316fee726670d1d31886c63c830f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:20:45 +0700 Subject: [PATCH 109/129] Add confirmed-request Reject PDU encoder --- include/ariec61850/mms/pdu_span.hpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp index 5617c756a..358324fa9 100644 --- a/include/ariec61850/mms/pdu_span.hpp +++ b/include/ariec61850/mms/pdu_span.hpp @@ -27,6 +27,12 @@ enum class MmsWireConfirmedService : std::int32_t { get_named_variable_list_attributes = 12, }; +enum class MmsConfirmedRequestRejectReason : std::uint8_t { + other = 0U, + unrecognized_service = 1U, + invalid_argument = 4U, +}; + struct MmsInitiateDetailView final { std::uint32_t version_number{}; std::span parameter_support_options{}; @@ -90,6 +96,15 @@ class MmsPduSpanCodec final { bool service_constructed, std::span service_value, std::span destination) noexcept; + + // Encode MMS RejectPDU for a syntactically valid confirmed request that the + // bounded server cannot service. The result preserves the invoke ID and uses + // rejectReason.confirmed-requestPDU so clients receive a protocol-level + // outcome instead of waiting indefinitely for a response. + [[nodiscard]] static wire::EncodeResult encode_confirmed_request_reject_into( + std::uint32_t invoke_id, + MmsConfirmedRequestRejectReason reason, + std::span destination) noexcept; }; -} // namespace ar::iec61850::mms +} // namespace ar::iec61850::mms \ No newline at end of file From e060588821be3ecd5886e9fbf5bd0d782f61ecaf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:21:41 +0700 Subject: [PATCH 110/129] Encode bounded MMS confirmed-request Reject PDU --- src/mms/pdu_span.cpp | 50 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp index e0236d03c..63e29318e 100644 --- a/src/mms/pdu_span.cpp +++ b/src/mms/pdu_span.cpp @@ -16,6 +16,9 @@ namespace ar::iec61850::mms { namespace { +// Match the bounded static dispatcher capability surface exactly: +// GetNameList(1), Read(4), Write(5), GetVariableAccessAttributes(6), and +// GetNamedVariableListAttributes(12). constexpr std::array kDefaultInitiateResponse{ 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, @@ -25,8 +28,8 @@ constexpr std::array kDefaultInitiateResponse{ 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, - 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, - 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; [[nodiscard]] bool read_u32( const asn1::BerTlvView& tlv, @@ -396,4 +399,47 @@ wire::EncodeResult MmsPduSpanCodec::encode_confirmed_response_into( 1, invoke_id, service_tag, service_constructed, service_value, destination); } +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_request_reject_into( + const std::uint32_t invoke_id, + const MmsConfirmedRequestRejectReason reason, + const std::span destination) noexcept { + const auto reason_value = static_cast(reason); + if (invoke_id > maximum_invoke_id || + (reason != MmsConfirmedRequestRejectReason::other && + reason != MmsConfirmedRequestRejectReason::unrecognized_service && + reason != MmsConfirmedRequestRejectReason::invalid_argument)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(0, invoke_value); + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(1, 1U); + if (!invoke_tlv || !reason_tlv || + *reason_tlv > std::numeric_limits::max() - *invoke_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content = *invoke_tlv + *reason_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(4, content); + if (!required || *required > maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 4, content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 1U) || + !writer.write_byte(reason_value) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + } // namespace ar::iec61850::mms From 7726e58514d4bd209c27c2c572420d304eb266d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:25:07 +0700 Subject: [PATCH 111/129] Return MMS RejectPDU for unsupported confirmed requests --- src/mms/connection_runtime.cpp | 74 +++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index 28c571361..5f8b9cc7b 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -100,8 +100,6 @@ constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; const std::size_t negotiated_tpdu_size_bytes, const std::span response, const std::span workspace) noexcept { - // COTP Data adds three bytes and TPKT adds four bytes. Preflight both - // caller-owned buffers so a capacity retry never advances connection state. std::size_t cotp_required{}; if (!add_overhead(session_or_presentation.size(), 3U, cotp_required)) { return make_result(MmsStaticConnectionStatus::backend_failure, state); @@ -150,16 +148,23 @@ constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; tpkt.bytes_written); } -[[nodiscard]] MmsStaticConnectionResult application_rejected( - const MmsStaticConnectionState state, - const std::size_t consumed, - const MmsStaticDispatchResult& application) noexcept { - auto result = make_result( - MmsStaticConnectionStatus::application_rejected, state, consumed); - result.application_status = application.status; - result.application_service = application.service; - result.invoke_id = application.invoke_id; - return result; +[[nodiscard]] MmsConfirmedRequestRejectReason reject_reason_for( + const MmsStaticDispatchStatus status) noexcept { + switch (status) { + case MmsStaticDispatchStatus::unsupported_service: + return MmsConfirmedRequestRejectReason::unrecognized_service; + case MmsStaticDispatchStatus::malformed_request: + case MmsStaticDispatchStatus::unsupported_request: + case MmsStaticDispatchStatus::object_not_found: + return MmsConfirmedRequestRejectReason::invalid_argument; + case MmsStaticDispatchStatus::response_ready: + case MmsStaticDispatchStatus::invalid_object_table: + case MmsStaticDispatchStatus::workspace_too_small: + case MmsStaticDispatchStatus::response_buffer_too_small: + case MmsStaticDispatchStatus::backend_failure: + return MmsConfirmedRequestRejectReason::other; + } + return MmsConfirmedRequestRejectReason::other; } [[nodiscard]] bool write_outer_capacity( @@ -418,8 +423,9 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( } MmsConfirmedPduView confirmed; - if (MmsPduSpanCodec::try_decode_confirmed_request_view( - pdv.single_asn1_type, confirmed)) { + const bool is_confirmed_request = MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed); + if (is_confirmed_request) { std::size_t write_required{}; if (write_outer_capacity( confirmed, dispatcher_, mms_presentation_context_id_, write_required)) { @@ -434,6 +440,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( const auto application = dispatcher_.dispatch( pdv.single_asn1_type, response, workspace, policy_.access_context()); + std::size_t mms_response_bytes = application.bytes_written; if (!application.success()) { if (application.status == MmsStaticDispatchStatus::response_buffer_too_small) { const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( @@ -468,11 +475,42 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( result.invoke_id = application.invoke_id; return result; } - return application_rejected(state_, peek.frame_bytes, application); + if (!is_confirmed_request) { + auto result = make_result( + MmsStaticConnectionStatus::application_rejected, + state_, + peek.frame_bytes); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + + const auto rejected = MmsPduSpanCodec::encode_confirmed_request_reject_into( + confirmed.invoke_id, + reject_reason_for(application.status), + response); + if (!rejected.success()) { + if (rejected.status == wire::EncodeStatus::buffer_too_small) { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + mms_presentation_context_id_, rejected.required_bytes); + std::size_t required{}; + if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) { + auto result = make_response_capacity(state_, required); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = confirmed.invoke_id; + return result; + } + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + mms_response_bytes = rejected.bytes_written; } if (negotiated_mms_pdu_size_ == 0U || - application.bytes_written > negotiated_mms_pdu_size_) { + mms_response_bytes > negotiated_mms_pdu_size_) { return make_result( MmsStaticConnectionStatus::peer_limit_exceeded, state_, @@ -480,7 +518,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( } const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( - response.first(application.bytes_written), + response.first(mms_response_bytes), workspace, mms_presentation_context_id_, true); @@ -505,7 +543,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( if (wrapped.response_ready()) { wrapped.application_status = application.status; wrapped.application_service = application.service; - wrapped.invoke_id = application.invoke_id; + wrapped.invoke_id = is_confirmed_request ? confirmed.invoke_id : application.invoke_id; } return wrapped; } From 6e7ac5bedaa7cde8115da9d4218281e95334bc90 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:26:40 +0700 Subject: [PATCH 112/129] Make static server socket waits interruptible --- tools/static_ied_server.cpp | 97 +++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp index d5ca5bb83..2a969af88 100644 --- a/tools/static_ied_server.cpp +++ b/tools/static_ied_server.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #endif @@ -67,6 +68,13 @@ using NativeSocket = int; constexpr NativeSocket kInvalidSocket = -1; #endif +enum class SocketWaitStatus : std::uint8_t { + ready, + timeout, + interrupted, + error, +}; + void close_socket(const NativeSocket socket) noexcept { if (socket == kInvalidSocket) { return; @@ -86,6 +94,57 @@ void close_socket(const NativeSocket socket) noexcept { #endif } +[[nodiscard]] bool socket_interrupted() noexcept { +#if defined(_WIN32) + return ::WSAGetLastError() == WSAEINTR; +#else + return errno == EINTR; +#endif +} + +[[nodiscard]] SocketWaitStatus wait_socket( + const NativeSocket socket, + const bool for_read, + const std::uint32_t timeout_ms) noexcept { + fd_set read_set; + fd_set write_set; + FD_ZERO(&read_set); + FD_ZERO(&write_set); + if (for_read) { + FD_SET(socket, &read_set); + } else { + FD_SET(socket, &write_set); + } + + timeval timeout{}; + timeout.tv_sec = static_cast(timeout_ms / 1'000U); + timeout.tv_usec = static_cast((timeout_ms % 1'000U) * 1'000U); +#if defined(_WIN32) + const auto result = ::select( + 0, + for_read ? &read_set : nullptr, + for_read ? nullptr : &write_set, + nullptr, + &timeout); +#else + const auto result = ::select( + socket + 1, + for_read ? &read_set : nullptr, + for_read ? nullptr : &write_set, + nullptr, + &timeout); +#endif + if (result > 0) { + return SocketWaitStatus::ready; + } + if (result == 0) { + return SocketWaitStatus::timeout; + } + return socket_interrupted() + ? SocketWaitStatus::interrupted + : SocketWaitStatus::error; +} + [[nodiscard]] NativeSocket create_listener(const std::uint16_t port) { const auto listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listener == kInvalidSocket) { @@ -145,6 +204,17 @@ struct SocketStreamContext final { return {embedded::IoStatus::closed, 0U}; } + const auto readiness = wait_socket(stream.socket, true, 100U); + if (readiness == SocketWaitStatus::timeout) { + return {embedded::IoStatus::timeout, 0U}; + } + if (readiness == SocketWaitStatus::interrupted) { + return {embedded::IoStatus::would_block, 0U}; + } + if (readiness != SocketWaitStatus::ready) { + return {embedded::IoStatus::io_error, 0U}; + } + #if defined(_WIN32) const auto bounded = std::min( destination.size(), static_cast(INT_MAX)); @@ -195,6 +265,17 @@ struct SocketStreamContext final { return {embedded::IoStatus::closed, 0U}; } + const auto readiness = wait_socket(stream.socket, false, 100U); + if (readiness == SocketWaitStatus::timeout) { + return {embedded::IoStatus::timeout, 0U}; + } + if (readiness == SocketWaitStatus::interrupted) { + return {embedded::IoStatus::would_block, 0U}; + } + if (readiness != SocketWaitStatus::ready) { + return {embedded::IoStatus::io_error, 0U}; + } + #if defined(_WIN32) const auto bounded = std::min( bytes.size(), static_cast(INT_MAX)); @@ -580,6 +661,16 @@ int main(int argc, char** argv) { while (!g_stop.load(std::memory_order_relaxed) && (options.maximum_connections == 0U || connection_count < options.maximum_connections)) { + const auto readiness = wait_socket(listener, true, 200U); + if (readiness == SocketWaitStatus::timeout || + readiness == SocketWaitStatus::interrupted) { + continue; + } + if (readiness != SocketWaitStatus::ready) { + throw std::runtime_error( + "select(listener) failed: " + socket_error_text()); + } + sockaddr_in peer{}; #if defined(_WIN32) int peer_size = static_cast(sizeof(peer)); @@ -591,8 +682,8 @@ int main(int argc, char** argv) { reinterpret_cast(&peer), &peer_size); if (client == kInvalidSocket) { - if (g_stop.load(std::memory_order_relaxed)) { - break; + if (g_stop.load(std::memory_order_relaxed) || socket_interrupted()) { + continue; } std::cerr << "accept() failed: " << socket_error_text() << '\n'; continue; @@ -615,4 +706,4 @@ int main(int argc, char** argv) { std::cerr << "Static IED server failed: " << exception.what() << '\n'; return 2; } -} +} \ No newline at end of file From fe4c8cb8fa56869d027f9ab44f3327b46b934a4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:27:44 +0700 Subject: [PATCH 113/129] Assert narrow MMS service advertisement --- embedded/association_hard_profile_smoke.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/embedded/association_hard_profile_smoke.cpp b/embedded/association_hard_profile_smoke.cpp index 558234eaf..cbb8d2328 100644 --- a/embedded/association_hard_profile_smoke.cpp +++ b/embedded/association_hard_profile_smoke.cpp @@ -43,8 +43,8 @@ constexpr std::array kResponse{ 0x00U, 0xBEU, 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, - 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, - 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; constexpr std::array kMmsApplicationContext{ 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U}; @@ -121,8 +121,6 @@ int main() { return 4; } - // Confirm the deterministic AARE itself is stable and contains the expected - // MMS Initiate response used by the host association profile. std::array aare{}; const auto aare_result = acse::AcseSpanCodec::encode_default_accept_aare_into(aare); if (!aare_result.success() || @@ -138,13 +136,11 @@ int main() { 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, - 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, - 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U})) { + 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U})) { return 5; } - // Association setup is not a realtime hot path, but repeated construction - // still proves the bounded responder does not require owning protocol trees. for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { const auto repeated = acse::AcseSpanCodec::build_accept_response_into( request, response); @@ -163,4 +159,4 @@ int main() { } return 0; -} +} \ No newline at end of file From 0c7512a14ebcc4ec1441ef472a903b0d27057ff1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:29:54 +0700 Subject: [PATCH 114/129] Update MMS PDU smoke for narrow services and RejectPDU --- embedded/mms_pdu_hard_profile_smoke.cpp | 61 +++++++++++++++++-------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/embedded/mms_pdu_hard_profile_smoke.cpp b/embedded/mms_pdu_hard_profile_smoke.cpp index e5dc63a72..270e2c08b 100644 --- a/embedded/mms_pdu_hard_profile_smoke.cpp +++ b/embedded/mms_pdu_hard_profile_smoke.cpp @@ -34,14 +34,17 @@ constexpr std::array kInitiateResponse{ 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, - 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, - 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; constexpr std::array kParameterSupport{ 0x05U, 0xF1U, 0x00U}; -constexpr std::array kServicesSupported{ +constexpr std::array kServicesSupportedRequest{ 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; +constexpr std::array kServicesSupportedResponse{ + 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; template [[nodiscard]] bool matches( @@ -53,7 +56,8 @@ template [[nodiscard]] bool valid_initiate( const mms::MmsInitiateView& value, - const mms::MmsWirePduKind expected_kind) noexcept { + const mms::MmsWirePduKind expected_kind, + const std::span expected_services) noexcept { return value.kind == expected_kind && value.maximum_mms_pdu_size == 65'000U && value.maximum_outstanding_calling == 10U && @@ -61,7 +65,11 @@ template value.data_structure_nesting_level == 5U && value.detail.version_number == 1U && matches(value.detail.parameter_support_options, kParameterSupport) && - matches(value.detail.services_supported_calling, kServicesSupported); + value.detail.services_supported_calling.size() == expected_services.size() && + std::equal( + value.detail.services_supported_calling.begin(), + value.detail.services_supported_calling.end(), + expected_services.begin()); } } // namespace @@ -70,13 +78,19 @@ int main() { mms::MmsInitiateView initiate; if (!mms::MmsPduSpanCodec::try_decode_initiate_request_view( kInitiateRequest, initiate) || - !valid_initiate(initiate, mms::MmsWirePduKind::initiate_request)) { + !valid_initiate( + initiate, + mms::MmsWirePduKind::initiate_request, + kServicesSupportedRequest)) { return 1; } if (!mms::MmsPduSpanCodec::try_decode_initiate_response_view( kInitiateResponse, initiate) || - !valid_initiate(initiate, mms::MmsWirePduKind::initiate_response)) { + !valid_initiate( + initiate, + mms::MmsWirePduKind::initiate_response, + kServicesSupportedResponse)) { return 2; } @@ -134,8 +148,21 @@ int main() { return 6; } - // Positive ASN.1 INTEGER boundary: max supported invoke ID has no sign - // prefix because its most-significant octet is 0x7F. + constexpr std::array reject_golden{ + 0xA4U, 0x06U, 0x80U, 0x01U, 0x07U, 0x81U, 0x01U, 0x01U}; + const auto reject_result = + mms::MmsPduSpanCodec::encode_confirmed_request_reject_into( + 7U, + mms::MmsConfirmedRequestRejectReason::unrecognized_service, + buffer); + if (!reject_result.success() || + reject_result.bytes_written != reject_golden.size() || + !matches( + std::span{buffer}.first(reject_result.bytes_written), + reject_golden)) { + return 7; + } + constexpr std::array maximum_invoke_golden{ 0xA0U, 0x08U, 0x02U, 0x04U, 0x7FU, 0xFFU, 0xFFU, 0xFFU, @@ -150,10 +177,9 @@ int main() { !mms::MmsPduSpanCodec::try_decode_confirmed_request_view( maximum_invoke_golden, confirmed) || confirmed.invoke_id != mms::MmsPduSpanCodec::maximum_invoke_id) { - return 7; + return 8; } - // Values beginning with bit 7 set require an explicit positive prefix. constexpr std::array invoke_128_golden{ 0xA0U, 0x06U, 0x02U, 0x02U, 0x00U, 0x80U, 0xA4U, 0x00U}; const auto invoke_128 = mms::MmsPduSpanCodec::encode_confirmed_request_into( @@ -162,11 +188,9 @@ int main() { !matches( std::span{buffer}.first(invoke_128.bytes_written), invoke_128_golden)) { - return 8; + return 9; } - // Exercise the real post-association path: MMS Confirmed Request wrapped in - // Presentation P-DATA, then recovered as zero-copy spans. std::array p_data{}; const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( confirmed_request_golden, p_data, 3U, true); @@ -180,10 +204,9 @@ int main() { pdv.single_asn1_type, confirmed) || confirmed.invoke_id != 7U || confirmed.service() != mms::MmsWireConfirmedService::read) { - return 9; + return 10; } - // Repeated application-layer operation must stay inside caller-owned spans. for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { const auto invoke = iteration & 0x7FFFU; const auto encoded = mms::MmsPduSpanCodec::encode_confirmed_response_into( @@ -194,7 +217,7 @@ int main() { std::span{buffer}.first(encoded.bytes_written), decoded) || decoded.invoke_id != invoke || decoded.service_tag != 4) { - return 10; + return 11; } } @@ -202,8 +225,8 @@ int main() { 0xA8U, 0x03U, 0x80U, 0x01U, 0x01U}; if (mms::MmsPduSpanCodec::try_decode_initiate_request_view( malformed_initiate, initiate)) { - return 11; + return 12; } return 0; -} +} \ No newline at end of file From eeebca8840ddda93c45cb637e905ad223bace6dd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 17:31:59 +0700 Subject: [PATCH 115/129] Expect RejectPDU for unsupported confirmed request --- ...mms_connection_runtime_hard_profile_smoke.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/embedded/mms_connection_runtime_hard_profile_smoke.cpp b/embedded/mms_connection_runtime_hard_profile_smoke.cpp index e4202ce0d..976e6cd5f 100644 --- a/embedded/mms_connection_runtime_hard_profile_smoke.cpp +++ b/embedded/mms_connection_runtime_hard_profile_smoke.cpp @@ -50,8 +50,8 @@ constexpr std::array kAssociationResponse{ 0x00U, 0xBEU, 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, - 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, - 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; constexpr std::array kReadRequest{ 0xA0U, 0x27U, 0x02U, 0x01U, 0x0CU, @@ -82,6 +82,9 @@ constexpr std::array kWriteResponse{ 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, 0xA5U, 0x02U, 0x81U, 0x00U}; +constexpr std::array kIdentifyReject{ + 0xA4U, 0x06U, 0x80U, 0x01U, 0x15U, 0x81U, 0x01U, 0x01U}; + template [[nodiscard]] bool matches( const std::span actual, @@ -347,11 +350,14 @@ int main() { std::span{request}.first(identify_tpkt.bytes_written), response, workspace); - if (result.status != mms::MmsStaticConnectionStatus::application_rejected || + if (!result.response_ready() || result.application_status != mms::MmsStaticDispatchStatus::unsupported_service || result.application_service != mms::MmsWireConfirmedService::identify || result.invoke_id != 21U || result.consumed_bytes != identify_tpkt.bytes_written || - runtime.state() != mms::MmsStaticConnectionState::established) { + runtime.state() != mms::MmsStaticConnectionState::established || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kIdentifyReject)) { return 11; } @@ -415,4 +421,4 @@ int main() { } return 0; -} +} \ 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 116/129] 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 117/129] 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 118/129] 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 From e7742eb2117a85144c01607dae56af6779eae714 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 19:12:57 +0700 Subject: [PATCH 119/129] PTP-P1: hardware-timestamped lab timing companion and analyzer core Port the ARIEC61850/ARSVIN PTPv2 Layer-2 stack into the portable C++ core and integrate it with the ESP32-P4 SMV injector as a guarded lab timing companion. Includes portable Announce/Sync/Follow_Up/Pdelay codec/parser, passive monitor and analyzer, transportSpecific/VLAN diagnostics, fail-safe smpSynch behavior, ESP32-P4 hardware TX/RX timestamps for two-step Sync and Pdelay, strict cross-platform CI, and the PTP lab bench acceptance runbook. This is a troubleshooting/interoperability timing helper, not a GPS-backed or certified grandmaster. --- .github/workflows/esp32p4-ptp-lab.yml | 77 +++ .github/workflows/ptp-core-ci.yml | 134 +++++ CMakeLists.txt | 13 +- docs/PTP_LAB_TIMING.md | 310 +++++++++++ .../arstack_process_bus/CMakeLists.txt | 1 + .../esp32p4_smv_injector/main/CMakeLists.txt | 6 + .../main/Kconfig.projbuild | 73 +++ .../esp32p4_smv_injector/main/ethernet_port.c | 12 + .../main/ptp_lab_task.cpp | 514 ++++++++++++++++++ .../main/ptp_lab_task.hpp | 21 + include/ariec61850/sampled_values/asdu.hpp | 4 +- include/ariec61850/time_sync/ptp.hpp | 183 +++++++ include/ariec61850/time_sync/ptp_monitor.hpp | 142 +++++ src/time_sync/ptp.cpp | 356 ++++++++++++ src/time_sync/ptp_monitor.cpp | 376 +++++++++++++ tests/ptp_profile/CMakeLists.txt | 38 ++ tests/test_ptp.cpp | 327 +++++++++++ tools/ptp_analyze.cpp | 219 ++++++++ 18 files changed, 2804 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/esp32p4-ptp-lab.yml create mode 100644 .github/workflows/ptp-core-ci.yml create mode 100644 docs/PTP_LAB_TIMING.md create mode 100644 embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp create mode 100644 embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp create mode 100644 include/ariec61850/time_sync/ptp.hpp create mode 100644 include/ariec61850/time_sync/ptp_monitor.hpp create mode 100644 src/time_sync/ptp.cpp create mode 100644 src/time_sync/ptp_monitor.cpp create mode 100644 tests/ptp_profile/CMakeLists.txt create mode 100644 tests/test_ptp.cpp create mode 100644 tools/ptp_analyze.cpp diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml new file mode 100644 index 000000000..5cfa867da --- /dev/null +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -0,0 +1,77 @@ +name: ESP32-P4 PTP Lab Profile + +on: + push: + paths: + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - '.github/workflows/esp32p4-ptp-lab.yml' + pull_request: + paths: + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - '.github/workflows/esp32p4-ptp-lab.yml' + +permissions: + contents: read + +jobs: + build-esp32p4-ptp: + runs-on: ubuntu-latest + container: + image: espressif/idf:v5.5.4 + defaults: + run: + shell: bash + working-directory: embedded/esp32p4_smv_injector + steps: + - uses: actions/checkout@v4 + + - name: Select ESP32-P4 target + run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + + - name: Enable guarded non-default PTP lab profile + shell: bash + run: | + if grep -q '^# CONFIG_AR_PTP_LAB_TX is not set$' sdkconfig; then + sed -i 's/^# CONFIG_AR_PTP_LAB_TX is not set$/CONFIG_AR_PTP_LAB_TX=y/' sdkconfig + elif ! grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig; then + echo 'CONFIG_AR_PTP_LAB_TX=y' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=.*/CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1/' sdkconfig + else + echo 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1' >> sdkconfig + fi + + if grep -q '^# CONFIG_AR_PTP_VLAN is not set$' sdkconfig; then + sed -i 's/^# CONFIG_AR_PTP_VLAN is not set$/CONFIG_AR_PTP_VLAN=y/' sdkconfig + elif ! grep -q '^CONFIG_AR_PTP_VLAN=y$' sdkconfig; then + echo 'CONFIG_AR_PTP_VLAN=y' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_VLAN_ID=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_VLAN_ID=.*/CONFIG_AR_PTP_VLAN_ID=100/' sdkconfig + else + echo 'CONFIG_AR_PTP_VLAN_ID=100' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_VLAN_PRIORITY=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_VLAN_PRIORITY=.*/CONFIG_AR_PTP_VLAN_PRIORITY=4/' sdkconfig + else + echo 'CONFIG_AR_PTP_VLAN_PRIORITY=4' >> sdkconfig + fi + + source /opt/esp/idf/export.sh + idf.py reconfigure + grep -E '^CONFIG_AR_PTP_(LAB_TX|DOMAIN|TRANSPORT_SPECIFIC|VLAN|VLAN_ID|VLAN_PRIORITY|ANNOUNCE_INTERVAL_MS|SYNC_INTERVAL_MS)=' sdkconfig + + - name: Build PTP-enabled firmware + run: source /opt/esp/idf/export.sh && idf.py build diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml new file mode 100644 index 000000000..40aa795bf --- /dev/null +++ b/.github/workflows/ptp-core-ci.yml @@ -0,0 +1,134 @@ +name: PTP Core CI + +on: + push: + paths: + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'src/capture/pcap.cpp' + - 'tools/ptp_analyze.cpp' + - 'tests/test_ptp.cpp' + - 'tests/ptp_profile/**' + - '.github/workflows/ptp-core-ci.yml' + pull_request: + paths: + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'src/capture/pcap.cpp' + - 'tools/ptp_analyze.cpp' + - 'tests/test_ptp.cpp' + - 'tests/ptp_profile/**' + - '.github/workflows/ptp-core-ci.yml' + +permissions: + contents: read + +jobs: + portable-ptp: + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build PTP strict profile + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + CXX=clang++ + else + CXX=g++ + fi + + set +e + "$CXX" \ + -std=c++20 \ + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror \ + -pthread \ + -Iinclude \ + src/ethernet/ethernet.cpp \ + src/time_sync/ptp.cpp \ + src/time_sync/ptp_monitor.cpp \ + tests/test_ptp.cpp \ + -o ptp-core-tests \ + >ptp-build.log 2>&1 + status=$? + set -e + + cat ptp-build.log + if [ "$status" -ne 0 ]; then + grep -Ei 'error:|fatal error:|undefined reference|collect2: error|ld: error' ptp-build.log | while IFS= read -r line; do + escaped=${line//'%'/'%25'} + escaped=${escaped//$'\r'/'%0D'} + escaped=${escaped//$'\n'/'%0A'} + echo "::error::$escaped" + done || true + exit "$status" + fi + + - name: Run PTP regression tests + shell: bash + run: | + set +e + ./ptp-core-tests >ptp-test.log 2>&1 + status=$? + set -e + cat ptp-test.log + if [ "$status" -ne 0 ]; then + grep -E '\[FAIL\]|CHECK failed|error' ptp-test.log | while IFS= read -r line; do + escaped=${line//'%'/'%25'} + escaped=${escaped//$'\r'/'%0D'} + escaped=${escaped//$'\n'/'%0A'} + echo "::error::$escaped" + done || true + exit "$status" + fi + + portable-ptp-msvc: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure standalone strict MSVC PTP profile + shell: pwsh + run: cmake -S tests/ptp_profile -B build-ptp-msvc -A x64 + + - name: Build PTP core and analyzer with diagnostics + shell: pwsh + run: | + function Build-Target([string]$Target) { + $output = & cmake --build build-ptp-msvc --config Release --target $Target --parallel 2>&1 + $status = $LASTEXITCODE + $output | Tee-Object -FilePath "ptp-msvc-$Target.log" + if ($status -ne 0) { + $matched = $false + foreach ($line in $output) { + $text = "$line" + if ($text -match '(?i)(error C\d+|fatal error|error LNK\d+|error MSB\d+|: error )') { + $matched = $true + $escaped = $text.Replace('%','%25').Replace("`r",'%0D').Replace("`n",'%0A') + Write-Output "::error title=MSVC $Target::$escaped" + } + } + if (-not $matched) { + Write-Output "::error title=MSVC $Target::Build failed; inspect ptp-msvc-$Target.log in the job output." + } + exit $status + } + } + + Build-Target 'ptp_profile_core' + Build-Target 'ptp_profile_tests' + Build-Target 'ptp_profile_analyze' + + - name: Run PTP regression and analyzer smoke tests on MSVC + shell: pwsh + run: ctest --test-dir build-ptp-msvc -C Release --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 576c9c51e..583c98f22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ option(ARIEC61850_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" ON) add_library(ariec61850_core src/asn1/ber.cpp src/ethernet/ethernet.cpp + src/time_sync/ptp.cpp + src/time_sync/ptp_monitor.cpp src/capture/pcap.cpp src/goose/retransmission_schedule.cpp src/goose/pdu_codec.cpp @@ -113,6 +115,12 @@ if(ARIEC61850_BUILD_TOOLS) ariec61850_apply_warnings(ariec61850_pcap_interop_check) ariec61850_apply_sanitizers(ariec61850_pcap_interop_check) + add_executable(ariec61850_ptp_analyze tools/ptp_analyze.cpp) + target_link_libraries(ariec61850_ptp_analyze PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_ptp_analyze PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_ptp_analyze) + ariec61850_apply_sanitizers(ariec61850_ptp_analyze) + add_executable(ariec61850_comtrade_inspect tools/comtrade_inspect.cpp) target_link_libraries(ariec61850_comtrade_inspect PRIVATE ARIEC61850::core) target_compile_features(ariec61850_comtrade_inspect PRIVATE cxx_std_20) @@ -161,7 +169,7 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_core_tests) add_test(NAME ariec61850_core_tests COMMAND ariec61850_core_tests) - foreach(test_name IN ITEMS mms goose sampled_values) + foreach(test_name IN ITEMS mms goose sampled_values ptp) add_executable(ariec61850_${test_name}_tests tests/test_${test_name}.cpp) target_link_libraries(ariec61850_${test_name}_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_${test_name}_tests PRIVATE cxx_std_20) @@ -340,6 +348,9 @@ if(ARIEC61850_BUILD_TESTS) COMMAND ariec61850_comtrade_inspect ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/comtrade/simple_fault.cfg --json) + add_test( + NAME ariec61850_ptp_analyze_help + COMMAND ariec61850_ptp_analyze --help) add_test( NAME ariec61850_pagination_probe_help COMMAND ariec61850_pagination_probe --help) diff --git a/docs/PTP_LAB_TIMING.md b/docs/PTP_LAB_TIMING.md new file mode 100644 index 000000000..eecf1ccf2 --- /dev/null +++ b/docs/PTP_LAB_TIMING.md @@ -0,0 +1,310 @@ +# PTP Lab Timing Companion + +This document defines the PTP role in ARStack61850 and the evidence boundary for using it with the ESP32-P4 Sampled Values Injector and future analyzer products. + +## Product role + +ARStack PTP is a **laboratory and field-troubleshooting timing companion**. + +A common process-bus commissioning problem is: + +```text +relay / subscriber requires PTP before it will accept or use Sampled Values + | + v + station GPS/PTP server + not installed yet, + unavailable, or suspect + | + v + SMV injection cannot proceed +``` + +The ARStack ESP32-P4 path is intended to close that temporary test gap: + +```text + ARStack ESP32-P4 + / \ + / \ + Sampled Values PTPv2 L2 + Injector timing companion + \ / + \ / + protection relay +``` + +This makes the instrument useful during troubleshooting, internal testing and interoperability work without pretending to be the station timing system. + +It is **not** a replacement for a GPS-backed time server and it is not a certified grandmaster. + +## Shared Injector / Analyzer architecture + +The PTP wire model belongs in the portable C++ core rather than in the ESP32 firmware or a desktop GUI. + +```text + portable ARStack C++ + + PTP codec / parser / model + | + +-----------+-----------+ + | | + v v + passive monitor message builder + health / evidence Announce / Sync / + PCAP analyzer Follow_Up / Pdelay + | | + v v + future PTP/SMV ESP32-P4 PTP lab + Analyzer timing companion +``` + +The same parser and semantic model therefore serve both product directions: + +- **Injector** — transmit PTP traffic beside deterministic SMV traffic; +- **Analyzer** — observe PTP source/domain/profile/message behavior without transmitting anything. + +## PTP-P1 implementation boundary + +PTP-P1 provides the laboratory transport/timestamp foundation. + +### Portable C++ core + +Implemented: + +- IEEE 1588/PTPv2 Layer-2 EtherType `0x88F7`; +- PTP header encode/decode; +- transportSpecific preservation; +- Announce; +- Sync; +- Follow_Up; +- Pdelay_Req encoding; +- Pdelay_Resp; +- Pdelay_Resp_Follow_Up; +- VLAN parsing; +- QinQ parsing; +- general and peer-delay multicast recognition; +- passive source/domain/message monitor; +- transportSpecific stability/mismatch evidence; +- sequence-anomaly tracking; +- conservative health and `smpSynch` policy primitives; +- read-only PCAP analyzer CLI. + +### ESP32-P4 hardware timing path + +When `CONFIG_AR_PTP_LAB_TX=y` on the current ESP-IDF 5.x adapter: + +1. the ESP32-P4 IEEE1588 hardware clock is enabled; +2. Announce is emitted at the configured interval; +3. two-step Sync is transmitted; +4. the actual EMAC TX descriptor timestamp of Sync becomes the Follow_Up precise-origin timestamp; +5. incoming Pdelay_Req frames are received through the timestamp-aware Ethernet input callback; +6. the actual EMAC RX descriptor timestamp becomes the Pdelay_Resp request-receipt timestamp (`t2`); +7. the actual EMAC TX descriptor timestamp of Pdelay_Resp becomes the Pdelay_Resp_Follow_Up response-origin timestamp (`t3`). + +The PTP task runs separately from the Sampled Values realtime task. It does not move PTP parsing, queue handling or response construction into the SMV hot loop. + +## Configuration + +PTP transmission is **off by default**. + +Relevant ESP32-P4 configuration options: + +```text +CONFIG_AR_PTP_LAB_TX +CONFIG_AR_PTP_DOMAIN +CONFIG_AR_PTP_TRANSPORT_SPECIFIC +CONFIG_AR_PTP_VLAN +CONFIG_AR_PTP_VLAN_ID +CONFIG_AR_PTP_VLAN_PRIORITY +CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS +CONFIG_AR_PTP_SYNC_INTERVAL_MS +``` + +Current defaults when the feature is enabled: + +```text +Domain 0 +transportSpecific 0 +PTP VLAN disabled / untagged +PTP VLAN ID 0 when tagging is enabled +PTP VLAN PCP 4 when tagging is enabled +Announce interval 1000 ms +Sync interval 250 ms +Two-step clock yes +Clock class 248 +Clock accuracy unknown +Variance 0xFFFF +Time source internal oscillator +Priority1 128 +Priority2 128 +``` + +The default `transportSpecific=0` and untagged Ethernet behavior preserve the existing ARIEC61850/ARSVIN laboratory behavior. They are configuration, not universal relay-profile claims. Some strict PTP implementations reject a `transportSpecific` mismatch, and real process-bus networks may expect a particular VLAN binding, so the lab tool exposes both explicitly rather than hard-coding a guessed utility profile. + +These clock-quality values are intentionally conservative. The lab helper must not advertise GPS traceability or calibrated accuracy it does not possess. + +## `smpSynch` safety rule + +PTP traffic visibility and Sampled Values synchronization claims are different facts. + +```text +PTP packets transmitted + != +measured synchronized Sampled Values clock +``` + +PTP-P1 therefore does **not** automatically promote canonical SMV `smpSynch` merely because the local PTP broadcaster is enabled. + +The portable ASDU default is fail-safe: + +```text +smpSynch = 0 +``` + +A future automatic `smpSynch` transition requires measured clock state and an explicit profile rule. + +Compatibility overrides may later be exposed for controlled laboratory use, but they must remain visibly distinct from measured synchronization. + +## Read-only PTP Analyzer CLI + +The portable stack includes: + +```text +ariec61850_ptp_analyze [--domain N] [--transport-specific N] [--json] +``` + +It reports, among other evidence: + +- PTP versus non-PTP frame counts; +- observed domain; +- observed `transportSpecific` and changes during the capture; +- source port identity; +- Announce count; +- Sync count; +- Follow_Up count; +- Pdelay activity; +- VLAN/QinQ visibility; +- sequence anomalies; +- basic liveness/health checks; +- expected-domain or expected-transportSpecific mismatch when requested. + +This is intended to help diagnose cases where PTP is visibly present on the wire but the target relay still ignores it because the profile identity or network binding does not match what that relay expects. + +Host PCAP timestamps are observation timestamps, not proof of PTP clock accuracy. The analyzer deliberately does not convert a normal host capture into a precision-timing claim. + +## Current RX ownership note + +The ESP32-P4 injector is currently a standalone raw Layer-2 application with no competing TCP/IP input consumer. PTP-P1 registers the timestamp-aware Ethernet RX callback and owns/frees the received buffers it inspects. + +A future combined PTP/SMV analyzer or application with multiple RX consumers should introduce one shared raw-Ethernet dispatcher rather than registering competing Ethernet input paths. + +## Bench acceptance — PTP-P1 + +Software/CI success is not physical relay acceptance. The first hardware interoperability run should retain packet and device evidence for the following. + +### A. Broadcaster visibility + +On an independent capture path verify: + +- EtherType `0x88F7`; +- expected source MAC / clock identity; +- configured PTP domain; +- configured `transportSpecific`; +- configured tagged/untagged state and VLAN/PCP when enabled; +- periodic Announce; +- periodic Sync; +- matching Follow_Up sequence IDs; +- two-step flag on Sync; +- non-zero, progressing Follow_Up hardware timestamps after the hardware clock is initialized. + +### B. Peer-delay exchange + +When the relay emits Pdelay_Req verify: + +```text +Relay ARStack ESP32-P4 + | | + |---- Pdelay_Req ------>| RX descriptor timestamp = t2 + | | + |<--- Pdelay_Resp ------| body carries t2 + | | TX descriptor timestamp = t3 + |<-- Pdelay_Resp_FU ----| body carries t3 +``` + +Acceptance evidence should show: + +- incoming Pdelay_Req uses the expected domain and `transportSpecific`; +- response sequence ID equals request sequence ID; +- requestingPortIdentity is echoed exactly; +- response and follow-up use the peer-delay multicast destination; +- configured PTP VLAN behavior is preserved on the responses; +- timestamps are hardware-derived and valid; +- no sustained response loss under the relay's normal Pdelay cadence. + +### C. Relay behavior + +Record, without overclaiming: + +- whether the relay detects the ARStack timing source; +- whether it selects/locks to it in the isolated test setup; +- time until relay timing-ready state; +- whether SMV becomes readable/usable after PTP is available; +- any relay-specific domain, transportSpecific, VLAN, timing-profile or clock-quality rejection diagnostic. + +A successful result proves interoperability with that tested relay/configuration. It is not a universal IEC/IEEE 61850-9-3 conformance certificate. + +### D. SMV non-regression + +With PTP and SMV active simultaneously retain the existing SMV telemetry and verify: + +- requested publisher rate remains stable; +- no new canonical TX failure trend; +- no sustained missed-slot increase; +- canonical SMV identity and VLAN/APPID remain unchanged; +- PTP processing stays outside the realtime SMV hot path. + +## Suggested capture filters + +Useful Wireshark display filters for the bench are: + +```text +ptp +eth.type == 0x88f7 +eth.type == 0x88ba +ptp || eth.type == 0x88ba +``` + +For evidence, keep the unfiltered raw capture whenever possible and use filters only for inspection. + +## PTP-P2 direction + +PTP-P1 intentionally stops before claiming disciplined synchronization. The next timing phase should add: + +1. explicit lab epoch/time seeding rather than assuming ESP system UTC is valid; +2. hardware timestamp telemetry/correlation; +3. external PTP observation path; +4. applicable power-profile validation; +5. peer-delay / offset calculations where ARStack operates as a synchronized client/source; +6. hardware-clock phase/frequency adjustment; +7. lock / holdover / unlocked state machine; +8. measured-state-driven `smpSynch` policy; +9. retained offset/jitter evidence. + +The relevant ESP-IDF 5.x hardware adapter already exposes hardware-clock time and phase/frequency adjustment commands, but those controls should only be used once the servo and evidence model are explicitly designed and tested. + +## Claim boundary + +PTP-P1 may be described as: + +> ESP32-P4 hardware-timestamped PTPv2 Layer-2 laboratory timing companion for Sampled Values troubleshooting and interoperability testing. + +PTP-P1 must **not** be described as: + +- GPS grandmaster replacement; +- GPS-traceable clock; +- calibrated precision time source; +- certified IEC/IEEE 61850-9-3 implementation; +- universal relay-compatible grandmaster; +- evidence that SMV `smpSynch=2` is automatically justified. + +The product value is practical: make process-bus testing possible when normal station timing infrastructure is temporarily unavailable, while keeping every synchronization claim honest and evidence-based. diff --git a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt index a5c7a57d5..b1075901e 100644 --- a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( "${ARSTACK_ROOT}/src/mms/utc_time.cpp" "${ARSTACK_ROOT}/src/sampled_values/pdu_codec.cpp" "${ARSTACK_ROOT}/src/sampled_values/frame_codec.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" INCLUDE_DIRS "${ARSTACK_ROOT}/include") diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index bd43d1526..ff51bf7a5 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -1,3 +1,8 @@ +set(AR_PTP_LAB_SRCS) +if(CONFIG_AR_PTP_LAB_TX) + list(APPEND AR_PTP_LAB_SRCS "ptp_lab_task.cpp") +endif() + idf_component_register( SRCS "app_main.cpp" @@ -5,6 +10,7 @@ idf_component_register( "live_control.cpp" "profile_control.cpp" "runtime_profile.cpp" + ${AR_PTP_LAB_SRCS} INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 69aee4aba..80be5cd1a 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -46,6 +46,79 @@ config AR_SMV_DIAGNOSTIC_PROBES Adds low-rate non-SV probes for capture-path troubleshooting. Keep this disabled while measuring realtime timing unless it is needed. +config AR_PTP_LAB_TX + bool "Enable PTPv2 Layer-2 lab timing companion" + default n + help + Enables a low-rate PTPv2 Layer-2 laboratory broadcaster for isolated + troubleshooting, internal testing and interoperability work when a + normal station PTP/GPS time server is unavailable. On ESP32-P4 with + ESP-IDF 5.x the two-step Sync/Follow_Up and Pdelay response paths use + EMAC hardware timestamps. This feature is not a GPS-backed or certified + grandmaster. + +config AR_PTP_DOMAIN + int "PTP domain number" + range 0 255 + default 0 + depends on AR_PTP_LAB_TX + help + Domain number written into Announce, Sync, Follow_Up and Pdelay + response messages. + +config AR_PTP_TRANSPORT_SPECIFIC + int "PTP transportSpecific nibble" + range 0 15 + default 0 + depends on AR_PTP_LAB_TX + help + Upper-nibble transportSpecific value written into PTP messageType. + Keep the default 0 for the ARIEC61850/default IEEE 1588 lab behavior. + Change it only when the tested relay/profile requires another value; + strict PTP receivers may reject a transportSpecific mismatch. + +config AR_PTP_VLAN + bool "Tag PTP lab traffic with IEEE 802.1Q VLAN" + default n + depends on AR_PTP_LAB_TX + help + Tags Announce, Sync/Follow_Up and Pdelay responses. Leave disabled for + the default untagged C#-oracle behavior. Enable only when the isolated + process-bus test network or target relay expects tagged PTP traffic. + +config AR_PTP_VLAN_ID + int "PTP VLAN ID" + range 0 4095 + default 0 + depends on AR_PTP_LAB_TX && AR_PTP_VLAN + +config AR_PTP_VLAN_PRIORITY + int "PTP VLAN PCP" + range 0 7 + default 4 + depends on AR_PTP_LAB_TX && AR_PTP_VLAN + help + IEEE 802.1Q priority code point for tagged PTP lab traffic. PCP 4 keeps + parity with the existing ARIEC61850 PTP publisher option default. + +config AR_PTP_ANNOUNCE_INTERVAL_MS + int "PTP Announce interval (ms)" + range 100 10000 + default 1000 + depends on AR_PTP_LAB_TX + help + Interval for lab Announce traffic. The default matches the existing + ARIEC61850/ARSVIN laboratory publisher behavior. + +config AR_PTP_SYNC_INTERVAL_MS + int "PTP Sync interval (ms)" + range 20 5000 + default 250 + depends on AR_PTP_LAB_TX + help + Interval for two-step Sync/Follow_Up traffic. Follow_Up carries the + ESP32-P4 EMAC transmit timestamp when the IDF 5.x adapter is available. + config AR_SMV_CURRENT_RMS_MA int "Balanced phase current RMS (mA)" range 0 100000 diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 93000d9a2..936d64843 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -5,6 +5,11 @@ #include "esp_err.h" #include "esp_eth_mac_esp.h" #include "esp_log.h" +#include "sdkconfig.h" + +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#endif static const char *TAG = "ar_smv_eth"; @@ -69,6 +74,13 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) return NULL; } +#if CONFIG_AR_PTP_LAB_TX + // The PTP task starts independently and tolerates link-not-ready TX failures + // while the normal app flow proceeds to esp_eth_start(). Keeping this hook + // beside driver installation also allows future PTP-only/analyzer firmware. + ar_ptp_lab_start(handle); +#endif + ESP_LOGI(TAG, "Ethernet configured: PHY addr=%d MDC=%d MDIO=%d REF_CLK=%d", AR_PHY_ADDRESS, AR_MDC_GPIO, AR_MDIO_GPIO, AR_RMII_REF_CLK_GPIO); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp new file mode 100644 index 000000000..658028333 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -0,0 +1,514 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ptp_lab_task.hpp" + +#include "ariec61850/time_sync/ptp.hpp" + +#include "esp_err.h" +#include "esp_eth.h" +#include "esp_eth_driver.h" +#include "esp_eth_mac.h" +#include "esp_idf_version.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +#if ESP_IDF_VERSION_MAJOR < 6 +#include "esp_eth_mac_esp.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +using ar::iec61850::time_sync::PtpBuildOptions; +using ar::iec61850::time_sync::PtpClockAccuracy; +using ar::iec61850::time_sync::PtpCodec; +using ar::iec61850::time_sync::PtpFrame; +using ar::iec61850::time_sync::PtpMessageType; +using ar::iec61850::time_sync::PtpPortIdentity; +using ar::iec61850::time_sync::PtpTimeSource; +using ar::iec61850::time_sync::PtpTimestamp; +using ar::iec61850::time_sync::ptp_general_multicast_mac; +using ar::iec61850::time_sync::ptp_peer_delay_multicast_mac; + +constexpr char kTag[] = "ar_ptp_lab"; +constexpr std::uint16_t kPortNumber = 1U; +constexpr std::uint8_t kPriority1 = 128U; +constexpr std::uint8_t kPriority2 = 128U; +constexpr std::uint8_t kClockClass = 248U; +constexpr std::uint16_t kOffsetScaledLogVariance = 0xFFFFU; +constexpr std::int16_t kCurrentUtcOffset = 37; +constexpr TickType_t kStartupDelay = pdMS_TO_TICKS(500); +constexpr UBaseType_t kPdelayQueueDepth = 8U; + +struct PdelayRequestEvent final { + PtpPortIdentity requesting_port_identity{}; + PtpTimestamp receive_timestamp{}; + std::uint16_t sequence_id{}; + std::int8_t log_message_interval{}; +}; + +struct PtpLabContext final { + esp_eth_handle_t eth_handle{}; + std::array source_mac{}; + PtpBuildOptions base_options{}; + QueueHandle_t pdelay_queue{}; + TaskHandle_t task_handle{}; + std::uint16_t announce_sequence{}; + std::uint16_t sync_sequence{}; + std::uint64_t pdelay_responses{}; +}; + +PtpLabContext g_ptp_context{}; +bool g_ptp_started = false; + +[[nodiscard]] std::int8_t interval_to_log2(const std::uint32_t interval_ms) noexcept { + if (interval_ms == 0U) return 0; + // PTP logMessageInterval is log2(seconds). The configured lab intervals are + // represented by the nearest useful bounded integer exponent. + std::uint32_t numerator = interval_ms; + std::int8_t exponent = 0; + while (numerator < 1000U && exponent > -7) { + numerator *= 2U; + --exponent; + } + while (numerator >= 2000U && exponent < 7) { + numerator = (numerator + 1U) / 2U; + ++exponent; + } + return exponent; +} + +[[nodiscard]] std::array clock_identity_from_mac( + const std::array& mac) noexcept { + return { + mac[0], mac[1], mac[2], 0xFFU, 0xFEU, mac[3], mac[4], mac[5], + }; +} + +[[nodiscard]] std::optional ptp_vlan_id() noexcept { +#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN + return static_cast(CONFIG_AR_PTP_VLAN_ID); +#else + return std::nullopt; +#endif +} + +[[nodiscard]] std::uint8_t ptp_vlan_priority() noexcept { +#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN + return static_cast(CONFIG_AR_PTP_VLAN_PRIORITY); +#else + return 0U; +#endif +} + +#if defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 + +[[nodiscard]] bool valid_hw_timestamp(const eth_mac_time_t& timestamp) noexcept { + return timestamp.nanoseconds < 1'000'000'000U && + (timestamp.seconds != 0U || timestamp.nanoseconds != 0U); +} + +[[nodiscard]] PtpTimestamp to_ptp_timestamp(const eth_mac_time_t& timestamp) noexcept { + return { + static_cast(timestamp.seconds), + timestamp.nanoseconds, + }; +} + +[[nodiscard]] bool enable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { + bool enable = true; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_PTP_ENABLE), + &enable); + if (result != ESP_OK) { + ESP_LOGE(kTag, "Failed to enable ESP32-P4 IEEE1588 hardware clock: %s", + esp_err_to_name(result)); + return false; + } + return true; +} + +void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { + eth_mac_time_t initial_time{}; + const std::time_t system_time = std::time(nullptr); + if (system_time > 0 && + static_cast(system_time) <= std::numeric_limits::max()) { + initial_time.seconds = static_cast(system_time); + } + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), + &initial_time); + if (result != ESP_OK) { + ESP_LOGW(kTag, "Unable to seed hardware PTP time: %s", esp_err_to_name(result)); + } +} + +[[nodiscard]] PtpTimestamp hardware_time(const esp_eth_handle_t eth_handle) noexcept { + eth_mac_time_t timestamp{}; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + ×tamp); + if (result != ESP_OK || timestamp.nanoseconds >= 1'000'000'000U) { + return {}; + } + return to_ptp_timestamp(timestamp); +} + +[[nodiscard]] bool send_frame( + const esp_eth_handle_t eth_handle, + std::vector& frame) noexcept { + if (frame.empty()) return false; + return esp_eth_transmit(eth_handle, frame.data(), frame.size()) == ESP_OK; +} + +[[nodiscard]] bool transmit_with_hw_timestamp( + const esp_eth_handle_t eth_handle, + std::vector& frame, + PtpTimestamp& timestamp) noexcept { + if (frame.empty()) return false; + eth_mac_time_t tx_timestamp{}; + const auto result = esp_eth_transmit_ctrl_vargs( + eth_handle, + &tx_timestamp, + 2U, + frame.data(), + frame.size()); + if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) { + return false; + } + timestamp = to_ptp_timestamp(tx_timestamp); + return true; +} + +[[nodiscard]] std::vector wrap_ptp_message( + const std::array& destination_mac, + const PtpLabContext& context, + const std::vector& message) { + return PtpCodec::build_ethernet_frame( + destination_mac, + context.source_mac, + message, + ptp_vlan_id(), + ptp_vlan_priority()); +} + +[[nodiscard]] bool send_sync_follow_up(PtpLabContext& context) noexcept { + auto sync_options = context.base_options; + sync_options.sequence_id = context.sync_sequence; + sync_options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_SYNC_INTERVAL_MS); + sync_options.two_step = true; + // Two-step Sync intentionally carries zero originTimestamp. The exact MAC + // egress timestamp returned by the ESP32-P4 DMA descriptor is carried by + // the subsequent Follow_Up. + sync_options.timestamp = {}; + + const auto sync_message = PtpCodec::build_sync(sync_options); + auto sync_frame = wrap_ptp_message(ptp_general_multicast_mac, context, sync_message); + + PtpTimestamp tx_timestamp; + if (!transmit_with_hw_timestamp(context.eth_handle, sync_frame, tx_timestamp)) { + return false; + } + + auto follow_up_options = sync_options; + follow_up_options.two_step = false; + follow_up_options.timestamp = tx_timestamp; + const auto follow_up_message = PtpCodec::build_follow_up(follow_up_options); + auto follow_up_frame = wrap_ptp_message( + ptp_general_multicast_mac, + context, + follow_up_message); + if (!send_frame(context.eth_handle, follow_up_frame)) { + return false; + } + + context.sync_sequence = static_cast(context.sync_sequence + 1U); + return true; +} + +[[nodiscard]] bool send_announce(PtpLabContext& context) noexcept { + auto options = context.base_options; + options.sequence_id = context.announce_sequence; + options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); + options.two_step = false; + options.timestamp = hardware_time(context.eth_handle); + + const auto message = PtpCodec::build_announce(options, kCurrentUtcOffset); + auto frame = wrap_ptp_message(ptp_general_multicast_mac, context, message); + if (!send_frame(context.eth_handle, frame)) { + return false; + } + context.announce_sequence = static_cast(context.announce_sequence + 1U); + return true; +} + +[[nodiscard]] bool send_pdelay_response( + PtpLabContext& context, + const PdelayRequestEvent& request) noexcept { + auto response_options = context.base_options; + response_options.sequence_id = request.sequence_id; + response_options.log_message_interval = request.log_message_interval; + response_options.two_step = true; + // t2: hardware receive timestamp of Pdelay_Req. + response_options.timestamp = request.receive_timestamp; + + const auto response_message = PtpCodec::build_pdelay_resp( + response_options, + request.requesting_port_identity); + auto response_frame = wrap_ptp_message( + ptp_peer_delay_multicast_mac, + context, + response_message); + + PtpTimestamp response_tx_timestamp; + if (!transmit_with_hw_timestamp( + context.eth_handle, + response_frame, + response_tx_timestamp)) { + return false; + } + + auto follow_up_options = response_options; + follow_up_options.two_step = false; + // t3: hardware transmit timestamp of Pdelay_Resp. + follow_up_options.timestamp = response_tx_timestamp; + const auto follow_up_message = PtpCodec::build_pdelay_resp_follow_up( + follow_up_options, + request.requesting_port_identity); + auto follow_up_frame = wrap_ptp_message( + ptp_peer_delay_multicast_mac, + context, + follow_up_message); + if (!send_frame(context.eth_handle, follow_up_frame)) { + return false; + } + + ++context.pdelay_responses; + if (context.pdelay_responses == 1U) { + ESP_LOGI(kTag, "First hardware-timestamped Pdelay exchange completed"); + } + return true; +} + +esp_err_t ptp_input_info( + esp_eth_handle_t, + std::uint8_t* buffer, + const std::uint32_t length, + void* priv, + void* info) { + auto* context = static_cast(priv); + if (buffer == nullptr) { + return ESP_OK; + } + + if (context != nullptr && context->pdelay_queue != nullptr && info != nullptr) { + PtpFrame frame; + if (PtpCodec::try_parse_ethernet_frame( + std::span{buffer, length}, + frame) && + frame.peer_delay_multicast && + frame.header.message_type == PtpMessageType::pdelay_req && + frame.header.domain_number == context->base_options.domain_number && + frame.header.transport_specific == context->base_options.transport_specific) { + const auto& rx_timestamp = *static_cast(info); + if (valid_hw_timestamp(rx_timestamp)) { + const PdelayRequestEvent event{ + frame.header.source_port_identity, + to_ptp_timestamp(rx_timestamp), + frame.header.sequence_id, + frame.header.log_message_interval, + }; + if (xQueueSend(context->pdelay_queue, &event, 0U) == pdTRUE && + context->task_handle != nullptr) { + xTaskNotifyGive(context->task_handle); + } + } + } + } + + // The injector is a standalone raw-L2 application and currently has no + // competing TCP/IP input consumer. This registered input path owns the RX + // buffer and must release it after inspection. + std::free(buffer); + return ESP_OK; +} + +void ptp_lab_task(void* argument) { + auto& context = *static_cast(argument); + vTaskDelay(kStartupDelay); + + if (!enable_hardware_ptp(context.eth_handle)) { + ESP_LOGE(kTag, "PTP lab broadcaster stopped: hardware timestamp clock unavailable"); + g_ptp_started = false; + vTaskDelete(nullptr); + return; + } + seed_hardware_clock(context.eth_handle); + + const auto mac_result = esp_eth_ioctl( + context.eth_handle, + ETH_CMD_G_MAC_ADDR, + context.source_mac.data()); + if (mac_result != ESP_OK) { + ESP_LOGE(kTag, "PTP lab broadcaster stopped: MAC address unavailable: %s", + esp_err_to_name(mac_result)); + g_ptp_started = false; + vTaskDelete(nullptr); + return; + } + + context.base_options.transport_specific = + static_cast(CONFIG_AR_PTP_TRANSPORT_SPECIFIC); + context.base_options.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); + context.base_options.source_port_identity.clock_identity = + clock_identity_from_mac(context.source_mac); + context.base_options.source_port_identity.port_number = kPortNumber; + context.base_options.priority1 = kPriority1; + context.base_options.priority2 = kPriority2; + context.base_options.clock_class = kClockClass; + context.base_options.clock_accuracy = PtpClockAccuracy::unknown; + context.base_options.offset_scaled_log_variance = kOffsetScaledLogVariance; + context.base_options.grandmaster_identity = context.base_options.source_port_identity.clock_identity; + context.base_options.time_source = PtpTimeSource::internal_oscillator; + + bool pdelay_enabled = false; + if (context.pdelay_queue != nullptr) { + const auto input_result = esp_eth_update_input_path_info( + context.eth_handle, + &ptp_input_info, + &context); + if (input_result == ESP_OK) { + pdelay_enabled = true; + } else { + ESP_LOGW(kTag, + "Hardware RX timestamp path unavailable; Pdelay responder disabled: %s", + esp_err_to_name(input_result)); + } + } + + ESP_LOGW(kTag, + "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); + ESP_LOGI(kTag, + "PTP domain=%u transportSpecific=0x%X VLAN=%s Announce=%d ms Sync=%d ms HW Sync TX timestamp=enabled HW Pdelay=%s", + static_cast(context.base_options.domain_number), + static_cast(context.base_options.transport_specific), + ptp_vlan_id().has_value() ? "tagged" : "untagged", + CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS, + CONFIG_AR_PTP_SYNC_INTERVAL_MS, + pdelay_enabled ? "enabled" : "disabled"); + + TickType_t next_sync = xTaskGetTickCount(); + TickType_t next_announce = next_sync; + std::uint32_t consecutive_failures = 0U; + + while (true) { + bool attempted = false; + bool success = true; + + PdelayRequestEvent pdelay_request; + while (context.pdelay_queue != nullptr && + xQueueReceive(context.pdelay_queue, &pdelay_request, 0U) == pdTRUE) { + attempted = true; + success = send_pdelay_response(context, pdelay_request) && success; + } + + const TickType_t now = xTaskGetTickCount(); + if (static_cast(now - next_announce) >= 0) { + attempted = true; + success = send_announce(context) && success; + next_announce = now + pdMS_TO_TICKS(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); + } + if (static_cast(now - next_sync) >= 0) { + attempted = true; + success = send_sync_follow_up(context) && success; + next_sync = now + pdMS_TO_TICKS(CONFIG_AR_PTP_SYNC_INTERVAL_MS); + } + + if (attempted) { + if (success) { + consecutive_failures = 0U; + } else { + ++consecutive_failures; + if (consecutive_failures == 1U || (consecutive_failures % 16U) == 0U) { + ESP_LOGW(kTag, + "PTP TX failed (%lu consecutive); Ethernet link/driver may not be ready", + static_cast(consecutive_failures)); + } + } + } + + // A Pdelay RX notification wakes this task immediately; the timeout only + // bounds the cadence check for Announce/Sync and avoids a busy loop. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(5)); + } +} + +#else + +void ptp_lab_task(void*) { + ESP_LOGE(kTag, + "PTP lab broadcaster requires ESP32-P4 IEEE1588 support and the ESP-IDF 5.x EMAC timestamp adapter"); + g_ptp_started = false; + vTaskDelete(nullptr); +} + +#endif + +void start_ptp_lab(const esp_eth_handle_t eth_handle) { + if (eth_handle == nullptr) { + ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); + return; + } + if (g_ptp_started) { + return; + } + + g_ptp_context = {}; + g_ptp_context.eth_handle = eth_handle; + g_ptp_context.pdelay_queue = xQueueCreate( + kPdelayQueueDepth, + sizeof(PdelayRequestEvent)); + if (g_ptp_context.pdelay_queue == nullptr) { + ESP_LOGW(kTag, "Pdelay queue allocation failed; broadcaster will run without Pdelay RX responses"); + } + + g_ptp_started = true; + if (xTaskCreatePinnedToCore( + &ptp_lab_task, + "ar_ptp_lab", + 6144, + &g_ptp_context, + 4, + &g_ptp_context.task_handle, + 0) != pdPASS) { + g_ptp_started = false; + if (g_ptp_context.pdelay_queue != nullptr) { + vQueueDelete(g_ptp_context.pdelay_queue); + g_ptp_context.pdelay_queue = nullptr; + } + ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); + } +} + +} // namespace +} // namespace ar::esp32p4::smv + +extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { + ar::esp32p4::smv::start_ptp_lab(eth_handle); +} diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp new file mode 100644 index 000000000..996c438f7 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "esp_eth.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Start the optional PTPv2 Layer-2 laboratory timing companion. + * + * This is an isolated troubleshooting/interoperability helper, not a GPS-backed + * or certified grandmaster. The task runs independently from the realtime SV path. + */ +void ar_ptp_lab_start(esp_eth_handle_t eth_handle); + +#ifdef __cplusplus +} +#endif diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp index cc41e10b6..aae458257 100644 --- a/include/ariec61850/sampled_values/asdu.hpp +++ b/include/ariec61850/sampled_values/asdu.hpp @@ -17,7 +17,9 @@ struct SampledValueAsdu final { std::uint16_t sample_count{}; std::uint32_t configuration_revision{1U}; std::optional reference_time; - std::uint8_t sample_synchronization{2U}; + // Synchronization claims are evidence-driven. New ASDUs therefore start + // unsynchronized and must be promoted explicitly by a validated policy. + std::uint8_t sample_synchronization{0U}; std::optional sample_rate; std::optional sample_mode; std::vector sample_payload; diff --git a/include/ariec61850/time_sync/ptp.hpp b/include/ariec61850/time_sync/ptp.hpp new file mode 100644 index 000000000..f9796d9b6 --- /dev/null +++ b/include/ariec61850/time_sync/ptp.hpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { + +inline constexpr std::uint16_t ptp_ethertype = 0x88F7U; +inline constexpr std::uint16_t ptp_vlan_ethertype = 0x8100U; +inline constexpr std::uint16_t ptp_qinq_ethertype = 0x88A8U; +inline constexpr std::uint8_t ptp_version = 2U; +inline constexpr std::size_t ptp_header_length = 34U; +inline constexpr std::array ptp_general_multicast_mac{ + 0x01U, 0x1BU, 0x19U, 0x00U, 0x00U, 0x00U}; +inline constexpr std::array ptp_peer_delay_multicast_mac{ + 0x01U, 0x80U, 0xC2U, 0x00U, 0x00U, 0x0EU}; + +enum class PtpMessageType : std::uint8_t { + sync = 0x0U, + delay_req = 0x1U, + pdelay_req = 0x2U, + pdelay_resp = 0x3U, + follow_up = 0x8U, + delay_resp = 0x9U, + pdelay_resp_follow_up = 0xAU, + announce = 0xBU, + signaling = 0xCU, + management = 0xDU, +}; + +enum class PtpClockAccuracy : std::uint8_t { + within_25_ns = 0x20U, + within_100_ns = 0x21U, + within_250_ns = 0x22U, + within_1_us = 0x23U, + within_2_5_us = 0x24U, + within_10_us = 0x25U, + within_25_us = 0x26U, + within_100_us = 0x27U, + within_250_us = 0x28U, + within_1_ms = 0x29U, + within_2_5_ms = 0x2AU, + within_10_ms = 0x2BU, + greater_than_10_s = 0x31U, + unknown = 0xFEU, +}; + +enum class PtpTimeSource : std::uint8_t { + atomic_clock = 0x10U, + gps = 0x20U, + terrestrial_radio = 0x30U, + ptp = 0x40U, + ntp = 0x50U, + hand_set = 0x60U, + other = 0x90U, + internal_oscillator = 0xA0U, +}; + +using PtpClockIdentity = std::array; + +struct PtpPortIdentity final { + PtpClockIdentity clock_identity{}; + std::uint16_t port_number{1U}; + + friend bool operator==(const PtpPortIdentity&, const PtpPortIdentity&) = default; +}; + +struct PtpTimestamp final { + std::uint64_t seconds{}; // PTP wire field is 48-bit; upper bits are rejected when encoded. + std::uint32_t nanoseconds{}; + + [[nodiscard]] static PtpTimestamp from_system_time( + std::chrono::system_clock::time_point time) noexcept; + [[nodiscard]] static PtpTimestamp now_utc() noexcept; + [[nodiscard]] static bool try_read(std::span source, PtpTimestamp& timestamp) noexcept; + [[nodiscard]] bool write(std::span destination) const noexcept; + + friend bool operator==(const PtpTimestamp&, const PtpTimestamp&) = default; +}; + +struct PtpHeader final { + std::uint8_t transport_specific{}; + PtpMessageType message_type{PtpMessageType::sync}; + std::uint8_t version{ptp_version}; + std::uint16_t message_length{}; + std::uint8_t domain_number{}; + std::uint16_t flags{}; + std::int64_t correction_field{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + std::uint8_t control_field{}; + std::int8_t log_message_interval{}; + + [[nodiscard]] bool is_two_step() const noexcept { return (flags & 0x0200U) != 0U; } + + friend bool operator==(const PtpHeader&, const PtpHeader&) = default; +}; + +struct PtpAnnounceMessage final { + PtpTimestamp origin_timestamp{}; + std::int16_t current_utc_offset{37}; + std::uint8_t priority1{128U}; + std::uint8_t clock_class{248U}; + PtpClockAccuracy clock_accuracy{PtpClockAccuracy::unknown}; + std::uint16_t offset_scaled_log_variance{0xFFFFU}; + std::uint8_t priority2{128U}; + PtpClockIdentity grandmaster_identity{}; + std::uint16_t steps_removed{}; + PtpTimeSource time_source{PtpTimeSource::internal_oscillator}; + + friend bool operator==(const PtpAnnounceMessage&, const PtpAnnounceMessage&) = default; +}; + +struct PtpFrame final { + PtpHeader header{}; + std::optional timestamp; + std::optional announce; + std::vector message; + std::vector body; + std::optional vlan_id; + std::optional outer_vlan_id; + bool peer_delay_multicast{}; +}; + +struct PtpBuildOptions final { + std::uint8_t transport_specific{}; + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + std::int64_t correction_field{}; + std::int8_t log_message_interval{}; + bool two_step{true}; + std::uint8_t priority1{128U}; + std::uint8_t priority2{128U}; + std::uint8_t clock_class{248U}; + PtpClockAccuracy clock_accuracy{PtpClockAccuracy::unknown}; + std::uint16_t offset_scaled_log_variance{0xFFFFU}; + PtpClockIdentity grandmaster_identity{}; + std::uint16_t steps_removed{}; + PtpTimeSource time_source{PtpTimeSource::internal_oscillator}; + PtpTimestamp timestamp{}; +}; + +class PtpCodec final { +public: + [[nodiscard]] static std::vector build_sync(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_follow_up(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_pdelay_req(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_pdelay_resp( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity); + [[nodiscard]] static std::vector build_pdelay_resp_follow_up( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity); + [[nodiscard]] static std::vector build_announce( + const PtpBuildOptions& options, + std::int16_t current_utc_offset = 37); + + [[nodiscard]] static std::vector build_ethernet_frame( + const std::array& destination_mac, + const std::array& source_mac, + std::span ptp_message, + std::optional vlan_id = std::nullopt, + std::uint8_t vlan_priority = 0U); + + [[nodiscard]] static bool try_parse_message( + std::span message, + PtpFrame& frame) noexcept; + [[nodiscard]] static bool try_parse_ethernet_frame( + std::span ethernet_frame, + PtpFrame& frame) noexcept; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_monitor.hpp b/include/ariec61850/time_sync/ptp_monitor.hpp new file mode 100644 index 000000000..b53f86be0 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_monitor.hpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { + +struct PtpObservedMessage final { + std::chrono::system_clock::time_point observed_at{}; + PtpMessageType message_type{PtpMessageType::sync}; + std::uint8_t transport_specific{}; + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + bool two_step{}; + std::optional vlan_id; + std::optional outer_vlan_id; + bool peer_delay_multicast{}; +}; + +struct PtpSourceClockSnapshot final { + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::chrono::system_clock::time_point first_seen_at{}; + std::chrono::system_clock::time_point last_seen_at{}; + std::array message_counts{}; + std::array, 16> last_sequence_ids{}; + std::uint32_t sequence_anomaly_count{}; + std::optional transport_specific; + std::uint32_t transport_specific_change_count{}; + std::optional vlan_id; + std::optional outer_vlan_id; + + [[nodiscard]] std::uint32_t count(PtpMessageType type) const noexcept; +}; + +struct PtpMonitorSnapshot final { + std::chrono::system_clock::time_point captured_at{}; + std::uint64_t total_frames{}; + std::uint64_t valid_ptp_frames{}; + std::uint64_t invalid_frames{}; + std::vector recent_messages; + std::vector sources; + + [[nodiscard]] bool has_ptp() const noexcept { return valid_ptp_frames != 0U; } +}; + +class PtpPassiveMonitor final { +public: + explicit PtpPassiveMonitor(std::size_t recent_capacity = 256U); + + [[nodiscard]] bool observe_ethernet_frame( + std::span ethernet_frame, + std::chrono::system_clock::time_point observed_at = std::chrono::system_clock::now()); + void observe( + const PtpFrame& frame, + std::chrono::system_clock::time_point observed_at = std::chrono::system_clock::now()); + [[nodiscard]] PtpMonitorSnapshot snapshot( + std::chrono::system_clock::time_point captured_at = std::chrono::system_clock::now()) const; + void reset(); + +private: + struct SourceState final { + PtpSourceClockSnapshot snapshot; + }; + + [[nodiscard]] static std::size_t message_index(PtpMessageType type) noexcept; + [[nodiscard]] SourceState* find_source( + std::uint8_t domain_number, + const PtpPortIdentity& source_port_identity) noexcept; + + std::size_t recent_capacity_; + mutable std::mutex mutex_; + std::uint64_t total_frames_{}; + std::uint64_t valid_frames_{}; + std::uint64_t invalid_frames_{}; + std::deque recent_messages_; + std::vector sources_; +}; + +enum class PtpHealthSeverity : std::uint8_t { + ok, + warning, + fail, +}; + +struct PtpHealthCheck final { + std::string id; + PtpHealthSeverity severity{PtpHealthSeverity::ok}; + std::string message; +}; + +struct PtpTimingHealthOptions final { + std::optional expected_domain_number; + std::optional expected_transport_specific; + std::chrono::milliseconds source_timeout{3000}; + bool require_announce{true}; + bool require_sync{true}; + bool require_follow_up_for_two_step{true}; + bool require_peer_delay_activity{false}; + std::uint32_t maximum_sequence_anomalies{}; +}; + +struct PtpTimingHealthReport final { + std::chrono::system_clock::time_point evaluated_at{}; + PtpHealthSeverity severity{PtpHealthSeverity::fail}; + PtpMonitorSnapshot snapshot; + std::vector checks; + std::optional selected_source; + + [[nodiscard]] bool is_healthy() const noexcept { return severity == PtpHealthSeverity::ok; } +}; + +class PtpTimingHealthValidator final { +public: + [[nodiscard]] static PtpTimingHealthReport evaluate( + const PtpMonitorSnapshot& snapshot, + const PtpTimingHealthOptions& options = {}); +}; + +enum class SmpSynchValue : std::uint8_t { + not_synchronized = 0U, + local_synchronized = 1U, + global_synchronized = 2U, +}; + +[[nodiscard]] SmpSynchValue resolve_smp_synch( + const PtpTimingHealthReport& report, + bool allow_local_fallback = true) noexcept; + +} // namespace ar::iec61850::time_sync diff --git a/src/time_sync/ptp.cpp b/src/time_sync/ptp.cpp new file mode 100644 index 000000000..073d8aef6 --- /dev/null +++ b/src/time_sync/ptp.cpp @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { +namespace { + +[[nodiscard]] std::uint16_t read_u16_be(const std::span source) noexcept { + return static_cast( + (static_cast(source[0]) << 8U) | + static_cast(source[1])); +} + +[[nodiscard]] std::uint32_t read_u32_be(const std::span source) noexcept { + return (static_cast(source[0]) << 24U) | + (static_cast(source[1]) << 16U) | + (static_cast(source[2]) << 8U) | + static_cast(source[3]); +} + +[[nodiscard]] std::uint64_t read_u64_be(const std::span source) noexcept { + std::uint64_t value{}; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | static_cast(source[index]); + } + return value; +} + +void write_u16_be(const std::span destination, const std::uint16_t value) noexcept { + destination[0] = static_cast((value >> 8U) & 0xFFU); + destination[1] = static_cast(value & 0xFFU); +} + +void write_u32_be(const std::span destination, const std::uint32_t value) noexcept { + destination[0] = static_cast((value >> 24U) & 0xFFU); + destination[1] = static_cast((value >> 16U) & 0xFFU); + destination[2] = static_cast((value >> 8U) & 0xFFU); + destination[3] = static_cast(value & 0xFFU); +} + +void write_u64_be(const std::span destination, const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + destination[index] = static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] bool is_zero_identity(const PtpClockIdentity& identity) noexcept { + return std::all_of(identity.begin(), identity.end(), [](const std::uint8_t byte) { + return byte == 0U; + }); +} + +[[nodiscard]] std::vector build_message( + const PtpMessageType message_type, + const PtpBuildOptions& options, + const std::uint8_t control_field, + const std::span body) { + if (body.size() > std::numeric_limits::max() - ptp_header_length) { + return {}; + } + + const auto message_length = ptp_header_length + body.size(); + std::vector bytes(message_length, 0U); + auto span = std::span{bytes}; + span[0] = static_cast( + static_cast(options.transport_specific << 4U) | + (static_cast(message_type) & 0x0FU)); + span[1] = ptp_version; + write_u16_be(span.subspan(2U, 2U), static_cast(message_length)); + span[4] = options.domain_number; + write_u16_be(span.subspan(6U, 2U), options.two_step ? 0x0200U : 0U); + write_u64_be(span.subspan(8U, 8U), static_cast(options.correction_field)); + std::copy( + options.source_port_identity.clock_identity.begin(), + options.source_port_identity.clock_identity.end(), + span.begin() + 20); + write_u16_be(span.subspan(28U, 2U), options.source_port_identity.port_number); + write_u16_be(span.subspan(30U, 2U), options.sequence_id); + span[32] = control_field; + span[33] = static_cast(options.log_message_interval); + std::copy(body.begin(), body.end(), span.begin() + static_cast(ptp_header_length)); + return bytes; +} + +[[nodiscard]] std::vector build_timestamp_message( + const PtpMessageType message_type, + const PtpBuildOptions& options, + const std::uint8_t control_field) { + std::array body{}; + if (!options.timestamp.write(body)) { + return {}; + } + return build_message(message_type, options, control_field, body); +} + +[[nodiscard]] std::uint16_t vlan_id_from_tci(const std::uint16_t tci) noexcept { + return static_cast(tci & 0x0FFFU); +} + +} // namespace + +PtpTimestamp PtpTimestamp::from_system_time(const std::chrono::system_clock::time_point time) noexcept { + const auto elapsed = std::chrono::duration_cast( + time.time_since_epoch()).count(); + if (elapsed <= 0) { + return {}; + } + constexpr std::int64_t nanos_per_second = 1'000'000'000LL; + const auto seconds_value = elapsed / nanos_per_second; + const auto nanos_value = elapsed % nanos_per_second; + return { + static_cast(seconds_value), + static_cast(nanos_value), + }; +} + +PtpTimestamp PtpTimestamp::now_utc() noexcept { + return from_system_time(std::chrono::system_clock::now()); +} + +bool PtpTimestamp::try_read( + const std::span source, + PtpTimestamp& timestamp) noexcept { + if (source.size() < 10U) { + return false; + } + const auto seconds_high = static_cast(read_u16_be(source.first(2U))); + const auto seconds_low = static_cast(read_u32_be(source.subspan(2U, 4U))); + const auto nanoseconds_value = read_u32_be(source.subspan(6U, 4U)); + if (nanoseconds_value >= 1'000'000'000U) { + return false; + } + timestamp.seconds = (seconds_high << 32U) | seconds_low; + timestamp.nanoseconds = nanoseconds_value; + return true; +} + +bool PtpTimestamp::write(const std::span destination) const noexcept { + constexpr std::uint64_t max_seconds = 0x0000FFFFFFFFFFFFULL; + if (destination.size() < 10U || seconds > max_seconds || nanoseconds >= 1'000'000'000U) { + return false; + } + write_u16_be(destination.first(2U), static_cast(seconds >> 32U)); + write_u32_be(destination.subspan(2U, 4U), static_cast(seconds & 0xFFFFFFFFULL)); + write_u32_be(destination.subspan(6U, 4U), nanoseconds); + return true; +} + +std::vector PtpCodec::build_sync(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::sync, options, 0x00U); +} + +std::vector PtpCodec::build_follow_up(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::follow_up, options, 0x02U); +} + +std::vector PtpCodec::build_pdelay_req(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::pdelay_req, options, 0x05U); +} + +std::vector PtpCodec::build_pdelay_resp( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + std::copy( + requesting_port_identity.clock_identity.begin(), + requesting_port_identity.clock_identity.end(), + body.begin() + 10); + write_u16_be(std::span{body}.subspan(18U, 2U), requesting_port_identity.port_number); + return build_message(PtpMessageType::pdelay_resp, options, 0x05U, body); +} + +std::vector PtpCodec::build_pdelay_resp_follow_up( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + std::copy( + requesting_port_identity.clock_identity.begin(), + requesting_port_identity.clock_identity.end(), + body.begin() + 10); + write_u16_be(std::span{body}.subspan(18U, 2U), requesting_port_identity.port_number); + return build_message(PtpMessageType::pdelay_resp_follow_up, options, 0x05U, body); +} + +std::vector PtpCodec::build_announce( + const PtpBuildOptions& options, + const std::int16_t current_utc_offset) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + write_u16_be( + std::span{body}.subspan(10U, 2U), + static_cast(current_utc_offset)); + body[13] = options.priority1; + body[14] = options.clock_class; + body[15] = static_cast(options.clock_accuracy); + write_u16_be( + std::span{body}.subspan(16U, 2U), + options.offset_scaled_log_variance); + body[18] = options.priority2; + const auto& grandmaster = is_zero_identity(options.grandmaster_identity) + ? options.source_port_identity.clock_identity + : options.grandmaster_identity; + std::copy(grandmaster.begin(), grandmaster.end(), body.begin() + 19); + write_u16_be(std::span{body}.subspan(27U, 2U), options.steps_removed); + body[29] = static_cast(options.time_source); + return build_message(PtpMessageType::announce, options, 0x05U, body); +} + +std::vector PtpCodec::build_ethernet_frame( + const std::array& destination_mac, + const std::array& source_mac, + const std::span ptp_message, + const std::optional vlan_id, + const std::uint8_t vlan_priority) { + if (vlan_priority > 7U || (vlan_id.has_value() && *vlan_id > 4095U)) { + return {}; + } + + ethernet::EthernetFrame frame; + frame.destination = ethernet::MacAddress{std::span{destination_mac}}; + frame.source = ethernet::MacAddress{std::span{source_mac}}; + frame.ether_type = ptp_ethertype; + if (vlan_id.has_value()) { + frame.vlan = ethernet::VlanTag{vlan_priority, *vlan_id}; + } + frame.payload.assign(ptp_message.begin(), ptp_message.end()); + return ethernet::EthernetFrameCodec::encode(frame); +} + +bool PtpCodec::try_parse_message( + const std::span message, + PtpFrame& frame) noexcept { + if (message.size() < ptp_header_length) { + return false; + } + + const auto version = static_cast(message[1] & 0x0FU); + if (version != ptp_version) { + return false; + } + const auto message_length = read_u16_be(message.subspan(2U, 2U)); + if (message_length < ptp_header_length || message.size() < message_length) { + return false; + } + + PtpFrame parsed; + parsed.header.transport_specific = static_cast(message[0] >> 4U); + parsed.header.message_type = static_cast(message[0] & 0x0FU); + parsed.header.version = version; + parsed.header.message_length = message_length; + parsed.header.domain_number = message[4]; + parsed.header.flags = read_u16_be(message.subspan(6U, 2U)); + parsed.header.correction_field = static_cast(read_u64_be(message.subspan(8U, 8U))); + std::copy_n(message.begin() + 20, 8, parsed.header.source_port_identity.clock_identity.begin()); + parsed.header.source_port_identity.port_number = read_u16_be(message.subspan(28U, 2U)); + parsed.header.sequence_id = read_u16_be(message.subspan(30U, 2U)); + parsed.header.control_field = message[32]; + parsed.header.log_message_interval = static_cast(message[33]); + + const auto body = message.subspan(ptp_header_length, message_length - ptp_header_length); + PtpTimestamp timestamp; + if ((parsed.header.message_type == PtpMessageType::sync || + parsed.header.message_type == PtpMessageType::follow_up) && + body.size() >= 10U && PtpTimestamp::try_read(body.first(10U), timestamp)) { + parsed.timestamp = timestamp; + } + + if (parsed.header.message_type == PtpMessageType::announce && body.size() >= 30U) { + if (!PtpTimestamp::try_read(body.first(10U), timestamp)) { + return false; + } + PtpAnnounceMessage announce; + announce.origin_timestamp = timestamp; + announce.current_utc_offset = static_cast(read_u16_be(body.subspan(10U, 2U))); + announce.priority1 = body[13]; + announce.clock_class = body[14]; + announce.clock_accuracy = static_cast(body[15]); + announce.offset_scaled_log_variance = read_u16_be(body.subspan(16U, 2U)); + announce.priority2 = body[18]; + std::copy_n(body.begin() + 19, 8, announce.grandmaster_identity.begin()); + announce.steps_removed = read_u16_be(body.subspan(27U, 2U)); + announce.time_source = static_cast(body[29]); + parsed.timestamp = timestamp; + parsed.announce = announce; + } + + parsed.message.assign(message.begin(), message.begin() + static_cast(message_length)); + parsed.body.assign(body.begin(), body.end()); + frame = std::move(parsed); + return true; +} + +bool PtpCodec::try_parse_ethernet_frame( + const std::span ethernet_frame, + PtpFrame& frame) noexcept { + if (ethernet_frame.size() < 14U) { + return false; + } + + std::size_t offset = 12U; + std::optional outer_vlan; + std::optional vlan; + auto ether_type = read_u16_be(ethernet_frame.subspan(offset, 2U)); + offset += 2U; + + if (ether_type == ptp_qinq_ethertype) { + if (ethernet_frame.size() < offset + 4U) { + return false; + } + outer_vlan = vlan_id_from_tci(read_u16_be(ethernet_frame.subspan(offset, 2U))); + ether_type = read_u16_be(ethernet_frame.subspan(offset + 2U, 2U)); + offset += 4U; + } + + if (ether_type == ptp_vlan_ethertype) { + if (ethernet_frame.size() < offset + 4U) { + return false; + } + vlan = vlan_id_from_tci(read_u16_be(ethernet_frame.subspan(offset, 2U))); + ether_type = read_u16_be(ethernet_frame.subspan(offset + 2U, 2U)); + offset += 4U; + } + + if (ether_type != ptp_ethertype || ethernet_frame.size() < offset + ptp_header_length) { + return false; + } + + PtpFrame parsed; + if (!try_parse_message(ethernet_frame.subspan(offset), parsed)) { + return false; + } + parsed.outer_vlan_id = outer_vlan; + parsed.vlan_id = vlan; + parsed.peer_delay_multicast = std::equal( + ptp_peer_delay_multicast_mac.begin(), + ptp_peer_delay_multicast_mac.end(), + ethernet_frame.begin()); + frame = std::move(parsed); + return true; +} + +} // namespace ar::iec61850::time_sync diff --git a/src/time_sync/ptp_monitor.cpp b/src/time_sync/ptp_monitor.cpp new file mode 100644 index 000000000..203a10179 --- /dev/null +++ b/src/time_sync/ptp_monitor.cpp @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include + +namespace ar::iec61850::time_sync { +namespace { + +[[nodiscard]] std::string port_identity_text(const PtpPortIdentity& identity) { + constexpr char digits[] = "0123456789ABCDEF"; + std::string text; + text.reserve(8U * 3U + 5U); + for (std::size_t index = 0U; index < identity.clock_identity.size(); ++index) { + if (index != 0U) text.push_back(':'); + const auto byte = identity.clock_identity[index]; + text.push_back(digits[(byte >> 4U) & 0x0FU]); + text.push_back(digits[byte & 0x0FU]); + } + text.push_back('/'); + text += std::to_string(identity.port_number); + return text; +} + +void add_presence_check( + std::vector& checks, + const PtpSourceClockSnapshot& source, + const PtpMessageType type, + std::string id, + std::string label) { + const auto message_count = source.count(type); + if (message_count != 0U) { + checks.push_back({ + std::move(id), + PtpHealthSeverity::ok, + std::move(label) + " messages are visible from " + port_identity_text(source.source_port_identity) + ".", + }); + } else { + checks.push_back({ + std::move(id), + PtpHealthSeverity::fail, + "No " + std::move(label) + " message is visible from selected PTP source " + + port_identity_text(source.source_port_identity) + ".", + }); + } +} + +[[nodiscard]] PtpHealthSeverity report_severity(const std::vector& checks) noexcept { + if (std::any_of(checks.begin(), checks.end(), [](const PtpHealthCheck& check) { + return check.severity == PtpHealthSeverity::fail; + })) { + return PtpHealthSeverity::fail; + } + if (std::any_of(checks.begin(), checks.end(), [](const PtpHealthCheck& check) { + return check.severity == PtpHealthSeverity::warning; + })) { + return PtpHealthSeverity::warning; + } + return PtpHealthSeverity::ok; +} + +} // namespace + +std::uint32_t PtpSourceClockSnapshot::count(const PtpMessageType type) const noexcept { + const auto index = static_cast(static_cast(type) & 0x0FU); + return message_counts[index]; +} + +PtpPassiveMonitor::PtpPassiveMonitor(const std::size_t recent_capacity) + : recent_capacity_(std::max(recent_capacity, 8U)) {} + +std::size_t PtpPassiveMonitor::message_index(const PtpMessageType type) noexcept { + return static_cast(static_cast(type) & 0x0FU); +} + +PtpPassiveMonitor::SourceState* PtpPassiveMonitor::find_source( + const std::uint8_t domain_number, + const PtpPortIdentity& source_port_identity) noexcept { + const auto iterator = std::find_if(sources_.begin(), sources_.end(), [&](const SourceState& state) { + return state.snapshot.domain_number == domain_number && + state.snapshot.source_port_identity == source_port_identity; + }); + return iterator == sources_.end() ? nullptr : &*iterator; +} + +bool PtpPassiveMonitor::observe_ethernet_frame( + const std::span ethernet_frame, + const std::chrono::system_clock::time_point observed_at) { + { + const std::scoped_lock lock{mutex_}; + ++total_frames_; + } + + PtpFrame frame; + if (!PtpCodec::try_parse_ethernet_frame(ethernet_frame, frame)) { + const std::scoped_lock lock{mutex_}; + ++invalid_frames_; + return false; + } + observe(frame, observed_at); + return true; +} + +void PtpPassiveMonitor::observe( + const PtpFrame& frame, + const std::chrono::system_clock::time_point observed_at) { + const PtpObservedMessage observed{ + observed_at, + frame.header.message_type, + frame.header.transport_specific, + frame.header.domain_number, + frame.header.source_port_identity, + frame.header.sequence_id, + frame.header.is_two_step(), + frame.vlan_id, + frame.outer_vlan_id, + frame.peer_delay_multicast, + }; + + const std::scoped_lock lock{mutex_}; + ++valid_frames_; + recent_messages_.push_back(observed); + while (recent_messages_.size() > recent_capacity_) { + recent_messages_.pop_front(); + } + + auto* source = find_source(frame.header.domain_number, frame.header.source_port_identity); + if (source == nullptr) { + SourceState state; + state.snapshot.domain_number = frame.header.domain_number; + state.snapshot.source_port_identity = frame.header.source_port_identity; + state.snapshot.first_seen_at = observed_at; + state.snapshot.last_seen_at = observed_at; + state.snapshot.transport_specific = frame.header.transport_specific; + sources_.push_back(state); + source = &sources_.back(); + } + + auto& state = source->snapshot; + state.last_seen_at = observed_at; + if (state.transport_specific.has_value() && + *state.transport_specific != frame.header.transport_specific) { + ++state.transport_specific_change_count; + } + state.transport_specific = frame.header.transport_specific; + state.vlan_id = frame.vlan_id; + state.outer_vlan_id = frame.outer_vlan_id; + const auto index = message_index(frame.header.message_type); + ++state.message_counts[index]; + if (state.last_sequence_ids[index].has_value()) { + const auto expected = static_cast( + static_cast(*state.last_sequence_ids[index]) + 1U); + if (frame.header.sequence_id != expected) { + ++state.sequence_anomaly_count; + } + } + state.last_sequence_ids[index] = frame.header.sequence_id; +} + +PtpMonitorSnapshot PtpPassiveMonitor::snapshot( + const std::chrono::system_clock::time_point captured_at) const { + const std::scoped_lock lock{mutex_}; + PtpMonitorSnapshot result; + result.captured_at = captured_at; + result.total_frames = total_frames_; + result.valid_ptp_frames = valid_frames_; + result.invalid_frames = invalid_frames_; + result.recent_messages.assign(recent_messages_.begin(), recent_messages_.end()); + result.sources.reserve(sources_.size()); + for (const auto& source : sources_) { + result.sources.push_back(source.snapshot); + } + std::sort(result.sources.begin(), result.sources.end(), [](const auto& left, const auto& right) { + if (left.domain_number != right.domain_number) { + return left.domain_number < right.domain_number; + } + return left.source_port_identity.clock_identity < right.source_port_identity.clock_identity; + }); + return result; +} + +void PtpPassiveMonitor::reset() { + const std::scoped_lock lock{mutex_}; + total_frames_ = 0U; + valid_frames_ = 0U; + invalid_frames_ = 0U; + recent_messages_.clear(); + sources_.clear(); +} + +PtpTimingHealthReport PtpTimingHealthValidator::evaluate( + const PtpMonitorSnapshot& snapshot, + const PtpTimingHealthOptions& options) { + PtpTimingHealthReport report; + report.evaluated_at = snapshot.captured_at; + report.snapshot = snapshot; + + auto& checks = report.checks; + if (!snapshot.has_ptp()) { + checks.push_back({ + "ptp.visibility", + PtpHealthSeverity::fail, + "No valid PTP frame has been observed on the selected interface.", + }); + report.severity = PtpHealthSeverity::fail; + return report; + } + + checks.push_back({ + "ptp.visibility", + PtpHealthSeverity::ok, + "Observed " + std::to_string(snapshot.valid_ptp_frames) + " valid PTP frame(s).", + }); + + std::vector active_sources; + active_sources.reserve(snapshot.sources.size()); + for (const auto& source : snapshot.sources) { + if (snapshot.captured_at >= source.last_seen_at && + snapshot.captured_at - source.last_seen_at <= options.source_timeout) { + active_sources.push_back(&source); + } + } + + if (active_sources.empty()) { + checks.push_back({ + "ptp.liveness", + PtpHealthSeverity::fail, + "No PTP source is within the configured liveness timeout.", + }); + } else { + checks.push_back({ + "ptp.liveness", + PtpHealthSeverity::ok, + std::to_string(active_sources.size()) + " active PTP source(s) are visible.", + }); + } + + if (options.expected_domain_number.has_value()) { + const auto matching = std::count_if(active_sources.begin(), active_sources.end(), [&](const auto* source) { + return source->domain_number == *options.expected_domain_number; + }); + if (matching == 0) { + checks.push_back({ + "ptp.domain", + PtpHealthSeverity::fail, + "No active source is using expected PTP domain " + + std::to_string(*options.expected_domain_number) + ".", + }); + } else { + checks.push_back({ + "ptp.domain", + PtpHealthSeverity::ok, + "PTP domain " + std::to_string(*options.expected_domain_number) + " is visible.", + }); + } + } + + if (options.expected_transport_specific.has_value()) { + const auto matching = std::count_if(active_sources.begin(), active_sources.end(), [&](const auto* source) { + return source->transport_specific.has_value() && + *source->transport_specific == *options.expected_transport_specific; + }); + if (matching == 0) { + checks.push_back({ + "ptp.transportSpecific", + PtpHealthSeverity::fail, + "No active source is using expected transportSpecific " + + std::to_string(*options.expected_transport_specific) + ".", + }); + } else { + checks.push_back({ + "ptp.transportSpecific", + PtpHealthSeverity::ok, + "Expected transportSpecific is visible.", + }); + } + } + + const PtpSourceClockSnapshot* selected = nullptr; + for (const auto* source : active_sources) { + if (options.expected_domain_number.has_value() && + source->domain_number != *options.expected_domain_number) { + continue; + } + if (options.expected_transport_specific.has_value() && + (!source->transport_specific.has_value() || + *source->transport_specific != *options.expected_transport_specific)) { + continue; + } + if (selected == nullptr || + source->count(PtpMessageType::announce) > selected->count(PtpMessageType::announce) || + (source->count(PtpMessageType::announce) == selected->count(PtpMessageType::announce) && + source->count(PtpMessageType::sync) > selected->count(PtpMessageType::sync)) || + (source->count(PtpMessageType::announce) == selected->count(PtpMessageType::announce) && + source->count(PtpMessageType::sync) == selected->count(PtpMessageType::sync) && + source->last_seen_at > selected->last_seen_at)) { + selected = source; + } + } + + if (selected == nullptr) { + report.severity = report_severity(checks); + return report; + } + report.selected_source = selected->source_port_identity; + + if (selected->transport_specific_change_count != 0U) { + checks.push_back({ + "ptp.transportSpecific.stability", + PtpHealthSeverity::warning, + "Selected source changed transportSpecific " + + std::to_string(selected->transport_specific_change_count) + " time(s) during the capture.", + }); + } else { + checks.push_back({ + "ptp.transportSpecific.stability", + PtpHealthSeverity::ok, + "Selected source transportSpecific remained stable.", + }); + } + + if (options.require_announce) { + add_presence_check(checks, *selected, PtpMessageType::announce, "ptp.announce", "Announce"); + } + if (options.require_sync) { + add_presence_check(checks, *selected, PtpMessageType::sync, "ptp.sync", "Sync"); + } + if (options.require_follow_up_for_two_step) { + add_presence_check(checks, *selected, PtpMessageType::follow_up, "ptp.followup", "Follow_Up"); + } + if (options.require_peer_delay_activity) { + const auto peer_delay_count = + selected->count(PtpMessageType::pdelay_req) + + selected->count(PtpMessageType::pdelay_resp) + + selected->count(PtpMessageType::pdelay_resp_follow_up); + checks.push_back(peer_delay_count != 0U + ? PtpHealthCheck{ + "ptp.pdelay", PtpHealthSeverity::ok, "Peer-delay activity is visible."} + : PtpHealthCheck{ + "ptp.pdelay", PtpHealthSeverity::warning, + "No peer-delay activity is visible; some power-utility profiles expect peer-delay behavior."}); + } + + if (selected->sequence_anomaly_count > options.maximum_sequence_anomalies) { + checks.push_back({ + "ptp.sequence", + PtpHealthSeverity::warning, + "Detected " + std::to_string(selected->sequence_anomaly_count) + + " PTP sequence anomaly/anomalies for " + port_identity_text(selected->source_port_identity) + ".", + }); + } else { + checks.push_back({ + "ptp.sequence", + PtpHealthSeverity::ok, + "No PTP sequence anomaly above threshold.", + }); + } + + report.severity = report_severity(checks); + return report; +} + +SmpSynchValue resolve_smp_synch( + const PtpTimingHealthReport& report, + const bool allow_local_fallback) noexcept { + if (report.is_healthy()) { + return SmpSynchValue::global_synchronized; + } + if (allow_local_fallback && report.snapshot.has_ptp()) { + return SmpSynchValue::local_synchronized; + } + return SmpSynchValue::not_synchronized; +} + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt new file mode 100644 index 000000000..36c0e4391 --- /dev/null +++ b/tests/ptp_profile/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850PtpProfile LANGUAGES CXX) + +get_filename_component(ARSTACK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) + +add_library(ptp_profile_core STATIC + "${ARSTACK_ROOT}/src/ethernet/ethernet.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp_monitor.cpp" + "${ARSTACK_ROOT}/src/capture/pcap.cpp") + +target_include_directories(ptp_profile_core PUBLIC + "${ARSTACK_ROOT}/include") +target_compile_features(ptp_profile_core PUBLIC cxx_std_20) + +add_executable(ptp_profile_tests + "${ARSTACK_ROOT}/tests/test_ptp.cpp") +target_link_libraries(ptp_profile_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_profile_tests PRIVATE cxx_std_20) + +add_executable(ptp_profile_analyze + "${ARSTACK_ROOT}/tools/ptp_analyze.cpp") +target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) +target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) + +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_profile_analyze) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endforeach() + +enable_testing() +add_test(NAME ptp_profile_tests COMMAND ptp_profile_tests) +add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) diff --git a/tests/test_ptp.cpp b/tests/test_ptp.cpp new file mode 100644 index 000000000..fb6ea0a14 --- /dev/null +++ b/tests/test_ptp.cpp @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using ByteVector = std::vector; +#define CHECK(condition) do { if (!(condition)) { throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + " at " + __FILE__ + ":" + \ + std::to_string(__LINE__)); } } while (false) + +std::string to_hex(const ByteVector& bytes) { + std::ostringstream stream; + stream << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + stream << std::setw(2) << static_cast(byte); + } + return stream.str(); +} + +ar::iec61850::time_sync::PtpBuildOptions make_options() { + using namespace ar::iec61850::time_sync; + PtpBuildOptions options; + options.domain_number = 0U; + options.source_port_identity.clock_identity = { + 0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x00U, 0x01U}; + options.source_port_identity.port_number = 1U; + options.sequence_id = 0x1234U; + options.log_message_interval = -2; + options.two_step = true; + options.timestamp = {0x010203040506ULL, 123456789U}; + return options; +} + +void sync_matches_csharp_oracle_and_roundtrips() { + using namespace ar::iec61850::time_sync; + const auto encoded = PtpCodec::build_sync(make_options()); + CHECK(to_hex(encoded) == + "0002002C00000200000000000000000000000000020000FFFE0000010001123400FE" + "010203040506075BCD15"); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(encoded, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::sync); + CHECK(decoded.header.version == 2U); + CHECK(decoded.header.message_length == 44U); + CHECK(decoded.header.transport_specific == 0U); + CHECK(decoded.header.domain_number == 0U); + CHECK(decoded.header.is_two_step()); + CHECK(decoded.header.sequence_id == 0x1234U); + CHECK(decoded.header.log_message_interval == -2); + CHECK(decoded.timestamp.has_value()); + CHECK(decoded.timestamp->seconds == 0x010203040506ULL); + CHECK(decoded.timestamp->nanoseconds == 123456789U); +} + +void announce_matches_csharp_oracle_and_roundtrips() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.sequence_id = 7U; + options.log_message_interval = 0; + options.two_step = false; + options.timestamp = {}; + const auto encoded = PtpCodec::build_announce(options, 37); + CHECK(to_hex(encoded) == + "0B02004000000000000000000000000000000000020000FFFE000001000100070500" + "0000000000000000000000250080F8FEFFFF80020000FFFE0000010000A0"); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(encoded, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::announce); + CHECK(decoded.announce.has_value()); + CHECK(decoded.announce->current_utc_offset == 37); + CHECK(decoded.announce->priority1 == 128U); + CHECK(decoded.announce->clock_class == 248U); + CHECK(decoded.announce->clock_accuracy == PtpClockAccuracy::unknown); + CHECK(decoded.announce->time_source == PtpTimeSource::internal_oscillator); + CHECK(decoded.announce->grandmaster_identity == options.source_port_identity.clock_identity); +} + +void pdelay_response_preserves_request_identity_and_hardware_timestamp_fields() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.sequence_id = 0x0055U; + options.log_message_interval = -3; + options.two_step = true; + options.timestamp = {0x000102030405ULL, 222333444U}; + + const PtpPortIdentity requesting_port{ + {0x10U, 0x20U, 0x30U, 0x40U, 0x50U, 0x60U, 0x70U, 0x80U}, + 2U, + }; + + const auto response = PtpCodec::build_pdelay_resp(options, requesting_port); + CHECK(response.size() == 54U); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(response, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::pdelay_resp); + CHECK(decoded.header.sequence_id == 0x0055U); + CHECK(decoded.header.log_message_interval == -3); + CHECK(decoded.header.is_two_step()); + CHECK(decoded.body.size() == 20U); + + PtpTimestamp request_receipt_timestamp; + CHECK(PtpTimestamp::try_read( + std::span{decoded.body}.first(10U), + request_receipt_timestamp)); + CHECK(request_receipt_timestamp == options.timestamp); + CHECK(std::equal( + requesting_port.clock_identity.begin(), + requesting_port.clock_identity.end(), + decoded.body.begin() + 10)); + CHECK(decoded.body[18] == 0x00U); + CHECK(decoded.body[19] == 0x02U); + + auto follow_up_options = options; + follow_up_options.two_step = false; + follow_up_options.timestamp = {0x000102030406ULL, 555666777U}; + const auto follow_up = PtpCodec::build_pdelay_resp_follow_up( + follow_up_options, + requesting_port); + CHECK(follow_up.size() == 54U); + CHECK(PtpCodec::try_parse_message(follow_up, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::pdelay_resp_follow_up); + CHECK(decoded.header.sequence_id == 0x0055U); + CHECK(!decoded.header.is_two_step()); + + PtpTimestamp response_origin_timestamp; + CHECK(PtpTimestamp::try_read( + std::span{decoded.body}.first(10U), + response_origin_timestamp)); + CHECK(response_origin_timestamp == follow_up_options.timestamp); + CHECK(std::equal( + requesting_port.clock_identity.begin(), + requesting_port.clock_identity.end(), + decoded.body.begin() + 10)); +} + +void ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.transport_specific = 1U; + const auto message = PtpCodec::build_sync(options); + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + const auto vlan_frame = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, + source, + message, + std::uint16_t{100}, + std::uint8_t{4}); + CHECK(!vlan_frame.empty()); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_ethernet_frame(vlan_frame, decoded)); + CHECK(decoded.header.transport_specific == 1U); + CHECK(decoded.vlan_id.has_value()); + CHECK(*decoded.vlan_id == std::uint16_t{100}); + CHECK(!decoded.outer_vlan_id.has_value()); + CHECK(!decoded.peer_delay_multicast); + + ByteVector qinq; + qinq.reserve(vlan_frame.size() + 4U); + qinq.insert(qinq.end(), vlan_frame.begin(), vlan_frame.begin() + 12); + qinq.push_back(0x88U); + qinq.push_back(0xA8U); + qinq.push_back(0xA0U); // PCP 5, VID 10 + qinq.push_back(0x0AU); + qinq.insert(qinq.end(), vlan_frame.begin() + 12, vlan_frame.end()); + + CHECK(PtpCodec::try_parse_ethernet_frame(qinq, decoded)); + CHECK(decoded.header.transport_specific == 1U); + CHECK(decoded.outer_vlan_id.has_value()); + CHECK(*decoded.outer_vlan_id == std::uint16_t{10}); + CHECK(decoded.vlan_id.has_value()); + CHECK(*decoded.vlan_id == std::uint16_t{100}); + CHECK(decoded.header.sequence_id == 0x1234U); +} + +void passive_monitor_health_drives_conservative_smp_synch_policy() { + using namespace ar::iec61850::time_sync; + const auto observed_at = std::chrono::system_clock::time_point{std::chrono::seconds{1000}}; + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + auto options = make_options(); + options.timestamp = {}; + options.log_message_interval = 0; + options.sequence_id = 1U; + options.two_step = false; + const auto announce = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_announce(options)); + + options.two_step = true; + const auto sync = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_sync(options)); + options.two_step = false; + const auto follow_up = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_follow_up(options)); + + PtpPassiveMonitor monitor; + CHECK(monitor.observe_ethernet_frame(announce, observed_at)); + CHECK(monitor.observe_ethernet_frame(sync, observed_at + std::chrono::milliseconds{10})); + CHECK(monitor.observe_ethernet_frame(follow_up, observed_at + std::chrono::milliseconds{20})); + + const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{100}); + CHECK(snapshot.sources.size() == 1U); + CHECK(snapshot.sources[0].transport_specific.has_value()); + CHECK(*snapshot.sources[0].transport_specific == std::uint8_t{0}); + CHECK(snapshot.sources[0].transport_specific_change_count == 0U); + + PtpTimingHealthOptions health_options; + health_options.expected_domain_number = std::uint8_t{0}; + health_options.expected_transport_specific = std::uint8_t{0}; + const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); + CHECK(report.is_healthy()); + CHECK(report.selected_source.has_value()); + CHECK(resolve_smp_synch(report) == SmpSynchValue::global_synchronized); + + PtpPassiveMonitor incomplete_monitor; + CHECK(incomplete_monitor.observe_ethernet_frame(sync, observed_at)); + const auto incomplete_report = PtpTimingHealthValidator::evaluate( + incomplete_monitor.snapshot(observed_at + std::chrono::milliseconds{100}), + health_options); + CHECK(!incomplete_report.is_healthy()); + CHECK(resolve_smp_synch(incomplete_report, true) == SmpSynchValue::local_synchronized); + CHECK(resolve_smp_synch(incomplete_report, false) == SmpSynchValue::not_synchronized); +} + +void transport_specific_change_is_visible_as_health_warning() { + using namespace ar::iec61850::time_sync; + const auto observed_at = std::chrono::system_clock::time_point{std::chrono::seconds{2000}}; + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + auto options = make_options(); + options.sequence_id = 1U; + options.two_step = false; + options.timestamp = {}; + const auto announce = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_announce(options)); + CHECK(!announce.empty()); + + options.two_step = true; + const auto sync = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_sync(options)); + CHECK(!sync.empty()); + + options.transport_specific = 1U; + options.two_step = false; + const auto follow_up_changed = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_follow_up(options)); + CHECK(!follow_up_changed.empty()); + + PtpPassiveMonitor monitor; + CHECK(monitor.observe_ethernet_frame(announce, observed_at)); + CHECK(monitor.observe_ethernet_frame(sync, observed_at + std::chrono::milliseconds{10})); + CHECK(monitor.observe_ethernet_frame( + follow_up_changed, + observed_at + std::chrono::milliseconds{20})); + + const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{50}); + CHECK(snapshot.sources.size() == 1U); + CHECK(snapshot.sources[0].transport_specific.has_value()); + CHECK(*snapshot.sources[0].transport_specific == std::uint8_t{1}); + CHECK(snapshot.sources[0].transport_specific_change_count == 1U); + + PtpTimingHealthOptions health_options; + health_options.expected_domain_number = std::uint8_t{0}; + const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); + CHECK(report.severity == PtpHealthSeverity::warning); + CHECK(resolve_smp_synch(report, true) == SmpSynchValue::local_synchronized); +} + +void malformed_or_non_ptp_frames_are_rejected() { + using namespace ar::iec61850::time_sync; + PtpFrame frame; + CHECK(!PtpCodec::try_parse_message(ByteVector(33U, 0U), frame)); + + auto message = PtpCodec::build_sync(make_options()); + message[1] = 0x03U; + CHECK(!PtpCodec::try_parse_message(message, frame)); + + std::array ethernet{}; + ethernet[12] = 0x88U; + ethernet[13] = 0xBAU; + CHECK(!PtpCodec::try_parse_ethernet_frame(ethernet, frame)); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"PTP Sync C# oracle", sync_matches_csharp_oracle_and_roundtrips}, + {"PTP Announce C# oracle", announce_matches_csharp_oracle_and_roundtrips}, + {"PTP Pdelay response wire fields", pdelay_response_preserves_request_identity_and_hardware_timestamp_fields}, + {"PTP VLAN/QinQ/transportSpecific analyzer parser", ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use}, + {"PTP monitor health and smpSynch", passive_monitor_health_drives_conservative_smp_synch_policy}, + {"PTP transportSpecific stability", transport_specific_change_is_visible_as_health_warning}, + {"PTP malformed input", malformed_or_non_ptp_frames_are_rejected}, + }; + + std::size_t passed = 0U; + for (const auto& [name, test] : tests) { + try { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } catch (const std::exception& error) { + std::cerr << "[FAIL] " << name << ": " << error.what() << '\n'; + return 1; + } + } + std::cout << "Passed " << passed << "/" << tests.size() << " PTP tests.\n"; + return 0; +} diff --git a/tools/ptp_analyze.cpp b/tools/ptp_analyze.cpp new file mode 100644 index 000000000..5b2450b40 --- /dev/null +++ b/tools/ptp_analyze.cpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::time_sync::PtpHealthSeverity; +using ar::iec61850::time_sync::PtpMessageType; +using ar::iec61850::time_sync::PtpPortIdentity; + +[[nodiscard]] const char* severity_text(const PtpHealthSeverity severity) noexcept { + switch (severity) { + case PtpHealthSeverity::ok: return "ok"; + case PtpHealthSeverity::warning: return "warning"; + case PtpHealthSeverity::fail: return "fail"; + } + return "unknown"; +} + +[[nodiscard]] std::string port_identity_text(const PtpPortIdentity& identity) { + std::ostringstream stream; + stream << std::uppercase << std::hex << std::setfill('0'); + for (std::size_t index = 0U; index < identity.clock_identity.size(); ++index) { + if (index != 0U) stream << ':'; + stream << std::setw(2) << static_cast(identity.clock_identity[index]); + } + stream << std::dec << '/' << identity.port_number; + return stream.str(); +} + +[[nodiscard]] std::uint8_t parse_byte_range( + const std::string_view text, + const unsigned maximum, + const char* label) { + std::size_t consumed{}; + const auto value = std::stoul(std::string{text}, &consumed, 0); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument( + std::string{label} + " must be in the range 0.." + std::to_string(maximum) + "."); + } + return static_cast(value); +} + +void print_help() { + std::cout + << "Usage: ariec61850_ptp_analyze [--domain N] [--transport-specific N] [--json]\n" + << "\n" + << "Read-only PTPv2 Layer-2 analyzer for laboratory captures.\n" + << "It reports source/domain/transportSpecific/message visibility and sequence health;\n" + << "it does not claim measured clock accuracy from host capture timestamps.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 2) { + print_help(); + return 2; + } + + std::filesystem::path capture_path; + std::optional expected_domain; + std::optional expected_transport_specific; + bool json = false; + + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + print_help(); + return 0; + } + if (argument == "--json") { + json = true; + continue; + } + if (argument == "--domain") { + if (index + 1 >= argc) { + throw std::invalid_argument("--domain requires a value."); + } + expected_domain = parse_byte_range(argv[++index], 255U, "PTP domain"); + continue; + } + if (argument == "--transport-specific") { + if (index + 1 >= argc) { + throw std::invalid_argument("--transport-specific requires a value."); + } + expected_transport_specific = parse_byte_range( + argv[++index], + 15U, + "transportSpecific"); + continue; + } + if (!capture_path.empty()) { + throw std::invalid_argument("Unexpected argument: " + std::string{argument}); + } + capture_path = std::filesystem::path{argument}; + } + + if (capture_path.empty()) { + throw std::invalid_argument("A PCAP capture path is required."); + } + + const auto packets = ar::iec61850::capture::PcapReader::read_all(capture_path); + ar::iec61850::time_sync::PtpPassiveMonitor monitor; + std::uint64_t ptp_frames = 0U; + std::uint64_t other_frames = 0U; + + for (const auto& packet : packets) { + ar::iec61850::time_sync::PtpFrame frame; + if (ar::iec61850::time_sync::PtpCodec::try_parse_ethernet_frame(packet.frame, frame)) { + monitor.observe(frame, packet.timestamp); + ++ptp_frames; + } else { + ++other_frames; + } + } + + const auto captured_at = packets.empty() + ? std::chrono::system_clock::now() + : packets.back().timestamp; + const auto snapshot = monitor.snapshot(captured_at); + ar::iec61850::time_sync::PtpTimingHealthOptions options; + options.expected_domain_number = expected_domain; + options.expected_transport_specific = expected_transport_specific; + const auto report = ar::iec61850::time_sync::PtpTimingHealthValidator::evaluate(snapshot, options); + + if (json) { + std::cout << '{' + << "\"capturePackets\":" << packets.size() << ',' + << "\"ptpFrames\":" << ptp_frames << ',' + << "\"otherFrames\":" << other_frames << ',' + << "\"health\":\"" << severity_text(report.severity) << "\"," + << "\"sources\":["; + for (std::size_t index = 0U; index < snapshot.sources.size(); ++index) { + if (index != 0U) std::cout << ','; + const auto& source = snapshot.sources[index]; + std::cout << '{' + << "\"domain\":" << static_cast(source.domain_number) << ',' + << "\"portIdentity\":\"" << port_identity_text(source.source_port_identity) << "\"," + << "\"announce\":" << source.count(PtpMessageType::announce) << ',' + << "\"sync\":" << source.count(PtpMessageType::sync) << ',' + << "\"followUp\":" << source.count(PtpMessageType::follow_up) << ',' + << "\"pdelayReq\":" << source.count(PtpMessageType::pdelay_req) << ',' + << "\"pdelayResp\":" << source.count(PtpMessageType::pdelay_resp) << ',' + << "\"pdelayRespFollowUp\":" << source.count(PtpMessageType::pdelay_resp_follow_up) << ',' + << "\"sequenceAnomalies\":" << source.sequence_anomaly_count << ',' + << "\"transportSpecificChanges\":" << source.transport_specific_change_count; + if (source.transport_specific.has_value()) { + std::cout << ",\"transportSpecific\":" + << static_cast(*source.transport_specific); + } + if (source.outer_vlan_id.has_value()) { + std::cout << ",\"outerVlan\":" << *source.outer_vlan_id; + } + if (source.vlan_id.has_value()) { + std::cout << ",\"vlan\":" << *source.vlan_id; + } + std::cout << '}'; + } + std::cout << "],\"checks\":["; + for (std::size_t index = 0U; index < report.checks.size(); ++index) { + if (index != 0U) std::cout << ','; + const auto& check = report.checks[index]; + std::cout << '{' + << "\"id\":\"" << check.id << "\"," + << "\"severity\":\"" << severity_text(check.severity) << "\""; + std::cout << '}'; + } + std::cout << "]}\n"; + return report.severity == PtpHealthSeverity::fail ? 1 : 0; + } + + std::cout << "PTP capture: packets=" << packets.size() + << " ptp=" << ptp_frames + << " other=" << other_frames + << " health=" << severity_text(report.severity) << '\n'; + for (const auto& source : snapshot.sources) { + std::cout << " domain=" << static_cast(source.domain_number) + << " transportSpecific="; + if (source.transport_specific.has_value()) { + std::cout << "0x" << std::hex + << static_cast(*source.transport_specific) + << std::dec; + } else { + std::cout << "unknown"; + } + std::cout << " source=" << port_identity_text(source.source_port_identity) + << " announce=" << source.count(PtpMessageType::announce) + << " sync=" << source.count(PtpMessageType::sync) + << " followUp=" << source.count(PtpMessageType::follow_up) + << " pdelayReq=" << source.count(PtpMessageType::pdelay_req) + << " pdelayResp=" << source.count(PtpMessageType::pdelay_resp) + << " seqAnomaly=" << source.sequence_anomaly_count + << " tsChanges=" << source.transport_specific_change_count + << '\n'; + } + for (const auto& check : report.checks) { + std::cout << " [" << severity_text(check.severity) << "] " + << check.id << ": " << check.message << '\n'; + } + return report.severity == PtpHealthSeverity::fail ? 1 : 0; + } catch (const std::exception& error) { + std::cerr << "PTP analyzer error: " << error.what() << '\n'; + return 2; + } +} From 68c933fbecd52b84af9735e35c5652ca15b16295 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 20:37:44 +0700 Subject: [PATCH 120/129] PTP-P1.5: complete portable runtime and ESP control bridge Complete the portable PTP publisher runtime/control layer, refactor ESP32-P4 into a hardware timestamp adapter, add runtime config/status APIs and full publisher profile controls, and preserve fail-safe smpSynch=0 pending PTP-P2 clock discipline evidence. --- .github/workflows/esp32p4-ptp-lab.yml | 71 ++- .github/workflows/ptp-core-ci.yml | 37 +- docs/PTP_RUNTIME_P1_5.md | 151 +++++ .../main/Kconfig.projbuild | 97 ++- .../main/ptp_lab_task.cpp | 567 ++++++++++++------ .../main/ptp_lab_task.hpp | 54 ++ include/ariec61850/time_sync/ptp_runtime.hpp | 479 +++++++++++++++ tests/ptp_profile/CMakeLists.txt | 8 +- tests/test_ptp_runtime.cpp | 240 ++++++++ 9 files changed, 1466 insertions(+), 238 deletions(-) create mode 100644 docs/PTP_RUNTIME_P1_5.md create mode 100644 include/ariec61850/time_sync/ptp_runtime.hpp create mode 100644 tests/test_ptp_runtime.cpp diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 5cfa867da..36359fd90 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -36,42 +36,51 @@ jobs: - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 - - name: Enable guarded non-default PTP lab profile + - name: Enable guarded non-default PTP-P1.5 runtime profile shell: bash run: | - if grep -q '^# CONFIG_AR_PTP_LAB_TX is not set$' sdkconfig; then - sed -i 's/^# CONFIG_AR_PTP_LAB_TX is not set$/CONFIG_AR_PTP_LAB_TX=y/' sdkconfig - elif ! grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig; then - echo 'CONFIG_AR_PTP_LAB_TX=y' >> sdkconfig - fi + set_config() { + local key="$1" + local value="$2" + if grep -q "^${key}=" sdkconfig; then + sed -i "s|^${key}=.*|${key}=${value}|" sdkconfig + elif grep -q "^# ${key} is not set$" sdkconfig && [ "$value" = "y" ]; then + sed -i "s|^# ${key} is not set$|${key}=y|" sdkconfig + else + echo "${key}=${value}" >> sdkconfig + fi + } - if grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=' sdkconfig; then - sed -i 's/^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=.*/CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1/' sdkconfig - else - echo 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1' >> sdkconfig - fi - - if grep -q '^# CONFIG_AR_PTP_VLAN is not set$' sdkconfig; then - sed -i 's/^# CONFIG_AR_PTP_VLAN is not set$/CONFIG_AR_PTP_VLAN=y/' sdkconfig - elif ! grep -q '^CONFIG_AR_PTP_VLAN=y$' sdkconfig; then - echo 'CONFIG_AR_PTP_VLAN=y' >> sdkconfig - fi - - if grep -q '^CONFIG_AR_PTP_VLAN_ID=' sdkconfig; then - sed -i 's/^CONFIG_AR_PTP_VLAN_ID=.*/CONFIG_AR_PTP_VLAN_ID=100/' sdkconfig - else - echo 'CONFIG_AR_PTP_VLAN_ID=100' >> sdkconfig - fi - - if grep -q '^CONFIG_AR_PTP_VLAN_PRIORITY=' sdkconfig; then - sed -i 's/^CONFIG_AR_PTP_VLAN_PRIORITY=.*/CONFIG_AR_PTP_VLAN_PRIORITY=4/' sdkconfig - else - echo 'CONFIG_AR_PTP_VLAN_PRIORITY=4' >> sdkconfig - fi + set_config CONFIG_AR_PTP_LAB_TX y + set_config CONFIG_AR_PTP_DOMAIN 0 + set_config CONFIG_AR_PTP_TRANSPORT_SPECIFIC 1 + set_config CONFIG_AR_PTP_PORT_NUMBER 2 + set_config CONFIG_AR_PTP_VLAN y + set_config CONFIG_AR_PTP_VLAN_ID 100 + set_config CONFIG_AR_PTP_VLAN_PRIORITY 4 + set_config CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS 1000 + set_config CONFIG_AR_PTP_SYNC_INTERVAL_MS 250 + set_config CONFIG_AR_PTP_RESPOND_PDELAY y + set_config CONFIG_AR_PTP_PRIORITY1 120 + set_config CONFIG_AR_PTP_PRIORITY2 121 + set_config CONFIG_AR_PTP_CLOCK_CLASS 248 + set_config CONFIG_AR_PTP_CLOCK_ACCURACY 254 + set_config CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE 65535 + set_config CONFIG_AR_PTP_TIME_SOURCE 160 + set_config CONFIG_AR_PTP_CURRENT_UTC_OFFSET 37 source /opt/esp/idf/export.sh idf.py reconfigure - grep -E '^CONFIG_AR_PTP_(LAB_TX|DOMAIN|TRANSPORT_SPECIFIC|VLAN|VLAN_ID|VLAN_PRIORITY|ANNOUNCE_INTERVAL_MS|SYNC_INTERVAL_MS)=' sdkconfig - - name: Build PTP-enabled firmware + grep -E '^CONFIG_AR_PTP_(LAB_TX|DOMAIN|TRANSPORT_SPECIFIC|PORT_NUMBER|VLAN|VLAN_ID|VLAN_PRIORITY|ANNOUNCE_INTERVAL_MS|SYNC_INTERVAL_MS|RESPOND_PDELAY|PRIORITY1|PRIORITY2|CLOCK_CLASS|CLOCK_ACCURACY|OFFSET_SCALED_LOG_VARIANCE|TIME_SOURCE|CURRENT_UTC_OFFSET)=' sdkconfig + + grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig + grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1$' sdkconfig + grep -q '^CONFIG_AR_PTP_PORT_NUMBER=2$' sdkconfig + grep -q '^CONFIG_AR_PTP_VLAN_ID=100$' sdkconfig + grep -q '^CONFIG_AR_PTP_PRIORITY1=120$' sdkconfig + grep -q '^CONFIG_AR_PTP_PRIORITY2=121$' sdkconfig + grep -q '^CONFIG_AR_PTP_RESPOND_PDELAY=y$' sdkconfig + + - name: Build PTP-P1.5 enabled firmware run: source /opt/esp/idf/export.sh && idf.py build diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 40aa795bf..12ab038ec 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -10,6 +10,7 @@ on: - 'src/capture/pcap.cpp' - 'tools/ptp_analyze.cpp' - 'tests/test_ptp.cpp' + - 'tests/test_ptp_runtime.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' pull_request: @@ -21,6 +22,7 @@ on: - 'src/capture/pcap.cpp' - 'tools/ptp_analyze.cpp' - 'tests/test_ptp.cpp' + - 'tests/test_ptp_runtime.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' @@ -59,18 +61,32 @@ jobs: tests/test_ptp.cpp \ -o ptp-core-tests \ >ptp-build.log 2>&1 - status=$? + core_status=$? + + "$CXX" \ + -std=c++20 \ + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror \ + -pthread \ + -Iinclude \ + src/ethernet/ethernet.cpp \ + src/time_sync/ptp.cpp \ + tests/test_ptp_runtime.cpp \ + -o ptp-runtime-tests \ + >ptp-runtime-build.log 2>&1 + runtime_status=$? set -e cat ptp-build.log - if [ "$status" -ne 0 ]; then - grep -Ei 'error:|fatal error:|undefined reference|collect2: error|ld: error' ptp-build.log | while IFS= read -r line; do + cat ptp-runtime-build.log + if [ "$core_status" -ne 0 ] || [ "$runtime_status" -ne 0 ]; then + cat ptp-build.log ptp-runtime-build.log | grep -Ei 'error:|fatal error:|undefined reference|collect2: error|ld: error' | while IFS= read -r line; do escaped=${line//'%'/'%25'} escaped=${escaped//$'\r'/'%0D'} escaped=${escaped//$'\n'/'%0A'} echo "::error::$escaped" done || true - exit "$status" + [ "$core_status" -ne 0 ] && exit "$core_status" + exit "$runtime_status" fi - name: Run PTP regression tests @@ -78,17 +94,21 @@ jobs: run: | set +e ./ptp-core-tests >ptp-test.log 2>&1 - status=$? + core_status=$? + ./ptp-runtime-tests >ptp-runtime-test.log 2>&1 + runtime_status=$? set -e cat ptp-test.log - if [ "$status" -ne 0 ]; then - grep -E '\[FAIL\]|CHECK failed|error' ptp-test.log | while IFS= read -r line; do + cat ptp-runtime-test.log + if [ "$core_status" -ne 0 ] || [ "$runtime_status" -ne 0 ]; then + cat ptp-test.log ptp-runtime-test.log | grep -E '\[FAIL\]|CHECK failed|error' | while IFS= read -r line; do escaped=${line//'%'/'%25'} escaped=${escaped//$'\r'/'%0D'} escaped=${escaped//$'\n'/'%0A'} echo "::error::$escaped" done || true - exit "$status" + [ "$core_status" -ne 0 ] && exit "$core_status" + exit "$runtime_status" fi portable-ptp-msvc: @@ -127,6 +147,7 @@ jobs: Build-Target 'ptp_profile_core' Build-Target 'ptp_profile_tests' + Build-Target 'ptp_runtime_tests' Build-Target 'ptp_profile_analyze' - name: Run PTP regression and analyzer smoke tests on MSVC diff --git a/docs/PTP_RUNTIME_P1_5.md b/docs/PTP_RUNTIME_P1_5.md new file mode 100644 index 000000000..51cb5965e --- /dev/null +++ b/docs/PTP_RUNTIME_P1_5.md @@ -0,0 +1,151 @@ +# PTP-P1.5 Runtime Completion + +PTP-P1.5 turns the PTP-P1 wire engine into a reusable runtime/control layer while preserving the ESP32-P4 hardware timestamp path. + +This remains a laboratory timing companion for troubleshooting and interoperability. It is not a GPS-backed clock, a certified grandmaster, or a formal IEC/IEEE 61850-9-3 conformance claim. + +## Portable runtime + +`include/ariec61850/time_sync/ptp_runtime.hpp` provides a platform-neutral PTP publisher runtime on top of the existing codec. + +The runtime owns: + +- publisher profile/configuration +- ClockIdentity parse/format and EUI-64-style MAC derivation +- lifecycle (`start`, `stop`, stopped-only `reconfigure`) +- Announce/Sync due scheduling +- per-message sequence counters +- Announce, Sync, Follow_Up, Pdelay_Resp and Pdelay_Resp_Follow_Up frame preparation +- publisher status and send/error accounting + +The runtime intentionally does not own: + +- an operating-system thread +- a NIC or transport driver +- a PTP clock servo +- absolute UTC traceability +- lock/holdover state +- `smpSynch` promotion + +Those boundaries keep the core portable across desktop, embedded and analyzer applications. + +## Portable publisher options + +`PtpPublisherOptions` covers the relevant ARIEC61850 publisher profile: + +- `transport_specific` +- `domain_number` +- source MAC +- optional VLAN ID / PCP +- ClockIdentity and port number +- Announce / Sync intervals +- two-step policy +- peer-delay response policy +- priority1 / priority2 +- clockClass / clockAccuracy +- offsetScaledLogVariance +- timeSource +- currentUtcOffset +- stepsRemoved + +Reconfiguration is deliberately rejected while the runtime is running. A control plane must perform a safe `stop -> reconfigure -> start` transaction instead of mutating live publisher state. + +## Publisher status + +`PtpPublisherStatus` exposes: + +- running state +- start time +- last send time +- Announce sent +- Sync sent +- Follow_Up sent +- peer-delay response frames sent +- last runtime error + +This is the reusable status contract for future GUI and analyzer integrations. + +## ESP32-P4 hardware adapter + +`embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp` is now an adapter around `PtpPublisherRuntime` rather than a second PTP runtime implementation. + +The adapter remains responsible only for ESP-specific work: + +- FreeRTOS task scheduling on CPU0, separate from the realtime SV task +- ESP-IDF Ethernet transport +- enabling the ESP32-P4 IEEE1588 hardware clock +- hardware TX descriptor timestamp capture for Sync and Pdelay_Resp +- hardware RX descriptor timestamp capture for Pdelay_Req +- RX queueing and task notification + +Two-step timestamp evidence remains: + +- Sync carries zero originTimestamp +- actual Sync hardware TX timestamp becomes Follow_Up preciseOriginTimestamp +- Pdelay_Req hardware RX timestamp becomes Pdelay_Resp requestReceiptTimestamp (`t2`) +- Pdelay_Resp hardware TX timestamp becomes Pdelay_Resp_Follow_Up responseOriginTimestamp (`t3`) + +The hardware implementation therefore remains stronger than the original desktop ARIEC61850 software-timestamped publisher. + +## ESP runtime control bridge + +The C-compatible adapter API is designed so a future GUI, USB/RPC control task, or embedded application can control PTP without recompiling the PTP core. + +Available operations: + +- `ar_ptp_lab_get_default_config` +- `ar_ptp_lab_configure` +- `ar_ptp_lab_get_config` +- `ar_ptp_lab_start` +- `ar_ptp_lab_stop` +- `ar_ptp_lab_is_running` +- `ar_ptp_lab_get_status` + +Runtime configuration is accepted only while PTP is stopped. The live status bridge exposes lock-free counters for Announce, Sync, Follow_Up, peer-delay response frames and TX failures. + +Kconfig remains the firmware default profile. A runtime configuration override can replace it before the task is started. + +## ESP Kconfig defaults + +The ESP32-P4 profile now exposes: + +- PTP enable +- domain +- transportSpecific +- optional ClockIdentity override +- port number +- optional VLAN / VID / PCP +- Announce interval +- Sync interval +- Pdelay response enable +- priority1 / priority2 +- clockClass +- clockAccuracy +- offsetScaledLogVariance +- timeSource +- currentUtcOffset + +Defaults remain intentionally conservative: clockClass 248, clockAccuracy Unknown, variance `0xFFFF`, timeSource Internal Oscillator. + +## Validation + +PTP-P1.5 has dedicated portable regression coverage for: + +- ClockIdentity parsing/formatting +- profile validation +- stopped-only reconfiguration +- due scheduling +- per-message sequence behavior +- status accounting +- two-step Sync/Follow_Up +- one-step suppression of Follow_Up +- Pdelay request sequence/log interval preservation +- supplied `t2` / `t3` timestamp placement + +The strict PTP profile is built on GCC, Clang and MSVC with warnings treated as errors. The ESP workflow builds a non-default PTP profile on ESP-IDF 5.5.4, while the normal ESP32-P4 SMV firmware build verifies that PTP remains default-off and does not regress the injector. + +## Safety boundary + +PTP-P1.5 does **not** change Sampled Values synchronization claims. The generic SV default remains `smpSynch=0`, and the ESP injector must not promote it merely because PTP frames are being transmitted or observed. + +Promotion to `smpSynch=1/2` belongs to PTP-P2 after measured clock discipline, offset/delay qualification, lock/holdover state, and explicit policy evidence exist. diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 80be5cd1a..9deebe0fa 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -62,9 +62,6 @@ config AR_PTP_DOMAIN range 0 255 default 0 depends on AR_PTP_LAB_TX - help - Domain number written into Announce, Sync, Follow_Up and Pdelay - response messages. config AR_PTP_TRANSPORT_SPECIFIC int "PTP transportSpecific nibble" @@ -73,23 +70,31 @@ config AR_PTP_TRANSPORT_SPECIFIC depends on AR_PTP_LAB_TX help Upper-nibble transportSpecific value written into PTP messageType. - Keep the default 0 for the ARIEC61850/default IEEE 1588 lab behavior. - Change it only when the tested relay/profile requires another value; - strict PTP receivers may reject a transportSpecific mismatch. + Strict receivers may reject a profile mismatch. + +config AR_PTP_CLOCK_IDENTITY + string "PTP clockIdentity override (empty = derive from MAC)" + default "" + depends on AR_PTP_LAB_TX + help + Optional 8-byte identity such as 02:11:22:FF:FE:33:44:55. Leave empty + to derive an EUI-64-style identity from the ESP32-P4 Ethernet MAC. + +config AR_PTP_PORT_NUMBER + int "PTP port number" + range 1 65535 + default 1 + depends on AR_PTP_LAB_TX config AR_PTP_VLAN bool "Tag PTP lab traffic with IEEE 802.1Q VLAN" default n depends on AR_PTP_LAB_TX - help - Tags Announce, Sync/Follow_Up and Pdelay responses. Leave disabled for - the default untagged C#-oracle behavior. Enable only when the isolated - process-bus test network or target relay expects tagged PTP traffic. config AR_PTP_VLAN_ID int "PTP VLAN ID" - range 0 4095 - default 0 + range 1 4094 + default 100 depends on AR_PTP_LAB_TX && AR_PTP_VLAN config AR_PTP_VLAN_PRIORITY @@ -97,18 +102,12 @@ config AR_PTP_VLAN_PRIORITY range 0 7 default 4 depends on AR_PTP_LAB_TX && AR_PTP_VLAN - help - IEEE 802.1Q priority code point for tagged PTP lab traffic. PCP 4 keeps - parity with the existing ARIEC61850 PTP publisher option default. config AR_PTP_ANNOUNCE_INTERVAL_MS int "PTP Announce interval (ms)" range 100 10000 default 1000 depends on AR_PTP_LAB_TX - help - Interval for lab Announce traffic. The default matches the existing - ARIEC61850/ARSVIN laboratory publisher behavior. config AR_PTP_SYNC_INTERVAL_MS int "PTP Sync interval (ms)" @@ -116,8 +115,66 @@ config AR_PTP_SYNC_INTERVAL_MS default 250 depends on AR_PTP_LAB_TX help - Interval for two-step Sync/Follow_Up traffic. Follow_Up carries the - ESP32-P4 EMAC transmit timestamp when the IDF 5.x adapter is available. + ESP32-P4 uses two-step Sync/Follow_Up so Follow_Up can carry the exact + EMAC transmit timestamp rather than a software send-time estimate. + +config AR_PTP_RESPOND_PDELAY + bool "Respond to peer-delay requests" + default y + depends on AR_PTP_LAB_TX + help + Uses the ESP32-P4 RX descriptor timestamp as t2 and the Pdelay_Resp TX + descriptor timestamp as t3 for Pdelay_Resp_Follow_Up. + +config AR_PTP_PRIORITY1 + int "PTP priority1" + range 0 255 + default 128 + depends on AR_PTP_LAB_TX + +config AR_PTP_PRIORITY2 + int "PTP priority2" + range 0 255 + default 128 + depends on AR_PTP_LAB_TX + +config AR_PTP_CLOCK_CLASS + int "PTP clockClass" + range 0 255 + default 248 + depends on AR_PTP_LAB_TX + help + Default 248 deliberately describes a conservative lab clock rather than + falsely claiming a GPS-disciplined or traceable grandmaster. + +config AR_PTP_CLOCK_ACCURACY + int "PTP clockAccuracy octet" + range 0 255 + default 254 + depends on AR_PTP_LAB_TX + help + 254 (0xFE) is Unknown and is the conservative default. + +config AR_PTP_OFFSET_SCALED_LOG_VARIANCE + int "PTP offsetScaledLogVariance" + range 0 65535 + default 65535 + depends on AR_PTP_LAB_TX + +config AR_PTP_TIME_SOURCE + int "PTP timeSource octet" + range 0 255 + default 160 + depends on AR_PTP_LAB_TX + help + 160 (0xA0) is Internal Oscillator. Do not select GPS unless a future + disciplined external time source actually provides that evidence. + +config AR_PTP_CURRENT_UTC_OFFSET + int "PTP currentUtcOffset" + range -32768 32767 + default 37 + depends on AR_PTP_LAB_TX config AR_SMV_CURRENT_RMS_MA int "Balanced phase current RMS (mA)" diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 658028333..64f2ab6e9 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -3,6 +3,7 @@ #include "ptp_lab_task.hpp" #include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_runtime.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -20,38 +21,41 @@ #include "esp_eth_mac_esp.h" #endif +#include #include +#include +#include #include #include #include +#include #include #include #include +#include +#include #include namespace ar::esp32p4::smv { namespace { -using ar::iec61850::time_sync::PtpBuildOptions; using ar::iec61850::time_sync::PtpClockAccuracy; -using ar::iec61850::time_sync::PtpCodec; using ar::iec61850::time_sync::PtpFrame; using ar::iec61850::time_sync::PtpMessageType; using ar::iec61850::time_sync::PtpPortIdentity; +using ar::iec61850::time_sync::PtpPublisherOptions; +using ar::iec61850::time_sync::PtpPublisherRuntime; using ar::iec61850::time_sync::PtpTimeSource; using ar::iec61850::time_sync::PtpTimestamp; -using ar::iec61850::time_sync::ptp_general_multicast_mac; -using ar::iec61850::time_sync::ptp_peer_delay_multicast_mac; +using ar::iec61850::time_sync::format_ptp_clock_identity; +using ar::iec61850::time_sync::ptp_clock_identity_from_mac; +using ar::iec61850::time_sync::try_parse_ptp_clock_identity; constexpr char kTag[] = "ar_ptp_lab"; -constexpr std::uint16_t kPortNumber = 1U; -constexpr std::uint8_t kPriority1 = 128U; -constexpr std::uint8_t kPriority2 = 128U; -constexpr std::uint8_t kClockClass = 248U; -constexpr std::uint16_t kOffsetScaledLogVariance = 0xFFFFU; -constexpr std::int16_t kCurrentUtcOffset = 37; constexpr TickType_t kStartupDelay = pdMS_TO_TICKS(500); constexpr UBaseType_t kPdelayQueueDepth = 8U; +constexpr std::array kValidationMac{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; struct PdelayRequestEvent final { PtpPortIdentity requesting_port_identity{}; @@ -63,55 +67,181 @@ struct PdelayRequestEvent final { struct PtpLabContext final { esp_eth_handle_t eth_handle{}; std::array source_mac{}; - PtpBuildOptions base_options{}; QueueHandle_t pdelay_queue{}; TaskHandle_t task_handle{}; - std::uint16_t announce_sequence{}; - std::uint16_t sync_sequence{}; - std::uint64_t pdelay_responses{}; + std::optional runtime; + std::uint8_t rx_domain{}; + std::uint8_t rx_transport_specific{}; + bool rx_respond_to_peer_delay{}; }; PtpLabContext g_ptp_context{}; -bool g_ptp_started = false; +std::atomic_bool g_ptp_started{false}; +std::atomic_bool g_stop_requested{false}; +std::atomic_bool g_ptp_accept_rx{false}; +std::atomic g_announce_sent{0U}; +std::atomic g_sync_sent{0U}; +std::atomic g_follow_up_sent{0U}; +std::atomic g_pdelay_frames_sent{0U}; +std::atomic g_tx_failure_count{0U}; + +portMUX_TYPE g_control_mux = portMUX_INITIALIZER_UNLOCKED; +ar_ptp_lab_config_t g_runtime_config_override{}; +bool g_runtime_config_override_valid = false; + +[[nodiscard]] bool all_zero_identity(const std::uint8_t (&identity)[8]) noexcept { + return std::all_of( + std::begin(identity), + std::end(identity), + [](const std::uint8_t value) { return value == 0U; }); +} -[[nodiscard]] std::int8_t interval_to_log2(const std::uint32_t interval_ms) noexcept { - if (interval_ms == 0U) return 0; - // PTP logMessageInterval is log2(seconds). The configured lab intervals are - // represented by the nearest useful bounded integer exponent. - std::uint32_t numerator = interval_ms; - std::int8_t exponent = 0; - while (numerator < 1000U && exponent > -7) { - numerator *= 2U; - --exponent; +void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { + config = {}; + config.transport_specific = static_cast(CONFIG_AR_PTP_TRANSPORT_SPECIFIC); + config.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); +#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN + config.vlan_enabled = true; +#else + config.vlan_enabled = false; +#endif + config.vlan_id = static_cast(CONFIG_AR_PTP_VLAN_ID); + config.vlan_priority = static_cast(CONFIG_AR_PTP_VLAN_PRIORITY); + config.port_number = static_cast(CONFIG_AR_PTP_PORT_NUMBER); + config.announce_interval_ms = static_cast(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); + config.sync_interval_ms = static_cast(CONFIG_AR_PTP_SYNC_INTERVAL_MS); +#if defined(CONFIG_AR_PTP_RESPOND_PDELAY) && CONFIG_AR_PTP_RESPOND_PDELAY + config.respond_to_peer_delay = true; +#else + config.respond_to_peer_delay = false; +#endif + config.priority1 = static_cast(CONFIG_AR_PTP_PRIORITY1); + config.priority2 = static_cast(CONFIG_AR_PTP_PRIORITY2); + config.clock_class = static_cast(CONFIG_AR_PTP_CLOCK_CLASS); + config.clock_accuracy = static_cast(CONFIG_AR_PTP_CLOCK_ACCURACY); + config.offset_scaled_log_variance = + static_cast(CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE); + config.time_source = static_cast(CONFIG_AR_PTP_TIME_SOURCE); + config.current_utc_offset = static_cast(CONFIG_AR_PTP_CURRENT_UTC_OFFSET); + + const std::string configured_identity{CONFIG_AR_PTP_CLOCK_IDENTITY}; + if (!configured_identity.empty()) { + ar::iec61850::time_sync::PtpClockIdentity parsed{}; + if (try_parse_ptp_clock_identity(configured_identity, parsed)) { + config.clock_identity_override = true; + std::copy(parsed.begin(), parsed.end(), std::begin(config.clock_identity)); + } } - while (numerator >= 2000U && exponent < 7) { - numerator = (numerator + 1U) / 2U; - ++exponent; +} + +[[nodiscard]] ar_ptp_lab_config_t selected_config() noexcept { + ar_ptp_lab_config_t result{}; + bool has_override = false; + portENTER_CRITICAL(&g_control_mux); + has_override = g_runtime_config_override_valid; + if (has_override) result = g_runtime_config_override; + portEXIT_CRITICAL(&g_control_mux); + + // Avoid heap/string work inside the critical section when defaults include + // a textual ClockIdentity override. + if (!has_override) fill_kconfig_defaults(result); + return result; +} + +[[nodiscard]] std::optional options_from_config( + const ar_ptp_lab_config_t& config, + const std::array& source_mac, + std::string* validation_error = nullptr) { + PtpPublisherOptions options; + options.transport_specific = config.transport_specific; + options.domain_number = config.domain_number; + options.source_mac = source_mac; + if (config.vlan_enabled) { + options.vlan_id = config.vlan_id; + options.vlan_priority = config.vlan_priority; + } else { + options.vlan_id = std::nullopt; + options.vlan_priority = 0U; } - return exponent; + options.clock_identity = ptp_clock_identity_from_mac(source_mac); + if (config.clock_identity_override) { + std::copy( + std::begin(config.clock_identity), + std::end(config.clock_identity), + options.clock_identity.begin()); + } + options.port_number = config.port_number; + options.announce_interval = std::chrono::milliseconds{config.announce_interval_ms}; + options.sync_interval = std::chrono::milliseconds{config.sync_interval_ms}; + options.follow_up_delay = std::chrono::milliseconds::zero(); + // ESP32-P4 intentionally stays two-step so the exact EMAC egress timestamp + // can be placed in Follow_Up instead of approximating a one-step timestamp. + options.two_step_clock = true; + options.respond_to_peer_delay = config.respond_to_peer_delay; + options.priority1 = config.priority1; + options.priority2 = config.priority2; + options.clock_class = config.clock_class; + options.clock_accuracy = static_cast(config.clock_accuracy); + options.offset_scaled_log_variance = config.offset_scaled_log_variance; + options.time_source = static_cast(config.time_source); + options.current_utc_offset = config.current_utc_offset; + + std::string error; + if (!PtpPublisherRuntime::validate_options(options, error)) { + if (validation_error != nullptr) *validation_error = std::move(error); + return std::nullopt; + } + if (validation_error != nullptr) validation_error->clear(); + return options; } -[[nodiscard]] std::array clock_identity_from_mac( - const std::array& mac) noexcept { - return { - mac[0], mac[1], mac[2], 0xFFU, 0xFEU, mac[3], mac[4], mac[5], - }; +[[nodiscard]] bool validate_runtime_config( + const ar_ptp_lab_config_t& config, + std::string& error) { + if (config.clock_identity_override && all_zero_identity(config.clock_identity)) { + error = "clockIdentity override must be non-zero"; + return false; + } + return options_from_config(config, kValidationMac, &error).has_value(); } -[[nodiscard]] std::optional ptp_vlan_id() noexcept { -#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN - return static_cast(CONFIG_AR_PTP_VLAN_ID); -#else - return std::nullopt; -#endif +[[nodiscard]] std::optional make_runtime_options( + const std::array& source_mac) { + const auto config = selected_config(); + std::string validation_error; + auto options = options_from_config(config, source_mac, &validation_error); + if (!options.has_value()) { + ESP_LOGE(kTag, "Invalid PTP runtime profile: %s", validation_error.c_str()); + } + return options; } -[[nodiscard]] std::uint8_t ptp_vlan_priority() noexcept { -#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN - return static_cast(CONFIG_AR_PTP_VLAN_PRIORITY); -#else - return 0U; -#endif +void reset_live_status() noexcept { + g_announce_sent.store(0U, std::memory_order_release); + g_sync_sent.store(0U, std::memory_order_release); + g_follow_up_sent.store(0U, std::memory_order_release); + g_pdelay_frames_sent.store(0U, std::memory_order_release); + g_tx_failure_count.store(0U, std::memory_order_release); +} + +void record_live_sent(const PtpMessageType message_type) noexcept { + switch (message_type) { + case PtpMessageType::announce: + g_announce_sent.fetch_add(1U, std::memory_order_relaxed); + break; + case PtpMessageType::sync: + g_sync_sent.fetch_add(1U, std::memory_order_relaxed); + break; + case PtpMessageType::follow_up: + g_follow_up_sent.fetch_add(1U, std::memory_order_relaxed); + break; + case PtpMessageType::pdelay_resp: + case PtpMessageType::pdelay_resp_follow_up: + g_pdelay_frames_sent.fetch_add(1U, std::memory_order_relaxed); + break; + default: + break; + } } #if defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 @@ -196,111 +326,82 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { return true; } -[[nodiscard]] std::vector wrap_ptp_message( - const std::array& destination_mac, - const PtpLabContext& context, - const std::vector& message) { - return PtpCodec::build_ethernet_frame( - destination_mac, - context.source_mac, - message, - ptp_vlan_id(), - ptp_vlan_priority()); -} - -[[nodiscard]] bool send_sync_follow_up(PtpLabContext& context) noexcept { - auto sync_options = context.base_options; - sync_options.sequence_id = context.sync_sequence; - sync_options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_SYNC_INTERVAL_MS); - sync_options.two_step = true; - // Two-step Sync intentionally carries zero originTimestamp. The exact MAC - // egress timestamp returned by the ESP32-P4 DMA descriptor is carried by - // the subsequent Follow_Up. - sync_options.timestamp = {}; +[[nodiscard]] bool send_sync_follow_up(PtpLabContext& context) { + if (!context.runtime.has_value()) return false; + auto& runtime = *context.runtime; - const auto sync_message = PtpCodec::build_sync(sync_options); - auto sync_frame = wrap_ptp_message(ptp_general_multicast_mac, context, sync_message); + auto sync = runtime.prepare_sync(PtpTimestamp{}); + if (!sync.has_value()) return false; PtpTimestamp tx_timestamp; - if (!transmit_with_hw_timestamp(context.eth_handle, sync_frame, tx_timestamp)) { + if (!transmit_with_hw_timestamp( + context.eth_handle, + sync->ethernet_frame, + tx_timestamp)) { return false; } + runtime.record_sent(PtpMessageType::sync); + record_live_sent(PtpMessageType::sync); - auto follow_up_options = sync_options; - follow_up_options.two_step = false; - follow_up_options.timestamp = tx_timestamp; - const auto follow_up_message = PtpCodec::build_follow_up(follow_up_options); - auto follow_up_frame = wrap_ptp_message( - ptp_general_multicast_mac, - context, - follow_up_message); - if (!send_frame(context.eth_handle, follow_up_frame)) { + auto follow_up = runtime.prepare_follow_up(sync->sequence_id, tx_timestamp); + if (!follow_up.has_value() || + !send_frame(context.eth_handle, follow_up->ethernet_frame)) { return false; } - - context.sync_sequence = static_cast(context.sync_sequence + 1U); + runtime.record_sent(PtpMessageType::follow_up); + record_live_sent(PtpMessageType::follow_up); return true; } -[[nodiscard]] bool send_announce(PtpLabContext& context) noexcept { - auto options = context.base_options; - options.sequence_id = context.announce_sequence; - options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); - options.two_step = false; - options.timestamp = hardware_time(context.eth_handle); - - const auto message = PtpCodec::build_announce(options, kCurrentUtcOffset); - auto frame = wrap_ptp_message(ptp_general_multicast_mac, context, message); - if (!send_frame(context.eth_handle, frame)) { +[[nodiscard]] bool send_announce(PtpLabContext& context) { + if (!context.runtime.has_value()) return false; + auto& runtime = *context.runtime; + auto announce = runtime.prepare_announce(hardware_time(context.eth_handle)); + if (!announce.has_value() || + !send_frame(context.eth_handle, announce->ethernet_frame)) { return false; } - context.announce_sequence = static_cast(context.announce_sequence + 1U); + runtime.record_sent(PtpMessageType::announce); + record_live_sent(PtpMessageType::announce); return true; } [[nodiscard]] bool send_pdelay_response( PtpLabContext& context, - const PdelayRequestEvent& request) noexcept { - auto response_options = context.base_options; - response_options.sequence_id = request.sequence_id; - response_options.log_message_interval = request.log_message_interval; - response_options.two_step = true; - // t2: hardware receive timestamp of Pdelay_Req. - response_options.timestamp = request.receive_timestamp; - - const auto response_message = PtpCodec::build_pdelay_resp( - response_options, - request.requesting_port_identity); - auto response_frame = wrap_ptp_message( - ptp_peer_delay_multicast_mac, - context, - response_message); + const PdelayRequestEvent& request) { + if (!context.runtime.has_value()) return false; + auto& runtime = *context.runtime; + + auto response = runtime.prepare_pdelay_response( + request.requesting_port_identity, + request.sequence_id, + request.log_message_interval, + request.receive_timestamp); + if (!response.has_value()) return false; PtpTimestamp response_tx_timestamp; if (!transmit_with_hw_timestamp( context.eth_handle, - response_frame, + response->ethernet_frame, response_tx_timestamp)) { return false; } - - auto follow_up_options = response_options; - follow_up_options.two_step = false; - // t3: hardware transmit timestamp of Pdelay_Resp. - follow_up_options.timestamp = response_tx_timestamp; - const auto follow_up_message = PtpCodec::build_pdelay_resp_follow_up( - follow_up_options, - request.requesting_port_identity); - auto follow_up_frame = wrap_ptp_message( - ptp_peer_delay_multicast_mac, - context, - follow_up_message); - if (!send_frame(context.eth_handle, follow_up_frame)) { + runtime.record_sent(PtpMessageType::pdelay_resp); + record_live_sent(PtpMessageType::pdelay_resp); + + auto follow_up = runtime.prepare_pdelay_response_follow_up( + request.requesting_port_identity, + request.sequence_id, + request.log_message_interval, + response_tx_timestamp); + if (!follow_up.has_value() || + !send_frame(context.eth_handle, follow_up->ethernet_frame)) { return false; } + runtime.record_sent(PtpMessageType::pdelay_resp_follow_up); + record_live_sent(PtpMessageType::pdelay_resp_follow_up); - ++context.pdelay_responses; - if (context.pdelay_responses == 1U) { + if (g_pdelay_frames_sent.load(std::memory_order_relaxed) == 2U) { ESP_LOGI(kTag, "First hardware-timestamped Pdelay exchange completed"); } return true; @@ -313,19 +414,21 @@ esp_err_t ptp_input_info( void* priv, void* info) { auto* context = static_cast(priv); - if (buffer == nullptr) { - return ESP_OK; - } + if (buffer == nullptr) return ESP_OK; - if (context != nullptr && context->pdelay_queue != nullptr && info != nullptr) { + if (g_ptp_accept_rx.load(std::memory_order_acquire) && + context != nullptr && + context->rx_respond_to_peer_delay && + context->pdelay_queue != nullptr && + info != nullptr) { PtpFrame frame; - if (PtpCodec::try_parse_ethernet_frame( + if (ar::iec61850::time_sync::PtpCodec::try_parse_ethernet_frame( std::span{buffer, length}, frame) && frame.peer_delay_multicast && frame.header.message_type == PtpMessageType::pdelay_req && - frame.header.domain_number == context->base_options.domain_number && - frame.header.transport_specific == context->base_options.transport_specific) { + frame.header.domain_number == context->rx_domain && + frame.header.transport_specific == context->rx_transport_specific) { const auto& rx_timestamp = *static_cast(info); if (valid_hw_timestamp(rx_timestamp)) { const PdelayRequestEvent event{ @@ -342,20 +445,35 @@ esp_err_t ptp_input_info( } } - // The injector is a standalone raw-L2 application and currently has no - // competing TCP/IP input consumer. This registered input path owns the RX - // buffer and must release it after inspection. std::free(buffer); return ESP_OK; } +void finish_runtime(PtpLabContext& context) { + g_ptp_accept_rx.store(false, std::memory_order_release); + if (context.runtime.has_value()) { + context.runtime->stop(); + const auto status = context.runtime->status(); + ESP_LOGI(kTag, + "PTP stopped: Announce=%llu Sync=%llu FollowUp=%llu PdelayFrames=%llu%s%s", + static_cast(status.announce_sent), + static_cast(status.sync_sent), + static_cast(status.follow_up_sent), + static_cast(status.peer_delay_responses_sent), + status.last_error.empty() ? "" : " lastError=", + status.last_error.empty() ? "" : status.last_error.c_str()); + } + context.task_handle = nullptr; + g_ptp_started.store(false, std::memory_order_release); +} + void ptp_lab_task(void* argument) { auto& context = *static_cast(argument); vTaskDelay(kStartupDelay); if (!enable_hardware_ptp(context.eth_handle)) { ESP_LOGE(kTag, "PTP lab broadcaster stopped: hardware timestamp clock unavailable"); - g_ptp_started = false; + finish_runtime(context); vTaskDelete(nullptr); return; } @@ -368,33 +486,42 @@ void ptp_lab_task(void* argument) { if (mac_result != ESP_OK) { ESP_LOGE(kTag, "PTP lab broadcaster stopped: MAC address unavailable: %s", esp_err_to_name(mac_result)); - g_ptp_started = false; + finish_runtime(context); vTaskDelete(nullptr); return; } - context.base_options.transport_specific = - static_cast(CONFIG_AR_PTP_TRANSPORT_SPECIFIC); - context.base_options.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); - context.base_options.source_port_identity.clock_identity = - clock_identity_from_mac(context.source_mac); - context.base_options.source_port_identity.port_number = kPortNumber; - context.base_options.priority1 = kPriority1; - context.base_options.priority2 = kPriority2; - context.base_options.clock_class = kClockClass; - context.base_options.clock_accuracy = PtpClockAccuracy::unknown; - context.base_options.offset_scaled_log_variance = kOffsetScaledLogVariance; - context.base_options.grandmaster_identity = context.base_options.source_port_identity.clock_identity; - context.base_options.time_source = PtpTimeSource::internal_oscillator; + auto profile = make_runtime_options(context.source_mac); + if (!profile.has_value()) { + finish_runtime(context); + vTaskDelete(nullptr); + return; + } + + context.runtime.emplace(std::move(*profile)); + if (!context.runtime->start()) { + ESP_LOGE(kTag, + "PTP runtime rejected profile: %s", + context.runtime->status().last_error.c_str()); + finish_runtime(context); + vTaskDelete(nullptr); + return; + } + + const auto options = context.runtime->options(); + context.rx_domain = options.domain_number; + context.rx_transport_specific = options.transport_specific; + context.rx_respond_to_peer_delay = options.respond_to_peer_delay; bool pdelay_enabled = false; - if (context.pdelay_queue != nullptr) { + if (options.respond_to_peer_delay && context.pdelay_queue != nullptr) { const auto input_result = esp_eth_update_input_path_info( context.eth_handle, &ptp_input_info, &context); if (input_result == ESP_OK) { pdelay_enabled = true; + g_ptp_accept_rx.store(true, std::memory_order_release); } else { ESP_LOGW(kTag, "Hardware RX timestamp path unavailable; Pdelay responder disabled: %s", @@ -402,22 +529,22 @@ void ptp_lab_task(void* argument) { } } + const auto clock_identity = format_ptp_clock_identity(options.clock_identity); ESP_LOGW(kTag, "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); ESP_LOGI(kTag, - "PTP domain=%u transportSpecific=0x%X VLAN=%s Announce=%d ms Sync=%d ms HW Sync TX timestamp=enabled HW Pdelay=%s", - static_cast(context.base_options.domain_number), - static_cast(context.base_options.transport_specific), - ptp_vlan_id().has_value() ? "tagged" : "untagged", - CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS, - CONFIG_AR_PTP_SYNC_INTERVAL_MS, + "PTP runtime domain=%u transportSpecific=0x%X clockIdentity=%s port=%u VLAN=%s Announce=%lld ms Sync=%lld ms HW Sync TX=enabled HW Pdelay=%s", + static_cast(options.domain_number), + static_cast(options.transport_specific), + clock_identity.c_str(), + static_cast(options.port_number), + options.vlan_id.has_value() ? "tagged" : "untagged", + static_cast(options.announce_interval.count()), + static_cast(options.sync_interval.count()), pdelay_enabled ? "enabled" : "disabled"); - TickType_t next_sync = xTaskGetTickCount(); - TickType_t next_announce = next_sync; std::uint32_t consecutive_failures = 0U; - - while (true) { + while (!g_stop_requested.load(std::memory_order_acquire)) { bool attempted = false; bool success = true; @@ -428,22 +555,23 @@ void ptp_lab_task(void* argument) { success = send_pdelay_response(context, pdelay_request) && success; } - const TickType_t now = xTaskGetTickCount(); - if (static_cast(now - next_announce) >= 0) { + const auto due = context.runtime->poll_due(); + if (due.announce) { attempted = true; success = send_announce(context) && success; - next_announce = now + pdMS_TO_TICKS(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); } - if (static_cast(now - next_sync) >= 0) { + if (due.sync) { attempted = true; success = send_sync_follow_up(context) && success; - next_sync = now + pdMS_TO_TICKS(CONFIG_AR_PTP_SYNC_INTERVAL_MS); } if (attempted) { if (success) { consecutive_failures = 0U; + context.runtime->clear_error(); } else { + context.runtime->record_error("Ethernet transmit or hardware timestamp failure"); + g_tx_failure_count.fetch_add(1U, std::memory_order_relaxed); ++consecutive_failures; if (consecutive_failures == 1U || (consecutive_failures % 16U) == 0U) { ESP_LOGW(kTag, @@ -453,10 +581,11 @@ void ptp_lab_task(void* argument) { } } - // A Pdelay RX notification wakes this task immediately; the timeout only - // bounds the cadence check for Announce/Sync and avoids a busy loop. ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(5)); } + + finish_runtime(context); + vTaskDelete(nullptr); } #else @@ -464,7 +593,9 @@ void ptp_lab_task(void* argument) { void ptp_lab_task(void*) { ESP_LOGE(kTag, "PTP lab broadcaster requires ESP32-P4 IEEE1588 support and the ESP-IDF 5.x EMAC timestamp adapter"); - g_ptp_started = false; + g_ptp_accept_rx.store(false, std::memory_order_release); + g_ptp_context.task_handle = nullptr; + g_ptp_started.store(false, std::memory_order_release); vTaskDelete(nullptr); } @@ -475,40 +606,120 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); return; } - if (g_ptp_started) { + + portENTER_CRITICAL(&g_control_mux); + if (g_ptp_started.load(std::memory_order_relaxed)) { + portEXIT_CRITICAL(&g_control_mux); return; } + g_ptp_started.store(true, std::memory_order_relaxed); + portEXIT_CRITICAL(&g_control_mux); - g_ptp_context = {}; + reset_live_status(); + g_ptp_accept_rx.store(false, std::memory_order_release); + g_stop_requested.store(false, std::memory_order_release); g_ptp_context.eth_handle = eth_handle; - g_ptp_context.pdelay_queue = xQueueCreate( - kPdelayQueueDepth, - sizeof(PdelayRequestEvent)); + g_ptp_context.runtime.reset(); + g_ptp_context.task_handle = nullptr; + g_ptp_context.rx_respond_to_peer_delay = false; + + if (g_ptp_context.pdelay_queue == nullptr) { + g_ptp_context.pdelay_queue = xQueueCreate( + kPdelayQueueDepth, + sizeof(PdelayRequestEvent)); + } else { + xQueueReset(g_ptp_context.pdelay_queue); + } if (g_ptp_context.pdelay_queue == nullptr) { ESP_LOGW(kTag, "Pdelay queue allocation failed; broadcaster will run without Pdelay RX responses"); } - g_ptp_started = true; if (xTaskCreatePinnedToCore( &ptp_lab_task, "ar_ptp_lab", - 6144, + 7168, &g_ptp_context, 4, &g_ptp_context.task_handle, 0) != pdPASS) { - g_ptp_started = false; - if (g_ptp_context.pdelay_queue != nullptr) { - vQueueDelete(g_ptp_context.pdelay_queue); - g_ptp_context.pdelay_queue = nullptr; - } + g_ptp_context.task_handle = nullptr; + g_ptp_started.store(false, std::memory_order_release); ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); } } +void stop_ptp_lab() noexcept { + if (!g_ptp_started.load(std::memory_order_acquire)) return; + g_stop_requested.store(true, std::memory_order_release); + if (g_ptp_context.task_handle != nullptr) { + xTaskNotifyGive(g_ptp_context.task_handle); + } +} + +[[nodiscard]] bool ptp_lab_is_running() noexcept { + return g_ptp_started.load(std::memory_order_acquire); +} + +[[nodiscard]] bool configure_ptp_lab(const ar_ptp_lab_config_t& config) { + if (g_ptp_started.load(std::memory_order_acquire)) return false; + + std::string error; + if (!validate_runtime_config(config, error)) { + ESP_LOGE(kTag, "PTP runtime configuration rejected: %s", error.c_str()); + return false; + } + + portENTER_CRITICAL(&g_control_mux); + if (g_ptp_started.load(std::memory_order_relaxed)) { + portEXIT_CRITICAL(&g_control_mux); + return false; + } + g_runtime_config_override = config; + g_runtime_config_override_valid = true; + portEXIT_CRITICAL(&g_control_mux); + return true; +} + } // namespace } // namespace ar::esp32p4::smv +extern "C" void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config) { + if (config == nullptr) return; + ar::esp32p4::smv::fill_kconfig_defaults(*config); +} + +extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { + if (config == nullptr) return false; + return ar::esp32p4::smv::configure_ptp_lab(*config); +} + +extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { + if (config == nullptr) return false; + *config = ar::esp32p4::smv::selected_config(); + return true; +} + extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { ar::esp32p4::smv::start_ptp_lab(eth_handle); } + +extern "C" void ar_ptp_lab_stop(void) { + ar::esp32p4::smv::stop_ptp_lab(); +} + +extern "C" bool ar_ptp_lab_is_running(void) { + return ar::esp32p4::smv::ptp_lab_is_running(); +} + +extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { + if (status == nullptr) return false; + status->is_running = ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire); + status->announce_sent = ar::esp32p4::smv::g_announce_sent.load(std::memory_order_relaxed); + status->sync_sent = ar::esp32p4::smv::g_sync_sent.load(std::memory_order_relaxed); + status->follow_up_sent = ar::esp32p4::smv::g_follow_up_sent.load(std::memory_order_relaxed); + status->peer_delay_frames_sent = + ar::esp32p4::smv::g_pdelay_frames_sent.load(std::memory_order_relaxed); + status->tx_failure_count = + ar::esp32p4::smv::g_tx_failure_count.load(std::memory_order_relaxed); + return true; +} diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 996c438f7..27d1598b8 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -4,10 +4,55 @@ #include "esp_eth.h" +#include +#include + #ifdef __cplusplus extern "C" { #endif +typedef struct ar_ptp_lab_config_t { + uint8_t transport_specific; + uint8_t domain_number; + bool vlan_enabled; + uint16_t vlan_id; + uint8_t vlan_priority; + bool clock_identity_override; + uint8_t clock_identity[8]; + uint16_t port_number; + uint32_t announce_interval_ms; + uint32_t sync_interval_ms; + bool respond_to_peer_delay; + uint8_t priority1; + uint8_t priority2; + uint8_t clock_class; + uint8_t clock_accuracy; + uint16_t offset_scaled_log_variance; + uint8_t time_source; + int16_t current_utc_offset; +} ar_ptp_lab_config_t; + +typedef struct ar_ptp_lab_status_t { + bool is_running; + uint64_t announce_sent; + uint64_t sync_sent; + uint64_t follow_up_sent; + uint64_t peer_delay_frames_sent; + uint64_t tx_failure_count; +} ar_ptp_lab_status_t; + +/** Fill a configuration snapshot from the firmware's Kconfig defaults. */ +void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config); + +/** + * Apply a runtime profile while PTP is stopped. + * Returns false when the profile is invalid or the task is running. + */ +bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config); + +/** Return the currently selected runtime profile (override or Kconfig default). */ +bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config); + /** * Start the optional PTPv2 Layer-2 laboratory timing companion. * @@ -16,6 +61,15 @@ extern "C" { */ void ar_ptp_lab_start(esp_eth_handle_t eth_handle); +/** Request a clean stop of the PTP lab task without touching the SV hot path. */ +void ar_ptp_lab_stop(void); + +/** Return whether the PTP lab task currently owns an active runtime. */ +bool ar_ptp_lab_is_running(void); + +/** Obtain a lock-free live status snapshot suitable for a future GUI/control plane. */ +bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status); + #ifdef __cplusplus } #endif diff --git a/include/ariec61850/time_sync/ptp_runtime.hpp b/include/ariec61850/time_sync/ptp_runtime.hpp new file mode 100644 index 000000000..e6b2b18e4 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_runtime.hpp @@ -0,0 +1,479 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { +namespace detail { + +[[nodiscard]] constexpr int ptp_hex_value(const char ch) noexcept { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +[[nodiscard]] inline std::string_view trim_ptp_ascii(std::string_view text) noexcept { + while (!text.empty() && std::isspace(static_cast(text.front())) != 0) { + text.remove_prefix(1U); + } + while (!text.empty() && std::isspace(static_cast(text.back())) != 0) { + text.remove_suffix(1U); + } + return text; +} + +[[nodiscard]] inline bool ptp_identity_is_zero(const PtpClockIdentity& identity) noexcept { + return std::all_of(identity.begin(), identity.end(), [](const std::uint8_t byte) { + return byte == 0U; + }); +} + +} // namespace detail + +[[nodiscard]] inline bool try_parse_ptp_clock_identity( + std::string_view text, + PtpClockIdentity& identity) noexcept { + text = detail::trim_ptp_ascii(text); + if (text.empty()) return false; + + PtpClockIdentity parsed{}; + std::size_t byte_index = 0U; + std::size_t cursor = 0U; + while (cursor < text.size() && byte_index < parsed.size()) { + if (cursor + 2U > text.size()) return false; + const int high = detail::ptp_hex_value(text[cursor]); + const int low = detail::ptp_hex_value(text[cursor + 1U]); + if (high < 0 || low < 0) return false; + parsed[byte_index] = static_cast((high << 4) | low); + ++byte_index; + cursor += 2U; + if (byte_index == parsed.size()) break; + if (cursor >= text.size() || (text[cursor] != ':' && text[cursor] != '-')) return false; + ++cursor; + } + + if (byte_index != parsed.size() || cursor != text.size()) return false; + identity = parsed; + return true; +} + +[[nodiscard]] inline std::string format_ptp_clock_identity(const PtpClockIdentity& identity) { + constexpr char digits[] = "0123456789ABCDEF"; + std::string result; + result.reserve(23U); + for (std::size_t index = 0U; index < identity.size(); ++index) { + if (index != 0U) result.push_back(':'); + const auto byte = identity[index]; + result.push_back(digits[(byte >> 4U) & 0x0FU]); + result.push_back(digits[byte & 0x0FU]); + } + return result; +} + +[[nodiscard]] inline PtpClockIdentity ptp_clock_identity_from_mac( + const std::array& mac) noexcept { + return {mac[0], mac[1], mac[2], 0xFFU, 0xFEU, mac[3], mac[4], mac[5]}; +} + +struct PtpPublisherOptions final { + std::uint8_t transport_specific{}; + std::uint8_t domain_number{}; + std::array source_mac{0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U}; + std::optional vlan_id; + std::uint8_t vlan_priority{4U}; + PtpClockIdentity clock_identity{0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x00U, 0x01U}; + std::uint16_t port_number{1U}; + std::chrono::milliseconds announce_interval{1000}; + std::chrono::milliseconds sync_interval{250}; + std::chrono::milliseconds follow_up_delay{}; + bool two_step_clock{true}; + bool respond_to_peer_delay{true}; + std::uint8_t priority1{128U}; + std::uint8_t priority2{128U}; + std::uint8_t clock_class{248U}; + PtpClockAccuracy clock_accuracy{PtpClockAccuracy::unknown}; + std::uint16_t offset_scaled_log_variance{0xFFFFU}; + PtpTimeSource time_source{PtpTimeSource::internal_oscillator}; + std::int16_t current_utc_offset{37}; + std::uint16_t steps_removed{}; + + [[nodiscard]] PtpPortIdentity source_port_identity() const noexcept { + return {clock_identity, port_number}; + } +}; + +struct PtpPublisherStatus final { + bool is_running{}; + std::optional started_at; + std::optional last_sent_at; + std::uint64_t announce_sent{}; + std::uint64_t sync_sent{}; + std::uint64_t follow_up_sent{}; + std::uint64_t peer_delay_responses_sent{}; + std::string last_error; +}; + +struct PtpDueMessages final { + bool announce{}; + bool sync{}; +}; + +struct PtpPreparedFrame final { + PtpMessageType message_type{PtpMessageType::sync}; + std::uint16_t sequence_id{}; + std::array destination_mac{}; + std::vector ethernet_frame; +}; + +class PtpSequenceCounters final { +public: + [[nodiscard]] std::uint16_t next(const PtpMessageType message_type) noexcept { + const auto index = static_cast( + static_cast(message_type) & 0x0FU); + auto& counter = counters_[index]; + counter = static_cast(counter + 1U); + return counter; + } + + void reset() noexcept { counters_.fill(0U); } + +private: + std::array counters_{}; +}; + +/** + * Portable PTPv2 publisher runtime state machine. + * + * Owns profile/configuration, lifecycle, due scheduling, sequence counters, + * frame preparation, and status accounting. It intentionally owns no NIC, + * OS thread, or clock servo. A platform adapter supplies transport and any + * hardware RX/TX timestamps. + * + * Public stateful operations are serialized so a GUI/status reader may safely + * observe the runtime while a publisher task is active. Reconfiguration is + * still deliberately rejected while running. + */ +class PtpPublisherRuntime final { +public: + explicit PtpPublisherRuntime(PtpPublisherOptions options = {}) + : options_(std::move(options)) {} + + PtpPublisherRuntime(const PtpPublisherRuntime&) = delete; + PtpPublisherRuntime& operator=(const PtpPublisherRuntime&) = delete; + PtpPublisherRuntime(PtpPublisherRuntime&&) = delete; + PtpPublisherRuntime& operator=(PtpPublisherRuntime&&) = delete; + + [[nodiscard]] static bool validate_options( + const PtpPublisherOptions& options, + std::string& error) noexcept { + if (options.transport_specific > 0x0FU) { + error = "transportSpecific must be 0..15"; + return false; + } + if (options.vlan_priority > 7U) { + error = "VLAN priority must be 0..7"; + return false; + } + if (options.vlan_id.has_value() && + (*options.vlan_id == 0U || *options.vlan_id > 4094U)) { + error = "VLAN ID must be 1..4094"; + return false; + } + if (options.port_number == 0U) { + error = "PTP port number must be non-zero"; + return false; + } + if (options.announce_interval <= std::chrono::milliseconds::zero()) { + error = "Announce interval must be greater than zero"; + return false; + } + if (options.sync_interval <= std::chrono::milliseconds::zero()) { + error = "Sync interval must be greater than zero"; + return false; + } + if (options.follow_up_delay < std::chrono::milliseconds::zero()) { + error = "Follow-up delay cannot be negative"; + return false; + } + if (detail::ptp_identity_is_zero(options.clock_identity)) { + error = "PTP clock identity must be non-zero"; + return false; + } + error.clear(); + return true; + } + + [[nodiscard]] PtpPublisherOptions options() const { + const std::lock_guard lock{mutex_}; + return options_; + } + + [[nodiscard]] PtpPublisherStatus status() const { + const std::lock_guard lock{mutex_}; + return status_; + } + + [[nodiscard]] bool reconfigure(PtpPublisherOptions options, std::string& error) noexcept { + const std::lock_guard lock{mutex_}; + if (status_.is_running) { + error = "PTP runtime must be stopped before reconfiguration"; + return false; + } + if (!validate_options(options, error)) return false; + options_ = std::move(options); + status_.last_error.clear(); + return true; + } + + [[nodiscard]] bool start( + const std::chrono::system_clock::time_point wall_time = std::chrono::system_clock::now(), + const std::chrono::steady_clock::time_point monotonic_time = std::chrono::steady_clock::now()) noexcept { + const std::lock_guard lock{mutex_}; + std::string error; + if (!validate_options(options_, error)) { + status_.last_error = std::move(error); + status_.is_running = false; + return false; + } + status_.is_running = true; + status_.started_at = wall_time; + status_.last_error.clear(); + next_announce_ = monotonic_time; + next_sync_ = monotonic_time; + return true; + } + + void stop() noexcept { + const std::lock_guard lock{mutex_}; + status_.is_running = false; + } + + [[nodiscard]] PtpDueMessages poll_due( + const std::chrono::steady_clock::time_point monotonic_time = std::chrono::steady_clock::now()) noexcept { + const std::lock_guard lock{mutex_}; + PtpDueMessages due; + if (!status_.is_running) return due; + + if (monotonic_time >= next_announce_) { + due.announce = true; + next_announce_ = monotonic_time + options_.announce_interval; + } + if (monotonic_time >= next_sync_) { + due.sync = true; + next_sync_ = monotonic_time + options_.sync_interval; + } + return due; + } + + [[nodiscard]] std::optional prepare_announce( + const PtpTimestamp origin_timestamp) { + const std::lock_guard lock{mutex_}; + if (!status_.is_running) return std::nullopt; + const auto sequence = sequences_.next(PtpMessageType::announce); + const auto options = make_build_options( + sequence, + interval_to_log2(options_.announce_interval), + false, + origin_timestamp); + return wrap( + PtpMessageType::announce, + sequence, + ptp_general_multicast_mac, + PtpCodec::build_announce(options, options_.current_utc_offset)); + } + + [[nodiscard]] std::optional prepare_sync( + const PtpTimestamp origin_timestamp) { + const std::lock_guard lock{mutex_}; + if (!status_.is_running) return std::nullopt; + const auto sequence = sequences_.next(PtpMessageType::sync); + const auto options = make_build_options( + sequence, + interval_to_log2(options_.sync_interval), + options_.two_step_clock, + origin_timestamp); + return wrap( + PtpMessageType::sync, + sequence, + ptp_general_multicast_mac, + PtpCodec::build_sync(options)); + } + + [[nodiscard]] std::optional prepare_follow_up( + const std::uint16_t sync_sequence_id, + const PtpTimestamp precise_origin_timestamp) { + const std::lock_guard lock{mutex_}; + if (!status_.is_running || !options_.two_step_clock) return std::nullopt; + const auto options = make_build_options( + sync_sequence_id, + interval_to_log2(options_.sync_interval), + false, + precise_origin_timestamp); + return wrap( + PtpMessageType::follow_up, + sync_sequence_id, + ptp_general_multicast_mac, + PtpCodec::build_follow_up(options)); + } + + [[nodiscard]] std::optional prepare_pdelay_response( + const PtpPortIdentity& requesting_port_identity, + const std::uint16_t request_sequence_id, + const std::int8_t request_log_message_interval, + const PtpTimestamp request_receipt_timestamp) { + const std::lock_guard lock{mutex_}; + if (!status_.is_running || !options_.respond_to_peer_delay) return std::nullopt; + const auto options = make_build_options( + request_sequence_id, + request_log_message_interval, + options_.two_step_clock, + request_receipt_timestamp); + return wrap( + PtpMessageType::pdelay_resp, + request_sequence_id, + ptp_peer_delay_multicast_mac, + PtpCodec::build_pdelay_resp(options, requesting_port_identity)); + } + + [[nodiscard]] std::optional prepare_pdelay_response_follow_up( + const PtpPortIdentity& requesting_port_identity, + const std::uint16_t request_sequence_id, + const std::int8_t request_log_message_interval, + const PtpTimestamp response_origin_timestamp) { + const std::lock_guard lock{mutex_}; + if (!status_.is_running || !options_.respond_to_peer_delay || !options_.two_step_clock) { + return std::nullopt; + } + const auto options = make_build_options( + request_sequence_id, + request_log_message_interval, + false, + response_origin_timestamp); + return wrap( + PtpMessageType::pdelay_resp_follow_up, + request_sequence_id, + ptp_peer_delay_multicast_mac, + PtpCodec::build_pdelay_resp_follow_up(options, requesting_port_identity)); + } + + void record_sent( + const PtpMessageType message_type, + const std::chrono::system_clock::time_point wall_time = std::chrono::system_clock::now()) noexcept { + const std::lock_guard lock{mutex_}; + if (!status_.is_running) return; + status_.last_sent_at = wall_time; + switch (message_type) { + case PtpMessageType::announce: + ++status_.announce_sent; + break; + case PtpMessageType::sync: + ++status_.sync_sent; + break; + case PtpMessageType::follow_up: + ++status_.follow_up_sent; + break; + case PtpMessageType::pdelay_resp: + case PtpMessageType::pdelay_resp_follow_up: + ++status_.peer_delay_responses_sent; + break; + default: + break; + } + } + + void record_error(std::string error) { + const std::lock_guard lock{mutex_}; + status_.last_error = std::move(error); + } + + void clear_error() { + const std::lock_guard lock{mutex_}; + status_.last_error.clear(); + } + +private: + [[nodiscard]] static std::int8_t interval_to_log2( + const std::chrono::milliseconds interval) noexcept { + auto milliseconds = interval.count(); + if (milliseconds <= 0) return 0; + std::int8_t exponent = 0; + while (milliseconds < 1000 && exponent > -7) { + milliseconds *= 2; + --exponent; + } + while (milliseconds >= 2000 && exponent < 7) { + milliseconds = (milliseconds + 1) / 2; + ++exponent; + } + return exponent; + } + + [[nodiscard]] PtpBuildOptions make_build_options( + const std::uint16_t sequence_id, + const std::int8_t log_message_interval, + const bool two_step, + const PtpTimestamp timestamp) const noexcept { + PtpBuildOptions result; + result.transport_specific = options_.transport_specific; + result.domain_number = options_.domain_number; + result.source_port_identity = options_.source_port_identity(); + result.sequence_id = sequence_id; + result.log_message_interval = log_message_interval; + result.two_step = two_step; + result.priority1 = options_.priority1; + result.priority2 = options_.priority2; + result.clock_class = options_.clock_class; + result.clock_accuracy = options_.clock_accuracy; + result.offset_scaled_log_variance = options_.offset_scaled_log_variance; + result.grandmaster_identity = options_.clock_identity; + result.steps_removed = options_.steps_removed; + result.time_source = options_.time_source; + result.timestamp = timestamp; + return result; + } + + [[nodiscard]] std::optional wrap( + const PtpMessageType message_type, + const std::uint16_t sequence_id, + const std::array& destination_mac, + std::vector ptp_message) const { + if (ptp_message.empty()) return std::nullopt; + auto ethernet_frame = PtpCodec::build_ethernet_frame( + destination_mac, + options_.source_mac, + ptp_message, + options_.vlan_id, + options_.vlan_priority); + if (ethernet_frame.empty()) return std::nullopt; + return PtpPreparedFrame{ + message_type, + sequence_id, + destination_mac, + std::move(ethernet_frame), + }; + } + + mutable std::mutex mutex_; + PtpPublisherOptions options_; + PtpPublisherStatus status_; + PtpSequenceCounters sequences_; + std::chrono::steady_clock::time_point next_announce_{}; + std::chrono::steady_clock::time_point next_sync_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 36c0e4391..61c821094 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -19,12 +19,17 @@ add_executable(ptp_profile_tests target_link_libraries(ptp_profile_tests PRIVATE ptp_profile_core) target_compile_features(ptp_profile_tests PRIVATE cxx_std_20) +add_executable(ptp_runtime_tests + "${ARSTACK_ROOT}/tests/test_ptp_runtime.cpp") +target_link_libraries(ptp_runtime_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_runtime_tests PRIVATE cxx_std_20) + add_executable(ptp_profile_analyze "${ARSTACK_ROOT}/tools/ptp_analyze.cpp") target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_runtime_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -35,4 +40,5 @@ endforeach() enable_testing() add_test(NAME ptp_profile_tests COMMAND ptp_profile_tests) +add_test(NAME ptp_runtime_tests COMMAND ptp_runtime_tests) add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp new file mode 100644 index 000000000..78bc2cf9b --- /dev/null +++ b/tests/test_ptp_runtime.cpp @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp_runtime.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +#define CHECK(condition) do { if (!(condition)) { throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + " at " + __FILE__ + ":" + \ + std::to_string(__LINE__)); } } while (false) + +ar::iec61850::time_sync::PtpPublisherOptions make_options() { + using namespace ar::iec61850::time_sync; + PtpPublisherOptions options; + options.transport_specific = std::uint8_t{1U}; + options.domain_number = std::uint8_t{0U}; + options.source_mac = {0x02U, 0x11U, 0x22U, 0x33U, 0x44U, 0x55U}; + options.clock_identity = ptp_clock_identity_from_mac(options.source_mac); + options.vlan_id = std::uint16_t{100U}; + options.vlan_priority = std::uint8_t{4U}; + options.announce_interval = std::chrono::milliseconds{1000}; + options.sync_interval = std::chrono::milliseconds{250}; + return options; +} + +void clock_identity_helpers_match_ariec61850_behavior() { + using namespace ar::iec61850::time_sync; + PtpClockIdentity identity{}; + CHECK(try_parse_ptp_clock_identity("02:00:00:FF:FE:00:00:01", identity)); + CHECK(format_ptp_clock_identity(identity) == "02:00:00:FF:FE:00:00:01"); + + PtpClockIdentity dashed{}; + CHECK(try_parse_ptp_clock_identity(" 02-00-00-FF-FE-00-00-01 ", dashed)); + CHECK(dashed == identity); + CHECK(!try_parse_ptp_clock_identity("02:00:00:FF:FE:00:00", dashed)); + CHECK(!try_parse_ptp_clock_identity("02:00:00:FF:FE:00:00:GG", dashed)); + + const std::array mac{0x02U, 0x11U, 0x22U, 0x33U, 0x44U, 0x55U}; + CHECK(format_ptp_clock_identity(ptp_clock_identity_from_mac(mac)) == + "02:11:22:FF:FE:33:44:55"); +} + +void options_are_fail_safe_and_reconfiguration_requires_stop() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + std::string error; + CHECK(PtpPublisherRuntime::validate_options(options, error)); + CHECK(error.empty()); + + auto invalid = options; + invalid.transport_specific = std::uint8_t{16U}; + CHECK(!PtpPublisherRuntime::validate_options(invalid, error)); + CHECK(!error.empty()); + + invalid = options; + invalid.vlan_id = std::uint16_t{4095U}; + CHECK(!PtpPublisherRuntime::validate_options(invalid, error)); + + invalid = options; + invalid.port_number = std::uint16_t{0U}; + CHECK(!PtpPublisherRuntime::validate_options(invalid, error)); + + PtpPublisherRuntime runtime{options}; + const auto wall = std::chrono::system_clock::time_point{std::chrono::seconds{100}}; + const auto mono = std::chrono::steady_clock::time_point{std::chrono::seconds{10}}; + CHECK(runtime.start(wall, mono)); + auto changed = options; + changed.domain_number = std::uint8_t{1U}; + CHECK(!runtime.reconfigure(changed, error)); + runtime.stop(); + CHECK(runtime.reconfigure(changed, error)); + CHECK(runtime.options().domain_number == std::uint8_t{1U}); +} + +void scheduler_sequence_and_status_match_runtime_contract() { + using namespace ar::iec61850::time_sync; + PtpPublisherRuntime runtime{make_options()}; + const auto wall = std::chrono::system_clock::time_point{std::chrono::seconds{1000}}; + const auto mono = std::chrono::steady_clock::time_point{std::chrono::seconds{50}}; + CHECK(runtime.start(wall, mono)); + + auto due = runtime.poll_due(mono); + CHECK(due.announce); + CHECK(due.sync); + due = runtime.poll_due(mono + std::chrono::milliseconds{100}); + CHECK(!due.announce); + CHECK(!due.sync); + due = runtime.poll_due(mono + std::chrono::milliseconds{250}); + CHECK(!due.announce); + CHECK(due.sync); + due = runtime.poll_due(mono + std::chrono::milliseconds{1000}); + CHECK(due.announce); + CHECK(due.sync); + + const auto announce = runtime.prepare_announce( + PtpTimestamp{std::uint64_t{10U}, std::uint32_t{20U}}); + CHECK(announce.has_value()); + CHECK(announce->sequence_id == std::uint16_t{1U}); + PtpFrame parsed; + CHECK(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, parsed)); + CHECK(parsed.header.message_type == PtpMessageType::announce); + CHECK(parsed.header.transport_specific == std::uint8_t{1U}); + CHECK(parsed.vlan_id == std::optional{std::uint16_t{100U}}); + + const auto sync = runtime.prepare_sync(PtpTimestamp{}); + CHECK(sync.has_value()); + CHECK(sync->sequence_id == std::uint16_t{1U}); + CHECK(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, parsed)); + CHECK(parsed.header.message_type == PtpMessageType::sync); + CHECK(parsed.header.is_two_step()); + CHECK(parsed.timestamp.has_value()); + CHECK(*parsed.timestamp == PtpTimestamp{}); + + const PtpTimestamp tx_timestamp{std::uint64_t{11U}, std::uint32_t{123456U}}; + const auto follow_up = runtime.prepare_follow_up(sync->sequence_id, tx_timestamp); + CHECK(follow_up.has_value()); + CHECK(follow_up->sequence_id == sync->sequence_id); + CHECK(PtpCodec::try_parse_ethernet_frame(follow_up->ethernet_frame, parsed)); + CHECK(parsed.header.message_type == PtpMessageType::follow_up); + CHECK(!parsed.header.is_two_step()); + CHECK(parsed.timestamp == std::optional{tx_timestamp}); + + runtime.record_sent(PtpMessageType::announce, wall + std::chrono::seconds{1}); + runtime.record_sent(PtpMessageType::sync, wall + std::chrono::seconds{2}); + runtime.record_sent(PtpMessageType::follow_up, wall + std::chrono::seconds{2}); + const auto status = runtime.status(); + CHECK(status.is_running); + CHECK(status.started_at == std::optional{wall}); + CHECK(status.announce_sent == std::uint64_t{1U}); + CHECK(status.sync_sent == std::uint64_t{1U}); + CHECK(status.follow_up_sent == std::uint64_t{1U}); + CHECK(status.peer_delay_responses_sent == std::uint64_t{0U}); + CHECK(status.last_sent_at.has_value()); +} + +void peer_delay_runtime_preserves_request_identity_and_hw_timestamps() { + using namespace ar::iec61850::time_sync; + PtpPublisherRuntime runtime{make_options()}; + CHECK(runtime.start()); + + const PtpPortIdentity requester{ + {0x10U, 0x20U, 0x30U, 0x40U, 0x50U, 0x60U, 0x70U, 0x80U}, + std::uint16_t{2U}, + }; + const PtpTimestamp t2{std::uint64_t{100U}, std::uint32_t{222333444U}}; + const PtpTimestamp t3{std::uint64_t{100U}, std::uint32_t{333444555U}}; + constexpr std::uint16_t request_sequence = 0x55U; + constexpr std::int8_t request_log_interval = -3; + + const auto response = runtime.prepare_pdelay_response( + requester, + request_sequence, + request_log_interval, + t2); + CHECK(response.has_value()); + PtpFrame parsed; + CHECK(PtpCodec::try_parse_ethernet_frame(response->ethernet_frame, parsed)); + CHECK(parsed.peer_delay_multicast); + CHECK(parsed.header.message_type == PtpMessageType::pdelay_resp); + CHECK(parsed.header.sequence_id == request_sequence); + CHECK(parsed.header.log_message_interval == request_log_interval); + PtpTimestamp parsed_t2; + const auto response_body = std::span{parsed.body.data(), parsed.body.size()}; + CHECK(response_body.size() >= 10U); + CHECK(PtpTimestamp::try_read(response_body.first(10U), parsed_t2)); + CHECK(parsed_t2 == t2); + + const auto follow_up = runtime.prepare_pdelay_response_follow_up( + requester, + request_sequence, + request_log_interval, + t3); + CHECK(follow_up.has_value()); + CHECK(PtpCodec::try_parse_ethernet_frame(follow_up->ethernet_frame, parsed)); + CHECK(parsed.header.message_type == PtpMessageType::pdelay_resp_follow_up); + PtpTimestamp parsed_t3; + const auto follow_up_body = std::span{parsed.body.data(), parsed.body.size()}; + CHECK(follow_up_body.size() >= 10U); + CHECK(PtpTimestamp::try_read(follow_up_body.first(10U), parsed_t3)); + CHECK(parsed_t3 == t3); + + runtime.record_sent(PtpMessageType::pdelay_resp); + runtime.record_sent(PtpMessageType::pdelay_resp_follow_up); + CHECK(runtime.status().peer_delay_responses_sent == std::uint64_t{2U}); +} + +void one_step_policy_suppresses_follow_up() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.two_step_clock = false; + PtpPublisherRuntime runtime{options}; + CHECK(runtime.start()); + const auto sync = runtime.prepare_sync( + PtpTimestamp{std::uint64_t{1U}, std::uint32_t{2U}}); + CHECK(sync.has_value()); + PtpFrame parsed; + CHECK(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, parsed)); + CHECK(!parsed.header.is_two_step()); + CHECK(!runtime.prepare_follow_up( + sync->sequence_id, + PtpTimestamp{std::uint64_t{1U}, std::uint32_t{3U}}).has_value()); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"PTP ClockIdentity helpers", clock_identity_helpers_match_ariec61850_behavior}, + {"PTP runtime option validation", options_are_fail_safe_and_reconfiguration_requires_stop}, + {"PTP runtime scheduler/status", scheduler_sequence_and_status_match_runtime_contract}, + {"PTP runtime hardware Pdelay preparation", peer_delay_runtime_preserves_request_identity_and_hw_timestamps}, + {"PTP runtime one-step policy", one_step_policy_suppresses_follow_up}, + }; + + std::size_t passed = 0U; + for (const auto& [name, test] : tests) { + try { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } catch (const std::exception& error) { + std::cerr << "[FAIL] " << name << ": " << error.what() << '\n'; + return 1; + } + } + std::cout << "Passed " << passed << "/" << tests.size() << " PTP runtime tests.\n"; + return 0; +} From ab7704a2e96dfafefa3555e84b8a4d8938ed92ab Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 21:16:35 +0700 Subject: [PATCH 121/129] Add premium Qt workflow and dock shell --- apps/arstack_studio/CMakeLists.txt | 6 + apps/arstack_studio/README.md | 40 +++ apps/arstack_studio/assets/INTER_LICENSE.txt | 92 +++++++ apps/arstack_studio/assets/InterVariable.ttf | Bin 0 -> 879708 bytes apps/arstack_studio/qml/CalmButton.qml | 14 +- apps/arstack_studio/qml/DockFrame.qml | 88 +++++++ apps/arstack_studio/qml/Main.qml | 222 +++++++++++++--- apps/arstack_studio/qml/NumericField.qml | 4 +- apps/arstack_studio/qml/ProfileInspector.qml | 66 ++--- apps/arstack_studio/qml/QualityEditor.qml | 4 +- apps/arstack_studio/qml/SignalMatrix.qml | 12 +- apps/arstack_studio/qml/SignalPreview.qml | 12 +- apps/arstack_studio/qml/StateBadge.qml | 8 +- apps/arstack_studio/qml/StatusToast.qml | 2 +- apps/arstack_studio/qml/StudioTheme.qml | 13 +- apps/arstack_studio/qml/SurfacePanel.qml | 4 +- apps/arstack_studio/qml/TelemetryDock.qml | 182 +++++++++++++ apps/arstack_studio/qml/WorkflowBar.qml | 253 +++++++++++++++++++ apps/arstack_studio/run-windows.ps1 | 64 +++++ apps/arstack_studio/run.cmd | 4 + 20 files changed, 989 insertions(+), 101 deletions(-) create mode 100644 apps/arstack_studio/assets/INTER_LICENSE.txt create mode 100644 apps/arstack_studio/assets/InterVariable.ttf create mode 100644 apps/arstack_studio/qml/DockFrame.qml create mode 100644 apps/arstack_studio/qml/TelemetryDock.qml create mode 100644 apps/arstack_studio/qml/WorkflowBar.qml create mode 100644 apps/arstack_studio/run-windows.ps1 create mode 100644 apps/arstack_studio/run.cmd diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index eb5ec7639..97120e11e 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -32,6 +32,9 @@ qt_add_qml_module(arstack_studio qml/Main.qml qml/StudioTheme.qml qml/SurfacePanel.qml + qml/DockFrame.qml + qml/TelemetryDock.qml + qml/WorkflowBar.qml qml/CalmButton.qml qml/NumericField.qml qml/StateBadge.qml @@ -40,6 +43,9 @@ qt_add_qml_module(arstack_studio qml/QualityEditor.qml qml/SignalPreview.qml qml/StatusToast.qml + RESOURCES + assets/InterVariable.ttf + assets/INTER_LICENSE.txt ) target_link_libraries(arstack_studio PRIVATE diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md index 3bf9b0805..7e8a98fcd 100644 --- a/apps/arstack_studio/README.md +++ b/apps/arstack_studio/README.md @@ -68,6 +68,44 @@ cmake --build build-arstack-studio --target arstack_studio --parallel .\build-arstack-studio\arstack_studio.exe ``` +On the Windows development workstation used for this phase, Qt is installed at +`D:\Qt\6.8.3\msvc2022_64`. Build and launch with one command: + +```powershell +.\apps\arstack_studio\run-windows.ps1 +``` + +Use `-NoLaunch` for a build-only verification. The launcher discovers the +installed Visual Studio C++ toolchain with `vswhere`, reuses its bundled Ninja, +and does not modify the user's global `PATH`. + +## P2 fast-workflow command deck + +The fixed command deck carries ARSVIN's short operator loop into the native Qt +surface without copying its visual styling: + +1. Source shows whether an engineering file or development profile is active. +2. Device shows the serial connection state. +3. Output shows the authoritative runtime gate (`OFFLINE`, `ARMED`, `RUNNING`, + `PROFILE CHANGED`, or `PROFILE BLOCKED`). + +Open SCL, Balanced, Zero, Check, Deploy, Start, and Stop remain visible above +the three-column workspace. Keyboard operators can use `Ctrl+O`, `Ctrl+B`, +`Ctrl+0`, `Ctrl+K`, `Ctrl+D`, `F5`, and `F6`. Check reports readiness through +the existing non-modal status surface; it does not bypass the deployment gate. + +## P2 dock shell + +The main window now behaves as a compact engineering shell rather than a fixed +form. Signal Preview is a right-side dock that can be hidden or detached into a +real secondary Qt window. Status History and Output Monitor share a collapsible +bottom dock so runtime evidence stays visible without displacing the manual +injection grid. The Views menu restores closed panels and detaches Preview. + +This first dock phase intentionally keeps the safety-critical editor fixed in +the center. Arbitrary panel dragging and layout persistence remain follow-up +work; detaching a view must never move or duplicate the output controls. + For redistribution, package the required dynamically linked Qt runtime libraries with the normal Qt deployment tooling and review the applicable Qt/LGPL distribution obligations for the chosen module set. ## CI @@ -96,6 +134,8 @@ Before this desktop GUI is treated as a proven replacement for the current bench The desktop visual target is a calm premium engineering instrument: +- Inter Variable is bundled under the SIL Open Font License and used for all + interface, numeric, status, and plot-label text; no monospace family is used; - Current and Voltage are the primary working matrices; - SCL/profile information stays available without dominating the work surface; - phasor and waveform remain persistent; diff --git a/apps/arstack_studio/assets/INTER_LICENSE.txt b/apps/arstack_studio/assets/INTER_LICENSE.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/apps/arstack_studio/assets/INTER_LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/arstack_studio/assets/InterVariable.ttf b/apps/arstack_studio/assets/InterVariable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4ab79e0102bbe0ffa1ed879b13e52ac8c6487833 GIT binary patch literal 879708 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 z z{w%e!pr3#MORiUuNvd|daEhHmchqxe%_xsuemj4x5igPGrKPjOR#-i;*`*b|i{8<$tUCw56fvm8#8NPx|tQDTFIbJI~Lz7=CJo6>xvuczl(XHUKhAvkil|}wq zJPyg!0}o{Xgs()|nK&W1!ATF(5*d;e6Dj69cdcrhIrQ{e_wiz%bu*U_nC#O|>>NCK zg1!8v*i>E_(W{T!@ahb7Pl^JlcVK5Xp5nX{V zXM1XeuVCwIg{NzNt`(l4xmhbb^Cji8EX(uNUco1ud6?_5fUjg@%y5f-txx3;(DUf1^4+;_Y+_0fp4nF6)FHPeA= zCMG7j8NaPT4X(A&TOCn@k9}?5RH`S_IA?I(gny%ZJk#`z397UMePt%?J`%B%r)VER zPVpp)T+de&eRx)siwBXsx(Pzo1uNSoO)2Suu(as=1km&>Q|hkUwxC$3zVGMU6$%HPXXnR1oBh3>^x5py~NP#dN1E?+kpcq;8 zt||Xu^}G16n?4LgQ?^k-@(&7AQH7kC1cy7Z1-g!72v3EP6!xT)Mj~Up1sT(LX<{{{ z^zyz0jeZWgqCM4dwFXpQFTENbJizxo2hXU+hE6T-#Mzi*STEC}d^-zxmRkNU)x%jx z!6o}H)$%c*tu3G{`Mt&gyiH;mrR#-Ow==@)jS>BW4Psp|k39@<3?U|Zz2YTn z)*N0bC-YfUtbIJ`ud0Iyn)z)popr*Z>xc3^1$KtMzZFwlD zG;64zXTyflwmJ<5#!RmA@bPNYK-#Krga>vg>PiF;4+R2?A?}|*+&h@XeP?omIoPWD zl9MgjcGV-5Tn*$}6PNpLw*7m~`$7I|OyatY&OZAJCdHkaD$f&JXFvRH$Hs4;9zAz- z_aqYhZ*}fw_2HQrtAcx5*;)@?JS}?JP@UFlb6%Z>iOc6KEzVh%lu5E5*BVwom5y+p zwkAK>^L%YbN;a^OdZa8>^J_M9ty*0UC(JUW*%W&jG(JePS*WXDht4pHS2k~kfgkrm&AE4N?L(pFmvlB6#R3%^K8_-Hj(R)CS4#e6>pL=>M;y6Nnm9+{5*UGJEgFhf+E<1mq$i}@l{hW zI#`UQ?m!*vL5s1v7OO@C%X=odmS`(Z_H~*5k_#0h!q)o9U+)|;I{oR3*G!hrc1an( zn;L{L`v?PoKoKs;Xg30jk=|&iG=^$W4l#0PA+OoPR9tK+M9a-Gl`Ackdx;NUqQrN$9r(p&^M_RW&5u2j0d8{4`+Qw&^n>>bFr+awF422w1 zpr*r46z>gbpvHSwGFVSdbwIkQ@a1ent?(5rqgHsj=JQ(N8S2GZN|3*~{>+z@&$28} zvP{J%o58zk#M1)4k_|D#E&8>F<<$x|pB|$4c1ooN8d63CbL_mClJ!=ZDM^c=T2?)o z!eikf;ZzKBG~2m^qYNz}NSz837-eTsH}ir7UMBM{oR%=(LZpx`X<^;95;9%Wr>4C% zZ>#*^O3OFoU)TF@o!@8F)B$1Vb6Re`Im2nTSC1iWFl{c*-Q<^3x%aaCTTU+19$Ws) zp>|uW=j@GI`}Xv(cQ-FO9?^Mp-)Ei4N(e-(wI5=gHlCqX>~WYn+%Wi(KqO=4zT=y} zOHKW5^YP=+`EKrc(Z>~LKW@t3moHbD^7lno@cexhiwqy>W1p^8WRP|%PC0VRKc86; zWu{Kk--50a*${+FNB>MK584}79=|8JJnhe8<>fXxtnnqm*6`EDE7PW3*+_p~LT}|5 zeZ8lD@j3get`6oYp(5x27Z!4Md>+0+fCEe8*8BgIvTvCH~) z=-#tk?-j}7pU+m?jdpArmx2jR3*`jpO`UmoDQ%IV^J9aIObZSa`3KLYgVA`Sd-3k^ z8F>%0x&*I&H^arfRq^oLC57k08f)7o9}Hh|dGWFbxzlsUdaMeM*b?lwzQs!UsQqEa z%3_Apk=pdP3s~e_x89huWj>h!Yku2QR8>$EdSpq$p(zn37SB6G7l2VnKs(T&3B2_k z$*&71G9{%@r@krJhyrd&r|3|`uATZME@EW7_r$%6aL9N@aCmqyez%(uvuK9=KnmTR z6&_<#*L}(O;B22gBCEh2%;k_2|8JK9EwQ zl=|^f!hr+47;7T`%8P;{>dJvs6*%R4nn}=rk`dL7C?zB+5j0)p3(`g2C(Os%qm;Ua zbnM@`RXsc5g9Bw%?=2Rr<%||hoa77a`%VFi_by_msyq;*G?D-?OZ<95HlPED71H5) z4n#IU{U$FIB(Nz|2)V=9NcNWO`VotsSU#T3|3r3U7w*ezg35z%miu+VPPSwJp&!G4 z4DYhfueHa}Ay z5%zS!8wNn12L~<}jtEi5=S1(BxO7t5@~CqS-ItGl-DjrvxX8>f zU$3cwu9LF70&{{QmRi-Z$(c1JKR_8Tes&1)7Vtjf1_q_OQk#7iCGQZi8j-Jo5p0p?6vRydUKEpFRL**{A$R0WZ2lejYx&sh9Xo3FKy)!&MlCkz{12#whl)|q2`%3y z64vqUs(W*tjqv28Jm0F$$lRqR z_TeXT*pp|w#4~c-8-FiFCv7lB6@23S$vf#P+~-RZ!PoN6D{wb)3WsCNw5w5GS65kr zF~&M?`ktO?Y;kmpYnJF-)=el+x$M=D|W0~`DLV?i2%$D80FEW!vCxI0V3du84+lsCr1nQc~-BaD<^_e)NiwwtZ8Xvy9sCdTLm!QyUI9@hln;jBpp0CezVC z;`zT&9&*nz&I~i1|G#n{B%TM2xOhgn)C+TBC9j|2A4!0Z$8UvWngEXFqP8%pP94}l zHZuKm=+sl`a+h*uH$5$Fx_m*n6;eEN#-3@^$n1de^HkY^NegyrsRXPwuO-r}QwJNC z?STqUO|4lWO`__WbrMm~NjHu(x=A;YiNxmoW>_hmr)kmC2?&x@WJeKeZ&B16{&%41 zOByX;_2j?g1vQ&CHKCka`TyL+VmaseCeS;CkLe85JLykQU$3n%{DR)`3zT$peoBc> zMB}g^sCo_lAjOA=0Fu>}mCx+cD5N?1te zy-5nl75up0YsO467Q6mVCIbN?r4^Nuz2`T1h1<{=Kfi@TTRE>>F!#iq_zyCfBQ!|4 zqS$xi+$qaOjaoJ(&x@LPSX~fv`_hg?kUHcmK4}k`G8@71}c6_qtMqb9*~tW zeQ&t@h&9{#+p6v%>*YbT3?OW{Q*l)I8t-DZ=;QT+)~uSPoo!qg&=1-H^v1ZTd|@05 z`0OnWa}+?=TnV5vS`z&8;)9zXZqVO7#7W_fL7|@g8yspjKA}J!3$`$hM|a;vp8a}w zIJQyl0%+?rwpTx|*nl2j3(5(A_H|Zhe>iRXd88Zy2uyZ5Rh3eoje&BYDwb9?(ol`6 zkc2bmwI>kLWr$ve$s&%Ux&P2wt*z=IQu^ zGqch@k)O6$$%K@)gF@zS>z zEO;wv&AoMFNKzn)qf7ySykb5C1muo9NzZp)_jx4)3a5FxKK~ zd+CVo5NJfPA)rhbGE>8JV>FP}$m`jOgb?51BD1FwEg}RZ4Bh!lX5F{zgl`@{V`r#; zreAsa)$AYg);(Auoo#q3BP`!J8Do>J9Cks;i^K#Hy>}psLJMRZql5_SxC;SJ~O9ZaykrgutWM5Q*JGqj7TVKd_6a z+@x&0nO)c1x>+Mp&zBh7M~lUUwB7j=y+vDT-tF9&)A3e^tuvygE*&f&5GE)rsFhf! zxBdR)%qm&zk zIvYBI%y1VY8Zl>c#OVVElDX!);%$ZETcumuIkjoj#I3;)?~^CXR(GG#rbpv$t%4^> zgEv>TI#8yqyGtic3l<-4o_FHS#`Skfx+zoVVPy%2$=01yk~FdwXwFJ+#T*cc&0EBL z5s@EWiy!vC3c=^%2KkS&UUNE~U3hxlG{X?#ieU(iS$*y2Rp%edck{JUNyvZ1+|%;g zr<$FTFP#RbK>yPcrzl!IUz$}ZObWL#tP8^>jyBX4{+g9JYE`M`E>&Y3MYqvaR~@Ao&z>`4MkHQ}j=q#Qa$$Hkudz3y%Vf%G->humb@A=|eEIWr z>x9EQx(@4@_sBmwe0$=%1reW~(6aCEIvASumlhsA{A{g| zoiZeD!io(CV{a^ryBupcfP>11b<}yNyV6zD98k6|D&q@(oEP$+ZIL4JQ})bTDl08z z?Mmt3e0lW~eLg<2XHe5MdU1&swiQ(7hCylvI34eR0tIQ@zNu9cp+k+~B0i^|`|Lcc z_s8#01MjoG3E57AvX{tz%SWaA&$>uBP9@85Q-3cTO5FFXUcK9`@+0A1K>_PoPylut zfOZm9q#MzOSCRf;1g6(x=WNZKm^QKUEZbcng&BG^`euNzxv}9%l#ne(?b|2Jq&WMk zKdh_RaN%rI%$MS1pR`CJ3}+&glV_$5@K(&A!q>@#2KIQ`(PdBL;!s9AbWGGN7db=g2Pdo}sc ztLj6s*T2dL_Dey@64o)bO&)t*Bq=BJcsl+bJCS|gYt*?8&ZvjOjm+i{%8$5Fe*KKyg2 zQ^o6jRxFL%a3l6Zt#dC|k5-X~X8UegI4Y^nfL@dO`G*@ScgwwZ8=TAck4o#>%3<2z zfUs#%X{G+?dvEl;QtdmJlZw>GCKBddoL~@WZ`ldSX1L44N?TbuKzp-tz^1Q+&=xMf z_OI2gBUC&vd~4?w=H5wLM=PEH9o{J@FyM4imAl>If`H)d>Al4Q(8X^^^1+k^=W@DM z9f3egthU!C@UxH;myP|9^Hy=AY z-FL;uDXE{XOt~2Ky6h?pEs}rXOYu8r*?4~Vz-@WiT#uM>J?IQVgYhgjdMFNZNOuhv!-U~`_`g$LJka5pc`qgPUWTXX zK!+~p=~=zo)3cJ^?e7Sq*fSpN$d>=UP$B=F)y-Eq*r5_=7AFvNgMTKFf|2k}LZ4T_ zLm|y8A@H*{61;0ef|=vv22j7$;jCFM#DFu+z*Kv zf-Q!{O`S%DXl``W@VBH*ODhdGj|+|sPtOY(5#v!_-oPp_!F}dB`+7|$XZnY34mssA zy-#$KR#UlLn_@U`7%kjOj&ffy$q*rhp6welG$yD|$k6d+s|;O*FM`9nuLUaQ7?_W- z9cW1AO*)-utDu5GHl7=#LC|$77hFyqKkpwRFFnuV&$IMN{uhp)l0r|E$0^&#;~yP4efl#t^uPi6@@HZxZML|s@IxI%S4iW!!c3_GzqP!%!K&P? zuWWtEi@<-tvv&4IX^8VrYLGQC8LS0P4tntetg*Kcyiq9?7%rWVf8Vf?)jxcKHQ0E5 z_UZWOGxKKOSSL@=_0S31#DP`U#evuml=NkXS+_UNGuy%<`Li3l?#`Zlr)b^76-}EW zI_oE>L4m-=Ub7NpMJk}`x8;RczsjrKq^n#wHiwZxmDF|?3TI7U8088w>1)Ec1j8et z$+jEQT^5XVTRmmwXUF92EcD2_v8%)26Y%g`J~k^~KEZ|JS?!&uvUy?2W12RSChUrh zE}y+GbDCGm*kb2J-qSK6nazkOdzDU*)lQh^7;E~^`1=)6V>t;&66A3FV_5a?7-}<_ zPOhY%zb_j9`WJ-xsX(a!(z7wSP?ZQEvrKs5gk)yPqov7@_U`>@Y0{(8d7J$FHqGPz z+LW`P-=DDHQ>WwuPoBsJ%1aRUeZGP<%v~%0QE?tY^VlJW!6du}gQuF`v6HeB*1TB{ zG{(UQ=z1OHKX3jQ<+fmueSs1XLPjPFyN0E)NDsZwb_@G&!2CgDQv8CFT}~z5$;h}B zJGdZSJ}VquDYTrFo#g5hGt4zPF#YO+1?OV@H($z;_ckEKm|DPz6PS&iiP`JNXnw0k zHfLi_0i%c4atqD_tNjcWBLF4d^ zp@|n4V7|lYBAo=)>XNI)8hLAQkvh80aNmZ5Rf3SS$XjkH#vp6bN!DkpROfM(+@Mtc zfKBm~aGo1|!g)Z+l}{NO-Wp~F2o8N0Yb-n>zyC+=M&OF~muk=Ge3HE;t?>)Y4|UD$ zd18Xt#E|Dur+1Eb6eK;@TMC`QXnTE3A6Z@omnYa3ztkOw^Qzmbi;5a3IyxZ#IXV*%acHdwsr3+=TuCYeOcj4fM~ki71bso}MX}51ZI8 z^~<%ZzsnMtYmxlx?RVtcAI=jl44e`_DtN<`iG?8`ze-V(b!x6JyDqG`*@CEOLC;G( zb|hA?iBKLL;mI4P?{`D%-F9p&MCu6yN(2A}pQ4)AyO?a!3_0-jYI-ix1Ep|)a z)$26Oe*5b)M)Aq39;cB4%h` zl_2=PX*e$~mY0=tAyd^vl`*WUio!l<$a|r0{MJ8}Q7I0`yn(csT?T+Z|UxFxsyZJ z`1$69OvxKZ4Pz$t!ie&QnPOhz$BQjE&14~0q_+^j8!zt^ti8tdj7*5LdF)*v@m{^P=V`HwZ|Sf% zT3l>iLb1S|$0sqsPN#M5M2_2yz&4LRk8DZLOYr^_s?=#mF?v3M_woFBbbop-#Peoe z-n9)qBWM}2h|0SK((@*~FXZpt+nUS&i1!{aD2W;)b^b3i?V@p})k-PTO#8pki^o`C zeXB9wKsCdEEv6jqwzQaZ=$vuV@iXjrHWOhN!>!WZ{s7U+Pj7hlD|!j6Hc5u1q2A8+dxP~ zfHy~*GTt2RM{z$juCm}LK{n9QN2YgdF5IT(LerwwPwqA?(XcBm6qqbY0#@tU@8#t} zrk%@_*ICiW*tr6?--%J zNBvaa&OhnBkI+s0On48&Rz~c?A?wA@#K9^g3Uyx4^pqAz;rQlfE5fM)&-3~7FCWnJ z1&u4-$K(Cu-)cVpmp|WSuSp@|BE0|g3BA9o=`Dp|qo~vV#-GbI5z-Q{kWTxAmw%i6 zhNDv3@%+2;tcgbbZSnkv`i%QMk#$I?{gXc{?N7t=U;KHK=8#k$X|dIqMl96S1Ob;0QsL(X zDInWW<{eG7G+X)*arF&E=)Z8?{;bt&zkqG!2U(gEQik0}* zUb~JqW7>6sH-4i|xsV-};?z2=9jqXh#A1_ljM>(t5+$;LVh z)jG$at>b`q;3ibAPS?Jt!w$gKvOSjS7~wi<(#ZKPL*{uP`(*s3xaAk-%$YXAx09)B zZ`EJ9VSKl)N#osS4C*&4Am!MU87E?v92(NabJoQAa(|010}cIEj$R?}a-jrA21o(8 z_Ob+htf)x=Vc)=Cfz9{^#Kg7$*ZL*{vEIoiuE z0_~AL3fiEcu)Tiv#^CNi+clEVPCh;*@;IU0)noRA`s}t3C`y#2r3BR5$EjyWXAf{d z39Y}Jex07<16I_=cgzGI_k|;d%=glr(#2kkU3Pxf@-Tn*CTYzMH5@%3f9bZ)un z1BPeK@QoVSZ+39fu_<%PqgPxTK49!*FC9`yhs*vzh>-FHQbe0`oHhzl|3V#{F1D^g zq3AX~UO+0wnIol*yAYRF0i?#c)j&!(r*mDq!f{wOM`}(`($P>OQXyk>hHzZVaMXkp z(|n5cW;NCqtiVXc*%TMQ3uJz7ifyOx>FC8?N4*ygbzBg9Gl-lG8>XHgUeg&}g5dUa_MxlI0&yvwo~mS^@K7 zf!mYhlk%2bU&_NjLLCoKM>OidPSYC6zQ`v2faN{_Snsm=@5gjl&RB#3H26+kR14yS z*VSMuEPS$QQ&m&-1ExtropseZ9pQ6B?wts!Z}O${ok5#b3ET8Y+~!+3(z`qoicck_be>FXD4{0LkV{dEZY7i9a%*ySJIs zojJ{1S1ulqIHhi#R+$Sk$|gs>86URnwR%OPM|k?Q4cJ$$IebWdaxOgl5^H_dZ*xMM z9=-GT7EOzH8X7--+~VQ=qvhDdpV;`%K4Rm3NzV92ey}O(ld^g3#|$4aE&b~?nRk=i z>;nz+hE9rjfA`WK&(52DRsQBm4*r~k=eqoPCx6bv^JD(J+w}fEf8N8N6RU&tBap{k zuiXpL`W0-)*jr1#@U=^gXYPQ+Ow6f+p5eEM-@?0p75-6msihRsvda07!aqK3DNRB^ zaAj|{b>l`ERxUUVcWm0E(PAl?{A-nG_O}f8l%9K6#lM>!uwCL@G_$#roR}ffpH8JsO+|6g3)IV&(4t|@F z@$G@BQxD+xcL%5LO1+!9>~3=M-DUVgY`paL(r1~s6K^hEdNWb@;`Y+p5DUGl+Y9Bo z7LcAA2E9Wlm)n=HuXHW`uA_Sjh)!BWY}#o{>b)82*}x|?c%Mn^t&|*j5}2vX^OSW21rMt>Mq*{5ecG zF5MKzp}_gW~f;vrEmCcW4*ygdgzEUV9U*=(RUlAlLWMmN<~Z zuXFfy7X2Lj28Z8J;R*6)u@Ni_WCgfaDWj$v%!6AP667t&Y6<0T|{bbv<&x^`+uz;Wfi|_y+GpDsQj7 z%fGn`9fed@z$K+?1n;8daMXEMOgFyQ9WZ@oe2?Gsp2}$vja0L3j&4MH;#F$AM$`BB zC-+`xn&4fu9F8jQ@v507S6t7gsolYdTpRM?U~#=L+;9nX;+^Qp(fSXs^FK^UiIE07 zONnZ``3e*UKVWNf1hNC#5iFaK8Oo-Hby3yThDNmywW6$U49dc;!!i0oz_|%v^o6ud z8CNLKRi^iV(|hGRc0}q9e-wR3wjB+(?G$Tnu_IbZ=)&QjDsLo_zIf>y>cMr@QbawV z+FC2M3B%YC(F?qe`h_u6i#P@CMkx@H@;AQZYtSJA^ZIRR1nmOt_`wtQr@VmW-t<&G-l5Eh36Dt>I09Cw@L%L`1!&=9JVCZd?V7K8_qKd_CQ6!=_n zYi ze7jPdA(|2PB>XFFuksV#)9VfW#A8Aox$`9!RIhad7Iaze47qw6_LUUaS46UZ+d(4Y z3tKJP(b`HWRes_z`Y|j(bNR0OSVb!HK6R%-yM}j9KL0H;@7p44g&i8Tb7Z&aL!khz z&KHllBCIV^K6&zP!*&hsoRZ7%=>wQ;=D=)2aXsi7th@MmAZAx3h}puek`mbm3xcS5 z0Bg=1Y{ID+SkaCss}^R7?8qK@oP1gNsJGPj+55r{^-DqXHtasxUlA)ZfB3 zoEJUh<22CQd!Un#@R#?Xfj&M12T|;$GDy=nDj)-qnv((D;)KQs-Q7=A_TINo{#5wW zP#^f0!ctWNOVw-Sinmq*e3bxsY^aP8@-W0SSA&0ReYcMVz&ViU=jhzU*+bYP24s!( zNbnyps_S5HVGn<|WZb~v_R70VZ_mVW{YQ2gGFA}QByDsVHM(clN#mDnbRFu}yQ|+c z*&}Jg=us}c>?Te~D0Fjn>uK*7iV-M*1#K@ZXrNgQEFEiNxlahl8Z#!*-*JTfaQ8hz zVCI;yOT7m=+YcVg*y*JkM-O-JZ9i#z(#FvvNB6NCHx-lZ2n=tbS}o@J&zMYP4EqI; z?HodA;X&)@?RD50EV|YQZtnKRsHG#zM7+Dfc&OH-iGNbTeY7hDe=-AGxiHv>Gch$uL87VMAEs9sdt)-vJlZvHibu z@7-OBqKFh5f(R%oB1MYy-g|EXA|PTzkfLJ8h7Ak$f{G@Z#2C{|PmD3fm_$=d%uCU{ zJYzJ!JYP%{*vtPrbML}}Ym)c*{r>qxcK6&f=bSk+bLPz4GiO9NF0e7#d>fF_%i%J4 z@NJ#h7+;*Ck~H%4^_-HOT;|cASPo?JhV5xvF@!fFxFm-=s>u_DY|=EyE;5F1Wjk>&S%n z$b@JAHQ=Ah#29fbSFKqOV`~iQE4e0qGo+*^Da@3P_V%7i6tcHeD%JL8O$=(c0 z5PNfwRD-4?h?y8|C!55|L{jBxyx-W1R3##@<)(OXt(S2ZdD;Bl+Dkqk zueondUKS(F@2~mT=hE7H=17nvD2hq8@GG{Aq!p1L#HMc>{$w@e>F8z&hf?-NrKk() zUfY>+Pu5;Nn3HqxYHi)sgW1^!uh!LHsHwS7U-y1Z&HMLPEMFzMk%6P)J-w6c-#IYE z)5~Hu$)?!aQuK2J@TF4t`?M%>R`xMIQ&OC=Cc3!TiS&ws<6S?gjw^pc@QE8sLMWe4 z>W&wnp-sp@E$_KKvPwttM@RNX+ZlUuG>pKS!}*SPYs#LPZ*pqDs?^BZNc^8#J>XRT zieqIp?^coH2gKW-*3^7TEDjtX7WA8V`@q64j^ySZ`C_5`$=#|wSCzH1FuEY3HY&0v zVrop`?yTx_n%X|a7^$aq3NiwEYK84(x@8FS*S&W@gF)o?wST8p%H-;EHKk8fn4B6| zoffqqB4R;Q+A5P%Ci72~)|{&r)BjejfSI+M)~Y5ls+QJjXI9m@&b9w-t>~`N6m%^) z@quoqG3&dHqOXDWk?3vv5AOKljkLtXw6!IFLv)hMtG8CoZ(b4U;}fn~DsK3DA}cZQ z&kCzBfK|9xoi5-kAU5wty=@j12(v>!>6FH&+$lPIr0}+Qc`BJ(*g2RUk*joW-%Ri?wQ(cnS0}1#MG0KGKv_nNo6BX2B5fVWC#jp+|Cc z+cvjw@*bbqSfBm-iy8^>FAaLMl_}fQSpQuA*wn>^xk1yt(A@cG?&q-@llYH$gnRdD zMYdg1=r6XSRlV7h(3-}Ey0RLhLgU~m(XsLIuE8c#`&T?Fgs`S@&6=Vdx5>G=Ifc&d zY11%~E$97sJFF*I7)0Mql}C2EKF!;eo4YG7cV|w{&Rn-tSJzZG_cT}6G~O?JS8m?! zoSfZxxx2D=P;jc7TdF%yz$1iP&5uROJUfLe>(gxag}v%v{aA`~#@-rqjonek)C1XA z*c%d3nYK2mbbo5%w27JN8Kr{{+05S(>rp#1w9RnH@~G%C&wi=-BNECBxz$rDlc$zU zFf=qCHbMxE4IA$mo98qnpfX!?b-rs#qeq%cx$z)l!N}X+#}6{a{mbH4 zOG{r}y!e$eoVrVy>ET|Hl04JH0|#AZy}fM3I~5i0tXTF=#ioQ62?;9`>HjMdz$25p zMf$5PVZLePANrE6<6Lwo0gkN0Xb%tW7H`#gi(fo>w4H~Cor61O^#bk|_pQo+rZS-W zbBKFpX}IJN ze)Js%LDX{hMiX^(1aonB>VdB-E5ANK&YpfnIli;A{Kyas=)CaD#3aP-t>PH}Lw zmtK>4da@lnjH#Y%)E1UXDH={KC%-Ynt-u(2DC8i$Ftg;`DqE*n92zoXc3!1xSJ@w7c06hmr-pe)D1lrFUh*#-pt*V+%{uqda2V&D$JHD}4gyM5@%I z<1<}prt79bLz08XhWiaUVm3S_XiP-la5Qh3;s>>v&>x|a{)%K&81Y=xIJ1{)s2?@a z(sYfA;JtA7>%Pg@MWxON5UFiv?~;|1*(Z9xno*(e0Z3QOl9}U2OdM?LFe2djy16e@ zRJ<@Ze$}jTRufE3T`Zkut&E@d@~l}e*AoBoz_11GBPW@gxZ63`MFy7pE8~fOSwL*< z#4!{5nNPBIuZajK^B4LNr!c2s?n5lc5BHx2V)I^_H6hZ;(!n zluecOJi1qF$>Q842MjSR&K$QO{eahiiv8KKt3ynuY?%M*9}BCm@7n!k)$A)z7B%=z zSzK7OaEi*On(qqtUw6KIgyoT;j+eyEzx$jOubo`~Ym3E(ZFb!yXgDc^QG8xK*SwFV}!Rle&9dpH0JwjCRcK^HO-Y}ZEY%_wzkeE zygN_r;J)C#+>J4Kw#!Lr0y0?D(|9e6AXBBu>zXmRr}JT^O3|)R9aoU9AH{LpC6yan z^64D<>~-K1rv&mx`i>Z65;h=--8}@eadO zbMrUel-?r3<5kIJrM0jT-z@1(B9<&v@^PNK6fS!*UYMJxX|4Tae^%E1Pihx@bRawX zz()&^vZ(yonaP`H`ptOGHBp!w+i@~`|0gvyA05cbI`C0V%_sY_Un)Ocfm8l|6&sWA zfnI?@AXqTnQOif%aE98_jvlhariWcJ*|gRS5zZb1*Ey4k;)VW=WV<+T9ZcTs7R^(d zliRj&sa#5n#zJESQb={iFD@&7-ONvvIOVH&~ zQP+E@>qyCHYTs`lJpmn5()`a}3X&IR9mvQyFe`mUO8>nEV*vztPa#}n_)PiH$vt}IDTEiO$;DwV3iBPfZ! znCsoT4RIjA*<(a;TraOo4~$jxtZY;S$n6YWM`$OWo2`l?SsdS8ENnB zTR$$qZ)G|37C*5@vnO$mH(8LEl2Tlnm^2l7tLB&UDzptvRzqifQ#(2NCVnur5t|do z42IU`$5jQLObLs~5q=e$(U*>gWqOx{VnILKD=-)ps^FKCv8tOG`+EG2>J#Y>US1B4 zUS80~OwD%gHJ4GNTSNe-}?&enPL$5iU^ zlJc%*L3GCGWut7kZ@Q`RtUhYgM{;lxZsKO|MsR&7)=0RtlMJvX2Is{$ox~qb5bL}1 zNr*7%qr2h-B3|alcYaFx*Nb(MQiu(R&|OXF0V+^E-T5g$UV0>~#SWAMZG1P}ZAjbS z!ctlUcl$~@U#j;Rn^-Q1agU#DYGE2%>Q%bpWR$mGY+JaGZz9#k;hY5fEkLkk_H#ADQ@B#Z3CgFXd6h&vYQ2@JID)tT8Nw3(x-ti&Us7F8Ne<$);eag zqac|P2R2FuTaXtj=t!@qIFO!xpdvljDZ$%2(J7CwN!pa3zbQ%jyKH8PSE`FknrCta zn!lRc&pUxSvs6%w>^y3{5Ts8w)9Jz0PZ$jvy!bU<9$45q4ynrWq$San*q*Qo!l&) zvlqFm?0m)#R~m@_g0_YZv>RkMWXl=xEz9uIh%v$3sd}y-H_+OA6gQjdE;6pp8@fxG z6zkL#&`kdfw|BeFILQFk;UQ;&KocNYWd|;$uebQxwuYAZw$^u070Va zB^1EsE_!hH`xjkDgM9Jadlyche((HqQ%g#wPAx6rYpfBoxs&t2cxw5oCArd2C9 zz;^1=qHlB_LrqQH#J&{$g2;j}J!G9maJfTJv1w55e_)|yN7+v8;Yt4Ul)?3nsG;U| zE!(*WNJT*UCy;2@lcM?U=q{`&@ia1x${I)F)(zPT1uYcp#tmMd;Me&n%FIP~9RUT< z#+T0#Qa1>v2r+hPB#xS1*S>(Hx&)5Q4~xiZdvSBi^MfasB<~{6^UD{K{tiAK){_GQ zCReUqRc0F!o9jDAy&iN+p;&j&oxl{K-9)8Mk2Ke@xmLTEinW{CTw21Bi$w>ga;LcR zsQ76!b7N!Uiqk{Gr*X$f&aQmi)>D!)voa4c3_df$$GG`VoaDdI&t-zQ=Y;TbU%#@T z&>Uk`|Dw3C3O`u_FK9z{^c;2bic0r>6YZfOa;f;hRK&cNMI{!^Do+1dOyZV_r)RcJ zEjYs0de4X$>l*0k7CrB@vZ-F|bTsj;CW<&FVg+eehEJ*=qi@tMhmf*tAPNiIJmv20f2n8cy`J&?{Y*G4s@EGT_-!a2Uy zzarGdJ0UPQVZ;yza;WLLt#fd7{KEb6@NhvZj-&}iq_+}U2S8d$>&$!IBZh98k}F{= zD*|(#tjXJyZupc@a!_dAMD>XEsi`Ys-`XEvR+iwA>dY&P_}VeKyXUQVyCP^-sFO=j zZHjkT@cigS2Ue!|`6Z7D8Z#w@2~oolUumFAs*j=Cq$JqC(A&GnFSx`+ZQzqS zDX`S9KFr-cG}PTajFbmXoapc8;vB@Zwt#n0z23d1(+)wLFzoTT<!P+^pOUgNHuK25sAbcSh1Btf0C8@d}wGwO`s|+J3%?iN37;wsOCaFI7LV z0ZAjT5mXGym`5EPwmQYS?vIWe89aG^N}BLl<{Qt2tq^~%iP+LMV*w)iuwD5J*(MjI z#}1?{DKJg(p25~}(fi$EoYGRbUuGQJ98p8eSA;$HMrQqkY82dug72Up?K5&*@IKen#2c9^p$nE_#XEb3Bl&Cqe9V<2uQVfk#`~@AlSxWkW}E@V>n1yB110nh71S3 zo)(8!F`RKS#74Zr4dxJDrh>VUa5UfX#ta7&0fSVFS5S)b!b*@qD$tl?IX6HiN|3IH znqP<$gShCFQnON%dLYDxL9pSir7Ym0N6^7ZW-UaP&|QY0Uc4of$3u&w`2}^S+Q7>o zRw@+`Lk2-4gq99xMZ$sOB}0;-pUaGww+yin&y!2!1K{{{m%0S*et_V;8>8cay4y2| zuMBa;CT=~0;I%KqnG79vF^Ipml;$UXEQ18dkXgXF$RL3-Bo86uiJSp=1w8@@mLb!i z=l?K`g~*U1K>o=fp)zDHBpku$gvpRnPTR9itmP;2Oat{BcU5|g;#QhfM9E! z(b+2clP@`UK$2ugJRsW`1RH4#XCEN%Fi46FDF$R6gQUukwO!8KKS>_wq{)!=T^-yH zinV~G%a9#iwcIsCqN-IH6rv#FT!cXkVY{U)?V&hVP(0D8pfS-HweeklaBuS-k_+1X zJ_;^T_k>D~@|vBkhg(_>AKDcg6BCPnhF3qh`1u#_U;K3S=GAMPx2{>U3Bx`Xq%O&% z;DJ7l81q1zb{;>zbLa76yJF(wV`Adtd_0J+x8WD>z4zr;=g(hT-n_19+xGSAnyrU& z`4+G`Xd?0gb_AGh!djF%pf4OUeCsgXN8pZZKe!l3XluNF9Zwxl`8cWUfC>i0Cu4#T zP&0fTcbv2J8nrwH#ZY3JLV=wj4_s48(^#Ei4cv2Je*F%c z?2K{}UO^na)0`)|Po6Yu@ya~=q}qaNqt&2T3>~}yim*VazjoI9OUILJN?DnpJgiP~ z@kxw~OHK6*^PV&=Q`jveFON-|?;GWxo^sM~V#bsqHr~$eVGRN9<2@Yg{Bqp`r+bFy zn;03D#!p)*bH4x$VE?i=ck1`{^*T4ltw=R!Gftl1oi;Q)B=E3!gmb|8rlrxz8w|&# z2ijVV^Bv~vRQZT>bsFcZJI!Qo{-=rK7^+C?y7Zl@=6J9%gcO#-(whlI`ud7oU? z@GXm%an5gwt;UUu4^QFZna`;$%6v{*RJWk8y`6mA$#0|OG&fi=GJ-jrRbBVFli-Nm z#@^LJV>z*t@QKFT4h1bW$FN~Cj!|P~2YnGvg1;X@lIrVy+YLx}qCDAT{$6G~l^q`_~l`HmDdxwO0`-FuV z2GynbuPGu5ae>{K`lrXIJqU@fzgDMy=i5Tw_61g}ju^}?l%PD+Z!up)hm}I|z zr08(>uwYNGpb$g6$D0Hf({R9a-+&@7?;?M{;sJ(mFU`o7^?~GsvcdxlA9=>69@hK~!c``J9-^!hD=aOyXLhU>XGyMJN zy}_6XxCQ)V`MrUThuQ7v_Hr-_;v1qT1|4rD+~`4t<6~z}*}H%?+LX{xesWMsj&) ze@dSdyUH(gVQ3+&g#)VfxR*T2&8x|AP zJU(Jfp0Ra>6&xrlSH=ve? z-;-noeE}v#z(*Wi49L6U6g+F_>PN~H7ih?ir6V=x?(|eHHu$?YR-Um72^nBFVysWV zB+rmpzeU^S6!HQ;%yr5{7vIE)s*NDBvC9EBdY(n>j{-3+*yHrD#HIm8p4~>v(T)S= zy_y;KRb0Tt2_|Q+W|zW!jpz1pZ!523!d-`s&<02>AeR_qy$p%r+6a<40H;ZYL;-S? zK{m*cXrn5Zu}P*e4Ky+s4c0pt4Q%@`8qmEg$6&oQw(xLzfl-Wn{b)awW&MfL;C^K^w(xgl zIqs`==%ulhzs)eFb={Zi(pQeH!byn>Fue70*(#u~LyjC^lrt`@&$7DMGC8)=lyGB7K6>S7k>!X$+lDX~Ne{FZla-e@0Jf}xeNKP?8 zYaNU3Rw{n%x~aO4s3&^!?Ia|n3c)Nuy$J1_W+d-m0a)_eb^NsWtLN-^pz+idA)zbs z3zh|kESnnZ?;9Tz6d(+ify;w>fSm)_@`4XYS(hB2yf!6eEk4$zte6oJA6#A@93L{H z)Aq5CFpjV)QPWx&zOPe@B*j$ftpAmyfAyUCFn^+n|E{LIt)A*Xobtb@{))d)j3HG_ zzkPiaGf%NZfiC}7mH(^ff2o|m=tlns%G0aS-%J0$X!I|1jbUli^^59F3_1tnKnyWz zg8s#Znm2#1vj)#oyV}*wcth%-!Y&nk&P&63iqO>Az@Fb%&)4(ZL&#;%!@K^ao}v61 z#LPK5fPtzcOIWy|55~US` zQMml?WvX|u+9T$ZhYA<4h989A`C=>)5x%A}f)@mDX0~WXYP!~GJ(Sr=FD;=EaV?%{ zn;IRA2BIHtdjD;yJ@HK2ns`Rz)Jeu$?>EU0>89+je0+DGz++qd<6e34LbYiBkM}8A z)+xlVKuD`wN{DXliki=rSFyE=92IgaSW=Bh;eRX?gS~I3;mX_^`-lJ!v!O${20kIt zG1$jr$dIARg?x(Ac8Ki|*AdwZhT0Bw7?)p(de@6Nq`E5#H<6-A9)>GO=RM7*8nJ6O zb`%iGhaGR~y5laH*qCYukLI}KR|4aw)J7yDQ0|OEVPKBai1dmHTYAd~LYfE*YeIkm zRb~-dy(y`C%2QKGN_u)~n(|7= z7GVxD%_Cbsc?;)(EYo+F7MAT!Pv1j7(wZx>3Ny;fGYYfm$?4@?cMN_NyfL*$%%C}4Vrpal=qt>@IAhM@a0xcA5Yg!1AQ&XQHhtC` zo0GCyUMX7p{i@C`&!&XjSH)Y=8-2Ku?oH>*-2F56d{8z2gWZ`;zwPDBhKVmt^7H0r zk&wyABw*v=iwEs&U|=&MIwi&7ibUS&3BxQSbkL5zx_W)g)*tQ=7#1wGNP@Goahx&N?MjuV3K1e8Zusk;6IU1O5`XZcdg$LK4z)iitKE^_OMqjk zgRH8^rMsI4%@y6NxMo!q`Jjz_P^Fm-)2o2euj#1EkpLfqT8)2}LSz+Zt+`RfZEhc< zG->PDDU`LTDmfd@8ijGSg{*T*1QqJhc`qXB{3^~y^BoxfG0?#N=!2!|rAHqnLJ;^% zlo>e4i{9>uH)@#ISZt9cBHN&F+%#rx+RpD5KaV`w7U(oOG*S^ONm{U0DtZ|DShdxZTV)&)sm)QEPG zzR|{2`n0Kx`l)qD1cli-1-6}x6hG&8pCe9_7cZ@Gak2{!wAMH+;6Afm%e*@;GiKbrgh`IzpSce*lU0JxrB8oh8JHiEkGcq(g#=&+qOmVe(tKc#Pma zTcB|o5ga;NlAkz%0h|P=hcTc5gpl14l4D#+->6k+m&ET)fr?L@I!mbPB>8*mPL?0G zhV@CeKDI`u!y_Myb|BEU(Qkj0^}RZ(rb@RTw>Li4cIXZf6zUl~eN9&X$rw5RTPY3R z{1Ykv2>f43DNsWu4+r^HCL9YVo`$?7HD_v=Gs~Va@vwqfMA@*Z*ByO0?5HZRXMoVB zu#vrt9OD2z&;j>24n{|`qwaSHT@FScAHDzCQNzYR=fLJ3@`eWH5|h`wGRJN!DotJ-9`v5|26x=iX*A5(=+8=;euJkw`fk)op}~EOHlo}R z3kaS(<#|zQt+DQ9p?h_|Hf;3q2AvNWby}aRGH=QBqf)donup38YI-sj)?G^&nLD+b zLi6`%utx|wdF$=zsGRP!tur1xlZ2*H!jGG&Qqb_MUhV_} zLGThm3eu9#)G*_#e}MDfW^GPQ-JBI0yYAZ+E52RVX{)37-f{ygYjaxLvg>PBUtiX1 z9KQ-t@?=qZSrxWTKyQbWP9?eNPPocXv{k1ZJ8dQYv9Xdo{I|V^w2oevU0=QC`m(k( zDov`6F00@OUR314=cwjXY6C@{^c&s-{cR}!&VUoM5WkJIzkyGG8*6_9pZ@Mge`9LT z!*oi^_t*Z0DWJbiAknydw zzww-wAHshBLvcdsqeP5=+6-RDlc4wy#YI3I0T~R)ML^mC$zTxkZpaBhXsJQ86#C9L zilHil>xog(Dbsc-KMGh$CMZ zav;?4%j@bo8|wIFPu1}ab)C!EACN8SYE_@YD1{epW=l%c(Vf{i1@tj}0{8nA!bR`| zVYDUu5v+}!A5yoqs4HBx9s9v}0>4tV{o#iyx3hdJe+19o#It`(&n!*Jo2p5;oT8JS z+UsFNYhY{0GhUh{+%j$j<)~cO@tP1q5xe;U5uEKI7G?hMOQoP$OhsrDXsA_>6u^TV zys0e8(T=lBnulL2ISoAq(IXQuElmx1h}B*#gmk>dgwtR@om&o#1%mT$Om>#Qit^SC z^~SzJApaHrRi}e@LV`E)Q`$Y>@3N}tVbyC1p5BRxfRNLK-V&ibsE-E6ZJ51t<>8Pw zukT#FrM_d@kiCbNqkd|A&-Cl1-uKkLUT%+`&PQ{?3vXsdoZiJ|ifth|exm0A`Wby> zkJJcw-2SQ1*ilcB+5{TvtHtX_V_j`W!6Z|Qg6z(A_VjLCdiui_ZzmD;O^)BXB`7Lu zZ+G&1yYrAC&Yi09Lxuo!ySB1ZajR{}_#QACOG}tud2R!%5AVzDPstl7ecI0-)W<`x zYVNH6zhl%->D7K=uRcaxY_k~t7)EqhSsAEQXHhqWA#b4SbnhI)U*m3dy6_+K^tfEl zb2%HJ;Q-|gF;h%N>ZD8fM7SvukS2?XQ1W!AGsPlLis{lPerJ;7D_FQTIuPimj2osa`cD%!40BP zk*CGvj*brAjtOyG%)EcUgCEHx#1A|XztDL?d?bkh@91=)(u&C_(1Dafv?3H-PwOT@ zk+k4+SE~9eRRi`&Mq;~8o8-_F`4*ml@Lq7=6!7 zW5(gQ(8~aY@a*kbS=+O7wq<2)%W+MaG%3ZE{Waf6d`gIK@%QAc_>Oq4g!pud8#P76 z#HU!ZmYmHJ&*LjONQ>^s%G!|wGTSC4PjXA1G%48)U$~9rRWbB6hF`RCqoxVu#Pi}? zAQ%26sNw&O8wq_Zp2tp6PbIZ#*H-nBD*jhhYW?r4!H@bcBog{5Gn8Js=EH#7J4Puayq+5h?}C)d|EOJl(; z@Ub7~@3pzv_w&!aw{G{Ff@5D++@0*ZKWF54`(_jrWcqn$W!?QHOCxf%K6`e1y4sDN z^6|%BV@COS?cC`FBi$p8USaez*2@ADxJ(R_KHist_*q&VinhjgtLvBda+VdkHOee4s%CONuuQKZF$~S$@it9`HC2n z5fky18Pmm=iAi~1nq2T}#Ood6E1r8tyg&uR6t_whj)QA-Q2~=}dX_KYr;EXzr?~I< zlFr>48}2(QNH-q#fn-caif&7~3(O<}HXso##+#ndMv*Oz_BbrSFZVf?E zFvJI@3YyoE{pMPt-(lBQ@VkL^XGaK%B{GDwG(n*+yO$gIAv>FOxIeAk8nR zz=N)D)cugjX$k`1G8EGl^A$DdSJDZ5H=Hn}TcX+lk8NG;S2+L3*h7!gQ8U1>FvRf{ zi+Lm|kj;S^Znl+U?GeXpi*Se@YhgwsJgB#hja3V(L(V;8b6VQwjN`|pPbAbN!_zls zW^PW$-?ldC6Mw}YkZ5ya?1bYJq9@@0G6PMe-U{C|Pgar}=oA;p z5#Z4k9r`tkqhZ(|39TI;XbUJ^4<78S>@FlNGZ%2D7;(DjBTAuC^CTtt19uAFC>xr; ztpv-_2MDlM;?N^jaY|}~E$!KR2x=()#;IuDS>sj2m2hQ6o!|fE`DiQdVu5BSS5{#B zx6et_F;T^;Zwy7j-#|BJd#b{#+)J9s4v`Du_K~kOZ)v6}ue7zbf7+%TFApVJj3vjz zI+ztfQS-Cp8-$|-h@&;r$Xnb?z@}wrgwHK_U1u356||v%DsZ;c*3v^tw+t5&cSORi zbVCX!mVzJGDw3hq+`>vtA}9Q~hxGq&{`?Qc|LoZ<-n(4YmVE%3rmpPI&N^^KIj&l> zyb}9iLR(wahvGeoajB~65;56ba|MUu_g?|#fgDK9L{N{$R5}U*{26B+uJ@Hc;Ta>w zkmpgiKj9~CB4-FqR@jTHZIAD4K1xbhC0{r|ooG|aRlN6MRn>>YWKZoC0fI8K$fOh z5a2KMqh#SCWA8~?juC1J&njngKWc_{zBZftW6m6n;cV_f$1-6x7pQr&1NYg`90A0G zQp07`^iP+93+6&W5N}e{G`SC5;nBUPZtt57F0eSG`r+Z*%AM_H!XS-mF84rVFqdn` z<-ttSPKnw)CMOMz`&LziH%oX^$TodV4Y@VS&)dgvzi_{Ni@Qm_;S70&200I~4*o(t z6m0Q&lqHCwsWz#zL{D>5Cy84^+qqFW;@}a@OI+v|xb*FLO?zuA-_kjQyLa>`So9{L zOF*Y^uYxFa34Dil^HtcJ5_*#>QoEZgOUd&OPqpq>j`)Hv7h{qrr`A@+N1+~y`beT; zcZ#{38p z8b%T~s>aTpd|BsCQgnna>-&_0luDGMk_vU?`SvChdi@9@jHyPB91-`^qG&#JNWo5J z($joqhMZiEay<(^XEYqQRh)iUoX%|(vdHsdjAqczo#J-k`;L*^DP$1`m98s-9cGH| zPJr6d;*>q>1arg~)VPO^Gw7OvNGT)o4Tunhm8Q~IWl$iU{Tz*E!u6mE1x+TeP|%y# zAgOZmEiqSY6}vWUAT;Cjkz2ROVUkD4h7Dqucv#HSRMH1|h~E|LxAwUMeGXH@ao6?2gLt?NI`p*oQQQi>@KhR2q6dE}4=bq$&Z0x~z&CIR zE7Jo%^ceWN!Xkb6#E2iS7e0Xdk6!o?K0z;hG`Xi2KAu~z7aqhd)eFa|OTF+0{rV(J zv0b1?4`c%;>V@y&+V#SZb5dkTpLWOfkC*h~e?Sn9`l$ZN74B2L@N3*Pz3^XoV|{R? z9}g$7f(MUDgW^;Z5|y!+7l0>Y)>^zdtA=cniJwk&mGh z#Y=$^ee|!4=0DI&Cx&~V7aq$YFj9}4alEfyc>H7FTZJ;c_$_jvM_)OgdQ5vg$G^l< z>G#FIgMLDj>Gp-+MV4WG@L!QS_b{d)86PvD8qTRFHo4`w=f5`a{;iq;%0NxwUraTB zX6K`nf$eW{sXKSJfA&}N(VrT8J$m>Lt1qup-(hrJ#P+2A%c)FIt>pKv)co?lsA$t{ zA#V1AUH{jW{0)kF59M(1^HF7~hdl;9oI_}#9()7xC3`v3r}L@%lf!!9 zuAHI5OAj44j#mT#uAMv`=;X;J%PWj;Uw9C%38C`zg-3Dqdf};v_t%F%l{e4}U(4gw zq8|DixD9&YEnK5s_@T$(zbnkwhmT1#UoU(BcUmue2=A;HKAQZb7d{^EXY|yI1aa8& zd9=QyhU?S|Z_ux2vXuKnFa8GZzFznq?%#Uh$GIlG@C)2Kdf^`sY0~Y}A25kZM!zro z8ZMyGSKnWGc+HRM0h6fY9Q2`sNmOsUC`?y6%A>Ea|KYxcPHbt4UPC9ghllh_Bh6Xw{}+<{ z#(kq#o%gAPkE$Qjk&C`&#B`(|KAh8Qp4A5AV>a#d(F~@e-Fo4!80~aA>WlA2+Szne zuUV|Dr9rd`n0{gB*ca`wPmdcn^Cp9beT(BNE#f1Ke1PVY$te25HB(ZZD}3_f0l zOVb*~N5~KGm&$PPhn!V_Kh1)Awem>Q-G0C$DL(gwj6Z?l*Ju`#1_u8QaFzPux0DL7 zxnT^7K}(?{WvDP5x5YY8lfz^W)6(ER6J`URszakfvzWiiuuElZY1KIxw5ye6z{c?= zO1n_YF@r(ZO3)Cj#T7dt-GQ!o^t7d!{?nEpImX18I@&r1MNAHeS*Tf@GNxpfpyaK+ zf;}dOWG5~^NNXd0MBYZ^huT2VC3aZJ5OzI|1kqlPgUx`Iz&(X~Qmho%!;tP`WqG35 z5l=S+Khs<*yiUwMnIAACASK){KGMy8oSA*J*Eab|An~ISMVkt;ier3zqWwn+N+D@_ zh&BIj-HU?wD}0Soqi|4nF%5N*=a@bZ;+CZv z4aL@`v64@qPA03zdqvxujk9-)jQ0yq37DbOC}Q4;^69_2v&+NxWzOu+07 z)1&H_-aI`D=Y9Hc5pDYb^s>#>BMSP+LG*-kigphAN0tlPYXISE(2pD?h}1IdiMyDJ z5c_qo0x%Z}N26`Tk5u9LO>*&JI7;??-b{w|evY>=N$PO9yxQP~VmhG_TkaHYFrypZ zt|;$;>)?Zr1gCP)am(cB;eQ4jeHR#h5bIGlaWIwAKO?3J^sujkLJQx@ zgwI(@_>d=-!Rbh+^yh2oC}SBNEqoE9L+M8`yNUYZrx`XnV6GC1`IlLuzl%N_tL@oG^M) zf4iZ0j?rUBQD{Gxj-nW}D_-h>>)_u+mLjSz#;51#3i!+PDSuTf=wN1I_0!>Nu)dqV zNCryvK>vH1(Z}M3^_y+o@LLSNREBSr;LxwmtPA}WB9f_j=%rkK8}WP6-ZrIl0GFv~Wg$PB$G5B0y;Wa0eY`2gwCl53(IbB_;h~(MRh` zr1plL?FGNdJ=D@sKh5eZ;RDX(!Pbbm6FVQ^-*B=~P#6+Lhr{F{nP5?oB>$5FmA9l#miY{0+6aZd-*0s5E^6>_@~ z553Qu6|0#XH?_~Ze&oMn^`Yfe1~)#TK4Lb7s_x5FRR-;!cSE~zVbh4T5yfPn{wI@x z!X@7OyKV^$swQ~C!7|U|c-cn1#8g~;hPrga_b^$@fbY!U(K7xl=A}`1B%_li!{Zs9 zGSHDcZ7n=Uq62t1)d^Pud^#6mRz_dANv3u431@uhbJSU+wWC~mpbT5$PN~t_F={2s)E+*GaF_r62 ztv|zeG+=Nx>kg1~1~G993Z9D+A8w77zp7ZM)P-yL3wRH&U1vcH)3x}z>n{pt__W6r z)1F1&gRq|Uy2OXflAfavG5+j1dKOkaQqL-;qe+Ig_wR->-jo*YS8{KsJlJZk8{W-( zxiTG8u$?90_wZC(c2gIpQ|kN0d})mFF*5c+sbe-mk|P=ZsLB=|kWdd8i>8FvYOlT4D}uwptZ zSm!Kh#}QpM~WubmlxVzulG zzsbc*aCoZWjE+1ra`B7~{7G3?-%Gl>Lb>*aLbJq^QJE~JOX~>!M+y6;B%>~rVn@o@ zTG=T!;9LRY*??ITcgCZp^0+2$48H;W<1Uj?3!lL7+04LZ06Wm3vm#oQX>PXO=go>Y zdq1Z$4&}$u^5_pnRBCA+!Z?>N@209v;$Y9og+8%gmUC3ugG>^g_B zk-^4{w1T7a5FIl+`oLki8JzOz*)O7eq*-)74zIKMi9Z8AJ!1w?HVPzY-!Vg>bj+|- z9mY&=Z0(ro4b_erK-rj)DEA#R5@oHWrZbK-W-tRX?MoJ%ab#mgq9TnMeDAdimRHA* zTXiDE*&XF#I+qJca(%Y2`3GL9nyoqo?3L1b;;vvimoG*=Ybd@FmXCb)poMgCr=kHIxEjP?ZGzO{S}aeZvjc z#(+9IE5^eEZakAxTFGHNF#JF1->N@giJ(Jh;G5In z-L(KT>M2x^XnZTiQ5s$E13pPTvK#(=7uuQXfcASGD&JBnk3bo1d5nMtC{+9(21NvjSx*W)99p$IJ>1#F zY{Tu@1>*k{Cir*{x+OlGw~SJzbOdmul%YXNpWb87C^7iu-f$?w3w$MfM}rUP3_{BZ z@IkRZb`bQ|p=H?dJu_T(%R{s$N&tyuD!g8|CGNfeCh>-X?Duuic(8cFe+z z@abNY%2FCmmO3nAo6B1R2ej@V)U8CSB{ zd15!3#@~Cmk-ftUX5Zdm=Hfj3_HFThmdzvTTZ6oP68L{HdQjEvZdHJ)Jei(Wd*zgv#Kkey-{S=SEfu{=x zr!@jn5K&Bt6FM8^becRz_M)RR8L-g}S{M?yk*#5@(nOemL^)FQEmZG@vjuME_Q*~{ z#-1aK&WYYM8;;32{Pzw`GNY#qRHJ17csF6fxpSH~&z&Ru;*z&mDcod=Q>-!@4x;3b>!P*s(yYa3)w-*v zt>C$HutHd|@dyWp)&3ZIr!%-@pV9TG$AQ&X;|mg8@;G*2^_AU4tAwgEe@V71PzJh8 z-({g*)7j zICe}!%JH(r6YX!uBfl})$$az1=kA>N`Rj8k#WT+siuZ}JVG6B~qF7bH{fJFIwZT1< zse(R6qoJGBG~k(?Na${A+Ji@Abp}#@!n`9`B0?(#8KTOD+J zoFulE2RfO^MqIsSx9$sH!vS=A36<%AV^>0>z+Fmxzh<|N z+Yaai8LGSC02(P;8am5zEsYAzZr$ZFutzfN!JyrK z2oqu*|0R!|YlVa1V5Yvoina)dFk-!l^q#drL_1^iUv|&evqRo}IWo5|eiO;N2o#m&kq$HXZ!-dw)o!rY^Y z%Oc8mf)gkjrBagw+fk#tQ0zxs3W$z7{_8cOC~Sh4)=3bOOr_J`cS9pw>= zV<7;V;t@tffn|nJ1&7NWPh~E;x6WAFvGi~fJDuzq(10gyT;Ucp|F|+@_0-B$74NTK z@#RY4MLum+Y*I~d%*u?qy4ib@Qk(PA)~74qRIa=^c+Q*k`8$dR^c!`mq5Orp#iy&I zN<95$N5@qL91UNX6t^JAvovDC>0*dVci#UEov4HXztN6L*IEq_5?;HKrbolc3|mjC z0N2TbaI=bo9gb1KIplHFf*!OGxO=O9cyg++spFpa{0ocMUae{PY-Rk)@;J+LgJ-sW zxZq7P=J-MJ`rxz!->yI0X59Gh%+*Bw`Wr;O#wlrjRK;`4!_SIO-2GX6;w*Rq+89`c~yiS?TAP1Fa)d(W)?;aKjnA5e3~jlR<}w4uGk zXe%+th(i)^NMR*OVXva+H65XzmM6 zo!80F^EQ3Gc-jjK%b%U24%zX~rMbs`Sp5tcZ~*zoMy@~6Pc+yw|H`H%muk{>7NY|$ z7h{EHsC+0*>SE?Gd9>8!=`m8Bo00^AJNoD$Q$ejo`St`cJd?M5T~1^4HL~)j?OSdv zU-LkGhEz>UUl_EZOKkh_BpL8(Q5Z?u>sHy=R=RT2l(Nu8&lgX7W9{nOZ)_yWn&ESv zs()7VO!e54cKPeyTeom49X(xlR6H9c@3PTjgts8n0s(G-Q;7;Wl7|=xjS?I^2oXM7 zxX~6|VQ@jhH~fLY$4hinYJBo|$C9X|#n~g9FDb&W5$gDmHZA8Z`J)yOntXksbkmxLbbA3!d&I44xwql-r<=E&Oo{71&SYqa?b?^O{Wf^^^`rcQPJ_0+ zd)v4tisCupn1RttCo~yHzD901U_Won!y3No*d4o^0=F(Ff+wDL=aWjw(0x z`EBKgvM+n4Pn@^VMKe2>q`$PS;$X(dxO%K${uHw>gpPJ+@_=jkWo`QVPYXjj{x!eB zrL;AJ0x~Y1lmU^m>(FkM*qBEyNQi~H(<@Th_!@+xz>=|5n^N_{l+i_6`|- zHrpkCea`%Kkprh3u6~vnZrv(A6b~q`JZMr^J*<1m=J?OVWTjX2lXE;0wiJ=I6TQ}o zKYix@j`#%33!PBJ0$~q$nzA$N^mbZUO%+Bh>%=(dW@jqwxsuQ_bLJ~i;qO(i`}g+F z6PLvT<-R#h!TugkxQ5R9@-X>~-Zc-glhHk1W5h<%U0*`S0Xuchw24K#C7r_Qa+!5O zN2*zLMMn?GSlDUNJdy8`4liz@)xP%T=JtJ7d)v2e>3H61*UK;Ovf6d_>`RV0JF@d~ z=RIHE^03*l`C-e<=jY^S=d|$E)6Z|){lWC<7k6!YujFu3^OjYswr*~EZe(H6)Dnv$ z%JjB|9Z!UZJ-MTyEnRuUvNW%#a3qwFiGiem+`|<%Oln9a?=5t1ElAbyggVIgljhV- zoLDz!)?(E~Va~bb$F9$sb^Vyjv9HL*vbLOUna3)Yl~kXtX!&Eywk^N!I3rFb7r|pT zhD;IbX@>OBt)rVEhs()?8gZ(78#h$5O`Lw08*)_m=UrqZ{bvW_{-{+#nbRnvwPHh% z?g#?n9qb)}Q$7cu0cWLU-Ld(P8XsgCFWHVBkz}ZA0k6;>=|{^s%+m!OP4o1l@+oyXU?u1 zsHXyREzarUo1tO|YtV6ACxsVoO9(5wtqyj`7b(WhV$3khvx1pn7TK=cz;I0-n_F(@k#j4 zJz_NK4$H3Whqi*9cBA>uXdko032)Xw^m15MaW-Ty70Y(n^vsOQJ7>PQKSDgy{L!ps zH@AjwjPM;dC%kEQ!kp3}TZqHNhMYO|F8n`E-m3O32^13yD_&W;;arK;u%m-UZd+PB zXOi&&vccAO`k~xTJ2VX2?#Bkcif#9E*oedHY@q@nl4ufY8KpbL&E1eZ)s?yd9KB~r zGP`od1+H!8@^G_(r=J^O7P7RW>GS%!&yLU7i2HMAmNrF1Zm^2XANiD!|D1JhM3bm_ z_O#f!@zA_Cs;gg_Rrd0tMX%4K<^r0?0aI%Qc5rEArkg90+A&Soc~~%J$To)&XRnsj zfEk?*K65$A0QHM3er<4V%8aR;|Qm`xJeOVE~9*e#HGWh*f>WQ;i?pS zYx^ZzT_ddhXI0e4WUk{=m-^<$ZFs%XV_kDl>Eb8JI4iq3&#%pz<2F8T>F9BO2e!wq zC>=dy?30U2gSM>qocDTDTn?HpUkv9TLSSm$=#`<;TuLLJrB<}lwiX@wp#|%aVYBXB zb=wy+%C8++{`PGCoN7tM?7E50b>f}Ml!Bur+kbB+x10aiG2^*;`D^Drl_OeYKQ$MM z2^ZH0gTcp!&V&TB22u&qDO={lQsd)dc{`-zsW=h59TiE?hWeR=C-ip`|MV#5IRZgQJJ7C1y_yZqsbq*m-nt z{RgWCahzjva`IT-Zt%S4%1WQB99;d(1hum8pfq;*E(iy|5gkR_U1bwbuc5N_7&wS^ z8rq{dW#g6bMPAzyHys!iS{z)y!kEkJ{PfPpTfeSfd~9pwZ51ts4-%{?qqj})r z9Kw;&n?>>DnAUrZYa8@S^mmLVlVj-m#nu0b>5T+JmhN#5-mcJlRH9h9*gvRk?{sXo5u>BrH z+d}dLtYp)KG-q(>ABP2vw6h2szIlr{90p|SzWn@sQ(;U9i&`QlDZYS^0T(>KQ=m2_}Ly!q2Z7rj*3`ty=S zH@J1lxf2E)C&T*>b8{Iw!D#64Agd`WKHoE7#qRkfvriOGJCHMd@yRTQIB%DOgUYwfAB% zPgsQvv>wnjTN%g$O{unLLrr&xaHQ$+K-ou zbNEG?)nr~>V_QsGb58oY4E5UvP2Y*N^Ug0>_->7=ANhLyv3_FY&XhHY*>(ONrBREX zor+e@7eo0DFjLb*g=Sb8p}Mwath>V!X&pYAq?cv8v9t3;V@p>Pn{drIUO`X;wpx|TFtJXATR_zy`P7n=?uf)t zTC-&-568h6uCiOj4Xizyb|yEIyji_IDdp_WZ56N7%s(`J$YINw6~(Kvi=NooJmMy_I9Os_Mo|t`d&fK)Z zqtmzF-!gH_{q1dOTLW*#YdDIQlmlJk>2X1=jx>-(i2{XgE`11_p#dmP`n z_wHQ;!~zI5q%Tc6EWP($R6wOl6A%GG=^_GR1yK|1NED5+L`9>CEiuJ3V@!-DCTg0= zGv%3PqVApjote9f*xq~J&*%U7=RMrLvvX(8%$zy>%&@WZ6~z^H!d_(N65{CAG=v5JZ@7Z{6y4S%n7 zfki6<7QU=d36rZ@Aj}4g+6HQZG0+fl9@I#{D6%Plju0^z z4sS{sWb1`r{_aCvI(Tj_Gmgu8(9dNF-7VFIwVVYm1D7tySO}E9D>$S5MkWzy-eVod#MmY@<(i{XlZbS$v5uo7 zr^dl!pN)q}$YgixUpepIGn*giaK(N}(IZE4TeL=cR?SjICB*9Ka+_s(@PPJ}zLlTz zL9s9wKpMnh+-W#qydDMy1J_p_+~T|Pa+W`&{Cd@ATi1SG7v&FKMu4JA*42tg@o?rSxlvLr3=;bZ zL0Y3R@*$%Nh=XLVWn-)uN5`-!7j4VhleT_VR`U6-xTU4?i<4uVOBXqZHZ%CbHKf&J zY=DY!kxP`#bANZ+^dV-WJjxX-7_@HO_yxyHY-;w*WIwns;qEpgv6}PG;PWs5@tNkocv{las3=3QFb7%EO)V@E`H>BO^P6nz*@-C)T_<0jzd6f3 zX+JW2>n&ukKgm98)BJ;{)*w`ynl#%w`)jUjpy89HGhQg!aOC8xzhXRlNNyVJ#noIp zAi-D=gFrge*KB4r?Qmqk+(o1v*n(o`dW=3ST5tU~o40&jSNHXn>Q~#sFIvUSkB%;n zu?k+A7d=15>dXbk)OeSwyIZx3e55@8(R$-)Yv=j-m93p_Jb$n>U|#owf621`>4#+~ zRQ=4f@I((cP@7?-9wHvX2B-T09k}#9J~Y8@{IvOjBivE=+A0Ij1JacH68GaU+sGxHCOy zgf`kG8<)6t4*r0djMX?x<8X^*FQQGt={=j)n0bvFHAQz?0+*%yCM}ucHjU8f zYhmMhdTydQjZ3B628MBQ$K=eVEY+{9!1(|c5u1T{mnxVFJk^2(L2{s@1na3G5AJLA zKplgT%zlha%xk?H_RUN?y(w}-Q~Y_tZ*ANN3p))gGp_>jk!#t4VWveP67ZL@NZ?wJ zS-?c2z%vbCZeRi1a(`{ly)DL@!4P(D(0J2puWd5!J$1S}qc<~qcEypB=l|}Jul?t_ zlB4Cba%T5tuoZc4t=sfLe*XI#H@r1xOZQs%Z(aAA-FD>#rPUUjxV)oFo;#9|aOAnx zqq*EBi|W$+a(g&~5VjwGh3efH^`M18eDtfKzxIy{h;=PU68eb|oLWYKO_$xp_rC{Q< zfzvZVZ9-!dZg5(+hR7K`19Wmp5s-vu&j70!vrk`{{hy$h_Wj4XasD1RoU65Q`Zs?uk&(TkeO9pR8e#B zB6DiR*@CMVDqhZCvb>_Ix2yg>_6O;b2s%?h$K<(NAYF1)MaiWig8I-vD?%TL3gu=& zh$}E>X|D1|R$DMCXMQftP--U(4h#&i_DZJ>?7IU4sG1sxwFvQH7fKJqT4)39GX#)L zfkp$vLHceo2<`yG0SjNrH<<-1XP;V%*cDmYt5f{4WwTylre|(@X?9K*n!a3X@ae_u zujtL?IFO0KejVf=^rmo(KA>~U?tg~Pwqt)L3I}zPf6!UsIDJ6p+F@=1T;>c|D+rmE zm{GSgP-Hd3P>?y;{_(keQ^Ka`=(|mrk$qFx&FwyPT8kgxxcqFtO`BjUBfv6f4pYZ- zRo7wgk|>x@6FQY*Gzv6AJVivg;!KowsOcDEb5!cu-`_{3j82-1XD%to@^o?-xyv&tX`z3e*lsm$ zDG*3OnI#9xgTF?tKft;2V2qmDaJuY*cdmb6uEN1#lBeZ(=X&3~jPYe>diQ0$zmwS| z%mN?OzJ1Qk3s6ItueR3qQIlt{iNi~GzMlmh9tV%mOz4oKh8e(1Hx_P3p^S{sLk{|~ zd}tO!ofl7=<>`i$Zz)=FDtF$8_{&$AJxJE;+mJV-I*eU&|B@8r=!B*>S46G6zsq^c z*E<%v4anOs_c<3Gn48cdZZggS5KVmtfg1>BKyj(wL(bf_Mk{Zko^zj$mQS5%W$icN zLt`eAs8&p_b8~lk z{Hd)kE8!c^An=UpgGm_T1r++^C1MN$%M+nX1RxXEaLhKOr!uyjXryDJJ#M0d7{kiRLD|dS%W5hBDko-BH!r2*9?bEV~e3=peH_E%JdZAxNcUJ}>G3i}v zGu*0b>wJad00am#>;M1(K4%)q0Z}EW4R^CWxQ9KpV~3KsUdurj4u`NJVl5l4)YG5` zwcS<}-Uu?FvsDN4%U_(eaC;In4=1FgXXGr(j7T?MDBprQ=j^XAU3xsX_()~mrV(*- zTANMF8bjn|-m`&T^{M_qpbav-bRCd>BsmNJgsKqY1bJN- zA)2S=JlG&c)JU!pAG>PsVfl9u6iM>EkO8q#K;D-%kp40_D^?0j;GvaCVg*Ofy08{- zYoUccIfj;ea=mK>pRN^Otm3Lxe!7a?)Fb$Hv71+2UcGv?)S~Ntg7h%`yQX;pRLWyk zu?p!vXc=TpL~g|-{%*BYYvEx;=8bHSyvvs_;~u>6wbiMyp)v1#m)#x_vx-YWGnF@4 zQ=uuyd1}yR6k8V=Q5Qp6MbS({i=oTFoFgNwE$TjLj2_g!)MToKM zq~sW9|B`S6`PU~_Q|N0m3G~O9YUNEp4p?a}5SW=100x`|78WSDorWl<`;bQl7(#!i zQ7cV=Ofvp9YNs)WgZM0Y53EE3B{ayVteXxUZ?6E8p{EqzMQnexv5*B`Tf*8 zO3nk|IfxEZ_jZ6hqeb+YWJ(l6emV~arjy_Epmh>1!IS)Jcy@>9PiVVf_y?=`dI_e` z!IRXF))TGh;Bn+@VU$Dz*Q~Qz-2i?_O6Z))>A%*4r#sRLm!59O$^!q`*+qrh-4uI^ z7QI@6dbpqmXPX-vn}>g{KA9OA6&;az65xSIGwB5A2Qn{`03Mwx)pv;O66jy%3C2=Z zVoPEah;nEJ*Si4<01p}qqBzhg@cBZV5sCeQb7f5!f<96|rA+$AsnPNCJEGgKHaA~w zkM5WsKl+qmY2Q1QXYl2{;I=z6|DC>h_mfYvQ3C_V*|?fS+hzFP*)#b5GMl8DIJRoA zIphRN`}r4?_G)m`QIxIr^TW)M%x-{43>$#nNHm58xLYaVMuc#)Q(hH5VkUpdSf0mk zPL5YtdL~Z5A4V_&<0O1c{)l+PR;T=R`s}?pb zj7FS5m$1#~T`h!&urNNEg&h7RU2iMz3j1ojvH_$pPWV>*$Ti5r*jO z=fb~)@7U*1dH1_>=f2yGdr(DZPfsThbRFei%sjS0`9s_^c8qc}^ZL%6K=fgoXsuKO zLQJaGz>SDE;Mu?c2m}=$1&aAggzDq6Qvj9mvK|4Qp2{97JGoo<*LC#zBm&x1;r7!( zBlE?q7#I*1J{3q-(u{-HuVpnL342HaU?*t8avtg#X8I7ELRBU}P2yUDO9%lYJxR0& zwJTWYlNfG56OR+fe)SiPjW?cK*R{2?V>Q0KV%=S2*!aaNx0qnB*h#6OO^GMA+NIS8 zy|Afk-hqSb@o#hSpVCWN`)aBWpLeYy~jI!0F{3H%I7BF1R8ytJHd`BX|uewI|yEnkmkXcxg2Qy+c7-r`MY{U)#_ zEdmmvPR`QagjUekI0H(W(@U9(x7;9lz>Qjh94~e zkz4=9o1xqQpXC*#KMp7Wkbp?_;lY3*sEWKVfiO3y2P6uT$p}ew7D*RF6P8H-yf)Oc z=D_R%?}u|%wEi}L>lR>`H3y*rP#(>KFbVC5$!ZIsK227Gd!UCWtC4cC{q9GS)u3EV zR)hLtvYMEDM(T_6(Jc{^&xlBNOGe6Rzfga8lG(z?lgzZcNs<}xCN&8L0z<50WUnJb zKowT=!UoDE*hn&3Aha%Kkw&d!ZMe#+acl*ds$@ge(W%nHOE&_4v4N}a$= zZ%IN+t*G=b64poJUIDE^4$xzyV83H7)Lq$?y%SA7R(5t{q5f(8${n+s->X^pUeoNp zO8wZFjzQ$Db!>Uo5mn!3$bGk8^+4&&`oKlE26lhHFt9Ftc7H?oau~x2Sd^#o0Vs#b zWT%nLRqh9}h;SP?)+{bN4YYN2IdIb>v>f7$9w{$XHL1eIb|AxywDYToJfhr2<>z7L z{7;fIV*9X&KD7QFv0ns6Rp9=RsV4mlq*g$&$ASTeq5?I%5!eDSNp!DpYx-c>?99@^ z(pj@h2Gche&fA=dJX5zAWWQL>CIZu`Dr@pl^+ovqV5sv9~lE_Z!O z^13F-C^sZy|F1LAhSgrw`!`^sE1yb4b=FFsvdJgaaAQHfzsakF5+ z9IyM~I<~n4 zf6nTo*a!W8g-5cR-4hetH?!f9e{os(sPY$(qhO7tD%u4}G?`3f4zU|T?FrR?YIjm! z+TDr!+mEH9f3|A+jPMhlN5{65;HzSvMCn0on>XXeVZBF&KkPjRdUu50hjTHqWH zcmM3~pI7JNTvy)D1Pf=-IcBr4+|rJz6bjHkaKC_zOo1#GfH0;`OiK`YL>n(?<*b{P z=hGm0Iy-DitXo=YjQoGSd_;t*F-({@7|CXt188NJP=TS}Xq0soKyzmP-Cf&X=wZOI z-+w*?ADylf1ARF7sgN>~nGjCX4dx+*Bna?PV-FcpH`HGsrb4ZEP*$n7xQi^(4bpTp zc}?i;iWf4{`sT%yE?7{i{F(fVdr@)S!JMK)6+bI04i)7btc%IWEy#3i7junpRzzz? zMr%ZBTzqO@YJ7Z3Urc9aR*i>qa7%Xfs<_aAfY3hKaZN4~+4(q2x*5**ubrD8@hu%xf)+cYDYmXlHQ+TFu4 zlHrUCF(aA(rJBqTpeRsK(gvZRP#6AXX;2`X&H>-3Q-=_!VZ`aFL4fRStc7we!*JXc!e47bBkE0P{^R zwiTJN47q{-)5roCpn@`Eh*P1GzpRq2Qf9%eRXF?7O3y^QEc-~snhQ7+9sPKxN4#ye zZG`6ly4%r#wV9O#56|*DkDhkg_^Bcy-DH=&~4Z_hAWk5OV^DE4;mZhIA;i=vM}z=^3qelq%0r%R z1tnhS)IFh18ylQvS*B&fzl84>!ewWB-;Zdi_d{O`GmsMeE11D&GKKeWLJ5ju?ygso z)NCp{f$T}ysAy;c%JKC4m3OP&?JF@jO?A4CD~p@2c2J$}v_Wa#yOn2<{Yx+5%Lbt> zgH=?OQzx(3Bvr>RIJ!85B(MC8UnC-iMm1K;uhQ%21~VVZ)vy3EeE>@@gYuoUoFF_z zJ}~@<6!jL&;>ecg+2^4iqMWgjD+I;?>i{yvf`ZQ;u1C zOb(gsVfF7f@pQJ?!EAHl49pjQkdyOa5%c_merS&7_QUdPrUb5Hhbd8q&w|J&r}jv? zqnN2FJ+(88_UXDtXL-?WQ=yn0>8iWmXvU)MWV*AA?Ne)FU8cq1Ey!dvqOjn!l$TpQ{6_Rbu)RM@$sJU=O*srwFi(i zlkgOf089SUl$fePvi+}9gLV&r6#QRj2_!z?4DSFXfNi5cv~9Mrw!%uLMpy}{GLjv5 z`VJ6eKw85IG*(6ml}Evp!p&(4YK>zdUIy@C`y9mM3<#4^t{M#6gO*(rzQ+j}Cu^n*{e3Vf3rzAC+0D*&x5n|p)5#{hdJJI zzN-4##%*6zRNp>4YkgYi`ueaJ3IdWE9a16!lczT(SqB`Cj$WMLxhOZU!MBZlspP{A z&wbt4hkSpE{ue5IFQ9&AP;-j0{YKOA;pTSHS$l4_#%AmeZOzGhe%iF>^SYh`-0c8p zyJ0(nt%{rvOR~Q-p;6R2Z!u)SC-Q-4%2S8y2Qb{S5#~{m=C9zNHfL-t-2CeNx0bHB zQ1xNQ!qVobFu$1WW39%nuI{Yv&XJ?Fx|BP|RBoGg?uZc4^I>V#g%v0QzuLaBtBW~V zJUcPZaNhm`4(^aWLB<0)B9_x4;4wdL{|BJa$L^f;+p>HU6q+pg^EeQ^mp#8JGDEbBV`zBp7%^zrlSB>w!P;I?UYVPjILcy_arlJjMKc_k!_l?V9Y4rU)UzCA z6hTnQp82*QfrIpI9jBSCslsRfz()$>^qOFpAJ1}q+FHQ^?qtISUu@d+MNR4XV|k4}K8<;KjlRB(*xJpN zbTE7C?8(kkjSY5V2rM5kTzRu~VAh5-!-~Vj#fK{l(>9QM>w;7&b|x$?b^lrc8zbmN z{c_|xk<|ev5_8DdAXj{00754}-$lO7t9Nm#`$o92Yhdl&UU}~yYX*v5oFmWOUy8oi zxl@!Yq=;&LGYBitzK=R6>4!WJFpm)Jae=x#d59&5+GDG_96Zy|4mqP}BCQWrSCBdG z8$+q;9VoSxRHic>|43_#(hRgt&qE`#4)1PMY$NPriKWFUxyLQ>QFjV6v7t25dekIkNS z`lPKT!xZs6G^c(IxZf>k1|mzDdNz>1K!ZGpt$t|AHGUbA00T%tWPIp>WFVlA3ir&( z+g&_&AUAhlZqAJKtgM-{QJHWC?og~R{bF~=?#qRRm*L}D$=3BR4DRge+Bx{bdK9t+ zx_{jG1JC5_9555UMdm{CmL}&Bv^OxmtIOf!9#T$z=i=yG!F#erL%+-5rX%4ZMa;O~IC*>3l(XJ2Uq)Gie8c*wM zgdL3^0Dhru zF^3Vgi9jCd@3bwbB~5!qExA~WoXu+QRuS7*0>zn8vHJM>xth9 z^zb{W&nJlW`6OB&L{pSPu$Q?>pVg8aJcE`Bvo{+gJHRRl&rH{7JM_Fqo}L4VXvEeT z>31ZOS$}wZG;Cfm`A^KZgw$$l>uHya)@@z;V$Ae|9b3B%E{$69ZT~5x3wQMWeRANt z#pvRO7gyb0wP)iGtuL?d`l|UweBr}A7x0BWOTPp{eHiyjCxTocM&OB(0hM6DVMF9( zFsV|n)Fk5TaA;2e)V z$o0bB50T5>rmwnt4!8c;yBEOlOx~yb1g^&c%RL~mKKNV7-JB$B85u#!9ku53i6j%yZ(INyOk{jyLEKLu zh|}D96NU@eX&jQiASStHny=w*)BKKs+`Qe(^3iYRVcr&&K4GRIwb=^K>^f*A3a>`j z`L|%ZN{ryPQ3c%R81?0sZD=h1^^Y&5Enl?aU+>+ z{|35BBCkmtnibq!PU!4^dS+f1-eJ7!a7`%c!r$fLAJNK4T*)$GY4uR)st71$g|``> z`p4>`iX8kkS|5hX7BF7I`D(NdzOwcY3w?yQ#X_9=uyCj*U_qAf6&s=24r>82G-55l zsLjg3r*!7P5j3@7&7p`UEMp@8o5@7L>d9%iIixCdE@Wz> z@wW?*IfImZ414dvDfCt%c7a)rEw=cpHfKmVX5qiyh(2Gq5<+6l?rweBzlSJ6?_-tTtS3? ztQ@7Zv3g6FDt~WdM=D47`S|<#`g{AMLw-I!0RcWfewt5o5S~a$!1WGxQm673JE2qg z9ZGTu4Rvu04#G7~At6pqp`q+Xr{G{Gc)*LvPfkHWFt>epBf1SYO|VoZuz&-|XmX?o z9J;+?%Y{UN28>r2?-(jIS~od zT4VpweGHB-a0mMj;itS#F%VyXrue~q7+?o89&Y37q&Yw|C4piRfrYr3L2lyiCA;n_ zP28}iGc=`A5gWLzIC=ewW#JJES(7C;v;C6%+-y5+r@^Pku<05N^q-(1_y#p2W5eG< zb{_>~N1VC0{a5^c(frtV(d6pH)`TR7IH#=0gp?(5uwgnSHS9I^{182mX!bkMGT20b zWY?7Y+0>VX7Yms*e0v>x2tEtZ_Rw^iN~hQN3z3U^?I4` z6Y0yyfnv1{W6K?-3bNCoWr%8oz$ShDhuP9#Ia4R((x^JQ+)d2g<D!Yw(J5 z)i*Srpg%b?b*2;s2OYhWEh zaHK#MI;1)cb0CqDc%DNIr%#9AK9mC(9z@j@Ur}9AkmDTWpi^0>ZRAvsHzOv-Amlo? zS-~PkQCEyFAXlQdTphp{PBUh^vUlX$e6+daE8I-)ivWI^(%i>)6Xyi<0%J2^h2daCD~o%u=4 zLGvRbYNG?^D7+wy5lxhOBzK)GEqW!Q*r_eLaA3{@a)lFgD)Z+}ZFX;m+ zOCh{LD$QZOq;ayA$QZrJu4zQJNA}|uWY-Y=(&H~7D=UZu_OP-tWc~1M?8AI1SfaPl zICf3={v-Gbjz08Kbi)(ivBYx*jxv^6wI5iXTrfkn4N)%vd~YH{3g1uI58mpRyaWo; zWO1_*ydb>3A8p6qZboamy1qqKm}FIz3pbbw)p7OM;UqO93vd#%DDdOPI@c)In#K=> z8|ZULN2J8D|#FIpclJokL#gFA~q3b3sRB{en)3Ew?e16 z%v4;2Hnw#G()R%?JRtoZl5T++qSxrpxca~at7)h&;3crc`FggeS>`6IOwczgA1jq8nUqEsz4=Jp}f%1B@ zma46^Es!Y)XCiup8T>IDae@9uX5VJ~N^|oqyanZxKY+6qTd-3!{gXFH%q%-axS_uh zztGYGe6k)aKM=SM@ux`=k}F1|YY8HP>qic_t6-Cag+~tKCHd&v!}z0;@RpWea9aYt zgdC%w191q$* zwuaTZ|L6Z!4}br^)l-i8-|7i+Z$#EoE%)9mL@V+Oa6>-o;FcAlmHD_5 zbrs`E2~z!u<}gvLjEL#1{NZT=AP8H_wK!BUa-6G|wz;<1q+s*t4EM2&d~M2-)L+ym+`V3uWmsvy-CpEr4}qWxr6E zw<8Dte*8GMHXyMgGjrZXl(3}+EVxnavxNr_VAB`?NTv`0V-6l2l3hgwc5sM*H`l_0 z#>rFj7Rjn}Gh|9Gp5E|$R%5`zPw{sH?O*k!Ie3S9%xsx?9rCVJbT^Srw#aapboK7%Z!UJZS zu#cIuuI6Irb1OcoK5=qUY09efP3dc zwgLs{aJ>*%;dMVE{t7`oFvl`y7t9!p8^9BEEhXSwo#j&xA<9+fs zZ)W@#_=eTIU0e57O_*=Boo!5Gd|XqEEp$)|q^c5x8KMYiER4g*=i}s53bt}&>=12l zAMHT@WmgY-c<=%zc~L20uV{WHb(9Z=KV2f>e-cLdDYpl|$=?JW-yUXJO`_%2R9OSW zB2%rP$1qa6sfRdz;w!`Uw)T$h&8Vp$r$MH@Os1n(;pS&&KXtm^=(L#W1+5FDmUrzP ztxQKmrR8W~#(B|k8<|}nYdxWb< z!Yq7lUL%UeuLSw0!qj~Lz?}f#Odd`h!1l=2AoB)U6~ad|s}#-c<+WT3uBH<@8Dz_S1DnYXQA~ovhx6@K3yCFsEioU=?p;n z90M~zj)zn)5%%t=ezCBzzPPMp1n-t*?-nsiJHpF1C{Q|qIiS2fy02EZX=`hJcygt0 zxu0K!PjF7WWacYW_m!Pj-e$*P-RjW5OCQEp{MVW*u-lovk}r!zMAes%BsMtll|2K%MNRc)eO!uNE*st~}cYT6~SxR4Ir z)OouUe&Kva(fcDU_5A(ZXU~}^HrF&_L~=~kCZH=e+yS_ct`G)27G4HqS3uYtSuHVD z*#p(!Be|VUx|299+sP@R{LOjuIxwHiF>^USZw6q|2hjTkibcezL?mA@Wat|S2dxX% zzg7EIcd0pd_&t0AnLwiUcT$`l6aiDu{NjaNy4gby%H zBV-Bj)xChRurPa6F4A&Hq=2Xn1h_~TorfDg4h9R3NA)AkuMov8?6R9J*jz$7A}gU861{2}4e30VI}o$!izA+(W@G{OsEp_?m>_WmxCtW4 zA6te0#6$$#zW&>~8Jg+NY0%PHE^-okJHkmaq9`Y+L%G7$sy~Nj_uDCK7eXBg-!`YI~!`WqNBgs4Iyz!Cup+YMClBg8h-~cygwrIbDZ`kr9yhOtMwvWEMo?4e3RXQ*Yt0 z?_%2^^HiJ22))|4`n1rpz!mx7SspT(YnFXz&?ud#z|fEY_NtTX3|n(Af3&+^_&&AJ z@nX#4MBT=1!S!hqlN-XzLcA(`gMh9lK$e|y0G8scOiN-4lk$*nXnY>xQ5p&lqWJ)W zIfB=XdMT!~jfg>9bqrZmR(^=A7+FqJuZ{-+cn;bITKsTlA_lf>6 z=<`4X-;-ScDmrvHiX# znRurJ302%-;Vxx9K%B-{2vu!uguwvmW1#JIG9KCnNtIA&q9fsT_}YXHscCsRx&>n7 zG=L!o=;{cl$3uEE+$%Xl~iK##Fdh>mWstr@}^0w!4t zU!2@-?d)#8v;&POUYHOmW5E6~LgwxooovR9kag>0ZNs4N@osgd#aTeSmBuVtN+IiN0K6WGDpsPY4Jz zPpS;hw2F%ivo@XLySry?7oQSmXfk?slHbe#gD7i%H%qx8PX1QsX9n=_Rlvgyurt8n zj392Cita=V5HXQRxHLRh|%F>#48X+=>MR+A^2OwyYW9hWjqF>R`gnHeBe8O&*m>?)NuNlG7)%0xZEP`J}p zlQPC^;%M!0dQ;rE!%R+IzEoPwvi44}gjeCkwZNk__zYP0FykJt#q&@*?u$jmp;K`i zMZ%fbD-XS04#(&!dtjJXVVI+U(UOd@p)L26-L5`+M8gut2u&qRn&(AkOyy;+>GI(4 zF(adcLjwcftsgg9o6{XT)!5I2+w)wF*3v!E4Y5;Z)CZNNMNN#!DVm`bmkMUIswtDX zS-R3`DMV&a?qPqyugm%&s>TVxATv$fJl4Yw8?n$AONY=6EHi*`pzR1tVyl-dU+7*O z?!>3XhJ;3Y1lZ?yw9lDp@8uQZYM0cJP|K%Pr_Wnxl^Qn5Ixf&R$`3tPke-S*2X=$oW zKoECVtm7+uP2RHg4G=Dgb%J5)9B>}0kiHEhX#|ioeXul;qy)lJ;V2hYD)+E5(zo(Z zND-@W(}zSDaP`TpjASfW2n`A4l?mm#^*Ir!D+h&#yoq;>LUDN4+c{%52)YrSy7j_> zNF>h%iS!7XmC_URc0O0Ahf;9g^?bce5kh*zD(!kf8#(0RSN}mddiVg+4tXzM5B1gO zg{?ySP*e#1EC=>=I*5LDQcTD*YX+GL5aITjaQ$V*;-J*D`A!+sD1+NoiXn)Qc&|z9 zCc#CT(m?#!78c4fyX2O6CBE}pmisx{XIHFDcaUzgbhMm1&w8|n3w(jCoF_HJv-p`b z+Z8UvkQrgEfiqxpH+;8o%>wwnAAaAY{v8;P+zoFUNb%#&O0T~gwqOmw;5r-zkVZu_ zB!L3Z&V<3|`wqG#Cgepqrgd&;O`M++CEeBmT{|g8Tjy0LQc1|O-lJ}ZcI5{@1*?m)lPtPNQd?^zsL%lX+(^_dtL>gmkZqE`I< zU0yP89@Tn<^S#Xmsg2>bseyKuHgO*MiRo_k84K>@<*8K4m!)R-uJkW3!2bH+g6J}1 zmmwDpW}p9;>QyN@mOhF50p;}_ z>zBkay`yR7NoYtjk3h4Dw(|%uv>ge|O@u51<_1h|PlBT_;;)PnMruVT=7lrE7PsOV zcQOqJ8f|;@$`^qDk$#_q|LOgrWy2PE>@z!lILaqFW|4u^m`mXQ2DO+*Q>weBIBO#ayngfk9n zeRxk#ze|Vdb%}^@aS0E{cIq$4;aLN|M=QFh>%k{Vd2GHB*dOj79q^xkGYnkRZb*k9 z=N$sKq#r)pA&?TnHtA0PMry{|z~1NiUtt_@@P*Wn9e~skGLC1Cf^~izUa8^ZV8Pz6 zml~oP@O6Q_sy}Q#{&tIradVG}A@309kV^#AEtE3Mj{q`w&_GUw3l0n+HQVUWPcQv^ zWQ~*=0U^hKjX3yNJ>qEt!-xkmQs2}xPLml073XjdnBCAIF$z+ngs2qKti|@^r3q~r z;o%u=@k_Y}u}c!e($c~bTgZ)YcHAz;i{ApT{Wm9vy4v01V|r)2_J6F5 z>)C6n7o=@uWmso1{lX*DXlm%x8e>Z%U1?kX+Eq63R&mp^y#Vyh;QhY`Y9|=*;BG9n z1_4tKhlUa1Z}UQhy}csEBs*``d6nwlW|!0L28rNKLtF{F^Kh+P9)W*HtcjyP8Id(1(RE7|G&*Rauz>)tcIk?W+a3ZC$3hB#Q-qW2dESxR0JZHF=nLGZiqnPGoZsusod!@NTxkRco z7p|&OR6S}Wpyz>+GZMjY3Sm4*F10{LR&GWzl~DJ0)1}CjP56E^GrI|mh{6Y(K8?XY z=I7%(H#~RYe|z3QhMw21dBSwR0S46<;3jkpVu89Z^-vfY86t&ntEdQn`J4X{{F#sN z1**8)_;J|kMkI{_%qr$osxa9Nh$nDRYBBpj@a*I=aE9~;N-cOQ{TN!)O@lLLG&Ia` z@bPirW}>pRwCXGrbr_b=I-Lwe{B%}^j4v=_)@GJgBC5C5#av;Y133b`1Iy)Dy$wlSD z@409}E?$o6a`7_Q54ys2l@7!p$h5tbn+SIi+@ZvcmUt`QASKFb;ku*|O61z!N)Qa7 zt^(`ACz1qlyHF&hyg+6|Dg#;Agv9VcguGHh%k82>wOJ&vKpE<}`>00xBk;4elJ9{o zTorzUop+oXYZPEpkh{qgMlvIrTqfww-8x~bY^H4UgucSaoY`33}0ziqT8oDpk$H$A-3ZrNJpbS8*vu7?@JUgqUWRi!4Yha+O zd(h9QbyjvuYv!!h5)*eb*N_lbmk|70MtyxodTnj`BzN=hsg9A6jv4h$Gt>nU4o(q3 z<%F@^HoTf|CRz}3lY#M7VIkBs`CTb&47kEKAH|oCz$Ay@V&-jCFA&FLKpfA*Yav`8 z^ERC0L?mD!D&(Xqn%(B6$8t+<9PN41G%<~SDviUNp72wjiksLS*kLkD#lcedZ~Fn{sVu5fBZW$s)V~fDWELG z(8R>hJJ712$-uB8!BhwARmaa_3{Z~;sU(<`~rNu0(c4B%xU|NYjCKu zV?aQUYam4B1O-Xh2e+Vcb7)*gEe8#27%T#4c;na^+9kodLHNMAf8c7%Tum6SRLeOu z)Y(2bm?t-YZg2_-baf1Z^YH~O;ZrZ#|sl)NkAxu zpSbj!rDK+^UkW&$#a&U2ggKGIUS!>j%nSvuFTM>yE64?>J+L8^$Du-=x`KxpFcLU~ z#3yUO(tVL0SRAb>ZgB z0yJ^$&c)CIvhC}`R}LJ2+N+@U6?JWWGvfAx+R9aHkV$U#jSHWwY#CnrK^gaf#7=sR z2*mnk)Kdp%gOOe8VP+@P+N?-fy=!M+Vtf#zl7{;GJYVaTmIl(_g9vzy5nluOO%9Ix zAwe2noW8Kwo&7)1N_XVxa(AMV4759PP21NbekP=e}a*zsocTBF)PU0 z+H`^gG}*&7;m?6^TEi+*Mke$EhGCzB>4YRTbMygU6N{gsp7IEl3EA3f0n19BTmE<^%R34Ne$E{R(a}Q4^4l zBgTBWg7mK*nrI{ya~4V8BwS(Bbbg9k`V8LO%E}`8nV|~@Ch}%ZE`CvHjIn_p=l85Y zhCv8qIPz#9gUkp3fGMZM5X6Uvu^i-_W94AS*xeCXZdy7eB_;Ls?E$`i{w}e4qawJ& z>^LE%c(iwAYItJ{I74guNN7h=rhh<%0>~DPnK%HJIx|>S&}2y5o2bVScL^-27)K9N zK|~heVBJEr3tw~h4e$;RbqSjgIXd#Fta5L zWG)_j1P$O^5W^*?Jo>1M0jJ_4x0f%PHhRhg9bF?GGZzv(AY6LH(;!Hol_HoF67)li z5k{gXo*F*Y`9(Q}M6GO{!n=?G8eXeapESY;La2R=zHoa78`@Sz#8fFf!Pc{12W zSv62RaE%7sbaxL0+&Cg#e|+~K4ZGRYqT9Twd{*L6*bS>Z!k)&5sh>?9&q9J3fRqpg z6*e6l@?ZoDBp!23y<3mOvt-+mc$P>7#Ix{aLJB)hjAvO|7oJk_csvVqL>5R)=&*p} znSteM{6CMMI4|5;juwhbWjNB@dHM2%sbekmN9Y)jG<6>49ddA-D)i;g;khCtwRcg^ z*beF`(xTEl%$Pcqhz@5cl*p6L6$*=A;=ft@}fse%%`GmSegOuw9 zE=^ZxTytVGacVwxQZt;{EEmQUm4p;VDw4-f-^kd+b!<#anlmM|C`#IhvcAJ7Y2XR@ zXDA851or$SjE_^>MTy1ig`2@x9(_Bo3 zhpTbm>+i>}se!km`ToNI9u7SFpYZpNjg^(PwUw34Z+2D|c6Jt4b`Fn)k39+8oMG`~ z!w{PTC-@%Rrv_;fO*?r8NHoxxRXmCy%AZL01dP9|t>_F|`*diK#nB}=K!gOQ$5b;- ze>{dYLH>~0B=~U4fu(&7>RN=dm%R=`oDH7K>{V@gs#v~v=j5`rFJ@%yURyq8r|t4r zzHDmz^4M}bce1~Ssj0iaiGTTY7iSR58D$h6r5dj~qBaOT!w4XIM!E9CcAtEz^2@!K z`xp3A(|`{eR_K@SADRgE0w4Sh?==H7e@6>#FJc*R`b(ym?qXr#YOW2I0SgNkP)%#O z&8lg#1{kBg(wK>oa1ZQ+85^#UHAt}j?fIknZKICH<;lx7&`0|7OkmxWP z-@$&&r+4t@yI)KZzBz&{u`SzFVVp%Z2?XF(X-EtRHgirac3?>)QYoCH< z$iYEFB|2G}5@|StrJsOx7mh%J8Yj6s%owmj^89TB@_ZMbW9joxITFxdrolhYv8zOJ z*ov?paNIJ_Va4I(0Z@qk zi}@Y$8`vjJ%Bi3 z>nXE`Z=wPS&{m#1znU7?{IBrU#1>s{$M521P?yYw4Fj$YvSEhR`AIC%;1HOT=!O|qHGjv82A3E3b0 z#H9x=9O}p03;8j5ux*K*5o~?nEg>gk`>(4xzis`Vcg1Sd+p`D03*vsK_BaO4$W8<6 z7#uaJoO!Pw2(UHYt_lIIiOMrlwL%Dyn?!7~h7e97DQeD;wpLo_M!JOt2ju+Ki{7LcHn6 z=H?&CCrm*H_c~ZkyTD{ff|CFrHM0O>JjB(BnhXe%!m%~|I|Jk6Lw3e4&kFJg_VD%h z2}&~U9;NQR2w`m;=8-tI)L)lC0 zok9Y84KD)I1M%(pB$4Df$v(`GKHL0I)pF?ecWQ~3-@Ud7mODj zzbTX)ffC}JISvy}et~p7QWxAdzTmc@b?^1U+%~UEtSm|_Z+=V2?%bJat^yyIftJINy5zb?a+8No`S!w+%|Yr z@#49q&2z*2{6lf?T+~!r(p((s?;DO+&TDIj9T(_d+Rv--S{%L=J z!q+drTLDHeVlYIvokGH<$@>d^)pU+`#Y4tHO9m0F60UGM?l@XDLoNpk}ha7G|Sd9VY2`c{Yo?|Ctp5poCuMQ3h zA3ojt3!M-qq4m}O+Q}{GB={-ZLdzC9`SR%;gO!{FzMTL9fe|DaIl<_j8qG1>5kH*Y zK9igCPlyeVb9S%CWeAvOH7pP^3i%qafK;Xc5$iw56g2biSu#bK@Yxd*#lJy#1H9rB zm=qb%el=dP!xhe7R$u)H@A6mp!hhbrd;*f;d&t=_D8#`r7+hQsByfY-4Z;X@{{iWL zg!aQ-MJyT~DzKGEq&FUh3!n-r(=d_@XlCm(n;{Vx4asfs>WG6je%%}IayQ~y#HfQM zv7{La%h$|4SpdKyCyVzeEYAp$X-QfV*ODF{p579-#P1p6825GT;)L+DwD5$*v1s|D zQW0_@~;Vjw;0&+V05i-sDNL$U7Qq#4pA2vjolTVhH2s!hkED+ivds)+#;;Cq_X>`f6 zCP@S6XOB$~lDy4rL-oM9i690y=e#}WP6}`bVEImdE9r;=_Aqy#_#3L;k5b+*v|$GbQ?>IhTqpg+3F$A5yULH26`cE_H>D}0pm0r$ zFP@0o9&Z|nmSJcb!~zo9M-76+m_{P?S@WIRA;~3CM)ApJmPd~#_t*ElgcDzTP5O&h z{F=gokfxO0AI=q&9xLB1?7(d>&v#*-W9d9Y4mq9YVh+MLfd9ihlhh`}Zsy11LPT!x zdW_w}45>5xpY*pQlj#i&)9t;z?YVUDw`VNKK><$ozR-xqUCk4=4owte1H@9cDUpi( zO~b_qf61SW=l?&>z5_g}qWk;ynnDRhL_|SBl(;QTEF{^z^xgzTT#_YONHc|I!`^%E z1$*xed#~6v78JpTMl7$!2FdrEnR9n<(Et0q&zC%VXU_CFbLPyMGi~sw0$eMybFx5Q zh2YD!U(`L}x|#E?9@l;JiWR81(fy8gF;<~~=mFRyaO&||-tmPC^!FCPZ=`aQa+kUf zCw8PaQaM?<>Ex5)ai(&P+DGl{dz=Z6b51-F9&6;AehDiJ0~jf#xCeuHALafV@FPI~_1#%HP*@%{I*|G;Q*{+8a7= zhAyW^uEW??I4WoZ(U{`w|(KO*0vnaV?)-C-#U^v$JU?12Kw?}?U`jj3afo*8dE zY_HOJd(YVK$a(R`g`>iU%C{Uav3cKpqhm|vruW@<3{sle{jRoLJA)OU;rWSW>SIes zB63-K}>0lIC+YP^~A5iiiWY24=kNkaocXv1T1}OV$xYJv?5A@33ZW`6S z6j2u_5nK|2BPP^O`mNJvZTnosNe5{lChha}JNdvGRfEER(RGi=4XgWw`?xzhkrDcx z+g>_#tzld#>jz9m*T98sMy*zd>-L7Rteh#@(C#~t-q`M&)xXtCvAY6~JF|JE&;Uc+ z<#++AjbZYdWmi1>$mLf)H2?6Y&%V6llvB1|G5?;sFTUh~CvUjxhn2^Db@4@;jsf9b zi_+eS!|Z(2kAiU3z(nnt-1~ECN~TRK!9`DM7gowQ;F71QQ%g&yp%4!s^S&T?plUaU zC;arje_!{cvKcom;+3Plk$P4v7>s;&7>cn8BeQ+a>>(fI1_!Jja)Li-snX}#HnrS5 z{C`o2DR1~2QRk9nB2xlv^EaaUrhFh%3jpu5M;oe%6ZqgmTcH7;n1QjW8SqIN7`u}J zW9Sj_6>KvGdLKV&lqBxc4Hae9H$oMl}b57z^_7G^pP*KS`cG^VGH1pE`5WxVAISY#SHc znmE2@&cTgWd~(^1TW@ZeQ88}cOtR8gBpa3g#v2EWWc+0De2$;2ym#{SiL1^#Z`H&} zvrahwgjvC44_Y{B{OtORYfgAz_3Xr?ST@zey5{~5+b1hidyN{hZ!fleaxRv}SC{k( z%{^%Ns6O_Z-mD`tv>CDn>6?nClsO~)rs-@kgr^6LGM9^ZUi^Y~zb2Oc(U+Mz41t2^g~ z6Xtu9iZY1;eRJh!K}JkX#Clu_j;1kv0XU`HZ}MS#e^oMYaBN7p&zK=Iy5{azec<{F z#~#$%;q?;*lCS^&E6~A}2Y81bcgT^~r7DUmG6lj6S68~9H&%nifK|w(MA|+`88P5; zecLg|{3WmK!X=7-d-7}29!fpqpKJ!R$KSgt^_U^EA?^kYafRFivDpxJ!|A7Evft4) z5Sp30C}A)lxnHzsTL;GX_J_L$$`^Mnl}9}MFlOtYc1636?7k5kv~b&g+fPN*O}OQ0 zb@#J=RN7bi`4x~f&g?n>=f{pb`mD1MZY#o_91O=s^KDx%Jxe|-6Aa3V{ji$+QTOf0 z0INFNZ-Mq@vR2czyRlr{jL$i=mZ`~X)io=hEY+uQeC8CPb9E{tRw1Z<;u%1hliwGh6_j7(RishGz+-Y z^RnyYeXip^1C%Y=@fn{j%77>2yPkm0c;!yzZ1p%nzW=I@-ui!t^~#<95a(~w-jXjh zRxq_&Kp4Jh)B!{4M~v3qI_{vx1w*|3>&jPBElPs5&{-{daN6TRvg~hxb|9+*{xr7t zkn9E|t30yrl(i#Z4m3SHJcI=UGd^Kg5PLM)+2sN3=0}#8hT^g(BblAOdOU^f2Be`B z*-a=g*nrN$SfJUX$<7YXQW<0ic`+%Ag>j)vIi2h|m_%Jo_FObRlU!JsJe22gvge~b zPm{eDb|}3=_TFfhPsrW}<@tr|eNmp@$v#TTLEa+Gy(LZVPxgHzQ)Z$Y%WJe`$c$Mr zd!eMuBR~nVhMiE-beYM6%q~lVWp)`4vnx^_#A{(!r9ubQcB5g zO0$)jWVa+s@#EUkP=$3&mc9%7da~yP?78w#Wh=SoNijTCD8lcQO&^sQs8tAev~P~K zfb5E7aoR`vvMLSN8ps_NTWD-6GIQ6ZkVfT^aqL+8fZTCBOxs9yD_eKj4&>`dIl4h% zTxleBC5kk2q+U9ka9}Ihp?7K*P%&BA_BK3kSCGy%8?oDJLD(zz)PxjrUfu^6=?$Yk&i{w5`Dm1?( z`*3Nn)t~HpNPAilvhNA^gULQZ>SHm5A*=65*iR<=UQ)y&I+RCA1FcudeQ(LKd|d4# z5LtE0TQ}a^kn%dUc zwdsZ)K}Iu|#LCv@dr!SXQx)S#?viLb;(r|T9b9@s+o=T$p&!2MyW|! zg(c{;RD%ngTBV^<1cy_?c(G(C)~{>94~OCnzXtfE5NfD29p6T9#*|dWLzhc!Fl*t~ zEVc9qITuHWS-MF)ELw-~Gx4iI=sNBm!(O)#&uI)?(@0}9E;NteKaB7x{8wYk$QZ<$ z1^zq>wrnYOOnLMUY0pJ$R+=1Tb0N^?+-p{PLv%GVP!Y2?U!hN3Rb zFso6DdS2IMaBGy7;LA!?k6&TNHWMM3TN{sE&eIj8Z{ek9ZPJQ1YLWJX8hYPMyh6lD z0cLF^a`t$5)`GKmZmrS^o;zzn5uy%e6=F3YHHP1z$d%PYv@B~y7IqeDI2qryg-CfWtb( z{%`Si4Ew*t@xx^6=ezv3ocWpX1#8>o$UJ6#`+zjCdNni z0tZgRR)rdg9$AGjjBh64CU|ZOdL%0YbN%1K3B@(^(&4+-4}}DcSzr3U}6DXW?6q^47sz zf}hWq{;fm|e+&yv9HVn!G7i-NfGOZ{bOM&qjhS3z-APE=7 zc|?C7w?i5Xo6WqW4WJX&Qln8PmQFLqPR6U)ILc}vT5Txu+tH;JyzHMiX4nbl9IW9i zj@cj|Vz^?IGaEm~Q3M4u+$B+t2~>W@w;260`osz}n*m4?F7P>1*m{25Re)t_IQqa{ z(w)*P(o0g6tl$QH4JV5X+>2x3#aTyo@u>*WTy5vzu{AVhAFTje%sFL}AVLS8A2l2_qf-y@|zdK0`iJK1)7ZK1V)RK2JVhzCgZEzDT}U zzC^xMzD&MczCylIzDmAYzDB-QzD~YgUMt@q-w56O&GIest@3U1?YOJsPH7+cE@{1d zw|tL$uk@z$y7Y#8pM1akfc&8Jm;8|Yu>1(#4SY;qCqFJfAwMZUB|j}cBR`9~h@Y2V zz?IT3$uG;V$gj$;$*;?A$m``d<+tRw<#*(F<@eIHXWrkFw%v5G6vz0l@TxFheurgm+pd6wc zsw~88_b_FV^tbe-a=4OI7AsXswUSbnC^bs0RHCGnrOGm;PN`QKlt!gVIYMbxT9j6) zRB4kwklvHtmui*e$_izrvPxO49H|_o9IYIq9ILERj#G|TPEbx%PJ$Ba6y;RqH05;V z4CPGaEahzF9H~q>S2<7GPdQ(?K)F!4NV!i-93Qf=D$`#6$(*DX-()Y^M(mLfD ztvI^}WY3FS%UDdlP98EKjFtn!@lyz&B`TzE;UQ(jhHQC?MEla?y4D{m<4 zl{b~Qqz2_}%Vjmj6wCS|korSg^X zwepSft+GY=PWfKxP<~LhDnBYeDL*T}DF0D@Ren=CmEV;=ls~0}@|W_rvJE$hbt&D_ zR>{M4$TE0Xm#V0+sG6#)hBQu=Ra3Q8TXj@d%~5mJJT+hKrS@ivUuuEcPZ}?^;9;yI zz*C#m0qQ_?Cv}jzvpQJaMIEBYnNdb)>qNI!fJJ-A5g*7OMNI zAvG*bP$PKcB8Huk#cGLKs+Otysbi#x>i+5h>VeXq>OtyQwOp-GE7iD~kS3{~I!+z0 zPEaSRlhn!T6m_aPO`WdJP-m*M)YjsxDLO)Oxi+ZIq^{O)B0DRa?|nwM|_vO;uN@E7eu% zYV}Cp8|)nnB)>T&Av>Iv$J>PhO!>M81}>S^lf>KW>p>RIa9>N)DU>Urw< z>ILeB>P70s>Lu!>>SgNX(scC-^-A?B^=kDR^;*c(#aD4eE{RP3q0+E$Xf6 zZR+jn9qOIxUFzK!SB`=v>^k)xj69I)9N$ov+8r|^Xd!gi|R}2%jzrYtLkg&>*^codi729E%j~n9ra!HJ@tL{1NB3- zUHwS?Sh^RlSbQqYQa7ldsh_JG)i0#k>LzuwG)MhX{Yw2>{YL#(-J*V{ey?_@Kd4*P zAJw1KpVeQ~|ERyJze#h|PW5;75A{!JoAj0X7gor&soT{qwOf-kSvpNqG*z0XX_~GX znyFcutvQ;jEMH`~+stuKn zm5!0tXuIJB)?wOkZ4YfvZG<*b+e;gz?XB&jjmATn`)VOAtVOh_7SoEfVy#3g)ylN} zv@zQLc&h3^?I3NeR<2cOm0Da&Xr4As8;_@eCu)>+G4FrIz+41QrZ%&Mmkh_Osmz>QirxwTc*`% z^;(10s5NOv;O)K^tyOEon&%2_rM602tsSWyB`wsB){fDR)z)aoL09~Zc7k@Ic9Qgi zhMTyxQ?=8y)3r0SGqtm{v$b=ybG7rd^R)}K3$=^1i?vI%OSQ|i%e5=CE48b%tF>#i zYqjgN>$SDo4cd)(`}Ah*7VTE;Htlxp4((3uF70mZ9_?Q3KJ9+(0qsHUA?;y2g!8EO zn6^%PTzf)$QhQ2!T6;!&R(no+UVA}%QF}>yS$jo$ReMc)U3)`Yuf3_gC4Hy8t-YhY ztG%bauYI6>sI_Y!X&-B!XrF2uw9mB9wT;>r+9qwY_NDff_O`%e2_>(GAC zwrW3WKWRT}zi9u_e${@{Iig;;J*-Fcs2Z+ntX{5H z;8mx%p3psgoIYNkpik5%;W@S``c!?IJ{=Fy&D3Yq&jFUZq#-DSe4vqu1(beW|`ouhZ-G2E9>l!mZiOdW+twx9Q9E75YkjmA+a( zQa?&RT0cfVR$rqZrysAMpr5Fpq@S#xqMxdtrk}2#p`WRrrJt>zqo1pvr=PE1pkJt8 zq+blJ_=(cR(n-=y`X&0M`epj%cs%<`{VM%x{TlsR{W|@6eXV|jexrVqezSgyeye_) ze!G5$ey4ty^t*nyeh)O-1yVo#UTDY%NCWlzu#saB^!S7I`}GI(2la>ahxJGFNA<__ zb^7D_6L`AsDg9~v89ZY0oc_H2g8riZlK!&(ivFtpn*O@}hQ3~ZQ-4c;TYpD?SAS1` zU;jY=P;b{i(m&Qe(LdEU=%4AI>l^hi^iBF^{Y(8T{cHUj{abyD{+<54-l6}XZ`FU) zf6{-}f6@P=|Em9{cj~|Ef9QYee@V9fx1{OYq_z5XX?ML#$Gtze!%~J$J4ZSYxBHC6 ztvrWGhfBi@&Cm_QF!8veZLrmWC!{{uxBIPh5LQ|5GhFF@BS$*L$Tjkee504q+vsET zH42P=Mt@^~G0@n_7-Z~h3^sO=9+Do$8ikJK^#}2Y;q%52V^?FSv752GG0Yfl>|yL_ zjDS`+A3H2=H};ZtF-94C<7(^YjD3vJ(v#9t(yh{M*nYX2bfI(+<|k}3_K(s}(j(FZ z(#=xRC^YspLPpq#7*Qi;6dA=viBXCN_V&Y`jSA^_=~>KGa*Z)q&p5-_-#9=z(>TyL z$QX+kvnr$$q^qQ>jY{aDPc;(QWOcrDsdSlixpbv;qv1)98sm)d#snP4pJYrnrWjL= zX~uM8hB4EaWz06_7;}wz#=*vXV}Wsqaj3D-ILugN9Bw3y#YUA;ZKRAPMvYNxq>ZJ< zGNaC@HyVsaqschJXf|4mR-?^WZmcj?8mo-e#*xNR#?i(x#<9j4<2d7Z;{@YG<0Ru` z;}qjm<22)R;|$|W<1FKB;~e8$<2>Vh;{xMC<09i?;}YXi<1*uN;|k+S<0|87;~L{y z<2vJdV=c}H-Duoo+-%$;J#O4;+-BTv++o~l+-2Nt++*Bp+-KZxJYYO%JY+muP3+}LP*VQexs8($h<8DAUU7~dLOjPH!^ zv6l3r(P8{xY&Cu~elmWR&XPWtDve*HvyJ~q?b1im$I>UpuhOT|2I&iI3H?mkXmlFC z8-Eyo8h;so8{3TSl4^7r-KJ#9redn5X6o37ZJL&8n~v$4IcBbzXXcx|%-&`nv#(iT z_A~qAj*o%nPUawUXLGQ*i#f#H)f{T>X6|kdGl!ddn0uNd%#r3^<|uP-b02fGS!nKS zhRm=TF{5V8EHaDD60_7SGxsycnERUtm^8)ii^CI(N^Aht?^D^^t^9u7y^D6Ud z^BVJ7^E&f-bFF!Ud82ugd9!(od8>JwdAoUsd8c`odAE6wd9Qh&dB6F9`Jnlb`LOwj z`KbArxz2pte8POve9C;)mimcgVks?Sw~pSR*ThYwOPxp71l~?m9^SB z(mKjI+B(KM)>>m7XB}^yV4Y~4WSwlCVx4N8W}R-GVV!B6Wu0xEW1VZAXPs|dU|nck zWL<1sVqI!oW?gPwVO?omWnFDuV_j=qXI*ctwQjI(v~IF)wr;U*wQjR+x9+g+wC=L* zw(ha+weGX-w;r$_v>vh^wjQw_wH~w9S&v&!SWj9{Sx;NfSkGF|StpK^>r-ok^_lg#wbA;*+GK6E zzO=rwzP7%xzO}Yk-&x;V9o7%lR_jOWC+lbH7wbRPuhwr?r}ew_hxMoRm-V-`&Dw5t zS>3i|%eG>xwr1zeJUKK5w4(B9V$*wEearW`{3HFKhN%qP1DfX%MY4+*% z8TOg>S@zlXIrh2sdG`7C1@?vZMfSz^CHAHEW%lLv750_(Rrb~PHTJdkb@ui4TKfk3 zM*AlFX8RWVR{J*lcKZ(dPWvwVZu=hlUi&`#e)|FYLHi;5VfzvLQTs7_o&C7|g#D!b zl>M~*jQy*M84_-~Pb<&~CRs zvOl&zu|Ksp*q_;-+Z*jK>`nG&`%C*P`)m6f`&)a9{hj^2-C_S=Z?%84f3knJf3g2# z|7!ncciO+(f7pN8f7yTA+wAQ&j-fb`BRh(tI+~+9hGXLX7Ta+g*U52mojfPs>E-lx z`Z#@^0;iwT-x=TxbarwEIXgRpon4$E&aTc-XE(gIGt3$8?BVR`jBrLedpV<=y`6oW z(N3YWuM={@PQ-~iF{j8Wc1oO5r_9;U8RP8l9N--29OR63%AE?Q(uq3>$8*Lx$73$2iA2YnFCbDi^?^PLNv3!RIci=9iHOP$M{%bhEn zE1j#HtDS3{Yn|(y>z%dE4bF|uP0r2EEzYgZZO-k^9nPK3UC!OkJ|Idz4NB? zmh-mrj`Obbp7Xx*f%Bo$?tJ8Y?0n*U>TGa6b3S)AI$t=OoXyUc&R5RY&Nt4t&KBo8 z=XDq`Q|p%H7-D#~tk!y8F5zH|$2-s2g*O++w%HEp^M>{oFC`{_X+pf$l-> zShw7*a4X%on{YjMoIBo~;7)WWxs%-~?o@Z0JKde(&U9zFv)wuFTz8&(ush#f;2z>0 z>MnE-a~HXXyGeJkTjf@}DR+rmfN&OP2e!6|P}H`FAn+FDaCw>H)^Hl&uB<@L#`=Eer2d~tJX zdCDv&=eCA)C|nsgxfxFgGZEruB;=bB0Y{3N8BPfIaH5?1!{q)jW5U0j!j+d2Tux>s znLM9xqJrXAQ2YvtUs0@=*EQ899sX@?Y-nuZ5xtN|&MQ;#B_-;Unb39G{^`EGpuahDG>rM5{=(QevegZe>-v zxvH&xNnL8CQ(4{EnyjiyHMFv@Xfs}PR8>?lTq@Fxdb}#3aA~;_uS$YKD7dIuB1BC_ zm`n)FgcIS29$%bn76l1=5i7o$$6jP6h|m(8(40iJ90@^lMk13>C?cu|ld2dI7*2$# z>R}?KuSS6vflG3g8QCh4gTHBf%%tTX5x~{Pyhs*%c zQPy6V8a*tE;DxEtsVH8U8oiQ;q*7ou7A_L4MZGB&qSlLxB81C043t7E!iu7e17dlU za9PwH7iiOQSzL1ZiA0QXSzLxas#_$ajw=isESbWv%|n(&3&XjAqzc2Vpw#jtKR^2=&2A3STKYLd0Yc$2UQ580#)Q^%U3JQwKwV^l z;KdVq@M4}EIs`8cOi_#kQB|U{T$_NNZL`m~=2U7Kw{iUBFt_S-syWq?CWaZMN=2zs zKBE&{AnZk{Qc7~cNfj4SVrSdhtwp@l1MgN-q=(6^m4018Urf z+3Jg)6cYpxEfE9}BZ3G=iESiA(fxzJV3vx-ss0=Y)26W`OGNp~@5cPNx7*Z>Z zsez1RMaI-j#^~vkV2s#BjM#-hLO2mC*QeGtHZD^W4K+O7nBdAlo+wgGaOY4AV=Ci# zu>_$(4AG%zNwG3j6e(O;p--!A1kayF{cswmtlVj}ZIGOr+v@9*ZLI=7aT?$fq86Ya z?j8}tB6_Fj;}OAxLJ`4yV3NDvI5E-B5-~zp5h1LI8m*H2E6HC79^pis{C&wlj2Unz zd{KW)#3{az;BdbTrJsnn(^~41Ewuvm)9TYq2+Scfz*Py+UW__=j5>OZxF1!(ixHy` z4Hk;hnB$Qc5fl6$=r7i$W%#@xRX7qk6;Ue}2~v&;F)0)-kEt_j8ySYfUZT>Pv8cI~ zHM%h~KsZIk#>_0?L`YJLgb7hagqR{fD?&^WM{ESw?kF=?iZ+Z@8nbx9?yMdAo-b__ z6B>&t^T}=AB6$>LuWA+ub7HgOzmF6GXy3o za~KuFEL@yyNtv?+CPcxb33Ya|O`pSP*q9@RlsQDob2usMbJ!#xcTNw2o)a8q;v`DO zspN4n6o&<)4~2=rM}!=Pnox~P2ooiQi4xzP@Loa{6WtGlNd7*z@<~8+B=}RsJyG$9 z&j3mYF(nCiUbc0Nd3@L~=J9s3=lR18Cz*s0X~PMU(i0?|CxlcUiiv4PDCSkFm9=S( z%Wy^1ou5sg@Jn?pO&Ifeej<5ayz;0@)CD5w({o68B$M~6d;)ru(JP|qG?+Zv}~W6`VvMQWJ^zys4bSh$o7k@QJ=$6gg2YWP=FFx#74Nz#CIuCn$t{dh*MiDY9_i7=6b%}SC;C5l!~g~S-eY=l{66UY+@GwgyV zail_?%HrAgW~H%`B#dc`eU=_BrAEU`NE~BmJYf=Xgaki{kmL|9Ep@B1*m0_MAYL({ z$8f@lHx!G7&FUb_!x2B$ykY9fkx1C6&Wr+>+=||mpm~3S=KHv1O_VAuN((WKCleu( z3s)BF)vFq7YPIS{k}D#y5;H}-G{tGcNoC8LqAr-qW^r%Sx(4C1JRdFzipBq9Xs85Gg{jBGJ8K@e)17`bEy7 zret$#;gUscAW@g#x~L_EX&pbmiNhc*V@ax^rZB8y23*tPws51hxp7%4kK3~t;Ef#i zG!|aqvN)vGfGpgaK=ao0h!6@#qDD;?Wnqu15D9BFg)LPMYuZS02v*Tt0)?oG;3P32 zB_RmsyfBFx%8K+_Rz;V8>$tGsa61zlB3dmgS8cXjqQGH7Y=jUR@k>JuMH9(HnaC|x zQIU`8)TLTl(mm&Qo{<)VFGorw&W3DE^Wue7RrO6;n)Q`5ky4tIk{Ht?9uHuqvs?`0 zEscNF5h6ZCizzYW&I*xwWrc{$Q5G$6mr~LEjv0xS@myeL1IBzos|XwWmTGArI)5ny z!IqZRWV()rbF#3zbO($ab0dXXk0Ap~5w6T1~s3ItL@Z6fM624+r;G;<;|ICBzmmq%^li8(NoaiRbo zNwgjz*ejtqLrGL?^m}bg=v^>FD$yHLJ|)CTM8Ap&^)Y4x)J8?VDf(MXOus`BO!<$X z4%8g%NU^xy%=sk$3ZItqfsWtKUN5 z;;7LYL;@#U6v8)&jEad2OQ`T=B`R)cSRtmB19T#v9d3hJe8yqD^2d{Wrfv#<6Tgv68~>}#flmH~qk zpVYzRlQK*Oc}u{gK81VmIL|1d7Ao=Gi84wENeS{#kiS1p2p)mHM@UQ%61}k621K=S zLh%t*3YpmwHS!S~hjKc%ElWo}CQC?EW+jGD-R4ilGVy#>U82O7mQmM?FDd*`Z&KBv zghmg_-kcr9h?`NCHNcCQ(u@ad^O=KE1fkw^m(HxXD5X)?$_54&zcnZGoZt})t zg(2BoNc|<5tV>W*eK{jyY8%3BiD$3y@u08ZTwz5PbH)lW!CsN+{+LcvYtf=tf)wQm zTJ%bY#V*L~MQT-MT1}JBnCGs_rp|j|#FqwWQ7lnbW~>TMvx!JN%GTE`(c)K3XmY?R zIgwyCFW6Dci|VWV>cW@n72qpGml!4%5cJtfsJ05sNL%f9?1&Kmu(%d7R}1D~tmd_M zR%7%JE}{7gHDyHZ5I@Hy>+6%eTG6tYKD!okY~|pzGCi%&O4rmU)#V$#-76RB7msuRo>ys++CogkqBM+0X&xPo#|&O)LOP8FF`;|_x&^0*k=iIG z)J9N3lPn!BffBlERWnCfOf0EDUCa>*9kGxFVnWFTwHh_Ckkmslv6uwYm&^r~#e`-7 z3Lf8`hFDtOjR|2I@yOkuUy#-|CM16N3;q@(Av`A5Uf|BL4wHzAWRw`GSYl$m8|9_) zh*m&(C_Un%FQpw*pkAQHxVT4`WA>Jy#sa;EI;7F9|)C(N?2~Dt;Dz0i;I=SrJN!mM}eCt@Aqumf5>9ssk ziF%}n^N3w|q>%H7ZFqueAb1nfMc)8SjYmt>9!>Z?TC(=Ul6BatEYVvTqtVh}Prcd4 z=jXgKHgn;&oS>h;4y{LcBwc&7Xyg%3_Qc{5^dwX^S`hMRe&vz6*Q50bkECsn)+sz% zpYY-(n60sm4$8{3+MLnp6S)aF0NNhWKcbZpy@j*O)?}O6CWxVc%pP^i-@!45;6N+V z%8Ezx1dmo&Jen_fwD#bU4DE@jF;o(M*CCEcD<~e#2RyN^gmnq(YKaoHsWxrlSHo6e zRg_w9WL(|h-$03I5y2yQ)}zG)kK|g978N{_Z#`OE@JP<}NEPnUZW~YNPr=KH(uFb$ zFkzQw2_Egj@kl@G3Edj5=oQ14msz@@8hIrK@3+L-1&M@Wf<#~vlNMwRR!_{@UvDJY zIVQ*$;W^<1X*KfpmfB#I@T@y{K=hmY83fQmo$nop^ip+#GFZ|7ZcUT zphEeI^?rn-!Ngyq6!S9FgW?OG1~G;P5uuR>O!W}zaWHk7cF_84Ostq895H*bat4^n z?~6yGxuMje_+o_$;V6HxGzgf=DcA$>LHP^91x)ct&W{OwAtWBcn-IdGK;n3Y8P^hR z&3si20FMl_)M!ezfQc}+0FcMUUIShhm}OdBy4m;S4Ua&azTn9b1UQmg)d)=?=1|S4 zhN@MR6z3cW#i)+M==o+yheQssF%R2&4(p;WoME0W~)97=@)R#hWL zOa!-^QqAecYL*>GE-V7eg;h}Bvh-ST$&T-p1J!P-p)R$A6)Tqm%~Z&o#hAjUd@m08 zUU>{!mV!!?$IB1@a)LzQK&ht)NG4%3V5ZF6gCvxco-~=i13~uMkRmaH1Fefq|Up(T1!?GuTWD(^-ap!)~fR zSL+@ZKz|9c9dvORY<~!(3 z&FT7-EB3N+yQsGq>9OEdVsTf5&kF=%Aj;HKm-2mCv`~Zw%t%Ff4md7fuSiy>njOK! zIPzJ&pnS8FjP&bLX+H8`&rZVT{hwFB=Ku`I>)Ee*|D#hm*&dbKqdVD|zT{*(Qf`l4 zUeEs1Ysb!$zeCRfpCHb_ifOck@t%Yj_`*V2hK(vA6?Nno#r7);RY#!WjHTR2 z7dEujHyaJyAd0K7QuR%(t1x04$-)*$y~bi;R0*S67#Iwh-x6V9ZH+;-!oaEzgO&Gz#Qv^0R=yUFt{?KNf<{61EVT)Y!L=lMHtj345)V*v_cpdb{PaU3o}-8!xm#U z9P%@^;1CXvTt1e=A_hb@II{$3C}q}sF{&~v4?}}Jz&tVRXFb>m&cX>P0G3=X4)_5a zF{bnM#i-7#Bq_j_Cu9acc!2w`a6#_F;2zwEIrQW{JZVmL%tmC#t%69ImAC3*(W)?5 ztHNNd3WHSz25VIqtW{yKR)xV@6$Wcn7_3!cuvUe^S``LsRTvDNFj%X?V66&+wJHqO zsxVlq!eFfmgS9FQ)~YaAtHNNd3WK%k;ugPE7iU^^F;ZgN&4gfvAk?bNoe5>4RTpPl z6;b_Gr7(W0F3z-S#zVB~V!u_9x@c7jkZDz<@3$%nNo|SrGB#eA9<7RU1jZCrxgA<{ z@jqG>NmHvLKfz2`JYqdr{8TqI)(fg$5|!- z;f%qAGiDVyV>W@aOmKlS9$MgxTLsQ|@GQ<47{nQaeVp-F0x>*T)*8ea^I|ZcAYzGw z1{$HK2Q21oVKH$8EM{I|q45Ch#}gu1SWHaeE@o!oqL2-i%sWzT4)3JG-r}+e64@~w z!R&|tF{la63(}z(pim@WC!v^JhJ~^cJ2$C3p-^73x*D6-ThM2grm9+Xt~`MxD|WIM z^1O4QHHK`Mt`aNGzGp_y62b-GHrLw&S&MDHpulWg28og;IU0)De7%JESP(@Pr|KG4 zV1k9o7q?bX1jx5yH{pc7Wb-7LB2_`qdd?xL3GL&4oleFuQru#%@OdjRNeA4 zU+saAAz~L*ChSy8D|Vr@rmF2^b93VgHtFLzmUu-Fqn5Nz#+ILQ<`cp8FBlL617S+B z_YRI+P^M_b`5el|%k|?0Ly78yg>n;XxRv~DYcqN(q1CHg`n}+<` znndvG#ucK5W#OoBX=|#cIt#@JHrbZv@i=^X%SA^fUZT2O_U+rV&fKlHUx6K?lydKU z?wy@0a9O& z2?bfCNNh+0ESj{qJmj(^K1_z#RlQ_iQJZeXIcD_ORQ2MUpMJv^oHqrXU)G>>T!+LEBeNPap(5+%`ncy|W z#Cs}y9wb(xacZf&FK!R6N;NJ?vm2JtXP}T4DM!VkOC(BZfQRt3qhfI-5*6zKk*HWS zfk{1=6mX=uLO9_LJ|*Idbpz~>6nr~cnaINi1J)uafWMN7eF4PA#aawt!Fj@@j>Fjq zo?KWc)gxhX1OTQ;wn8jhM2f_EDUeOmRI%{|`*DPVjuy`%F>2PBI9Uf+^yC;-2ixIz zezdp+f5FycLU9LJ-mOJ_J3W8k9ceJr#j}5CB$*_@*Y}kcIHteS(TOwog zGPZHP&Bf73-)@i3*d}Cb6En6+8QbKHZA!*AHDjBWu}#m|W@KzLGqzb7+w6>OPR2Gj zW1E+;9h|Yv&)63Dwj4p0qS9RZObBrKEK9iO((Fn=LK#iLa=4z`kHe{x;?V4vATHcx zG8IHCyn-Er(#duVa>#THf&(4HkCN#aevoX(AQ!)5_%SjagB*e#!;eC`Hv&b+ARyB* z2&RtV2NknAsy$8Y1k`H@H0QiWQrKR8AMx?y>tFqx_&|>?LvWzW_#yl*(<21$GUO8M zGQLxw%aBvgE<=t?3c&8`2%k0N`2gr26EEQxEe-;FgDUyNiUK}GfnbqvX~0KpclA@_ zn_{EAc}}!LHV`%zNCnf6tPkYDARvmt%Zu@j^;f^V7(6)udp1%rtmML87(i&~UT8bVz#wc;Qy*?9?~3~WOU=2IL@tvHyP*w)5G>h-Z{As0Ny!$fb$57lL#Wx3zn`VScVdTg}lt+y+AcegB2_dR--hC zhfZ71?ITDJD_I{Ork|K*%0tjs%Mt5yfO`YSxp|J+c)= z!f}{vwQ#(J19Ucq15CM1gpOUB!9qnvR+(cOM2KQY@_}H4SP-9BNAuIgBq~VBv0xhz z_2)JIqwMJTSwypw3xV`1gIG`W$SD|141!eDqF_;rvPpvr1XC*x7PUB7)Z$?Eu*W=G zz2acfP#y$)g838^m1cKJ`vF0v!S0Ct(gB}r_eQX6M+Fe<&)7Td#|bB}Gdc@kYjXe) z_{q!cQV#f)21|~O;MrKEK@63O^zv5yqshP@g6M}%h$WGq-RT=h5_hIBPtp0=InV&e z4s&v_SGGfE{dt!TfUHwzLEfze$OI*Jwu>LZ3M9{tfUOGQk>jZvvOQ+QvFFX<~b4zR6GZ0i6IfQ2KLEmNn8yfZte zI4G3**qP!(EHFX6vr`6g=@UqdoOr`Th#vaLO9eL zs;$f>4el}k`dA??aN1&PC)~XcfK`;^{FnK3X=Xz2&!@94{sg}C zpHuu&EXVPUMcD6%PEfkT6rW-Qr}(A+I>qNpb-09pFJIv3Y#3MOH8&>tS_7Obtb}02 zT+@sLiCl9H53Z38g=q&d#zdx31uW8wge#Q^6NOV{i8dJ>g@AHp+5~O3e^M!28nfnN zvuz7jUzPbYt@^5DlfO)W!f=twAe|t)YdgN zHf6kM$1~P_L{M=c0!M=dULxXP1n@!lqGLNnbnLJw9=B?!-jqNruLkQ^cD?2=fzusaP?lhn9Rk1?E+Xx4++E5}mw>S$;hbe`>9wH_hf32`wm8xNXT6Ty8o?uk z%W+Vc?fVypR74_0F?KMZ4)xctm55K4!*aY|RTR9I}bMz#3@2FQfB5d8T91X_?U zP~iAGk%>6lj+UgBrBnA2|GpJE+m)aT>JoG@T|#Ws2ex@WFvS81$jLuG!3%0G7mY4z zRpDt#c1{l4jH)zjTB*{|b*h+OB!mMjVki%5P3fkT$v!}rLnhH{d5PyaGZ~d&gHx)R zCtH*#a`>5J?&NUPWPM|FO-D?uxIu|0FcBkyiP5cFF>!+f&SME$kBJjIfCXk^bSqbk zj`q7a(VDcq1n3TSMjgvTa2Y22#V2X|!qg$C`;`B=><{#k}i`m$cF9rrxC=^+iBpHb1H}{PeQ}gSbl@0K zWQ-_0CieaV4-}sE^wW|c>hIT=;`_G?iX}=WUJHES5+BAmV|0EfCe9BtA)DN(eByo) z)SuE56c7A}{G)_lT6x7eBoQx4_>6jLA@e0psW{==Pm=hLA&<=OuvZ2kbY>kp^7*j+zEXIg%d=W@k`3jH-$d+a( z!4!BK%K{sAu?0xjTUTHcN=n39l3vofDu)Nbk#lVDsqQ6mMPB5P%N=l6MOzCvEW#EHHJy+-E__+EIK9JInl!8odYIse3-mb!4&JpVbOMgIp$z;%)#{8 zBIP64F$5vbB(Ncehc7P^hd^;zf(Q?;!^<5KY#LJ*-oh|LbgBX4c}`HKrr;vmkhrOY z&2-4!PnP_}c{XgiretYXSBQ3Xg^G&uu#KBP{S3wnHf)K7uSJV;g{6Vr(UDx1izdL* zaaAMQ0lb=z=mFMS8p*-pa;iC5#|FG*`9S~zJ}VpgNij}Nw6IsE*`DgWfE`KlAQ7>e zABu?8ewd=mM(DIxgzlS*_+vlaI~SoV2Qc3f>Ck!O2%SHU_!lqw*NFJ{#nI{S2wfZ% z@o#t{Rv95y5TT(Y;)_Yd8X|OHIYOt|B6K7%LM$OdEFnTHBodE_+lnyBuWBGGcHH<@ z?6>i)*p%a2u~~=jCBb=F!C52mgqU8I6=ipMhp`QYZyw{j{%Ow)+lbcESfAOA5~911 zqI8@rN@H1+cz2Y>v?%fLD2;7V;^k2qAYo}&RfRmyk(piU7Riy zjME(waXNVN4Mj7R4ii#affXj24xSCwtxID#XnIaZRQc=38BT6UhqjXX}N+;o?bmBcq zhc%-lYewl1WR&PNN{1bzbl5RUha97H$T3QXAER`bF-nISqjZ=tN|%#G1s4lNeJUnN zB}#`IqjZ-`lrF4_(!Fm{x^XH>Q;8@|C8BggSd=80C`mR^x+Eehwrz%@ViOQd8h&XL z5EL%FHR7}x2|5=BGj$8O^T7xzPSIL%I-4D*v)OUFj4e)Qv*Tj(62klOITZrt>B98$ z6RjO5s)-Y|#OYjhoX%Cp>0EW3ZZC@a<)UjL<8*UMoUYT16P3k@%Hl*_altOJ(nR&3 zv%qmW3mm7jz;O~w;xs#q6II5E>fuX4zl!IZDEgNaVp@YdJlGWKD=)Y=La=BuKX9kT=CSq1W z^skO>|C(`IHh#ABZB@N81?I}0K&Wi-+NyeI3qry8AcAgO*j8l=-7V&{IXq>Isw_|* z2l$Qz1`0ny6LykT)gqjaHNHC-t_TD{qpF!8iNCIs7p0TBY3un@iQ!9HU#-(wEYneM1IWSYioVRJcRnBIm3&*2ay z-aEt77QqiE4_kXN7x*?MF=Gqb=GtW45{iO7zzsFLeFBaybEE*!izjPvf{?%*P6W90j^!W# zKVVi$V}4Mw1%x){Vyy(mnI&IPNJi7@a#{v{LEl+>TU8D#1`i95008>@H)Z3$IJQ;w z5#{AqzOlTKQqSzG8gS$Z_~!ZW5WY*6_I($g7KQ1brK(`mfO9Z@zienm4F60cm~>xw z*W-Fuwx}RB^Heqb8*iv;_($zBY>W@hp$Oe28}aXGB!x|cj-*Fu$d1soK2n*me2TL( zq$M~b&3q#?U5n5rR`6B2uB?0!+hRt5xVpQ)xT}oc*9Z~c?+`oxyYr&v)XLVrJNU7l zkV|f>n%Sj~lmiY`VCvup=fEPd4AVOsJYBVj;k%~`>jHF(+LAO@Ay(lUwPs3HT%4EJ zl=9DPFs>j3UhGX_w_`E@H>P5{ECDzs8sPvtQOR_Wix2{+Pvq4lo3SC99n@uo$fxtg zeA6kGet4=-2oUS6V*I|>LZ^#KUWdI1Qx`UW7Z zGzK8NG!THW0vLdBvloYGkSs=r5zNkSyj)K1K4j6YUgf@Jns1roTavyd^iMOa=>IHLR~#J_Y59-GRh%^3iz9*tv=z%vzV=Ud*54pB!?T0~Q^{ zvQIDG=@@}guCAXA1~f6YVfg^{=jQ@ja8F$;Jn|NazRK<(h1gIK?5yniniL#+`w1=b zV-+lG#O?KITpY+~l$8;|B>dqKZM;Rw|J1i6N$Vvk(lz+q37@;={*o*Y#7p)v-m$+7 z-z(&Hd_Trp@3Q>2GG3CEiOO1hZ&Yr?_ZGYlE-SAo>+yX{c^lsklrQo9TG@tgmx}Os z5Bp$z4^gY}tx?n7^Me!}x%J3bdO~!YsHWlCL+H8F1YK{0Fp&fy5i`I(oa%}~^tF$%v z9WV}|G zhi|@}FRAPuLfG*RA-+TH-SHic_XlP6`XIhh8+c}~4dQ!%4LswW!Lj(_r9pfXb^_lC z_5^&V*i-PGW&`JVX>bOy?dx#Cp;}yYXe2=z|!}kRH6nsy!QB%AOcssri z*q|1?*9XeC-?rbw_aplwd^gx%;k(5F_2Tuq<&uh*=honRuJaYXe>$kO)9rx5@#>n1 z@2>8y_>Oc(;ycO(&ER#kgCrF%q@m>OJv4l$yVE5Fuc4uYcoA(jd=GOE!x!(Mq0h?b zyAs|Xme8vK89(CNkG&Tw-il=}MegvP-931b?g_je_a{<2451~dRvL)!E%f!i31nw~wKbQu>8%rdq;73X7--6#qk~DCfBn@iB z??Xx2xeKL47aEKI;NR2I(*aQUvaj!+b&*CRAnJPB8E4+Q?u8fMd4I!ao4@{cOUDnt z|Fx~luxvLczjwiaU54&HtgvXmgg0Z}LR@Z*hmlz%b?v$jH~rS_iD%=$r(0s%mImU(-&6bg=AEb<#et0c=5YK-YeLcw<)r9@YbPQ_v`Hc zm($g4bUHD-_{=fi-PqkGt&z@?F2U=^*GrE{uSwqmp$wa>Rn&FTy2P_x&q~i8*xuPL zZ5q zMN-$SIqT=UHb1>#gS&adcX=Hhzij@+{?*+jf0teM+?+@(T!^=xMM!O}a?|>|?)~f? zJQTS1=Ka3v{A$RzKWDyj>ecN_z`^VtsdGeNSS?-pNZsjLcQMwehOEE#wlG3+J?c1%H zvStp~ztHquw+)cC6iV+TfVIRr=|IX|m9$e=t~{fy?eZl{u6)e7vUIPsD3kL>(tQ~Rf~ zym4c%jk}NBRN!nHGUTh@Z~opH_n~eC4I>ye5}<_6V~MF`kRJ$AME|sPn$ZQFF+CSU}I-zXZsMRb5keCVAG~; zUFJxs6L}SMc355gwmR!kvLPMq&X9ujy*@1dqoCdE*mKb5U3k+Q9gkAgj_+2+o^#)! zQ?9wKb@V-*UiE#Kt$gcgxV@UQ=kDtxo1C{l82#n@Kf7}K{a)$U8^Ygwvmx~Dw;RG= zbvT;}I`2!^osZ1>vh$Jon>t?_7w&BDKt(%;NSj8kmpVpn0#G1z7IaEo-Lll(-3@Z> zl!m~~>*&-T3afaj-7g8+=~8FR=Z_UcI-W(~XO%ALc~Jc88)G9kZQ6gx=De=IzW=^o zPDi_R4-f~0I})(Bz7tjN#IvH?|5X2!w|D=h{rUG_4ycKt2BCPcAw$|bMviRnh!i|8 zMbMtoEXox)m9#7Gsa|>GbIuREtdWX$;SkF<>+{}{0P*d}j(&??CES^4>P2Q<2W zZrbwYkj;PWvdh;O4jTOR#zEV*oKTS7^7EeGeEztZ9HaIo{@@{ z!sVvg*b|ngckT=?)zyaAh8ITkEj(@H*6lNGHfUc z(DeRxeY`6C+p^P7B!7GEbm!!aCB3eR8+~mF;gs~-w7YLp#HPHA9?LhKG%3n9*_z)m zZa%TLXTaui?=JP5&oA!zG$jF>FV;>sY_7Ikir-wTyI|d1Yq=0dN&Myp%h~wNvNu-` z;zP-x`pulTT)$cL_N?D>Trc@i^5mA|af;wAr~K^ax17@RPi{HwSL3{;vZMX{*5d){ z07{J~=-bT+4Dw44P9pWdW-4oU=XLPUh6{;qf_KNYm4l1muVz zbpbh^wr~uiwZ57kj;ak${cuXJF8!etWakcNoq1<@PgU8jpY%@UyN*8XT)nF(M6KR+ zGDK8S0u%~zI)qc_{ut(YGB-b_Mw@%WQr{uB{K+P5uI&lQmRsGYIw)7tn9aM3dTVNS zH@h_U+O4VXcyYH=^fl$4<7TmJkIhfGxc8gH%TMk7?!~6Ad-uiq?cZD8(O$K;qPyba z-b?N4efM5zf3+(mKpjA?wZH7Uw>>@ef3n79U+KxJ9{VaXovWVNXUk|Tp#-NH zjTiRWo&~DPxOQq^jla!r|Mz;i*Z$*Ay7t+BqK`+}{u0xr&ihLT*Q@rQcH-LYKiB!% zllw1rzT!zq2TJ@XVZ2`ZFLnj0OhV+!6p+iEFLj{g$^F*?FSn<}bAMytMbEsoFT0+~ zJN|NIr@YdfQcYfMo-YV&L+3e5mEH1!HeS;GxNnrZ@MC!&<<%drcD&s6$7@3`*ZVDbnkcT@`fB<=b zc)6c*J3Mzp6|}P_$hSefbU3$1O~B!O@oonXw{K_D9QIOFbUCu^sY{)Y{N&rY=18H* z_4tufXRmZVa;B%!>qyyI1eBcvId}F#=OeYA2yL`n&`{!Z1nO6EM7ErDD)@Y4JJ*7* z;(1HKF|EC<;6lIi&IQ%pefs?L*S>zee#!~D6hevlry3`_z3@YeZ$#m4t6P2H!DzQS zO6m&>0xP@=j|HCbE-d}BPg-HAEkTFDK3r3HzI}yXVYN4AMte+!s?c*|w&&5E!52+O zb6=@3oQ3N zRupr#!?B{iXFZOcG+yvJcG`$j5TFoBLJ?3FQr_X%S>tK1V|0u?R+n1tdaN<3+^guj zUpLiJQeU*=mBwpDJ72k0OUbpOUB3d^^U9TLMf+a|+CK*5$FQ;|iVhhMDvOQ;m31gO zLQ@>=e6>T-QJM?{r>{~ns;;i+c<0L`+wI=~Snp%k57+QFNv8;!{Oenkjh( z0oM}B+=?0-5xFrEL}>)+XxiAJD5Ih5cf~uNFY8cT(px1`A{C$NtrCh$`{1-ZLjPjTIo`5u(CFK$dIaV>7* zk<7KE!cgqm8$6GaGlt{eKY!Yjk`BlBrve={pKgD=EaFtV;}<%d?s)u?*Jq*t= z({;gJPy6^@a6j#PT2__)?brONvgP^;b=mTe3bCwM&y|%GCma>aN{pPcjMHAQmebL! z{9sUpdwEe;O7#4h@>3mvO8pw0$}jZcY~_Mrx0g#oll?3PGLN$-g5~nFC0e=SY-yLH z{%1@5j`lixNj%y>N#ofo;*rLR60gF*iZYLex{9-%>fI}#9yK`ac(tkK932kN zseid^*ZEUVH(k6?Agf(3V8#3-CA}_Gd5dngokKJ^w%nIJ^|stsJcDh8%}#E%LQgD{ ziwq?-wzC(XQQNR$_P14fC>m_Fwo;pouB}0Q>6)#u5UXn35_w{&X!Fg+iq#imyRDyR zsqNws|EoUHsg`cMreS1(>t|9G`BuaAdNdDwdX+sdE%SJqS(N1xw7 z$+wjyrlOk4lIZepE6cj6giG7rj%jykd)%HUE}hpLxpL`(`d~FBS1wg*fIxtpK`yEH zU%6DR0jX6g)tA~|`_8SZB1t?+NlBG0i7TmUKI{HeRr5Lb?p0iq)Kuj@Z2R^qADc?J ze756xJ0Z?~TIXPXudKE-tbMm{YsdVzK z&Cw+I*XGrRgLo$BYHeu9S}gr=muB;+>Mx7Viu%SzkmiQkC+fL|YF|o#nn1*c$|oA= zVzHs>oaEGS)wfA%px4$=uTflXXgJfYOG86>H=hQ&LnsdC;F3UVG*Vl6{ za=i;zFE@8pHp;S{<8h~nM+UMRcUe|8$PHX^Pvv>8so_bEYijaiK^%TaE!MfV<5enq z9q(IM)RWhh9`sO@bv?}s7u#QJ5U!~~>ZQgSRP&6xfVwvcfmfOu#e>#Ohs6A``|HFj zv6mI1(9o!qPQA46qSR2UvP%thswPQLHq|RDQ}$LVtK;|9C}mk)ui|_+T*ecDnk#Ct z)3vLb<)u1*&6?+I-jsCGY)P&0!7a5d2fgkOP1DeuSWn1_*x+i@D3$dWnPkx0wNj0X zvZj&Vv_XpS7)ImiQhh~(yQ^A_Zh2m=MyK0dh#6jIb1HfPvRLNkS?T1|?&?+N-Foh_ zbGU7n%H?3sD{7a6{#TT)bf|Kb8vfPjcJ^2M_Pbqv5yaoMvBLduR8^IG$uI}G>Q0wn z?w&5Tv+XuS)K!2eD%$xs)E@AVxyJJz-fp(j?Y|3czSLfG>1wklZu$0j{@9n-dKN-@ zA-_3=km!wzJR#h8fP#k-p1v4eJTb6fzzJWT=c%jRnB75XJC*N6ZyCMlJ)@ULaXXJr z`%^sH`>0$l9q%04p{S@!c|gt)-<-ly+)O^@dC6B(nwRbC>seap>+4%u>es>d*YqY|fIdauoqL+=MP@QOZ{7}7NAliBfA{g`qW(I~t{{8=wysG-_AgyuuOtUS z6~MdeWs<*icLO2Ym+iQAmCRVN$~&HXuxhbFMNY1p^|Tw=^?kNN&1id4@WZFq0!S@r z7tRTkkhMQ7E>beuj{Nh7-Hk3}4X6{Dx^uQJkj%_oUEhJs-uto7i{!n$xix3VAM%b@ zs>yuNi=_I;AA70EM~9{>D#-C8?>GC9BL#Dt8p+y%h3CD=@`9zH(+ZZI?@9I-e90Y_ z#}tda4~+ex{&W&8UX# zu9)e1n(PHVMb=mRSm;i^t^F5KldtNw*j40EU3KjhvbUkBrkd;^v(V1(3)}Lpkp<7Z z?^HoPVHA(@;pn49K^{hyz!7o=$5eSLdz?z-spOCHTk>1vWBJ$eugTYPs+>x;$Z>KU z$(7Brne3KBc935udq3cRA&=o)KXB8@O3w6Q))R6j z`Nv0Uh3vt_e0;5|+>=ZA_?o*sh~$5AwWHjg6fdlZl*35zqH1S(Fgd)a+7n*kqH1^f zH}uRI-qA%@T;vht^j~>BvbOxy?v6Yw{>t~1N0MEOt9bbZ^4q_iHp*6VVR>D(y_$>7 z?vf+x$&q!Pw#vUH-)@w>;BEThvJ$QHY<^`sR986S)=TEO;)3%#WFKy3!Fg|aBH8=X zxlZzP69?KW?0dNcd$he$ z?n)}u1L61LqC~y@n!S#k$PB_$>KbzX)gIL6JYMV~>+C;~O_@=0sJ)PTeo6*>8#iQ1 ztZb2|lhMtC;m;ud)P%}mvM;HaoDBa(^5vBXIa2l`=O-pGzj9(Md_B%1nSUuON{*HT z$noDB0;D_K`V7}8(Q63^|$-!56=GVW_AP?tFKj4pX>U{dH5faKRlH#kCdaiopk0sQ`HNpdC> z?E7(E%)c<&6@CQy_V*t0+lY5O-iQ|`R8Ac4KcJU#6!(q%_O4H${l%y;eq1{{ULp|< zA@XQaDlg`qm0#)p0hHql`&WZ=aJ6&fQNwHGQQVXA@DbJWXs)~b(yuPT?9QgV&DS`lH>&P zCHb=}`ou_nBr}ra-=Gn{A@4jX|6W#bK4ktc<(aY*$CHnA@`o6vT*G67$*jUhQid}ZwK#*!JYy*h3Tnak+*-@H8THL~b8ul?pVv?I-#BO4qOJm=r? zj%00%zrMxa*y3+(@pq7&NH-pbgg0#**@r_7q#${ufSdrIr)S(?yt; zPz5l3!p&al;VqtuY$b}gm;rudYg&dCw0}k#DB?&hv0KvMjS78}$R+heYIn2>m*_np z8&vx;zQsr=POAbf%>XB%GNBl>HUYIuDCqADTKqydVvc%#9q9bm2cjQjc17&12zcp< z0_fyKUnv|6(xycS!p6qaT=G#-Jr3^~0OC6AGzscEB#wAz7!H3hqc_!y2 zSEsb5E>H1o$){~@hf{Nsk0l>V@lWwbZq+Eenw^EGq$HQ52DYY15vZeeOVQC1*ip?& z(SeJaqzHtc+=aH&TO0mh!Gsu#7Q5?D-gMU>EPt}7Jl@AIGxBbc2es>FR<;$BgA(Qw1$qVitF4N`XfB1(b)_wov&+j|xeMOYB zGwt^k@n<5*d0AC=iXtaW%DNN(PCQx49C9cA4PNH(RxFduQP**4R*zQvF#f&t;$iZ8 z)HL#%`_JLOQ};=>`>)|+j`?Ed7~-2r=gt4h?{Vwj{agfYP4D~n-;sy=tzPDRj|I2q zm^t-!-h*&5XFN#kTjOG-d(%&zHQ7rZlzG?LLbhhkmG@1gtFM;dWT6$^>dOl<7swfz zi*Mz(J(qW9E^B+q5jO!;PRmN!nk z={n>fnuz=E`}@h?PxSh^m~F3z#+9c|>~iZmnV#D#CU&E~ zY@FzSr#u~F!R;Y!=hR#K3-|NvN9901cYE5OdYh3$@ zR&;Nlj)(Lo51MFkpqr^&Ix+fY&h2MESC`vU{NLlW_NCl^BGXLBjkRqpL*8>VNN9>-GIyDYwpBx5mf#(IPRohCJFi?#Twz@{VGjV1sW^orx#6E-j`^m#5&bz;uKOc*( zkx;KC_JFE9Ay?+0%>4uy?aTRTkJ3YU~ z9J=;iZFOtd|6<#`kD~AQ(uc=c$A3p& zZP$E{n*Y7pq4oLuBbV_r-!!ycx3*p9->Yu4PV~Lw{j|mX#JSTowwB+vPIrp;@8HS9 zvijU_z7O)A?0)lneDSihk1wwW%L(Iy>24|H{H*Z1h5gU*by@fP_V2%YJy}Wr-SQt# z>GWHKd(G|g&Yv$w{`BYL{3m0`Cmy^sds9~Wy;8|jvIg8M{87X1^iB)w&tZ?6@8d|1 zdW<9_>-Dzt*`ws!_Wd-?N4|6;H`$go?#9{u&4aD+mtSL9rJ-+uMsMmWp z^8NQCxi)JK`fL8fl{ATOn~r{aM1OL3hkw7{3pips=xyIsY-$_&m~h|6l+V988_2?< zM=K(0=|iU6%;CTn{u{#n1PBgU=c z+s~!D|9uKSm(wlbFJ|q#g~Lwo=f0u!9^p>u|Nq954?X%7$;-3yX)dxc>!IHSAdjC} zxn!Mr^qSr|hdaqE?lT_Sll(iE`t#ho?ZNo@l#f3K)xR~Hl@|t z>^W&jEAQVq7vLS{a7Q_lM%~Z6)LN$p@!Q^C+?ww0dO2y#ZE=6*+l^b}-|d{XXX|>p z^_%kB^kM-`J!$-{dB`OqxrSzFJw{5&fYm2ry{cm9T(4@uxiJHnela@Vf zIdb--mA9t7bzE#44&BObdwwr0Z=AFq`ER~god4ItES)`O#j^_fdjm9k## zrdgsv4$=ke+JVK4D9qMTctRYt76)dnsGT?t;5df#0&JmRy-n?)5D}J8<`E^cqT) z;h&yVyu96$iRdEg6Rz)R#9#!Lp6=d3oq)dj28tjLL!kC*m8m z12%}Z$VZ~K!?zJ)^ame(V5lX$xK>^iwVH9g2`?qfiwxybhJ3|fBMR6(B2f!OyzKx@ zrj#h84RWdBfb=b+8z}|vbNHJjtZfWCBs^a*3Z}oWvetq!=gm6%)iXFfEGC)jNIyR=5yAbl%s zR2r3`%5Y_rGFlmY|0q|caMjxF9?nyB? zQyJaF;!{pfJXN4{0VKvnzACxntv)a2+s}!I?6ZIn4mqUgFY+do@#gi=bC4?VXz!xM z!yr3z<&DyZ$YTNWkWiCN;vO96W#sUtvKhXN(j>*l^5;nTrua{ppzdK}o%}g+UITgA zBQHSw1>mlcBv#%gq+5V=3lMq$=?=2EpQ{w|=PE(|TA}Uvi zz#5c^BSo<-bIr>TgYAnrqJK{JpdK`h7BtI!H+94>M``Xyg#2b?W`PWuD7 zBp`Sg2v3Lq1=6jR zhoUBHP=hs+25sPk!xgwA{4Qwu?&Jq$1g;?xhocs2Pz%Trd0Fu>bf`P*uW7|#F``A2 zl3-;mO`InFQJgNmBhD1x6WaWpj3>aH@l0uT1ZJU?KO#D^i zXRyj=Bx~@$cd^_AZUPy^eE_R`zl2r3rDP;V`M;Afu*Wxmyupv;-yxIv#r$Vv5wp#= z1h)CI$!Cfr*x*~q?C$-8+1=X+i+gj)7C|TI$U$KMEal}37ln)Dkmw`&ki%kE(U%-y zHtc?)?MMol^}1usdR>v!Tk1`UrKhE*$#E%A3M40_P{~40N`s}rlU0A@+*O~ezTmu7YgB8v&Z>W@{>61sZBlLGx~jIQws5{`bQbr7+8G9V z{9rk*H`kroiQ{^xr>o!Lo>G6P{)p?P{+s%5Tp#t<>h;{y>W%7++%xJe>aAR$db@f% zr=@n^xL|d@`Y>lu*Q#r|5DmJYGjpL~t8aCo5;)Nh<3ioTqCRB5n_TGV8|R@@LT84~4P6wvJTyCWV`xrjUg-GHDPi4i<$_ZfmU3qoS{gR) zj;h z&A?S}u=umy8fVdk?XZMf5-j~JgTwY&Mp(vJGA+|Avn=!BmRMF=)>*b%axM9mVoSND z%F+-fgt>-!BZmPfZ&aK9b@WJUi_-Qhx!xX*E4*y$gD{wkySg<1e0sq^%i~K}hB}ZXCaRL{Ek>l^& z6mAMR&aL2fkrP}Yta{pE)$xaQeDX(P+wMG<`1f`sITz(>UwoOe@NY=ZsHHC?P@!J zM1va)zCfeUDEObKbxOX_Da|R3KML8Ki32NT5<`+8hyCH=$N(|~|AhGx=C3r&Uq6sV zWEW)dUz{5UYjUtKJApJ&iR513rgHD$js@0cxnI$Fje8UGS|RrqwK>au#ia8fq%(=T zN@qI0GnG-kC;u{BFa8xcf5_|md~f~(eh#0(&*eYoe?ezJ{#DF?YZ11N-^IT{C76GQ zN-+N?YNHk@V562_2phGD{8x%p#UQ?s+Nf2i>0GI3Pv=TSH)@?$@f5XAtB9cTt$2^h zx8kqVMy+BowNa~BLS!VeLlj$t7$HWnjmo`ZyO1m-E4~v_g;d23YMoZ` zz3{B?tRjcbyow*FjatP{*rlDID4`Z<6{o14DC(&dTER)UD%geYusN#`o}ro}1X7!` zLJ-v-K}+>V2&VJ3priA(V4yZ<1tVrQYYS#rn++F2sg4QZ(6MMCl3Iop2Ej7y z8^X&*61&{Z?^0 zwbLrTL+!MRGa&tMi0`W3QNJU;r+!!cuK2$CBlXAPEUF3OpVgnLKNaVw|Em70_!spO z^%8L|bYq$Lp?bY~y*Qt0i1;zp5OD$35OEtKus4HFdrCPj$1pS$^ATPm6`vDN&MGL;=g4Qzlllw9wzapsKhItW)dIDBtDKw ze19hK&oPPr4YkLt*vMr4Ad~e&)E=|q7?b!SCh^789<$;ywa2Weq4t;+wbUN7q5<}p z;}mwP1A>z3fS`g7Sn(7BIsnOj7V_^*rC)HTwwZ-K)HbsaKy5P%fm9Qa%csIB#D#5U zVHnj4VFc5P3Dh>TFq_(D7XCtQGYcOJHPDj<^lz+%&zZI?6D3h2e8F^PCDWOIP&>`S zD%ff6AgmTUiXDYD&>vqRo9WEg)K0Uop6Sd+YN1)!L@hK6+o*+RVF$I)EPPKbGz&S@ zLbLD#wa_f=q86HkT&7*SsSRdfFSWreoMk$8o?2fPu2AdCqDbv7iz;e&83F;j%c2YJ z))Yh_ifZAG?^wi3- zWT3hyg;3p-5~%J;iPW;Pl&tQl?kQQR21;pE1EqATfl@zeO<8)DT2q#OLA6mDK($eN zj%uSckXlWao~Krmr5C8xWN8@eCI3Zw6|3rxrIFCdWzuWv}G|JdL4CryPU(qdDF1mh{66 zkLG*~KDyTppbzC188r{)bQ25%;j*dIO?CJWPw$z-FhdWA)4kV<9?;)t*r=Ol0DVXo zXn@l#ru1%3w*nzl%4gnJ8OwER4RdvybvtzjbjNgObd|a~J+F7xd+Gi3ee?!>q><>8 z^!@ci;F=7v`jPsv`Yio){cQaL{ZjoZ{d)a&{T}^c{Rw@AzPfd6q5jR958b_4^KRD- zhqSk9cw1?1k)#`PaZCTU^h%q)aI{VvIW}tU&>Ch|tC<)X79q#wZDI^W$S}ib@W(S9 z%tLF=#Tku9V`j6sbVSO*QD`Vd{x%%7Mq<>6-_ zhCBQ|Gw$R<=jvY9y>1?29-^B->BBgSPWPU9q`5*j)m%Xx&MM3!@x=8ur<Ai9=XX%~ISq_K!(+iRjOC4rX zKL@&nTJeE9ZsLriXP0Ty{qqkqmLgHnP=;Vm}kQ+ zFfY)Lq_oxHNi}okrPS%iI{Z6ReHQquZgYFEwwMlg>x$zJYmm@d^B#S*#nYm}3Iwlv zLQC<4{DGap9Z%IC%(dvuJ=<*-p3*;nbF8033*quw9ojM+>zqJ~f2dENp$4;=H^iE& zLrV=-I^$y#j#>3Y+0?etWyj&#~Q`xY4kPv8+FETV}h}taj1<4WT?pj>0VvDjE{tTHy31e2@D+tkezU^1DaLnW+4_Lx#k1586r zqfFyWlT9;BcGDcwLenzSYSRYO4%0qUfvLoF-c)0<2MrBTg?NN?3F#T44Y7p8hopxL z3K<@9Eadf&2_aKM-V2!*vN&W#$l8$2Av;43gd7VR5ppJ^GNcYGiYC0aTZg&OoxWYd z7}DnbQtPUS`M7?%(rL3QsdlU@TGs|NAIDsL`^?%h@3qcyrJ<#_&tR=H5}k(}^ADR> zny#-O+Ri8#*Za)4K2~F_bx20*=x82$ee7!)ziu9r%vmjC#f|ZSjRJSjYNm7M1ysvg zv~a#_dT z=ar)TMHXF~cY1}PrD(NM+HRKlp|uu5OF~Qf_L*VvM|reFS|;9W^_h%$aI$6a^> z>A`~%54XDDN{WJq2frRXf$_HEY$}ce!S4mn3tk+&B6w}^=HQ*d2f#aqd@6(Mg6nj= z&e__BI-Qr!Pe*-&E>f4|xV6&t*A3B)v>J3{by>RUuzn`#X6qK{mcn0!V?E_KoNkZq zuIdpe^@TVarXQ^zub+~U z0=F9HwFaV}sh_J~q+hPj)^F73==1c2h-=f=W~3NMvc`~~tf9`JF?bq$4gQ98hLt$e z8Nv+-hJGw$uwjH@j3Lu7%`nR_->}565^=T~a%oyaxuF;boHf!KX{a(ZP~Rv-Pqjv7 zj54}rjG`{dhf<@ru^apu#sH%!V@9j9CK;oRDWC(4Lye=XNv+Pf&^XRG$2i$I19Tzf zCRt5`jCLBqSYuKpnT;Ea%Yawo*kIgY+{Z!+u4lk9GL{(6GmhD$GS(m_(j%?aly34c zbuslcX-yVWJdI&Wr};B9$TU1{nCW%X1k+U0dqDG&tk(Xf#W+@&)|xh(c3S%hiY(mhWb+6>O%cPb-= z6GHoi4z?Z+9g(sxbWF-Vpv=%|p|es7Lg(XHVm%Db%FuP7TN%y;$`383*x^FUllxH@ zS{2$r{frF`XX$1UEUp%BOE-F+Jcc^!VTW%CaQN5he9JP6$+FNAot$Y&v8=WXNS<#Q zY8ho2mps4KSyoeInQWP1nZtOQI9rHxtJB9=3M?Be`z$+fl;9`;J)ee;?$RVnjl~|O zN|VAo!n%a@4AX{bTdWf1T1Q$(hFOwx!{Woz!v?`0?g$Ax)6XO9^{`{06Bs@dHZ|l|q>cV;G^XhOv9M0kW8TO)QZJmRku{x#3!42;d zZU~PIPeK}&;zmfuzKnh0L*Pb+kImR;9T}cQ>q}jRcldOi&kkP@zO)6 zKcXZdJ;FvQL$wY+l2~WAx=4)!MS7+Ti1dw|2I?P~8963W7a1OzkTQU}$bJqV zXmI3+R@@RcCNdLzq{}cxu8W)%(?4>4O`t|4u(G$|9L{Cjyo@DLUCHg%a^U|`Zi(VYPB6=;{=IEW# z2N3sI^qE-q=*qO28N&Uz=(-rbO+42O*BIv*uNbckpf=7sCePRp_ zPE4f3(U!ltryP9(ZvWQ)$%uY1m#(2MW@OCB^zfLm>7FrJaMNREQ#;uFn8kZZF4_6#l9#<9D5HG~L z#(T$iiw}r5#Ye}d#1Duc8b1ndT>Rwt8S!)C7sfA(Umd?8en@B4b+2?>1PseRw;JFoBJzAO5!?Yp_} z&b|lw9_xFiZ)M-Q1U_g*f-?>;9DWIX5)28E2}ue46NV&=OcoYuYTVLZ{y5y~x zU5bLgUviaoWO765+Khk{!7<{dxTbis5fr0mN&r1lOexXU=_x5U zk8U^{nlcL3awn(ENSTweFl8CWl?@n6_N5f0lvod^oVOnC=V4`YP)bdTJym6$om$zd zL9KqOM{1YUo~hbYOKN;-dg`Fm;i<2uPDq`a`d;e1)WxYQQrD(#PTiS$AoW=4nbgYE zI;Np)?6mS$XRDXh@811oT>!V#y2`rVy4|`bZ6;=o6X|2D71nBN6J}`lG@rB{&^U8? zLYg%#7Kb%$VA?RMPidpERvMo+g<-6cGE&kOr7g$EwlOUyEibJwtu)Ps7`5qy>9l9M zZ@PcFE^(3Tb15` zQO*^k7#)uSSi3}Hbv^)VwNdmw1uNk>84EL(q1QHK?7+-ikWrFxKBFeX-cQxfqhFVP zJ^N|#4Kp_tpM(%UP!(}z*p<<4pzg#2)PpEM1)>2}6CTusIDx{dG^mC=4eCZDP$i4$ zLI~q`0d*x$Fq*<>GNVDD8WITVLTFj;9L>#*xP!W~`nVD``J^qHADbKI#5@V0IFs!=1O$HFh>unVb=?* z-Hby^cO}t`9{}pk(^gWlnrKJ|V2_s8rscZxi$HnDwUdp&ZloRMbChaYYD?Qt!)hoI z0k|FM1BzWvplZ^QQQAK4{1#AGc13Qi_1su1x%0EZR}mi?hoiFL%G$@Be}-X4uTiN$ z8#9g-)QJe7*aHRX&VR({C!iV<3<@6wR~w z77Z^bXQTku5CbS7 z4!Na$=S(_-y7LD?U0KaVLhGzyG9$27!d@G2Bqm+%{9Is>#dK!9rXjSg+=)M>{P&<5 z(v#t>psqyAFzs=P<>SU=!;M`<2ZjmpMW{$zKvk@6ZbV=l+9OWvs$nr5*p*$cJ5S3J zSZ+Lt$Cr-p!(9BwijN78ub?1%?0b8GeQz&`LHOQYDH=or(Xj9Boy0IPia3igVk~Kg zT|w!jy*OGNO}dD$i?5Te;usOucf~ivH^>v%;qU==VSFrpOajD(;@`+K;%DM#L@$0W zE++-xJxV zQm6!S2sN>tz9ugk!(idMu)6VuQu>FDWT z*3)X%(`we!YV`3t*eNwroC&$XJ{I5)(CZ4;>)z<~g`|i0srV`J78i+&fd4A~m3WAY z#l?`DzdXOneT!Ok4*37vdM#Ewmg`qGnQ}W>SK^Na7k2gM!1P4GzM}@BR5mJ`h=?6X&BPVEknF@+DJx~-irq*UW0@9dphf$L3*DPUl&T+9 z2Z*RT2)$B5uMQD!)e&fy2<>V|G-?mE2XRxkSGOm<)t+ik;122zz+TWjH>P_cbkCnS zX?jBg1*U-trhzJ^f$mHLG2@U=nFcEG-S(@*jp?C*>7kP8;WJDR-IyLK*hr~jx~E{e zhux*ny$($G{MzWAKhr&L?5XUF&>I>UNWbg^hiaf7(?EZwf!=L2@Ci1ua!l)ZafbLV z+O6EM|4UYsS)0;YN>igU3OG?;0lC(}k>rj4FV8-1A`wiD^7+>Ys9JEnW8 zHkzhjn)bB#FO1C`^ldZpq?#7MMre-dn|L4k=F~>t)X=vx*b7-EmLbm@`qqW%TUVxU zU6{UgW%|a8)#4Szq*^D6SH-KqRQGtX4x_$919?%#1nJO2QQ{>Ym}(<0i4x{srjzZN zPP#Ll6q!!;U^=N{I@yEiWP7HQ?o212U^=Ogx?_i*o@u6rX=Zn(nK4W=`!LPy%rsMT zn`Ua5W*VjbQh(A>8Xye-rTQr_&Gcb9Dcq)$0@F!>>7XK4=U%`~%< zG*6m`5u~ktek^@Vf|zD{FwN}7G*heOuzOaabWyrNC*76p2+uTA$uv{RG*ihmQ^hn> zp?qHXJTWo-R51NiGyRnAsh>`5^i$3BQ)2q5X8P%=I;=WOIHskN>L=AtL}I$?#B}v3 zrmMY}u0F+dwHMRXo=jIIHcu*3adm94*B zi4j=M(u!=QtKj#D$4yyA+Tx?ogRG|&M}b&RD^39s{IL4PZhF?!A{YkN)8dmrkQ9XW z2CAY+p$D=tv;e4rA})+@hBL;)K)ZoTjC)X8AW*S!JJ4@|3XSW5eh+lmxC*&oR5cth zE(O{Pw9kltAW(b*l#8}hD7FFR7^eey0BtvBwV=($v8c;Nl)HhJ%g+H?$Iy#F*{oJM z!cfC11EdKnC1O~?LfZo^qlha4T0#-Igm{bel~|?h1rOH(bOvZ1i_g)L4Kw=DctAspv@Y+;^@c%)q2PU^ zm}Ydv{}w2uK)D)UGztt zuOH=!e`yUBXCg%(!y@qB1+OPdu@ot~87wV4UzTDfLVYOWIv~``&;>0?+s~Ww$Tc8O ziukvI+!=zT8`x;UBUb~HA#M#2lOc}Q0MU@*W1s+r(2DwMiWI*Fs${&uK<6pq>JiV8 z^B(!CzF1#@c&|dC4m6u04icTC6lfj&6o&kP zvKT_I=qE6Q5nexzBE@fk##~3EDWZL(AIT8zaP=q=HP{a{l%XR)gDJvFoahHK1nt)M zXNa~zIzt!_^eGgHV}TN`qgaX*C&7!PNSFm=p-5WASLjU)tpn0gq^tr8q)4R#>ce<7 zK&T@^n}E7eB)taY%Xrg~pASW1G}oy2GI*ejI`BMLJS&hZL(xDQ7VkqKi6X@+$Q9ZP zDIlFXq(Tp3hA^nK5_kqq(4A+}j+WJxvv?zTe}kV6Gmek}-U-Ib;Fju+88DOZ3&AU( zJg!U_s>=`Bj+zeS{dIYI+y^Oo^PlMUK;9LK*@7A0@=@vhPBBd{1or^CDs<3o)nV4) z{1nr4+k-LtDLM*Y<6HC{KoWRcwM~fU1hi3GP1D-*b-7yT0)JJ0RX0sHlcoi4x)vj= z0&|3Js%{2N3*Iy>>cTq#P0?axRnA3CvuI6~zvJ|}3A)KNKSh=(2g+xGm>ghD zOLP_%+8Jq04EX{%!$O^wUvO^gQjJ@<)<411WD9E z;&DX_f!HV|Bm+Sokl!+t!NwBB)8IL?cqmiHMkyf)h>cQYF%SZgA8H@WW;%rz5R(%8 zTLKBLVfjUKLU2`ZE$V{P;A(^`@U);MRDirVLWQo}Qb(wS5z~>DLwg2S(Oe{q48b;v zIGVPC<&3KiKEqHpPzgo+LB+3vi-R!>as9z71dkK{%;*djj@*pPucM{c5v~ST(hH$z z6QC%d`3#{I94iw+4>X(d6v)f5GGWM(GscGCEEf7X&;*Kj)Yv$N@n(THit$DQjbJ>w zY8c26T{WP-Xc_d4F`Xf-8jL9nVbx$vUl7NzoevCI5h)IHoYc(=S5LW{^Bmpg9Wci6`C#-mAE?0q6*AbHh?w5pU zS^M$70YWNV`I|tj{S;pT&80lWZXiedeGT*;<81+A?WaIV+G&hO(G-fXpPy*6DB^K7 zTGpltw4cF|A6nMH@)K49vHXM`KrBB2*I{7!3G0Daeu5f^<;M`qkIw{e94q%JAmoYC z{*KaI8A4uK4MhT4$1so~KOnRrdJ9r+NM~p~PzpoCfD#x&e;Z;cQv3uI$#@%qEEMry z5E6u%0eu6smZ3R7s5f|xh{yUv_yoKqlqX&UTEr0KC1?Rd2B3KqDbk>i>^g*g;ITH~ ztw5{|sHS6w2v&efC!nb`1^)uZdDI8(oCF?A%b~tOjyC8EG@8X5Dhv%8*~b%O|2BI_ z(D0y9NbwTVPN$)YQTACugIO-mAq8t8?js=9Lflv&)iBQ^p-B>*6 zysnBN3y_T>MKn+aLxT_x04Wd}*u;3HKy?hw0;-{ij%~TY|6?iq;>}RKCn&9vT8+{yRINm5&TAK&a)-#a(3ErHbZyoA zDqG3gZL1bkEmjZ4oGJB``_xXddj3G!=Ag9uRLwT!wU?TSvi%RAT54A%Z|aD+Z1~hw ze{1onJLPn@s?k>JXdG4A`BX0?HT({+an=jQ1H9)F|Dfb+yZrCo9 zVz%sEc2zaDj#3rWyHTnPB_C9^rc`lNRg6+;P0DN9y{kB{AIPRNsxRYZlaQL}IF+BH zt~))b@>Jz%*>t9wsb+JhsPq`6I#bOAl*X!+uTtCZYsO-e-z#^co|D>1X=|zN*gjM} zyymH-2Cs$wrqZ>PzAd$eBXsSlArd{!N97W;>G@_( zKHDs$R<}MGaH@|`Do>_XsDG~10kc{BRSuz4(OJC*wNdJ|%3kt;yjID4c;e^vE@a_#)iU-XN1^SCniP@|~N z40OJ&kt?gG*KS13t)8l^)@*aDC!bxCQdLX3El;cYXJ?=uteT@3w0hI3$0@IoioRJj zrDi>9Z`Cz5>(Xj$byulhMkZC)R;v%Hx2C#tb$3&dK~??E+9q`@)Y$PF>$PlCt7^OL zOWQ8RW~HM{1s$YDpw5>XhN_ZctpsOD)tSxLxH|U#LiIHjs@2(c>Qe+$bE`Hgf0PO} zR&B6S@7Bn)s;8@-HznKpuDjLBgHr44eX*Y%ncHn!S6$7drd2JkosqV!)8`_TRxQwH zNP1TiYOHvR^I9vn)AF}ZIIz3;0BQ_k9g8dN#1Yf4S48edzMDy?id z%dS$YSK0q8?IuzWR`#skfx5D)e~qrs-uI(#R+d-OUUY9|adjWrrs%w^R{w4FCAAOy zUaA2fw2~t3LT5`2#MVZtAF87iYsHr*b-k2d{6$jTP0^RP&f>h0sU6pz)gxu=xKf{O z==0K0W0$4W)AO<_H`H9JW0!{-E1#y7SY&S3;x4-K`=)ZczJ;1rxwu9VVYV)(yY@|~ z%6VOt7sxh9&vQtrxm^x*8IrEIuj>-jw90F$Po-@;^uC!^DlhFi5jCjG`Yux5smR=l zLtV1cYF`(Nt5fP9=={9a1}tM&1Fh1)oZFwU$w}c&J#>x!IKa4JoaA+YusjJ8tN*Hm#oSvNEN* z_Uk+hb!EquRfVW&9hX&Ur?exwmUWTuQAJ%QqaN%UuUxLNeMc?N;B41RO*iHLRB9^f z6{*RnXQd{gUX)@D`f;i8*i4NxW#{O!p-XGjpe|47J)GV{suaI?)l&FH^`>^YyGyZ3 zw`oK@I?LmzZk@H$nCdXIlU9$^l^x3~2co8RY^#_}m5%GIxT4;u&dX2_c4+AMntDGz z)Y$$6y_zJ&l_6Lob<}Kn-cDot!zj&1`-7+rQtzT-Qv1G8tWY~g`H9XAoMTY=kxsqj zwHs7#i`DbnOTA!9^`@5Z>TK~hL(w#&c8e*kiYZp=ZBZ-h?cJZLit(L}sxQ7uzwlbc z80BKV`xV(n)1LO6id3vUpd*HN(!T3I5SdodP^o>_4lnp-ij(~^`bAK9sO z+Sa4f9MrUmZXHjiZJj$!K@F=b>}cnWN8fD!e23krz3ta_*qJ(_!}eP3 ze>&pI4qGd1MXBWun``&W8-=09vKwsVL2D_z7P?gGIN&-nVNp5A{#5zo2PS(t(`94&x=g!aB0P1Q(A9aEBuvGBkb59TWXjo?=Brt zUt5<_H4AHYrPaLJZm0)qW_7p%HLXKs#UfKWwwz-hl4^^(N3E39dMcHw8CktHt?FxT zMosGwuDDcczK-xXVxj8otr;WR!pO9i>$S`36T(nq`4LK0ulx||uaUWBy{fI=Un5h? zy4LJL4JxasUW%GqR#KygQ0d&VL`_y&Whi4Z+k<6J`Kpwye0j~dv|3s-3Uy`q;_|(w zLXGVTIh(Hg<@3t7%DW#&=C<2ebtt9EXXqUdw({vU#i*(6o~V)6RBu}O#11pn%0{UP z9qhat)ziY7N~!0x7Dg-UK~*1BMr>+YdA;`0RB2tcdHgw@w|}`-l+@JfZPg!YjvkN? zdRedZ+}y_U9w=S6l(m?xn^b4|taDV)ueOQ%m0a1`6+mD{U7LyfC^q5AW5>C*-BpW5@=uSp%NmRA*M*ORS7<$~&Urb4e*&MnZI zk@~p(-38{M5y6=Da|`XfwPCMvV!_iI(TdRP?PnEeANN)w8p-lb8m$|>l9?efZ#)dNxEDv#=28l{y73c8}a%C`zE zo4ZQAFD~ewDs5j_kU({)+*qa6?OmnbuPewzecawj%9HMowHiOi6^j&t+1^1QlWOj&Wq zc~YaK3X_U8$Fi0=_Gt!cM#pm0v@)kGjG9_;jj{&cJrs;-cgoJ|{3Y~pyA%1ERp&3^ zWV>VeiU`!veEG#x<>>tG)O)>hSiVM%ZD79U9Mvm7H?6wnw?=9E>v&p9cO$fWB7ZVA zuQHLZk?RP*-O_x`wEN9qOvzjA7E8SyjH#%#mfg2iIxlhDj*ZN1ovfIkv2|4|GxDob z?X~vu+p1nt`w-Uvccc{80J|@t9oGQe6x7&`Yk;-VZmfMSR6;W& z<+mG=n2k?AZZ|B^-;_%06E^2k9bQOi+aAw5Wh!!c$g8(Wc8=nKxf@cY zt;Xfnrq!t2%Cu_8?V3_;_U780>uhZrb4ySiT6He}+*Ih}HrsNJO7#rJwER4Gywv%! zZOD=D{efYx?86+b6u+-Z59ZckOP0NpvpuESEXdK!s9v(oJvrM@ew%qYHs_tfew#Tt zJ5XNnnw;mQ)JmJ_xt=K84bv5l9nW@H~xug1&wQ?d_9l?P*5?2mVsYN_j6eZ1P%g5qUDwjx3*SvDqH zbFMqhUN0Mwyjcgz2h#lF>q;8e%ootJyPO`1GmV>XW&z-Yv zm0hmVt=TuEODASqlw27q&dDmZ_OyGqNO?%vYw0R{aGlh0)a_DBQ8$Jf+oonlnL_D3 z*i7M5PoFN7&N)-Bbhat?<RGrF*1yhTkh4 zQ+kEet5PMUqfu*1N0x5TIcBJp{;k70_C+0ASE^mWeO9*arN^ZX%GR^=u+(O$?xp*s z21#|bmfiVMwWT}N-rn#{rIn?f)Ag#YW%tR@2L&ffpU^ojR=pB=OTT}R?QrQLsr9nO zOYfGl@n*^&zWb*794&1v^|Dk@nw0vLlvA21wL84FtrBtQYv7}##_AB>iI<~*Htfy_CF6mqHwA4=T ztdd?O{md`2^(k3ue$lbjB_HaD7j;DEl7mvu%2rbHj?_Wf+LmZa^6aqz<<_A+=lTV5=iibNuR7hf2(A^P~=xnAfID9V;=f z&6hgb>Xg)vrFOQmvCRoJwpvdlYKLo;+BX!jt=Kn0!&Gl|D~&BQL26|y8{2f1F5?KT zg;q;UMQ&8-qE;H4-zn_1TC8$Etap&9(y%@OYPHDf`R9htYPF!%VzsBzeBK4>e(}au z^GfPvv-VgYq2W@jkI+4;x6O_XX|HR==Q;X2jmB1do@1Zus!u8n>$8zoS5WU69l_ax zakBB*gTBwCF`u<+N4RaNXDu&4-K%=tP#SsuIFycP#XI59-BPLZ>WK7t?by`WRGL4Q zBTQY2vR<(lhtytiYLvG~9iUWuaq)iCcU7-1YC)(maSU~>O1HP_qw(G;wG~^A6uW%r z4%OR@P4D-MnXS-5snl%gyv6MD_DOf~N~;&%C$)?tChORgUoMbsKDJr1%|+=5-3x1f z#TJ`s-nKKPMy>fNzJ@w;Wt)J~amA^z-X=8)n~o?RaYiKuqEwpb_l4?%vc7dislDP_ zls*?Ju0$yci&K8r=OV>z&1Rp@C!($9r&QkQ;%d~uyc5N?hPFlq<{c^4+V!@E8uQ-e zh+88ATOMzDR5o1~TOMwyNY`iC16zGqTqN7KrH&NGrSyq&USrFBsDXJqT59j}bOq1D z+uk&_@_b9}efoTMVDXTa`&D{MXPZ@AoK`oq)ZXWv(h-yFy!t$FV5=D|H=qW!n%;7+ zj(tE!9BsK=Dv-LlrS?AiyeMyMOYK@x1M^0<)ZS;GL*~`f`*}LsjF#7_<-62_{Vm6% z2DRMN@^-0vWP7cp?R^VXZ&FL`Y5Fu|VD52B^?66`VQWR7UJT4V*m81Oz0w%XXp*T= zVbPf4aZ*=?8e{V#H%kpv>BypCrc_#A++RxXWOK73=7URg-XPMDQaPVRhL{S*Rs#XqSle_vR$S)2uBn-%FhQD6&Bew&{S5Dc2&Fky(qA;>AhuRadBAtD=IhqnY=br z$2NwKrCKQ(9X=$bcQrYu!t#OjH0Q%`qm-`iEw+Vs=)8}sy&mDssMm{X!}5@)-!~Lh z>O0%?{vo`tsJz9Nv?^}#f=boD7I%klH>I3pcG!G>zQ$3jd!Ln0t8{93LRw7>Utvo1 z#?f*o^4JlZ?Uq_*3o#WWrbQt7Ju=Q!n;*EO8wia`vH|LN^REP8tQK5P-CQD$VP77T^Fj5 z(rm^1>aP{?l}411`l?EohBBn|XN&2azqBT0K(4nztp?m^~g_AT`dE zeFCe`-Aw5kqtCxgDIy9E2K&_B<5KSi7Hb`)_5~Je$jk^f&%r$(;i!p zTIb40v$JUB1=+TyRAg?!^97q#??u@*Vtd(twO~WR3$k6MmY)x7HGW@eeV|zLZj{w{0?n2;T53TckNXL! zdjj+L&Fag{f|aIp#Le{ON!e~FxJNc!4GN}UyH&R93T&o-BQ>#LvXn9reO725(lxMv z>#_Ho)L64A*5(#WD7aK=s%%`N>?QYjQ>o`AU-Kqfdtoma;o07)cTUNz-f&Z@_q=B#x-#rFuk&m~%FvQ)bibNf zmRjZ6h(3^7?v-1qdXVK^C-q9GF`3%YHPg)ny9 zG^sCsH+QZ|dxi@0kK`XyEBf1@{GZoXp>u|C;{ZXyHtI zzQNgIp3?^1S&X}@;bm|sTq0&Tg{GacSRNIV{AGRIX^Ukvyg|%yqNbf%EaT8`p&O0f zax7nmJ7FEXNYvlsK@ZCy*az0YZE!a%h1T;p&nt+#-!SdoX!NdxQ$?S@nu@#kQ+mqi zFjDU+=@8EWhb1Dyhltp7r^68JX3 zlPeNVH5MC-{^k&7h15e9rI_B-X`dIB6Tk3ThuqtN{cYAjUn5$+-6xv|^xJ@`V7YTZvz~>2`7?VRp<**9! z)A7v8by=t09BaXS0h(vx?oW*A=(3fSa8glWN98eJz7t3%iGTg&rF6nEpJb-u${iye zcLSsIE41xYaqn``$MbQ|>c_qF#2m+>Hzys3Pf05au~L_v!I_GF4*m#^G&(!P7=Py) z_w0;0^f~UNBH4VE6R_I~v^wjZP|l{wD1cZ+Zm!1nibT%P|QiMplh&vAm%x& z1=mXBUaCJfQ*n1Q{3WzpCGIi{&R(?5QQW!M=-rFu5;2FJKkhPihn~B%=A>p8uewL6 ze|MV}f~1H~|)mjRY<0*&%2fOTH7Ji9>YU zvA9aI>n70&Mv-8C^Jp_Kl^Hr8sb<`H0G7bHVkSq$oma73W%Nd($3XLc+&N%$nRka- zNX@wOV@hq$i95uD*T%Fr0h-_9-Zx-dXlIOjSHKx!R;q8_Xe<-q6!<-(dl#H#bWWga z(F@^Ejb4AFd)mmJ6L-yfapw=%e+zHI@)Eiuy2XSDql~WY0CAUiaQ{jj2g?tP&NtD| z!XfbM&~kvdJIu%`h&vXC38xHN&lBWD38$-cjuSu|MLz2yFSTb9)$!CGm|EBD`Ei%o zbTd>x?lc_w%wi_)E;2eN;c@sW_SCV}=+3YPRzlm0;%*Pv z9riSO_=Md%;aEf^9P3FAe#=X(T#u;p0;}&XfcfwqcsHCc>NzE*Q<*>cM%+nxbv$-k zk8$S~IMe7_Z0Dq|M~+2c+<69`Pw6kvsqx;2z8|LgkV~DZ@Vn5X^jY|vn3>uwUE9Ip z?gd6~J*BA`X@}kRy|`Nrhr%|ZKAaHq@VrlUlu2}DI(FV1o_&|Yej9f$;wbv>eh)p# z=r2ZWJ?}9MjIba1Z=1_&fMId=35Tr(_xoaAYK?odv9P9`htMk_ zvF9dCd&I4Cn`!5ll(rD_QoEd&S{KFCoJF1OVun+IWtW(l+Lzqp(#_~+-1`=#qhO=a zeF>eKyO&Kn%%Jl;`Y!5R1CL<&3M6J+^LA!xM9w&LGfHoOtXXFjdJAOd_fjp-$HJbI zicH?g#+`}SkHV{=&3DzDsd>E$ASZLa_NQ--^n4Cr_tJOPT+9k$ZyysjY zOCIkN<1YPoKcvp*M(=F&a-;KGNF68Tv3q0*rDE74a-0{fjzo^gpH=TYjIH*!*+7AJsw*dtNeFm|C6on=&X1Cr}BR; zYt}lt`(Ixf#mW5{#RXFn3sbM$@r79#C;s`^|M>qW{&n>K*DPz>^vkb^tu1__*`em; z&2N?u*EVmA?v1UDuKMyTmi5JJN9=#?rBM4{dF3Aex3w!{|MJS%ZMGEuwe%k>^)bDY z(A2+wW$Zp@U%8?odhqWv^`9K~?^{b98QqY2X_0RD`^a*SOIK!g|JRW!YV?Y(Z80p~ zfI4o>y#37ovt|^PWmOcFrRyo`qpA^IoBDsoiDpaAC_Uh9cX7FmBzin^Z*1(}$AoQ$ z9m&!Ew4Ufw<(ZyEt*8Gqe;FrY_0dVuN&o%0|C^HO|Be0M&PQ~^muu^P2Y6XuQtF?0 zW<3Ad&wq%He~GOB_fqYO8}epEH)LEIE6&)R-4H8IWZ2q?@3GxMF)6FoiBUZk>+K(} z;xpq?8^_P+*ykG6f6@bb zpQ^v7S-VYe?M<(OxA;XU9WeiMw}kEj!rAhl#Z*^Z0&a3*!@kzhaT5o zvu8Hd4_&XHb&fOiX~zxzpr7j;S5JWW>d?3N`SQ?h`uV!-8&vNPO`C=;bX+&)%p1B{ z_GeYkp38ew(oWY_Z-G%XtXmW~=Tbid_tsI%d8iZIk?J#0d#=&Yd%O?Ch2Dqal5j*^?gZ4Xr*^&if7#ICc1-;t zCkS$y3Wp90dN(~abO>jk8SAM2O>qLorN=$1M`MGikumBU2fSuYONI{j@|qqQI?5|* zx_0OoZ&TCup;xG6tIn73?{u8{W1*u>Pt~6chjnDs30X<~XO!%vWMBA3l@x}j8=a85 zUq6f8cj52h0b|r@S~s+<^7~hu0re+sg!Sufg!LP3g!NnGBiWs>^|pSv^|tuvqh z*4z3~I?KHJ!#a!Tgq`+HSJ&@w&K7q&-Nijl5Ahx6T=89}r+CN-yGwNZF83j${4=Kh zEzM55`t3nionybebbWoJ+Oi&9L66$fqY8RdYd){9wfUJkD?I?c?t7cDj4UNk-Q+oqZI z14H}a@1W-4qM0@i7tN1+W|ru$;BNREqm!v~52%mo+{PE-X1EJl&qr9#FFI*Gzv!6t z{GyZA^NYr+H##bMh0$s5>D(7>^M4Ny!1v%mWAHvaY7BYMhnlS+yQBA-j?Op6b{O?u zc&_6NDRH|SyrYVE3Y4}8PuXBhSTXEAz}G1l9t{%NEamDor{ja1Y~Ma_|@ z@roMnkZTxkCgTk=-b@?skf}D_i%M*~7pXn<@72Mx)jv`FGwy)DhK=xbxCg#rRR2Wv zPgMUz^-pxN%zr~>TRab*V_uY&7e#qdloyB0HZO|u;*iDCUI(WbJwQisF zRa$)&)mKq{9lYQADypx8_gh~@^;J|~>ke372k*DO4&HBl9lT%fG3Pp8t=nq*Z(V=+ zRa$;E?ts6Bjqr822fksHUq$&pRdlj#-UpZ4yo*|uqGn&z?2B8SDT8}h3`i>m zM8!beWQ&2p35$WjSr!9>6BYw?Gb{!Mms<=BF4rd)iqX0QwjB(oS^eN#_%&m+4?N#WqkZ88#@M}Pk9`0?gdf3UM)gA1%>fJS zx+yj`Z5;5K`#RhM-++7Jn?}2~4p^pZt7%wv}4erAbY2%k2_?tqKoQn&&h zho8U`MyJ4it7%jJM)z&_4*V~;5B?S^+fbd~LFF9M$~i>k9O8TMAXGLe%OUtaJPJQD zMwPFL1K>boO#7p#{jq#yjAha2|XHz65oZP@UuO6Zk1S z1)GdoFGdfBU>M%mRM_tw<$j{pj_PRb7_*I9JEBu)@zQU(#fzwT5fv|@;zd-vh>90c z@ggc-M8%7!co7vZqT)qVyoibyQSl-wUPQ%9zvY(A_FHXHBdw?r6*c{qThxe(ntscj zLc5+`aJyZ5FF0h^-U|-NUz_@EmcNX5z&qhQ_zZjr+W9xjU&c@1r|=YPGRj{@UHQcj z3>)PmV;0Od%15Gnl;1R_?`Mh~;}ZA~TnZnC%itq$Ia~oBg{$CWMqBq6kWF8(OL1hD z3t)dZ!l(!`b~7r1jJLu2jPz)1U;X`JlW_@r2rh*W!)5RhxE!v4dhev8^xjE)%&1-% zFM$2w2&1A|R9}qUjOvT=Hh3S@JE^|9=h0%M?`Vq-QPCkPI{J>*+%38wOLJ$u1KtVe z!Drx0@DK1f`~-dqn~a(@qX$DUY}Bk7vtYK-qO9*&&7E|rU*~VNeu?UrsD7QlRsDLZ z&oTAOsQf`x{vaxU5S2fO${$4K52Er1QTc-udS_Iui;8tov3~wRi*-@4E-Kc~KS=L7Tg0D#&}nH|c%Rd@68m_T z*-9(36_weF%525cO`H0Rvz)rmILoO;7wT-%%BjTw>dewQv#50~YF&%Ujr*Lo+_=wa z%Z)|l#-et;K2gh!`<%AiSkw;DXPo8Tea2ay+~>5@&hq5mi!Bf8U2J)B@5Po4iOPmV zWkaH}AyL_osBB17HY6$=5|s^!%7#Q`L!z=FQQ45FY)Dk@Eh_gGl^Kc3j6~((qVjN2 zdAO)NTvQ(3d$Hx=y%$^d)O)dIjmfm}@(@rCBdhgK) zjL$&j?Y(DO-YzO{7nNJ9^m(`uZh|jBW%a6~tX_Nx%NF=^s0?5BZRnTbcK9k(=C9IU zp_P&M+NBZbT=p)Y_au84(0hWt3+O$`vh!YVX-q1$_X53V+Is=6? zFM(s=WVjmItcIN)@>a=t`{ga8Y(00%XQp3)JK-OoW~k?D_MWNdUir`Jd<>7nPmJ=P z@kTfu>O0kBxdqOE--k2d58y0#E1V5)gSW#w;2fj;XWRh)1Wy>{KVv4$g4wVIEP^fJ ze51uv&wcWxw0vo#C-S9fdLmy+$BLZ+=N*(sO&n1J~(2@7B$RP^XrMUSZH5sP6f zSOT@yWN!_%;-uTccCZY#hvl#XtbiS%R;Z4ug4M7F*1}G(GwcGp!n0sEcsA5Kd9`y6 z>;cb(J>hw<7u0)l)$9Y$hkcE{-rI|(j8S~9zvDHH&!hM}iqE6?Ja(;>#_ob^jn3B{ zuW4Qn{Y?}MU;v_#SKijU-X8DR)&HCu{5R02jCSol=PkQ-i+I?s)aT65r_5KM)2MeEVuZ6tI9G)B zqs&*7`HC`MQRXYkd_|eB*tOPv>@K+0n1N3+@JTcJ<6P!F+O(AE z;}Cxg7eKwol12G~sC+?O1RsElq22|`u6Kc=-UW(F;lpqld;~6sdKaiVD|K@>ippHOue98-`-EooR@1rH zYuWThw>92r*ktrQ=)(YpU>HVV6vkjPm4=#lF!$t4`xDNglJ`JCN&%x*6&)`P53BCYdgqz_@a0~o7+zNjI zx51a;cKA!U10I5ZgoojW@FRE(ehiPpPv9x|7x)?cEBqY(4H9VqkrpDlLPSw0!%9QM zQix~?b;Md>? zcrhFaM?+=fYT;6N8B}I2%QxXz_$@dNUJkE->y5#6a2C83&W5+a+u4!jf2h01i) z!aVpxcsKkJya(P3=ffYv1@Jz&5Z(_L!3W?v_*3{ad3Dc2JjnVJGN$^_8oJMaz-w3C}n;@Qv z;)f_>k23b?E0n%!jM0-AJ&6s+LYuKw=*Qqs;2QWid=ld4812XKXY38^dyURxw%V$9 z*(U`x@7gB?qUKiA+=`l8QL`#)Rz=OKsF@TslcLTbYW75pK-36C^Al4mF7atl5_%I1htxFX@FmW!{FE8 z2zW8nYF5qB@DixiEQ?mNI0k+bYBkHE)hv#KTFuf|!1YF3&EhPm)hs<5-Ue@ncR;OX zmEH-pnx*f8TFufwgm=Rq!F%Ana6bGoTmbKbTFp9Et65wGwVI{ZLCv_dW?a;ai<)s! zGcIa1i<);)^Db)MMa8?Qm=zTRqGCW)42WByRVKY^#9Ry7Kt=l&DFbXHH$=%t!8Or$v%gwdDqsoEO{^= zYE8?+7;Q~g@3J*ry~{qCs(IHwjS|Ph?_i$$LZLLddt?Q}ap0i&6dO40W zT(9$-5zeLh-*mkOICnbpoPo|G&USsm@rtv{xz72G^SU#|+3URHOmp@*?>RR+hn&OC zZ0CqxcepWqQaZ=YcKbVbx&z(ootNAh?hNN6_d)kD=a~DdyT|#PyVw0lU%LHQugGoV z6?-M_1ztOEq}$&c<$c4w!Mns8>rVH^d$+kWy*s=+-DTcg-aL1OcaL|k`>40nd(?f* zTkZYIeaic_x7U5c+wbjnf9t*Lz31-t{@{J$9`HW(K6gL%PV0}a{-Q@RhTPBnn4h7? zx@7tZ&-0Uh3w@hrOTW&`_J{bxyx#tBf4JA*AK`!98{mJ#zrq{r-{?>GF7|KnZ}GnF zf8W2)8>MfzU*}!xKkcvg?)2aA-|*)7hy6c!KlD%er}V7bj3C3iC&&yky?cYKAjg{@ z)C9HO!k}}|Sx*f?^WT2O~Hh3y{#(OGwHrVN{5B?Y&@pc9u1Rr_3gO7ucz2E2?EnKfL6oeAq??TB? zfxgA1MX1O-9x4fydM83{LT$X0p|Vh!_vcWDPzOB|u3M;^_m@zgP#^EJ(1oE3^(6Xh zLf3eohbD$5dVdQ|3Qh7(hrS#7uGgfe_1&R|8{8SX)AvI2Li6;cMh}G^(l;199D3Lf zg;s=C_~Fo_p;dk)v^um}Pm)>_TH|Mg)`iyj%|cIyp7Aq7FN9w3vqCS0Uefb%^ey&! zmfQ!S5Bwbao_asmzM)=EnbmjG`}y{*^nTL5kKQlTH_?au7U4)Z<`>!b&ik$G8|VF! zaACOEFAety_wdWYqr%ht_V(@Y{s8-Scz;m%`{7&tI{QX=e`q8c$?%6oT1VRYU(+|X zU+9mtZ)x{0iyV!7=#PmUk9_KXEAr>aDgR1+ownm&6%9o*{0Y&lXtqBonusR+>!QhM zq5s`z%V?!PC3;r0n?E~xesrLJd$b{XnSXcmo6!mW(&#nOoBY+$A4KQ*8>5S&5BuAr zk3=8!cSnC3ecFFLx<2}>|3-9Ebd&$4?!esYzZHEsy50Y6^p)r<{_mo%Mql;cwQqa( ze;<86`o4c4=EVa4y;wBX%s&)s9?SBN*xjrC(OAb=NB@IZ-&kM&!`Oh>0RN-d*J305 zW3fwOm-xqH*T$~%KZ#9_P4+*H-4dJSpN!oWyUqVRc1P?^|8KFoV)Ftgc37T) zTO5R955<-Sk=TmZiXawyG`1$lh&>rw8^mK9V$TJ+u@_>SgGB7-v0nxSu~%ZR1|_jS z#oiCv#6FCj2+CroVyA*C`CS+4QpU6>;yZ*F0d;+3wDEN!|w1L*aMymd&2Wz zFW4LQf#<`%@B%mlUIgpmP}l&!0*Aq`!r}01a0I*c!v*j@xDehC7r_VMV)!7`U9e>@_({`#0W5?qU=eHy zi(xBR0!v|Q*ao(RM66H5`b4Zx#QH?6PsI8}tWU)HM66H5`b4Zx#QH?6PsI8}tWU)H zM66H5`b4bXo%5apd%$yHPk0_AVtpdkCt`gf)+b_pBG&Im%?n|FH~&A~qmm10pscVgn*JAYua|HXvdHA~qmm10pscVgn*J zAYua|HXvdHA~qmm10pscVuNQn`zvrK{J% z@E33!d>Jy{*l$fYW7gbjoC}-*r7t-B;DxY18~_KxL9h;vfEUA&@au3C{01BiFM*fB z%itLJO*j^Q3+i4$^+CUl74_R#QTGjsx^Gat3QmAm!)xHT;Y9cyI0;@0uY=!(li~N^ z6nH(H3a7yv;EnJmI2+yuZ-;llIq*(+7kmURhb!Pp_$XWjAA_smPv9E(ID7&=3D?4> z;5w)~HsuH1u_@|~O;NvF7xlY!QNLRkbq}YgdpO0PLEXtItvflz7odK_F0J3Ni@K{* z)LosT?&=h`!e78`@MX9i?u5F}Q#JM5cJVi`5xx%h!Z+bt@NIY)9x=MvFb;EIF3f`o zn1lte5Vn9tuq7;ptzZc(g{@&5*cP^fWw1RghaF%A>MZhhA*-`oaEi02~Mh!8$k?(wav*`X*L8mUg@!qJIRJ!iV89$V%~+ zqgg55N;E6NTZ4WaJ^@(~9xK9QMR=?Tk4W=~G>;hb-l6_~!F}+z@OSWC_5VBt8S;Gax<#A~PTw1EMZq-h&IN&%Eoox7H>z z9t=U(LwX)!?SyD2L_2yOvFeAIoiM91;+iEwY)3dt1HTO?!iV89_y}AMS3p)r^ilLva6QChG0$|& zhXD-12#i8vBZik^-LbHiWArx0P7<4e{ReOsycM#>W5i@^9+tZyaTt35y%W9){{)Y~ z_hG7^%wvqXYc|~M&Aw)IAGGX4PhIW@FNFP}Vo-L)ps4XxY1-}7D z!%N_$@G_`a(@~lkQ8OcIX2fyua(D&25{`#g!3j`vsQQ{iQFAD24#n@lN$^^D9sDkw z48I4b!0Vw_zmC=F7jJ+!!kgf1cpJPO-T^fys(&ZE3o2@4Sq@jgmGDux3O)u`!=J!4 z@NxJAd=jpOiZ!*V)hzxLJ`JCNib2_*g&W{=@Ok(%xDjrGFTfY!X801^0)Gy-!e78` z@MX9iD#~=mSK%)B8`ub6hkN0h@GbZ@JPZ|;1?w#P5VcQ;IWQOI!30dg0$2!Jz#^zU zL&s{*5VdEB+B3vb*c!HhZDBiD2HV4O*a23+j<6C|!D^`8MJ?CDPOvlV0=vSqU^jR+ zJO}oG=fa-wJlG5NhJE1qurHhjZ-6(#>5!PT>_h#z1Osg4M7F zvidByQXN*G2Fw%kf~qS|sR={h(B)i3`=CxmSIW9VGhiNc`yN! zumBdqmarJoXUnkEPAO~++rYN49jt=**)lBEuZ1Jv#qgVOEc_O{8eRjx4JX3c@HTim zyaUdGM0j*A`Yt#R5_QpK=tm$?7bWVVM4jbj>KpMBU5`F$w7g98VE~!aSOgt~#Fgb` zs)>IrJChy+8L4Gwvdn-#fV1GOkp5eCraJUFb~pM*kd+nNiGCIS2_Av(!&GmH0n5$` z)-|h#!{OJAUc_>=tOm=`#0z16H~=b^Wmm67MYO2Ci<%{IB>XxY1-}7D!%N_$@G_`q z)lr&TQFAM5ZpCr%a(D&25{`#g!3j`Np!(N9MS=80_#HS2UJI{---VOm_uv$GJ)8=) z7pk2b;EnJmI2+yu6lRjpM-0n z;!bU9KM;QkpN7vs#iQ)c!VT~__&od>+z2B=< zt8f?m4Qzz3!@clL_!fK{9)^m|tOm=`MC~+U4$OslFaeXW02aa)un20`(Xm=pqIMlo zyN*~2Tf;W6Eo=wNV0%~&JHQIq5mv$~SPiv5spVSO33i5EU{`n+>;}(<=fEEDT-Xzy z2YbQZun#;R_Jz~n4e&-d9TJn4qp3f)z?tv|a2C8361SG4sU~r2Ihr(aYdMi z2+PZ4VMTaPpji=?m&wkGu)Iv#RzyGDWn{D!(NA|78J~w6;U;L2-%ocL8Mi=-_I|p{ z$Y>GXPj?v^Ew=mVE+Zq6Y#E=q7O4MCaMjr++1j8@_qc8@W!A#g3 zX2EP|E7$IMG}_9wdmfFpa_yc+V?7)yHt1d{vwQ^(gI|Lq;Kgtx91Smlm%_{782C*% z7JdtkgO|fA;8d}}X9ZbqD@~MIZYwS#GF0b#MqYUo`~yD!5L%E|>*xg|p#p@OEfxxh|Lk zZH#q+jj>L5c^Petb-K&TXk)AkY>ah*jj=AUG1dh(#=79gZ~?pzE`;~PMeqT*4%&*V z(_LQ1XW(J9aOp(bi5~U~8u?u(eaC zyT6RAAl>~Xt-HUBVLfQGyY2xqZBb!&bQ+tB{X+V7l&oI56U;1mFdrt3k)vXReFiF8 zpBpSg7p=Dq5$kP3#CqGHyTz>jr_lP@VA;Bu35i3?)@6x9J7a@=9xB>-8|?E?(azgo zpNEQtuq7;ptzZc(g{@&5*cP^fRj?Y?z*;y0UJPw*HbiZ0HbiZ0Ht0S#`9$}*8OOt` zv0nqf4JX3yh;`9P@LG5syaC<_r^B1zQn6q3VX?vTckvOp9Ik*X;iK>=xE>ORx@*qP zNTlhmIn%GeS0OWHIeu1y<@ln_enTt-ZMSTQMWIDPgYL3Z3%bkB$n03AFZ&=k96P?! z-FH^!7VI9!x=P~#bxCTBBpM*b$j8ymUS^b@2 zL(In5pnLhuvKRgd%MtiKOnHFFwD%2J4fc7ia=*CyrPlxhm7?Rc5c7Ecy6&Zv&UFG95)05Vp2;0^@?~)yvu8QO(bjU*$rdv>OX^!* zGk8jO2G4;GIn5}wqcZs34A04ucKGfH-P|PYIn6~EdzRA^ZI&p{{0^KzT2Fa~?ZvqF zf|%pfqMyWWXUXEZ^#zPE>9oXNCFas{EkO*n(zg)) zY>wSc*R)xS(wc62lg@I^6=V2We={qc!5KY95px1cZFC_lZq4WFjT2mxvH$;%SFHR$ ze9g(}nb9k=t)5QUI@UVpWZvzub1$S(UMq zUw-K()$}X7G4WbJ^)xksF(zdHT!aZ#MbM|4VC&r$pA8=GD0)6p;zVr&)S5uWr5( zl}HYcB`C2nd#QG+MBMy^S`z#FYrb?p)!Y?5sUuTIwYKvq?$h&;yHWPE{?gAxY*k+C zyw?BI2>;K^nRD#tfA`ABwUU_jn^Z*oon@nw`!Y7f%1ai<$}2}_je#-&y@WS{-1Ga^hoqzY*qB&zk6kO&zO|a`oDW+_K%H}*V8@8 zZ-`a@C*HLBI_Ll5tNENSML=uCuEnpt<}>d``&SL;H#-ztRWO~kl!{=*Z2C3de#-l+ zqQ~?)sh^+6#zsFEyT-bcT{^V7uf%J%}@WF(yUKR|1#!h%ok_!TA8u3@O12& z*kSwE`bfMY)-^t>e2b2Vrd~OVGB)r!k-3(>R$Fg%td6wSVpB49Sc_l2E~c&=dOEI- z(NX_!eaNV_i1_FITeHs&ZToU+*JYpm^2$9kdVboj!%Mrw7yCrYrz!hC)Rwvep=6Jo-g8HY}&^le!Y_j9(nIJRuyyBdu6>vDu9=cO>>)NyoFN^4edj z>siD)qt1Po)DM~#1&h_H*=E|)`J_6@cBE;Zj?iDj>!_JL+4w0vPwOeEw^dqYt*B(U z+S3tc^M0dI_;xn$fb&((=%49@p1S$fRGHcBw-|c9v7VEsrxstWRH5gu^sh<14g&p+{+Et_%CR~#RcEH^%v7D3daknCuqRmA^Q-LXRXk6* zQ_ve8Rkk9|2B%DFXCY3zpg#Y2J z>(u8nXAaBzGr#cb@0zaGxi-m9WAtp~FU`xBytB!P{8JBB=v*t*ixu=DV!f7MHpwS5 zLpAz&So3#;Ib5V;Uz7Tc#u90ID(W{aiUt~OT>o<9!H7k!YWzR!oe6vv#rel)kJ)!O z7a=5X4gy3#L~aogxkQABh{y|wC#eTjW33_*YrPt+x6yi&QpF>RcoC{-EwR=rUTDoh z4tjZ2ts>qRRm=XrGw=H*@0+{{381Kd-+X?vv(L=Vy)(~mcK1m!F^UN-CbXE)VnU0t zu-rQ3aNCrrwh=D&kSplvO8T5J?N?y|xdQvg74AzwjY(Ix^67qAv`17+OKgG2>3*4R zahLB%DyRwC^rMgu-aw#U4VsdG2D{?9(r($v{CZ}TIVM(Gqu9e=T zEwI7rI9onz?ysZ{W5u>|!aaSjv{IvUx>ooTSFTvSCZv{;h0NDa%wMSv^S; zMPiBgCAb%?6ibXTU;>y3t`tj5EQ0bhc!@|hQv4Lvveq100*e#so}u35@;~@D`NQR> zv}bhmrNoVw&a_2p!qSXRTq10YP1U7J({1wtJx~o=M!f2gmX>T8<5dAHhhW#O3hTeE z`w+X1kr|*(f6{YR^jsA^S4GcN!Oyu`w?^zJ=u!%uuJ0Q9jvYwUMtMwnhin*hRxkHWN;r6O6pE0ux*Rx$EJ@$$}TfVMGY!M@(Ok^

R*jRJ1?^iQ`xYAuqn>-7iqg^r+ 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

!+-?iHy`H`{?S!=5pudjt1=3|0 zIAkxW5HL6r2OTEw^;~st^X8I@mA8Uf>P#I|$%ZOf?iC;yxsp9U&d{T|Dx8**sQlQm z&buk^rkp2qYCVZ@f^Z8`3eGP$KYvmFy8LxL-x~fU(gniJ&C7LrnmKRhJf8DpPGinL zDfe+a`#ew{;Y@bUaISRj3|t?mrCwWvizb?dX4=E+j+Ux4Q8RZsai_~8oheRksd7PJ2RZ~oEx2$fhBt<$OokJhzOrxHvu4eW{Ikarfn8>+7-`crL z)nE2nUHe{pu-itFn)PV*w8VeJ;+dLL;oRli?W}O_ zb?$c_a4O;czjq#S{^0!4dDMB>dB|B!ys^Z)!YOmEc7Eer>s;^L;N0Xaamt-roMp~! z&aKYP&QgarW^xvb9!{RqvomStrHfwd54_9nz;td|5b$}m z;D3QFF0c8yhHJ7T@Rf7BvnKFgo?i+h6J!DIcOx6*fLxFVdJ3~wKKoGzai1N`{d1H& zTF&P#I)Hm_2KUlg+vxhm-+|wMz+moZsyoKUsft11M>$73^PM8+C(bd>vCeVM@y-J0r_Kq^iOxw*v2(KXGv^fN=gz6l zY0g6DbauxUJ7+q-U}x+ccErwSKdjWb(D}7C)Iwid|EyupOw$a=jH42zw%3VX1|vIlUvl@EIYHN+L2{HHibRMT>^d8o?~Fw zz<|JRfq{YD1A7F91%?Mk1O^2L2ZjWOa-WY7qmpNTOSu{SjuR7tX3z;H1=E5V!C){i z*gM!aI3PGEI4n3aI662sI59XmSQwlhoEe-QoEJPYSQI=ictY^x;Hkky!83zr2hR^) z7`!BSMeyq2b-|m0OM}aTcLbLQ?+vaD)&(C7J`!9Ld_4GM@R{Jc;7dpvgMSac9DF_a zR&ZnRePQ%{o&2|!&Q?+hejNNf_+@ZQw#n-?N!e-H8QH3$anLRsuUiOjMyp4gL#+z3#vVD1~FIUT$`hI~ohlN6yC% zPlO6qkpCfamJG?+a*muU50&%eVe)Wh_yzK(@+4UdS3HqtCqLtv$!R=2`MF%kbCaLQ zW8|^&IC;EWB+rnGWr;jf{z9H5e<{zF=g4#AdGdT&Dlg#K%CF@`@?!OLg=Z_5t7j@a zQTY|mQ?8c3k=L+38~x#ndFamlu4Xplyx;kp!)p6$F1O1QVsiee!HwBdvhU0}HRs%( z_Xgk3c{%5bywc#uIRkRu&%QtRru^l>&vQm*PtTp1TbHvXKPhKo_N<)p?0LCw=Pb{E zIrwGv(b=nWR_8p~^Y5yUa?^5qXFrxVB>T9W>AAmt;r3xyx9Vu(hB|%tyFv_e|`R(kb>MskeHS2xisq)&{m#Dw2myo^MEL)V#U)B@I z>LN0uFa4i$g0O;_!OUI4W5CLpoilqE_*M>DX5VIAI|nY5GqG<>3-nDV&LHA&Bt2CY zNpI=DtXbr8TqGA6+#LSPnuK(Av-EXl0{*f_AXWPU;gZ**?bR22O;6VgW0y5rO50}h zK2rAM*^loM(~^|f$X+4Lta-{uOx>SZlXjv1vajQ6juEL9TpV1SH3~)RIV3I8qF_-L zb;^7+PC6|(Eh`i0iWsTGj2QXLgb`$3LYOMC!>k$k%cQpmi^@&rR{fVrYY-NeOJ*$n zSCB_fek_cF4Fu>h7W})gnIZWrc%r$C2D5|_XSOqU<~3XL6ZQ>CK!pave$iw0N{QG4@`*A{nXb%GW-1BjNhB8a$3Ih&lzGa0sLz!J$`{H)VCnlJc{1Ub&!LG)$M3E25^^mOfI!`2Pwr$NQpwD7N@VDi}2( z=<%AM*!n$E!PVh=ybrQ_;Jbi@wd`k}JYJVGl~K zMbBm%`ZfDY9t!s>c{JRLZ#D|Wsm!^ecj zhbM+F3||$V8deQ`l6vTov#6Mys)EIn4GyrIz=% zV5YyKTFGBlE2~w+e7~6O$6S9cwYpkUeNL@`x&G%d>t6@+{PooOYD3KQH&Pp`O~foe z=J;Py+o&(AZPiyW*Zx^y%03tCm$7>JRkfqqS?!{B#r*#lnD2fKbN@Zm*D?3sQ_T6R zy~UirdevXSn&p1}s`|FtUtQ?$Z{*7WHO^mC-%$sugVo{cyI2J|y_nrUsh&_zsoCmjH4Ah1hcKgm1oQdF)x(&>&y*L*Th+^Io_a++ z>t9^GrlzX}nAJa{o>OzwpVVCSym~>ss9sWkR&S~I)Vu0K^^W?RdSAV*{;pnEZ>Ybh zH`QNNuc~97hTC&lLpl7Pl`-nfKP+FY-zY3wQL=KW$II4NvMk~MX~~-9dR3IEuSCuA z{9|eQ%2JAZRF=|*zOwwkDM@^`Db%LF8E1WB z)-v^)Z@l%dNC1r5_@C892;r5>PKA?SsJHo}DGJBkT80_FfjL=2gQHQ!2|L|BBO%{7Eduw|KdpCPC z!NJ#&VjpiGY#(LsZ0~LFVec>AfE9kOnGJ@_`787d_ZfC|ENvh~8wx4(67&}L z8+HvWuRx|Y5;Ex(=sRMqKEi8kX%89O7|5tsqc3^Tuxn=N2#MP`NUYbPXNmHKzxtN8 zkjM>(M0*)}o(Dv{jTOlxeSn>gA{8i&s6~cJtgRc$ODmCk*6YaBl9B1 zMovJ={df*PPh_k+_v1hF0_+^Z{~Bf{Ir3T51ZLC(X4C{GFDnm<``$MX0L5;WN&G2 zXYYzHp+9oBoV}7rHTE80-hr3GF62n;J&3?+!ZvDKa=^_(d!PurF0hA}va!FyhMr0( zav1CHoK*ggZ*fdHgl>WFfbb5wQIb~JIcaddX{ za>O}CIL14sI_5YQI#xK69chmJjx0yE+M^RLkH|x z_EYv%NRedh`6Xkwub;7(N~BZN$aQEFHt|i^eJyrDODHX@wLy99Aq&duTg=>vea?Qy z-ewgw_6YVb9OXyB`y=)_YnU%8jgS~-{AbbUEQpn1PqXnX0lOQn@HV7Cp&-s$$dGfJZ??}4|!iT&OI_)6pP7BPDrXtbh1 z*f(*8Y}hMtpGq0~6sjA$5}q@@+(!746R3%?hq)tmFQ24N*hla)y=Lqe=x6K{z`uoP z;q7?QFj^aJ_>alk-eFfbGt28;kk0b_u% zz&Kz$Faekd!~;`+slYT~29N+G0&{@5KoT$?SO6>p76FTarNA;^1+WTO1FQws1Ia)N zkP2)8wgG8CI*(IBE_ zM2CnT5&a^DMvR63goybOOC#1sY>C(%aVX+ML~cZW#GOcr)FMM8qaw>iR*kG3*(9<} zWar3Uk#UhDBF9Hgbqscla*QwI#n#!th>{Ue5iySbW$xlza~RTlu>(O!(c9zSQOr%X zz<;Ee{~CjTN-@uyi2qA5FT5K6nnE@+9siv|I&o0^gZ>+J4`&bkxji)gGaSZ$hR68N zh{mp!^e}&K=cIyE)p=dZ6`CqdD5QmY^*~Eylmd z=LY@CpkEjiwFvg8KcHWdB*H-$lB1@h4!#P>(ZJCRUxFk;K<$?t9UNUnjfd}_42qfs zGwK#7Y8U>s7FfPEbj)`U7rjD-@DuK+p`fUrppdho_w$maBYHwzEM2g(y*uU*0?6W! zvD(Du2*>wr5!B&9`&}7kP)BvN*bQI?74gD%X%Q4Hcn^Gk7D4gPG#KBREGSm3Oh65q zfv0%KH2jMsp>)Q^mhQD9QNuvEg4K3SUDkDfo9Nhp(cVvzD`-vk9JSgnv`jB^f!JY2#5)G+55=Xhs4{;Lx4JjFS~ImbEQxyZT9xdzXRohzJc zohi<3&J5>%bt`4wk=4$O~=j!b0;p*+` z?;4C541ZTY*Fe`W*BIA$SG;SQE76rin(MBcTvJ^MuDPxSuEnkuuC*@gj&dz^t#Yk* zrMl8wyIludhh4{9*%aW)cICP*y9!*lT=(3BP3l&+%^i-q;9uDDO)c#E{wfn$T{m2J zTo14)Ky?SYL*1p_4tI>Zg1ahm!5@93FHmc~w4lCPzOpPqkADMdpn`oiY1oY<_Q%-J zbH0ndTmbgbJJ5SBg_SZv?mDguuDpK~vK#APPzz^UW+QCz24=j$&v=8yc!O%Z!D_rA z#CSuf@dl^y27HmIzkll6{G)Ffy|NdugXbm7OW4UVS(%JoBm2;suYyoy%nYtVEOuDF zMXGY~9oa0u%NC?ZoCx5+NT3SR!>BxZSq*TCOMBiM|K|zVYq<_RiQ{;qSF&JlVhij* zY=>QiBhf!Oiq+abVW*$>ANB^nKze1s5`>#L5^2*MYvq9a;{3QYOY$Ydafp} zmaew0j;?O5US^G;us_;7sw1w2f36J_?mpqpabI@daNqUNU)KwF6y6@|uI#SvuI+9B zj*^Iv%U#Z0$z9D|%U#di#NE=}*4@$F&E3o0&ppsR%ss|E-W~6r=1z1cxffEI=;Y{a z(YvEFqmM>sM_-7}kG|zz>|Wts>rQcRb7#2syAQdKx=$eEY?^>F*ir8Q~f0nc$h?nc)5w<;r#J8sW+f?1JZ5?}7gbtogwDD6IR41?`I6R~+j8%Uj_XY`{+4Vh&%)HEwTFq>qM%9_QBpPj`bq=c9h%L zkHWEXO3PcIJ1p1_r07`B%&}~?1k4Yy_k}B7 z?C9sJA9nY1Rq_u4W&Rl5t6Z!*wDiP@0UV?1!JzjnLqP9aupSTVEZzn6T81Mg%rXj; zTSkLQmNB4ymiIs{ma(8#%ln{T%8Nj^$XoHo-~Ee&KER0t64uw0mBXRGE?345CJC!* z%HV7b32SSf#hOnE-}CG0_t4)^?&D+wKdiqgEkBJD9Q@=7mSyrQmOe0jWLYk^v-E{2 zRsO=#Lp?|qjQGF8$h1D%3kyd2OEA`NXlVoUFsx9UCO5Xc4D)dGNpL2Yr7g@O(L0$b zH?zC~^JuJJnJSr-21)de&myT5HRzFptAJx4E+T zceY>@vCz^TCzBl_8Ebx0#KO)~!N<+heL@f45o_JWa&v zxFoDvX)ji%%abr#pNADJ9V{c!zKL=FA0!}>DR~q&aY0I^hpldaZ*|cO0{TgkXwjDHG+X=c$L!VgtUONl= zllBwndF?#tMeQQ!uNr!e+I{T-sIKYQi-7IXpmKl;ss(7EK>@*_p#dd8qXMEp9RX;? z1EK?>LCcC=3;~q`s({uFs0-RIpdD!6fWDxE0tSg*6TLt<%?D>4bjP^{pWy7WJvf)3 zIL;&RunLfK4aZ3X<8Yq9Je(b{A7=w(VF%HTzijVcd~6 zz&^fj`4a!{{V(Co+>jLE4-P#loTesnlM*2@x#<0n%#&d&IXb;a3u1#GA|?SiFA zzf1Sow>T-S9m~Xy^jbU|wWS-cZkcJB$vZ+OJfC+Gd$ai)Vox&fC2y0r^Ec%k@(%u% zyi?BLedPV}e%?<$ARpv!%ZKIT{2g)r5Ff5oQ)=)L*v(sqk5L*aE&2P(OG-N)kKMf8 z`84d+PIb*e&vb<=8Do_Ft}OIHFJWA8&+UiNMmYMJ70~aj=Wgz9hyGkG4jajCMrFMpuij6WuJjZFJY@-q8c2M@3JFo)$efdQtSM z=#=R6=mY4xo{hd7eG|Phvd=@&CJW^7D+OhU~3n58jm zBV!@ws1?}&5|_4+bo7ku2bs$#$U3G(CP3=4D00Q0WYjk!??v$_Ey@Ndwks+&s%liN zs0NWXNN=JdJ@^V=z`qQn`}q3ykcRK?IKD?Av5hcJs);pPnDT~{x0xa3^|dW642fb( z)a&!q+K^(jHKZ7?7*dQbsMVvWt1&mw&5)e-HYBJ0j1wUy3h4_?f;7A_O@<7n8BM{8 z#y0e^kjBtd$Ya{mG^}atNYgRn(U4|9I@5&E&ykx^0;Du8Xci(N&X3~80i4y5^^m^vf0`#ty&==@SEu)3# zMXjJO(Pvssi_j}tM_*xvWeP18QXg6ZxzBX^8nT}RT80%W`)Rq50?`V{fez9ttWh~k ztBtvlHHM7)8$$}d*4X*J!I%d~#%#z`N-<>Lki=mfy@Xv~C9u!Sf&bfv)S6zUj`Rlg zra?4jb7P8fBBTHeMSSs7fcCd7|mmOf=V-NBX_5(Z0 zve{Xd!)~(MOh>mkfLnPn9*C9tm5Ns7H{ngO7r85cjlad?_((nuvgPTh2cPnld=+1f zk!dpDDE?cF*@=@p8#UqzzsmEmN8^szpCL(-A0*mAQm_;zm6s|>)uiX7mmuMOUFs>l zfwu4+>_i?U4aNz_A4(IXkFX=zx7Q^>N|Y8!UrCFlCD@(3N?I*#z{>gUSQjr=$nV6e z_xzc&*XX%=B!_Uu8!MQJ~I0g7o`ziWDV(#f&;cDHBmPSP1~Ve)e5xh+70a{`h&N$+uCmdDmLlc zML#dVgMMCR%%e8MSM|?Ic>dp%Zb(tdigK<0M9KQfb;keGay3i!h*6@x@-$2Hk7emA z$^MU(%6{o&zx1?d@at>#;T>9Hp+-4oS?px1o%=v2Kgc0<@1C`?9 z{6od2geaj(38kbGri3e{jJo(A)kFpJR!W?LQ(2V({AY}NFZ#y_s$jHl*??0QaVoCm ztl}`91l?ho2l}aHK4_9*Mvn?+NXtMm2EzU#26K@4Y^(#oneqxo-j-{M75YA0{H~!Qy3NBDAXT9$H*N%#QCTLZ;#s`SQ^@br9~#dO5uSK{h&lL*X1GPB zdxa(uiqBPqBTV2cv=b)N&h&@SEceh3KyqWm4bdw!bC|Gh{tqsu4N_saJ<0dT?ooPu zX~z8ph5n!9c-)pqF7orFuP5!r&Q@Pu`sm{^|D%hz-{5h-Plo$vH*pgGlm4IN_$yaq zZ|&c5Va)v}CGt1mPqxC}@eI@ z3hh%0?NeZ{p}rs-xR|EoB9rMV!t=R`5KWWmDnj+yiEvGm&lQ}7uEOMV6(-Zw3>o1T zxr*3|P{eu^U&z8_@|tmmTcN8k`CNs`#|!^Ou4c%>&U7_HMrcL6BIIbJB@k`VJtfA- zQJWeaoCGzlJ`o#67B1fS0&IDICQSLBP&PUMDZGI^8H2AXyz zZ!&gm`Rqi>O_Rwh(q`J3yeZi0W!jm%JBoOB6uoyx5ig`(g&bm^n2&cy(RK=y8KWDh->H28C=I z=FgZ^aDT!ZEq^pZjGp9|je2EAa=iw3=H&?^QV2s%I+WKc+}VTQCC z6w+!?NUK4I8FaWoM;LUZK_S0}>u7_HF(~BMuz~y<6!L4(aR&XspyLhtp+P4Y^do~# zH0UIQ#v639L8ln>V}njL=qCo9X3*&dong?K22C*NEQ2N*bhbg~81z$v&Nb*~22C<3 z`W%QS`Wc{~8+3s|zcA=RgMMkyuME1_po7aPgUho6lsriq^wtCt45FWV(v>!DlC0LDK|RjH|ge8zVce z&13R0|@O~ zmat%eP1wKju0g&*z!y~a`JwFE9)XGyJvz4VgsDWT5G1rqAKd|g8Ag$?Se zHpA~J;m0pln1wu8n~5CG}3j)2uf?+d@*@P<#G3-N)xAZV@!*JbY zxb8Mw#k&e{cCL8iNzf}CU#>Qbg&IvQLk!GI9z z4D%K00<(p_f@=aTHheBIe4^eWeAHXyIcjwPV`$A#Ptckn2M&SevG0W|J1qROBf>xX z#jw9=*!&7Qi`@cEV7Cp|JL0`u#4w4Am@nW)>2nd!1l|U8CKqX$#Stseu7=HPf|qwQ zc)P=Vg`+G2EPg0E)C!cHHq*}{{QJqE34V(3@23j?G(*_XOwdF~F+`rs5+OjY3;%@H z068`X^cpKI%&d%X6{8ZQ2sKt)z#?IDo!Lcb47E4F!q6I^o}hiuk{H?tq!BFx{4X?o zqGf>3MZzaX_(&JR*An@s!VjMd`(gYum}l`M_|L<6#o~)Vivgc#A7H=I@VQEa##h5U zgRcP>OgQ5@R&P`6ti?{HpNDuNgiIKrIqV2X7apb45bw z;9C}@lP{rkG+laK@Jjen5T9N~%n=j#K}_Huanq6{#0P#5ANWChkUGQ%A;-gh2CE4B z0#*q$k3B88SY;6cs{->?R#ot_XJEe0stI0(?+Co81Ne5Vpm|a;&?{1);F5~Nd{qh( zTv9O1*Cm_r#t?(6pW**)xF$&bL6f97gLiOg>#p#^~d>#(24jB;csN2FAg+Es>9gHMs$b676S1x%Fh zHD;77n~mArJcKugByASbfYL$=ABeE9CQ{S@ln`Q%(2>p?px32eL|D>ImhN*S%+JK}9zxL)N(P2fgN;3Bjc9Q6S?A!$`@2m4*o&sTgiN7f^rHg=@%>0fwH(Rv_f7Y$a$OTZPaP*lN%j zY>kKy`v&wHTML^+whnYQTMv4lZ2+Ce(&2iYiL#x;METERn_-jBP&?2{pmqfKGt`cN zG7L2W-xu00)UPDaJZT>MK!OH3Lz)en0%<pz-j^1F&XW);)Jnt( zwem~QS<)iVThdpcx246Pze-C$f0LHNXTJ0`%(}Ep#6emv;vgZVhyzlp-Ib8ifXZwm z@?-|mmtyF7Y?IKDid5tID#M;@xd8dHL*y0ej0GWIh5SL(B?aN&egl|=1YFc86?8Tv z13bWuF}tW;R@fwB23pjs60o^WC1IXJcKC^q3!0n}KL1C^K;v>e12Jm4wLK-+N+8p}mq&Eg_wZt>!vcesYo^0|=% zT;$bEUJ5?Lr82O;Dn)?iNs+KmkiuaukPs`>nJAdANytgm76<5k30&xHR)YO?>1mkf zNR<&zd8rC$1*s}%Md=yPN|F<f%|zaIfsSvMbv5*>>@}gI1iKl}1?*+$qMqUg57ftNtR0>OU;D!P*+J;26|ahV zic;-p=y?j#30Z}pDF#h9Xa=ZH66Di`51%B+r|&3y7QThO&%cn4h-V=i@#!K=@$B=T zZ1MSrgu$>E@)F@Lq$NJx=N~(U3ZH%cQwse$>bGzX4;v&BHw&=BIit#$txsHrk%+P$xM-*$t!Zv=TGFLX)<|5UYd3$ zugFiIoybwsWb%q!HSJ7Zk+Y_q$t!Z#XD9O4G?~02k4-z1*UV=z3ia^{laE)Je7wTs z;}s?!uQ2)EE5@xRubI!n&g3=oS=gDpW$!q4burqnhd=_#%^SzLO6~4pFXW?qT*UV=z zp7-UIF!^|e$;T^9zV`}~k5`y{?-ddNlh@2=VQ2E1`7G>AUNfJCOwr^OZGp%?VQ2E1 z`7G>AUNfKJR>W)Ov#>LH&H60tOkT4-3%RBFUNfJCoylwFv#>LH&3wlDi+Ig^7Ir4D zna{$`C?;x+SG*qOX$J_|dO*UV=jfAytT zn9TQ@`7G?r_nP@E?9BHf4o1lc*{#V7_aZx!*UV?(&*U}pSx9?LUNfJCoylwFv#>LH z&3r~^MZ9J{3pAUNfJCoylwFvygL}?=|yT*qOX$J_|dO7x6dB1Nm6QYv!}C zGkMK?7Ir4Dna{8(;x+SG*qOX$J_|b^FE|U|D@?vP3zILOg~`V&Oul>;CX?69XS}tD z*UV>OXY!i)EbL5PGoKO9B3?6}g`LT3=CiOfdCh!A%8Gc+d=_>lubI!n&g3=o8F^8} zYv!}CGkMK?7Ir2t(r%Oya<7Qj%x7U|@|yW9>@qONCuV#JJ%0-Q#V`pX>?%XrN;D9d zi;2&ZnA9AAiNxEO6g-TnYn8}00>F7+q3DNWd_wi09{;6N`eTniS3yntM>gn{qPGs^ zhd1Hm2U|h-<^nw;MCyh86GxO7T zueX@Fd34Y-3HM6hX+3N6=)fw8CmIJFp8a(n_xH0mw=0`D`};;p_MAz*>tueqrESci zxyPzqZZx-K-)kl2R(tiQW}lsWzHHN^Z+ZnLC#9DuNT2ul$lxCH_BDw;Gw)=Li}mM+ zwMc3B`NsMsYc1IPiu2Hdv?isiEy%8wTjz`K#ul%;aQ?8;-z-dT6kc7BnhSRiJyCCA zZta|U3-c;psJu`E`7*h2SoKAV-!F(>l-{Uhr9}n(&qgh}fAL<+#m?4SYA^ZbwbKDh zzInsCamo4mr)wYqOhWR$jlN$mlDhyU7}~uSDvyvHQ!VuIdP~ zz8^@kAgML;!&B2cz_hzvkd~TT2Bw0*{o$#1+Q39Nsodt7@9rzRdAS>8wJ@z739_ND zAOk=)3R8N6gzlSnUD?-v^TGCb{P7u(GZps*Y|gE+ugvD(f5&5qy8Fs*Svz!Z%$99E zK+?K`917d3Z8=glFL+Bor5|n$)6po9Q}sa3))J(4dQ^H&HJI{95D&4@PW0azbo5mJRqc+R3IF|- z6Dc7j>Ex!qJAOWush5k$PK!7ibav%{1NrAZYj>j9x!j234{}!ZTX-QSwQlJ5ImcWJ z^KwphZz97<1E21Or{^YMs*K?BxE+`NrDtBL`^85L5 zy&~(JKVJ8T66bGPS3bO$RpZu$i?<`@KfILNC@JOAZ-GDCE^iGhe*f~1-B(Cg_E!DQ zmN#o;Zgl>+*S3_ue#1R8|CclGeRbx}s;F|xZ|C($cK7>w>RtR)_4jp+N}heN@@<>* z!J(FZeh+i|Z$9ww)y9(Kom!o9Z%+8H5A~nIZ{BB{TLvDpo+jKdoA_XtnD_o*1LWG>XoMYTiY7@4b=PYY4v*-z5m*-iT(Bd zJ6>P`p9`t zE3NdAN9$h=(?^~ji7-be4VGTgM_&*A^%Z?gdf)S0e}DP=<<{%(uX!gY8k0IAPTcrg z!=IXW?KtnadZ09}9_{$x%*A>1@dte0bXqh%_iO|DU~205TsCZaQqpYtcGl#;Dl~r9 zqKE|AJgZ>OHTr1woCZmB{IfaH7MlLq>0g8CQ&4|{MzV-`>%yv2{DR&4Z_(i;%dbSy z4bVVl0li7fmrp)kgC;NkEOH^GuShsnosw6679LKER_^?LHpLt{=O+)UYB{qN&D7+c`=AS-te*K z3)-;ZQ@`T0X~Q?)SD}p?jvTB=_mbmp+@NX6OEW9bl;r$O2mQG5gWs>y?2R9NSDuz{ zoD6z=<7d~e8`MgBQ>GRa(1etQ2V!YU%3{#HDeDcJWSG~aP5}KTHGY32O-P-(zZ|Vk zO$42ox@dnn);0C+{*IKAO0fB6^GEy2(&o*RKtI|1Dd@$`HFmY2-CHK?jiCu!rhvw8 zNigU_&~aOqfsQxmlC7WaiKeOBmhSe@gl(%pCko2OZ+igq!flkT(P!Jg*yX0l+n4Wh zvw>-IGF-GYZ7yhH+9J@2X)8dd7<59~8kn=wycsTia9UfK5AT@rdo9|%Bl)*r+M6Dq z?x1t&Us5HCPhXK92)YI~lIPD|gCp2KYEXmMzKdUlRO?})CILtkvrEJ3L=XloW| z*uhIY;FKl%C@lH%`!r<7m@QGh?<=dhXtzWZHRk`z4Ld&Cf!|HU7qo z2R-QCtt6|JvhG~cw$hH@vat#Cmj{cxmY`|00$)}KOwACb1vwU`(+9SXmtCNgk|}w4 zw5jy=P9)llz7^Y=g40WImsofy48WhW-K5n57W zTYUg+S#-UD9!GnRT=di5p`_Gn9(^EfpQdH%LupO&%@ab)zPVO^mv)@_r)HIshclPqRpY(2ECo{5<@y*Hhkcp?*i*9*(i*zot@s9ukLPIz!je}!(( zy4F{3LC0sy%@ObEv(LBCn^Wq7+$(xz7W73xW4$Fi@r8RMQon3rcy+xeZTqU6=DkW^ zEjjJ<=CNtZPSn&pu=&d^EA_YO{_-=mym7qOieuIFa;%!r>QK_kwbk@Dsl`fnD}6W% zUK#eg9?sreb>X4+4L*AH56|c?kiPoaGI|5rvbwp7k~p~9S;pIwk6)8j)q97{fu^@( zrPpLt!rS|=$!g|(j=%WLkt*KhOcC0BHVK-}*l3}(XYIfFrIhZVQ)}f0dUeWOd$_V5 zzy_`R{%Jj$1qdyO^;v(Yl3tQ^6>k72*%6rpTe9D1XU1cMLW@Tf9R+{w^S_RffXuT;biSq32utrp_Wq`CpYq;j?+NW8`y3h~ZxPy?^p;QiN@(xX4xx=gtGBvs`T@C@@BCy+je^p zW^bYOq_?--yNqgUJcXfw+~r1!`U-70eI&GzG)QRgu>nG}v59GD7+q*Yp>?IfLK{f^ zg*Ji?q-DhFZD{I_L9crk@|Njn+=#6bT1hrhXc25_dis96y`Olh!`h{%lTVA%TcgQ@ zWq)Sfd#5U*dt{&AeSDn>`)v}mL;ER0Z$kOsX2j?%so#OafqDnZJ#Z{df0aHz;A$f( z`+>Hy{ucfEUFj2Gn3)-P4HfhLp}ZKR@8+RhPTfT-zP}QU?|;IPi`!Ae(|!!=t~a9i zQ+F;QuqtP6^wtNkR%d?S=gp(L=ZZB&loEy3k1m`${s7;~+;casqio;EjjD^P@%Ggc zFW|NN3p6jA3cNO2$2t`x+{6ca{^o)o^;Xp4*X<>|zthFr0RgBJTkhQ1 zrnjZV=suwU_TCM=?7czp_X1_);KX~fjM_Sw*|L%I-}qm@hSHqhx%oA{3ybN2xzUzr zEglAXm?Ktq;8+eb*~OkI<{LZtLsmPQ41N@<|uyFS2Jo?4rl$ zb?Nxa!G^BC=&zU4E70xkUOiS1rK_*i)XO7;}=9z(~Q z6*qLfo}xdeSEDoCj_b7^p5+~z^ww+Z_1Mdqd3rtXWtuyxv)%~dtZ3T=dS$xOB2(xr zxZ!TSvHmQ5(0HicL~o6QmAdLJklx>-y6dfUjRtq?p}(w4wCs(#`YU<~`e|fyp)>zM z4fGE1-?DBky^H=n8}Md*y}LJumJDvMcSkEbq+3t@P2Ell2XxSTqsrd69Ip4lw|wwy zu-;d1#h&pSslTmvV$!Z?=!2MLv(U#dcR?pTPH(Np4Q{Is)Z6F-2e#1%>n}0w$NKtk zd?eS7R}?y{aYfQcdM8sWrKvtr??T(!SBKt}4*pmidS5zoG)*7nUCXA)P4rRtR^s=! z)<+{f%c_Uy@98$071t2+)xP>BBtiHS*2`io*hd?;>O-)8?!!gJpyR*n##gAi&(Nfw z)<7RY6Y`JfA7hopxFx|l)&}aM`F4Fd^vck;>Q`ySo;`X2eXhSZ2?8KmpuZO{`}E0> zHnDnonpA*3Ml{|*4rAi>!!TU(> zMek)jm37rG=smo7`W7};&(&Ld?>*EoB|ecR>i+siSoRi7Gm&HQGzG2aBK>uIEHluH z`v}$OV{B-jM4zI<^umIe;#dmmLK03Q6YEk{_>LqQORVrAu-ZuM2x^2DK0?pZJJTxt zEX_gn#bxRNBO$mXT)Fb!s$kg`BnMf7lpxhw$y%)_ypTfEU$}FxPf5iK)ic@z!xSt1 zh2ojon>_Q8l41g}b^}+C{)b9a0xi4@uHVzFRjtoxpK6L35+CkUxZ3p3$|3zp;YjHx zRard01iXS39r4yC*j!%1TG|?9^;n~E>~e(7W^Kab|7Z*UL;Ng!2p^&i74#pf3VevQ z0sJazxHa5b%<8ndaG-N(;olZ4T;N{jUr>@&5$6I~nU!1ptg7G$Dq*zE zk;2U;1LYX45-!PlQeBsx@+*c-olmMdxMb;HP^v107Akj<$+Y=bbhl(G_|~`|E*`hW zSi`L4trbLg8h;e3Hjdvn)Jbg!bWJKD*`%_6Rya4id9O~&O6fQrPW3`gn2hgi(6~sm7e^9T=Rjm!>s%j}~pw+FG@wo~c zT*>t(bb%D4hd(Em&wwFPpH4aw~}x7P6s=12X4ZGqP3IZkiW+6wxZdYyO2#bQ2H1J{c+ z<h2dhf3ffdb4K^zX4rJn(On6$f~<<1Muhp?YiLIi;_>YWV%U=qob6roYhDFzzqUD5}Nfc^7TUpUd^n zHgcpU%Z{MXe@|)6aRFj0$u5*UQY)jC!sV4Xv{$PvKcfwnqqQ+O+|PmwR!hmHcwf0R zE`sk1H53ODc(f8YT&*J?%HNaA0OjP8JWhL0^V2$OopI2Dj#k)$Z|fqCUb5qWj4@gV z9KCQA|BiMX&Tw0MNo$~m@;zF2{*kq~JXmY3)#SJ3N?IeHhaMFAa=2J?qP0GMTPu!> z{S>*T6e9Y0@_V?T5$A|V8vhMPIMm|(aaDb9-XHc|5&myD&Y`^Y^xrGd5C8k$-}C>y zt1o0ef`_RgHWk|uBV6BMvoV_@ zbSQrx)g&mfJ8aE=qNJ+0Xz@AiRrwXZ1s4`yHq>&y5LYnP!f_nm@C;n+`j#YdnYVyC zjSCwaXpQC8{Gr^RUy-ZeTFp@I0E**yj$oXE2d}(2?(Pf#hYj|jyb}+?5@wl8ewwWL zg>ojBkW1iV&YoPsb)BVn2oI4}TUfC`pMNzY3GlGD&@;E<8(I5Pc^>L*Kqa-j0>b69IwD+U#}R`HuGX4COZt~5=0a?v(-Azj<9JwQOspKCx z&vT!8A{@;r6u%&jLx;M|bD@fS$o*hmZz}B>Tnk!HgL;(8|9_g$>N<>ZS{?k4-b0UZ zApWmvpsy4tdN(*{!Dun84y(hjxKKj3I*P{Zq9&kyM!Svi_M=g;6pk^fP=#aD!jYgD z^M8pAeoyuR&3P#aT2buq7jOLP6SK7PS+HQ@v8#Ed`; z3UOd-krKILt!u5zFY!zNw5|kO{e{AHQBYQYzDN#|V&q0r40?5)q%d5ojEj}!hJJFO z#LxLLErw6jEPR^QP^+(1)4FQic#>RQdrNzkchu}g?(OD_wKh29>7hJKE03J0g@60I z@}Qt_zSa7)+zzF>1S-j(OK{-eL)oc@X~9}qtqxzRx%mg0LmQ^mwl=jsiwl1n%Z+hp zR4c8b)=_J(_0??J3%na&D!0*|*It$b(AO!2e%CfNM0hyio_KD~f$7&&H(4WP(&>lzo_2r}ZAo*>z1pZWoyQrfRA)yK4jd^QdxxyYLD#uDL zDbUyZ^7Xnj-15O6$nWByrxCoe>_C6%cZ_PMh(5g5K`Vov7V2?fEqK!yPZ>2qw&T#n zU$sg;8JO&po8$II-#B=u?9^W2`D)Hl5KdWZ*vpy$xwLWLH;hVAJu->P% zCh`lK3kw`uYMta3+S7aZ7K?9vg#0|(f^Om~7hk?GqnFT4q)2Nj`ViPWC*z3U(IguJf7yso zfacF@2sg1|3pQ2=Syc+O*2Q!LCJC_R3LOeViRXaAwq;yUDbbejOV~g)%TVKO#l=PETR2w2_w=D?iV%WAOmh6h7KvgQHP10t` zAIq<4U9?ZIH4S2X@l%oPYD0z&eg|d-eF${^Zd@<%EI~J(NA!7XD4u;hR)Z5e`b37+ zgL?dzPU(+5`dkGy?H}17XAx|n!^`vRYJy#HOW>`?0S1= z;E+))mT34}eTFi)_Z|9rf3eS+!pVbKkJ{7>=lu4-eY z0%1ljFb=<_*$)#erdftBu|5LpB$$ew(S-fQrkjQn0Zg-%<0gi}umT_R6C^o`>}}X3 zF2X`<#gO@iz2Ff)tX+O0e-!h{dNAOlQb}Q2OmElpXP&9N*vLJ>gB_Dlp(xrwkrzg~ zMA{qx0u}ip(j?++ycb7|A(dqK6ZRO36h0xf%!1LF;U?0HV_QW#gU|#w^Jk(8i6^9& z1z;A;coHdQ#h7>}Akxf=vmoD-NHq(_*%OAFcs~n)S;X6@GZi0T!Ua#2gn#qrfd>~L zVz(5y0M9W%9{|tT&rhP#N^!ti;0C6issgB&eks6RECjCwi~(?StmO_qm72gP0I`$3 zSg&3WNCb|d+J^(U*GHWXAS{2p-@hk-u>IjzYX_{t+IBynE3ihi0zd;`Jg}RnST+)f zI0b?)a4~QRN4DXe#is!ID5TZ^!V5-D1S1{68Dg3V*h>^L705)DMxdc}h)MOB9ReSUkt#ITp|5s{um+%@AI*WkfHc zEShft5SJE+R|~wi#VMkel>wAV%WR@nFtE z#{gr1RHC-<)3zRfJZqay^a|qf3gYofJg}droeDGo@UC`yiQ2<|`v$;xAOq{$@Z15< z9S~lJH2~uOYIR^3fO^mo@9o$d*hbXJ2Eg12@9nGs&4DSvS)wl00JwEoO4Kz5K>E9) z47=VWdJW;dhVWm*`??`M-C6?)zzL%65kOC18Bjpf1L5>Qo$G--d)*3j2G$bwlmVD~ zB93nm&<;of@a|q!fp{Q|=uJQ10#R=d&;!7GdLtcg!LASN`t$)-0eM7y%K>+Z`qd*tGzw`NjXWBSG>rBVjj02S1@Qhch~s;) zz(At+{eVWm1OU9_InV$Y4`dL1i02RS{2`t{6we520-h(}c><^i6&bC#CMxIwhd0pt;Fj|F-Y?PvqweLE1>bkx7}jsW7Geu^jq z;bed>17)!bdA4f=u!?B+R2)LR7Pv{Yw;F)(_rl*^_}ho~@5A%HQ~=NWfp04Sh~u}Y zR|f)tUI5&`Q-S6H((_#=5te6DW;ddPYXHRcP*nhNIE1hd;a%TXB07S2AL$P)B>KTd z^ds~iQ6G+l18o7+gJXC;UJ^jsjw7ze_Y5XXp9#3Dm+Kk8ZB za=4$E;|?(wP9t^0kEabV6*x>R8YiqqHvuL9dx^!U#LC5?i;wq~2VVt@E-K)?6%h9d zhh`6Iq!m1$tRT1B30)f`Vo<*E%!2Y>4#GX$iRvSEZ#u2M`iCFy@pf>=X z`gm^O0D1t(qXx%_HN?9cHUg#rhln*od>b_Z#shdq;~)V38zV0ppCHx*>1%?tG(lRL zAg)c}zbX7Th5x2$z&&EkngdA7iwyvz;l)g1&1C@3&Bp@ZYtalq`dcF3TILXI6#;Yv z5dT)lqt+#X%f#Bm0gHhP#9sCQ-GI5o+V&&%Nis1&ji= z05^$sstO?gI;8`MW9Mqby10OWKpL^GR$|?dwr*{K1OR!_JrF=VyCd!0kykyC7d?=M z9&qo8xW9?`yrp6Uhj;fyT>6d$@VS?2#A2(@7J0CU3<+0Dkz?Fp8xrO$CthL%>Lf>t#7UMt?OR65fTLsH25u?`z_9`xE5NZ59Phza?<2hT5#IZV!>WD&?pclNHRDNG z3$9NP|4$JAPr&slI6nj5#%?71doT%KRFUxIP!cwcBw@1~3154Yum#6&@Y^@g?;C{m z4Z;dTSYgiq8v$^B+Y^{W!d8U&9q#!a*glVhpCSA67y#e@90AmjuoGN6!L@Tc0DJxt z2rLA)k+6FU340Om{ZmOe06l)4M#68sNeG_;Kt2LIzwaa=>J$ml0RVJ}-UOtQaHI=> zbUA|SBS?p%dJ>NHCE<8F2`6xV0{5O+4xwXI<65?F|#5vvwL;zJJoF4=r&gT*5 z^N4c-;+z0`p&k$N+x^bjpBE zSGNLLBwX786p)bJ55WDX$As)W5^`YM+*Lpf3Dn2_+uD1mJxD z_m$$lQrM={0z?8<3=lxB>~#RVWd$UZgSUJx2^F}%(glEgCBmu1brs@TJp}-F4e~)9 z;#*%wf)!z+9uu(6TCl=awtm1o66t*)9%Co7Nn~#um(~K=Bq}A?g%rDZ=?X~1`=vxz zwztx90Qb6KUnaNtz;P1YaqhkVh$hhkJ4boU1a=W-XaSsiMv#ap1kr0TaE8QIU4Umu zY>hc&pC}UBK&B0Loop9OqVE*oAb=gK{BhlW5U>!~Mq&s2)^R9_0jo*u6bkGCib(7{ zp2V)$)v7BvxgfV6WuoQ?Stmgtk0NnpD?tj=y;@Dxp5&-v)#r=;wL*j(-BuMOT0toYE==pLMiF3OE&j1^N1QK8I1||aU14l`mCj$lm@t%ixzZOQ~ z>t!U)4<_-ADiRkA0$vAplDP0S5*Ok6t!cn206M*8C2{dE0Crxy3n(D*?cTr(z!o5t z#3lX!;=Kg%UJ^^=c3H()lf6-Xx${XB8i9um=?6W1V&H7O*1JPFu9 z;#%Cl&KH3E`b8vu)&-bD;s(fk4*fpQC2=F}|92dTUmPXzON9NU0ayU+0y0V51es0H z>#G_PHxC66#^z`ex9lS^Yytp1!yx-DWVgydFaVox#r@ym{%yGD`^^A!`Efdl+Y?Fr z$p`B^V7HxPNZbV*?nYYgi6?PiZxRpSx8GD?4~Y@ri~z@B7ZQKBkodwuHh(dRg|CrV4E;+x064-&isj&}JW65}u4`~l z9bnBP5f4;J;#iX8tt6?FNOHm+Zn_wfoY#=#I+rB(5hQs;lH`fK+C0|*r%3XW0etU; zy%)W<1DPbXf}A&gYmL1HTVsbnAM6e9gZGTDVNRl2r`U${tAQGq}sq9% z;Rt^KX)pphjff`c!BCPO8U%pjq2nYOdIHc7epfP-krXrxfP4_*9fa#(gfpry06jti zfCQ38Bc7ut0!x5hKpIJ7+5)g4{I4_yVUK~_!}t#U1L@(NB#p&6e6KVX`i_%kBp zE`_A=BLV0&K9!_L^uTLC3`rBZ0CNC@@hHN4^hp3R6CpcsJ&;J!V;z9mz&4=_xBoQ>Fo%NqV|BNmB!WwIodg|KAbr z-?K@Yj_;-;?CI|V5kLt^GhmY$;F^K^W+1F*2p9y+1@-|IBs~io&KwM6ku)onr02Xz znv+1%3%K{iktDr@?_Z80>6Lhr=D|jj%G`h5Yg6-Xp0$^{q!Kt5_S0GU6a^B=Gke4+HmdH`|%1ANg)7qrQx z=rsViqRU7+5(rEKV5cJy0OEfX-yOwwNAca!NC5X8!*|D~0`CLiKmkd|^}u9cIe<7F z&n4*u>~UfuU<7snnIy%u1&}Xe76UtgbdpZuyOW61Daf9VC+Q4${zP12AsZJ$(m8}1 zkMPe!4sCD=<3&=^7~nWbm->-(88%M?{}qIH1w2;{l9Y~kWxPaECIR^FD!8*|0lN`h!<3Lg_xN^aj3$E*PfTJYk z$v`lGu<{Z}%Ex{AOMn=X3cyn^A3$6R5w}9vuW%bsMN$#sS@Z>vM^Z85i#GrWyJRr1 znxygyl4{40EG`5RNS1~I>wzkg zr0d)hm<~Y3Ig@0U5diLU)dP@mn*`uDcMkwzy3Yny0TBT7a4%>Y9+3BV5?Bgs<3PJx z_BsGSr&fJ|xj-1n-r)1mk=({YGU_C`Z4t@sB1!f`c>X&{ZXZH&hqgdA$pQ07?vzGy zXK;6kBe^@Sd#WV&0$=aRB=<=o`JNX@z8Cl3w~6F|xE>7M2P0mC5#Hbpz)_%#Lt{vO0N*_j4B);8Do7rN!y?$IiKWN-oP%Pg5>9X0LaYI1K^#5Fz0LoV4vr~ z@%%{ObpUaAKAvQ>z2z5>Rxiv2HUg&rE6FcHw-=$)i|c@+Ko!X^^#-N`u-QwIKnck& zcLSjJ%PRrIWmZU8U|2nF^4(DN1O{R(t`WeKnYNF#Y3^qvPD=RwDL&~YAgd=)yr zIs%vv;Ja5-Nd8AZ0J^*m{@060{wKo3ypQ}(3$PCW|NOQ9_~#n|*kXPr$#3AhH^u^s zf$cyl$rzK87X$+ffG{9|*!11WKqvs&cOh%^0YZR<0ODuFeWq@}902h!C6OG8dqO7x&@J>h z$z~Za0N^%5FLM>i7Q}NIbXc|;K=>>0{fZirSB@b0y`?0h?;(E>Px5M9uZEthVV{o> z&PR)YgFqe0YeoY2Z4Iv1l#%=~I6qzh>>zn90SI%g5y0tMf6Vdqch0{H#Y8j{!J_x1RFJ@`HY-)G?a41AwO0{DFce&2xKH$Z+v3CW-L z1)%%qQ6z6fT5g2y8xg;ar%3*{3jq86`+Xpqti-$K7{Cjct}q}f*RZAE<1&yu$yysZfDyAWVGfbh0?0h0j4ZyVzGy&jkj zd;uW5AH0Ftz$PGxwZx;ci-*)hChdw_c{GX5?ezE`&B=7J5pzjXE zXGc8AKjZhG#{fnk9H=6BC*r^JHQ)yzo8(`*0ds&bAf4o0&}kQJunRivf)2Z(!|o@6 zH9#84d-?4@2j}(D^WQjzkO67h%VXi0?(%F$u?{Xp)nmV=}@^ zM*LFd1A9omv>8YtIThhv4ge7Uw3mP|k~46B#z>N{Lboi$Ije}|?8zkO_5JW|#|xzNT1rYEgmI4-Dfgz5avx*{jwfYMPf`YhcPQ>1 z=1g()a!9l-lnIl7a8f4Xo=H!V@^}F$Pp&0p z%2-mKjwEF&!uVSVDSv+rNFZfKKVUZS1CU0_GyO?<)`ygt!$_Hhcsv&f#E~)wVb7Tk zYyjd(c^+Xs56ZQ;NGRMl@a=d29RPtMar@$QdT0~?<1@ad;xHMuoj3VWfjt4RR~}N zB1!p>00S@|*Z~xfvbr~bbXkowTr&lL?3y#AeCz^%3*&AI#@v*32nThQ@)^S1fb`h_ z9@I(72I%_vK~nw=`+SMxSFpk6*Gc($8YyA0_tur9d>>58k6lRl3A+F64$7Ncm+bDZ9J@=)U_kU^9R??}4uHqsks!!;dO^2LP)8@az)-@a$U$ zL<7)ce@_5<>|YNc9tTDO7T^FWzvBL1@!PM*NjV5R9fa(`H2`${EdZDVEC&vfa%d4L zhhd9I$TR$o>nO-Xw*?@71o1fv`J>=I3htvNq@Z7^VC{l}H4DngrKFsS0P09NT|f%_ zmhxvdDY1dTOkg8WL`qx@Dd!dgJ4lHK|M_52F6c?QI1ET7B?)m&#(6T%Q*cZH&!sK^ z_%9*Omnuj}g^g09NP*8$E*~T%4Z5Wv%yfL8jbh#@7jKd=Ts*jI7iRm3H$ zF92SwKTxi%1Q1sCP+%g5lFk=klHfcsmoB-JOJ)V9Gu45{r5q+)JF^~ZU~X`}`W11zL=nhfARJ>>PV zq;^>ZYzE>;?RtRJZktH$zJ%1kjimP2Novnir1nZ9wNDhO_aMA}a{%blA3Xgd0O)sL z7XUg9m=0u?Sev71R9 zw-10F#zUV+#*jK;3aO9Ez-%Cw)QKa2)ucXFLF(fO{|R3Ja!EjiP9$|+ z7^$zlM(RJJ0odf9;F}M9=Of%Vz_-8x`~bv~`lbpD1?ByNKtzRiql%0MS4NsiAX7H4g$1zIhjr zNvdTUsmmb0ERodZ1cU)NU*Q9c1z?wzBT0Qv59E^i{#qasC?WNOZU8txSO-9_RnT!2 zY`tnDsUIRftK9%_tX>2pk@^vIT?4y)jPO6+M(SF8w{8HbpQe$z-iy@FhLO5K2FghN z9Q^-YOzIa1`%Ca_8bs<>UjSL8ZiY=Z!?v4^064y$3Lt)4@Ov0+^zBk$7f?m&)}cTY z0M75g|NUH2e|Q0iC-q10{0RMjg#EYs0FXm_S^eoasXJiXpP?Ikw)!*Ty%TZW3A_F> z9oR_fu2rP&2G?%bVy_2q6hL_U`T{e7bpUww!7lsz1Fr!^q#l?8gp>L!!ubuh3CH=N zXi_6^U*t+se~0~|5SKru0o#BAQllXg9SUTVdIa$~f^abgs2<%y>M*EVaJ<$cg z`H3$KL}buDk=hwiBpD@wjd7`x2nb{})J`&)jRtwA<1ZN%@})rBGk|1>OE?z+fm)~K zTs(g%Ej2YM_v*DPad~+sPS9SH#m%FQA_z|HJxHvp6U}ZO0j3YS?D*;M#baq@MF!iO z%EgP3k@UIA5-fNKZnR$*H!hfd6}$xxqckZoagtf6i44LImz3nWj}06B&M111Lg@u6 z6DCax!ahSQj0U=3vIJSr*oI1p>FG&9wvpB|MuTmspsM(5Ze5a7U0+^OR#ugrf4!Kp zvz?r%5PFqdueI5#ua;0X4!Env+h6DE>FVm~R#hjJm;3sXn|9av3m4AeuJif%=gmC! zE_xy-#2#-*#!&>=(+Gknxb783l2MFDbe0)~zA(=PoZ#(8h3rrR!ew`0kT^pUXL$7{ zhV)w*)SGn}z!|KJA=IwGNv^;}Ca$GlvoW}jOBeC*?{$P<$_ZR<4gVG!lyin?&fw`_ zxQ8{L^fpL-^=%kQ-w_Dq{5n3P52!6j*MQJcdAnAT8 z2%G_%4#W0%4XnLbw&DyW4Gc|}iOW1KwIXfAJ09)ge{k`7Ca&dZ?zwrHuzPSVFT#jz zNpSjH=tkth$^){xPoa=PLb;m|atDu;#&xgbxS_~%WVQJ*`A;W5)#wG2ky(mvXRA~Dg zk3a@l>rmFRNjO?4VAtDtH^IqX))1l#q*={_buR$+(JZ=`zBL)ChgtG^o4&_cj#;ce zff&T%{G?f`SxY%M^D#@_zf%Ctyc~H0Z0?QNSZhQo>ZGwfWF{{InT;BuU>5z(g0Kj} z$$t^bW=T9t9*)`o17PG~XG4=sYugyGV`(h*o0K50kW@VG{4 zpg)B?1Q3BM(2mj5WE1yH4pz+8g5AOTAY-+b*jYPitOqn!1FaPp@5^@HSdI5E=Y3UA zZ{3#nZ<@9qcHXWUZ@8UzxyC!g&Ks`r{>XWU>*?YDn0JT9yUfnJN8|m5^A6P0Lv~)4 zf883T6-#;XV`keixOa$I#27fNjq~efaU8RN9_LwRX;vOh!kM>Onxv=cIIFa$ev^z} zGsY>n>!C)BwPx{6#yA1rCx9mpS{cd3EcMgVFq}C#@{|77c9~XwM%3Fn(Cvh zys3{8guQoP@JLE(uB)=L*{+J$0t1~AUusE@Wo7d{7ERM8do3$(>b0~h!oMbaF6_=G zdM>aF^A)TdHh7-Vny(1ae1*;7E2y8r*4t)H;7#`;DtIsADsL+4QBP}w z6CIfAG>SIX$kO!ch}iZ-Z2Lhk_PULZh^^<1ev`G1oE+mi7~#2TNXN(;PPh0-OLGG` zNY`5ja4WvT9l2Cdax!(%lg?w4y;>_}wckuIxujBh7y9E5$~t~yugnRt zxM8Gec#PJwZbkc9;ys6uarh3eBEq4NCOBGi4nKK|{Aezp%;m3e`O93seS^HiyDJF4 zp=}()+QR$UpV!!*!|cxl6Sw>0u=^9R`#&Iowc9v{7fegb%`6J-?BXtptvs>wUU}w~ zgsdw#q0+2`gj$^owy!EIPoT>tv%8C%&BfVTAy(D8wo$DnXS`IZ*v$u$VyIeQUFEFP z>CC+b4Jtbye&E1?V`pPx&K$wX;j3-J{JnyLg6{M43JdE$X1u{*aH$HTbTE4Pwepj5 zb4v;{Gl~l;Os$tMU;b;MYu{nRh8;hC{Hk-C$%_{+Hn-lsy-WW`XU?2Cef*$76LFk5 zZumF@g$n7P2ism{AMw54QiCw`7%HY6Sr=?u$UefxG~8pP4hY7xeY@`d&Mw&Q(B02d zHU-m0_Hm#6$o-U*lr(72ptk9MYz?-}Wgl~|w6uiGLR8*y;m40hp3BeA&&$4?n^c37 z#M4Q+xknD1$;->Td@>hB!|YyBCR9|(&Z6opRjW=WW&=ObT5q*lLsiL1R|y-LdwF@O zZUn}xjDmv8m#$vDTIuHL-_r*tg|1!v{k^S)VD$F(r#di}6bU6owUXkgE5BYNnp%U( z>FW9Osi~=MZf;&at!nXwy*=n?&FS>Kuj3fu^0~}MxVpi}Y)mT>R#+xDT@cC%SGfr0 z#!(5@ziQl9YIT{7O7uw=y5Z5Z4s1-qjGHANxGdl?rE!|%Q<~(i z3&Iy9T&NRSC z7@QT@p^h%OxE4bz&N56&~?FBn$B&MZZNXpF2YET_Bs z`ub*UJ9zNNuhy+w7al&@w!}6AZ=k^5|M)zHT5d8vQ}E6S{>{V4s9)OpI&Il-;>3wx zzupjpw`cTqI&dZqIejha+rGeDlPFK`9jd(b)?2SUBe$@4NCgE2C=LgO1q(vxd-|3( zu@A1EAiNnxXe0F0KHUUws-rrgt^K+u_MDM`@$`c{>;C)ie|$8VX{#_EUu>ff<>@0w zj+`At+l29`12RqG*07||5Zf&6^NMXTZN1Ad6oJO7%<>23ZFQv?oz07{rdHSvSBSOh+4g@ARn@p_Km*y zGIPG^>`w>wM;oiCGjU&h_Y#v4##Dqc4|$z=@5e}>9(DQY=r~=;ufuX=owKLAo2y)R z^AKf86w$&&Hn555+iz2?0`7b*NMJ z`83oPz5gD}{4a&d&-J4Ce+S!^vX4{Ph5Gt>eQ#fjyHb^U>eQ(#bxNn8C#FmavMsPJ zRl?3us{!u>({A=rmfq)Sjr}Ib_O5NAI{BH`o*zP85lSUSgj_?F%B&N6_in&T18vjr z5(@TtQ~ZMlkEUg`Q~P{NtCbfYdE}W1-7Gz#&mo7UT|O5b8UE{m{lDzqvo}0C`5L@! zc1m>kzP-D5?muww(C_rIsk=^BQ%0@|oOHeH>hA7rEr<7z+^MwA3p?pbHRa`%Hd$xx zkynQ;X+@zbFE2m8urTd%(uLG)o4YIiQEpmN@}*1pSL2SyQyvtq0HJfJe{E$&ZEamu zDfna$suscL?yM?G`h^^x1Lh+=-$HtRjP#t3^u&{(G^AClRvxaHFICmcmx`U8om-r~ z_m^7hDl04TNT>}Kw^>VDnBD^?O`7DJb?%QNN1_fKh>neoZE5;@O8xz{0AGCmx#ymn z*k8KK3=xc%;o#%{bD7Ja#~&Zn%a3XDpJfuyy_=FeJw0_~S^nO8@2%go_dm)tLw^4G z=UsdEL>@eN@Jhy?`}gl}aW1;wU6helGT8)_1VMCfVXhi-?b@}(D@`Fg`Wpsjlfz0?3Q@I5+A z%i7k`LcZ+ooP4X`G?%&mSs7mTpE!EWL@h3+=9YTgrvBu9N1><*mg&flmaJ5slEF)U!wSM&xOAxU(CTR)i|f z{zD#l_I%>S zq_osaDd$h``(gX`?ceV^eLguQ^-5CGh4@&M?e0}o=vwP<>6^_^6%!0a%n=# ziOMTO{S{{yMNxF_;HxVor+V;}R@T>2!BO$QGER+X4A4nmv#Y94j7B>E?_l9;=aw@Y#`sGWPV2w)% zKP5RKAuSE_SdBYu7`S03BG^c$Tf-W#SAPf(|LN=X>(`I6{S&X@d&TyS)B5At+1Y2d ze*B2-4SX!NMYb1h&uE{Sc!3`}V*Wx= zULX7@mUSG~+P23j9xtu4YM(;7!unVuR$Lp&B(Z# z&eA0HN=63KB?E@ayqcQ}^F)~%_nWjNPy|A1RaLEV^pCpO`$vDJUi&6euW^s4`IKz7 zMNk%@wFqQhBdy228fQ@CbdD2jAt`gO$IG#&mTL3JZ z;<9RkY;z+KH4$tK!>35V7)Crcq$NR(Vq+L?z3()J(ItY7VGPhD7iy9_qSzQlMQ_I# zhGP`BaSMix(?+m(UFPZzWz$oDu3D5L) ztcq$XRP6Xo*`{UE)lPIC)|_FYXK|ICPCwvqu^vxr^H7}|S(uGhI>W+7Ap-fm2h+KT z4?v?=$*!X4HyniC$f3*Z@-C2PE3&kCKgeUe79&X!Yc&F_yTW2>ZEcpA0t#G53E)_6 zq}~RyU;ts6W4zuIJf7h3MM`^u#}kWR@J18upPJS&!6(5d-YDsuJgZFVps27DVSipS zN{hn>FEC$lQ)5sdqd_C>>LwlHBI26IhnnrCi#u2^l9s+$rRbmshyFcMIf$x%@OPKZ?u$%H=CsAYZ`c1G)THT)v*m_illF z2QEL7%YVb=J-NI~gM3pt-zW~Jc$Q9!xo`m&PUOOwO!)S_mAkb4EvqT#G`5|=_PX%~ z1)LhRZ#MV1bB5tO0-LyeHjlvH8{{3acW40DfU?(t$DU_hEK;xs@5aPK?;QIe9`@gO z*ugyPgFNiYhOnE}y@m44c{}HL=Ix3vF>klgmwCGZ)y&&H`-H>W-7M*BU%lxYFZ3Y6 z`4aQ%HNMQRyH*P)Sx5B=^r!~fM}}SP{)&wm4kBkvn zwuQ@bWBGQuNo4ufKD&6c4p?`s4N&{Vv$|!tro(1U2k&ZDx41vys9Tz~TP?SHe{T1a z-0peY?mjKBdjOaB;PN&uZ!hsREs%%d*}BhSF29k>58&ZH`~R!_y|?Jk*50r_x+~J( zF@tNAy!?><_LY8Btk`KQ{iLrq!wh^aS|4vv@pXPv-C>S~b$)CGq&DClk zkQIYB@ZK!@n^}na*JzY_ec2T;fnlxXkzy`ODbH|%3 zCPWW^eA%CB_U8$Ee}}aj*vg9=R<--sd#lA$HzD z8ZV2X0H*UAZ@!&(oyPki=k2AZB~9?U**&BI?>%RW9GE9%s|kl{ylw2fCp6wGc3z{H zr`ZGAR_c*Hw_1UXjWKK9p0${<3eFmM7XH$=a@tJjB|rEWHi1N}R`R z&mJZjk0AaIu`07c@DO@}E)Z7*X2}IvgxwM>Zq#y|zEKWxF#fgH>c7mD%H5X@eM7J3 zh7|<=ZL9RZZ56h#Rp(eG(+1wh9qHnZE|$XE@=BVV;Fhq@DC#=(Fe!shhn);7MN2Jy zBAW3JjY3@w*h-DTw%O!mTWnI&>T*+Z(~M$OagIsx8W=b#@J*wb>RD=UzYm3NhoZz! zg73wSbbvtZFI-5dqkIgKvLTR5$(Pd7WH}+h%`G8;_L+=oJ;61TU{VBaa6S}61D!hc z=wWGp@4fd88*v{d{lmh-Qaroh?DXldu#+dl!l)E-AFai7?)S$`%AZ%+pnj;bcyWCE z#EF*H-h{V;XahCu!2hXVeYO3_h3(tzgJp71eR!kzkuVBxSbrbm04sEF&8OLcB_-8( zxjK(?`<$HI=*V->AEQ#&GOlJ|w6`udHzg%Em(rkzM40C%n@O>06FZ@pq$)1X$+5I= z*Uqg%QZObJ7#P^2tFP+iHE>{H;HXi7f%K-y)8^;jv17;X`l6yPejXkkPN7O_s;6fu zHXglwYq-rmtz9q@Hb9$tGe5XO6I|&)Z11VxF3l5mrTvhGG_SgH(WD#>e5~hVNb}`= zcbSBT_po7@odD9DHra}3xydMJR>&1dbDd{9lk$A5Q86S*Gg+@tt5$FZCmT=s;^iS_o%CL!YH0L z;aOW=TwG>VF-%%hLhcHNO{?o_L_G6mbyn-lz4EKvFk<9hTQ1b+#GRZu_xW+{%1&*@ zl;@^n`Q8srnQ~uzd`wKt#T@kOO|q(^>Y7leyp*egp!9pjk?>FXQto#9_^I=@^4@*= z?B3nGlbglewK@g!o;j{a>7di~n9BOireWjHK(|s&KZ-R z9fL4o$L5mXmhP= zuQWj*;Z%`u5iR^yLLU-}HnUaw36fCHbMc$V8%vQlK1beIh`jLz{J{Vh7Z)d4!4reF z#DrYv+w#0Jq}W!^rho;Jm$~8==bZ;0dE}ASSI$I4MjqO|`|!z=C+{LJc?$Qn*Ft^i zg*kKPJa(UO*O@EW_ALAOZ_23dYA_7&)pdF3p@;tC3@2Xu=wzr+T^wS2)Alcx8*Pil z6(3#uPx9dd`}XboHT<_@zyJPwc5XsMM8sX>%OReEgi&0{X5|jtv&H%Jfzr~_%xk5d zR;x>AZ+DE--$lL^tMe;Dot-?H8%|5OP<=P~xY@p*oTmBIbUq-s6NMVi)D2}12Q1-q z_|%)_735~Eo0-n#y}0}iE}zQeLz|aoEiO$O#fbIvdUEMqT)K#dp4K4m7+tt=EVC{5 z{AFC&gA38@TQsJos21Jo8$x9Jcst&T@ar59abATz)c_-^k@p zHOM~;KQb47WD)$x9QctLuw6e}Me}vcwF+0)DO08lZ&$B6IVlSKREDX~ZM{WlNolEv zlMS17Z(%i#rJvQ-d~M@4{OPBsPoMr^o0XtDQ=;+R06;oqp76r+P>2giTUWIm`47a+L+S>K){&kvG>)cbm(G-&GxTbFLeC zb3Jvpx$r-$teVP}O;%d}T~_@sYpz>z@Vz%zU;j~#9)4F9+08k83zr3rOVXxOpm_ObLXMuwY3Na3SQP}RZ&L@-0d%?RwVh5K`;qulN$a{17o?Lzx zmoMP*sSWZ?*KEFU*$B0a3wvO6DgQy3mboMd{}`IGnF*LMBA}!UR*VM z6;Sz?J@ieDWyi)IjXNH9bhC-) z4`-cYv_sTw4d}3Hz?K24q9xsCo?ROK%7&5?l19@`+d*0b?4$+Kpy@mVIpyV5GfUc8uS6$`PpAu%yA`SPX9 zY`}+Iq^Dz~2WfQ6NRLooZx$5A(sA_YhbK*bc>MVBSkqb3edxn%$cJ5wACIw~f+og# zgs3R9Fk*y-Ekb_RbJe%|zgo3w)$G}`J6}5b#rEymQW=GPu}Ui(p+=+(AvNt|JhVc1 zYraWg!#pL`p~A$8W?|<}OUG;1vZEr-VwDD#pIp6?afJ=|u#41GjPx9DVx$MFNXJcV+S=h2n)iene3Sire$vG zlp~YDV9ZbTw`VdEs+RV#0(8Tel7xv~}x* z3CMFN3!J-#OrAU$P3v=|SOTWj)fc3tjTm7@J~N4FzpoEw`ONmNZvF3R0Rd^r*Xzwn z&e1Ostd`XTdzN!vwF(QqLOgS#*4WiiCr*6q!B*53`n2xVZ|6=kGMh>0_xBL`kiODB zAF6-v7aQBJPa8|8nkoz`revd2lby|u+0}AQF~-VtY|yKoYD}umsg)H8RM(k#$!>ad`~aIODqw#+*q`I<59W}xbYe4jH;>Va7^64X z*ep!QUvo$7iTj4Rqtm$Jb4P4mh0h&%;>mgvqu;`w$C0t zjWa%bgb`V`zr#Ix8jUl@+|kW!S_=ttM<`afi_aeIz!{%CVj*C22h1KZHq3Fj>#4iF z#CSA{ZNqE~pFQfUaXDs>dTV@+*`t#hCuWZ@8^fkRz>2ve|J$s- zjxy}5>%hwF3s%e=1>KgF&m1{sr1;Dcn}!iEBgJQq*t{N_>0&cSY(@;Mm^t!ng7xN^ zqo!H4nInvlYgWa~k%4m->dDE@=}4c(vyb9msXfFj#RRY#1UoulK1rM1+K4Oc>Cioo z*!&sxba2yCH=H%z(}Crm8yFj>xSg>Uj0ZHvqmag)4nBF5f-~&tkgLbau!bCY^X!%f z3HI5okcQbU_8?rt>{bU{@!2i-Y|L&6>ze>^b&vo-8letDd80zHjWJjw6dm6Z|gN&b8Kvm*D`ll(U`qFII6+HhXv z*pu0EWNZLxIMmB#c~<8H`Ug(&7{7xNPuaVBv0``A6Y8F*4cP7{jE_B>^s+V&+84s? zIsOs03EE63Rg_w1@abg_XZscL``&VPd5 z({d);e~0ZtV5ASU5~=Z5*03V^U-1{yFB-p1_hIE zXF|Iy5q&ir`oU}C5JT(OVKAPfy+f~7$7laaDUk_WqJHd9h4slexY6J{_Ooiayp_u* za`|#D@7zcpc3^w#I96_GyDQ<*e)deS%`6_25swMlxfD=Hb8*(%EJ^v=K1--W^V-6` ztVZFsXs+i~vrp(jLRq4Ko>!ZGtmkEo!3r_KI#?LUC7bPC-^=6Ck;mf~9*=MykL!)% zp(nJD`FiCWX72lOc~35%#^p1)e1{gumvH$EE+5I|1GxO;2KmPQRY&-tY}Q-ruU2#E z04{A$#j*zJW@F!thd!Fi&*t*mdFVwgkdNi^t+;#ymoMV--5TVZE=l#!AGz%@F5HI; zM{?mLCajghW_7pnuzPXwC@wB?ak(MvX60LG`DW$E++e$pc=#uI_(BW9FXZxpT>nE{ zKAFou)dKlTT)sV*&*k!$xx7b%d~;<61sJ0hfm)gQO)E1>3>&R*9qbsbP*8Snt^K#K zCVLqB^Dg_NwdK2;cniL1iGA$8ME0j2`=f1GG1xBAe7ogerp9glF}P}I^Rt#3Z}YPj zCU5iKgEL2)zu7cvq1SV?A{%=x-imw`q3}M#W}NXAXG~MxXGq5x?=w7yvzz-2jveM2 z`V8;jF5YL@fHUmhHj7nEAK{$$8Wou@`0$any*PQuijE-Kzb&Zqv z8rVKQU`4NC=xtefuYs*yW30T_;AUsldJO{GgO0WDS+C(4&Wc_Go=4D1ZqvH*Uc>!% zR^Dsyva|ADL#3US^%}-=R`eR~X@d3UUPIHYe7#vGjg_r83+9~YH8}cWj`Y5vjj#0@ zSbFnb!xo%1^coK0iuW1{amITMjQ7sH278*L*T5JXdJS2);=Kkey|nil^fX+j*g8ZPP-3 z;e!zIP80G&tM=xxcd_h?vQf6-wza^$a+QvBRVJ~6)L|6%4tWE3OJ2U17Go89YGUdr zJX^K{SP_IJQ?s#a(QNj(OHz7LXSk^1Il^^W;V37S%D`{?Mov_ws<8eQ> zB7mX|wtLxly6^|uS!|z1?0ShklyM@~&}nw?VV!*5MrxL2zk}tBgM|&EX)KkTrBwq9 zYpFP%8)~$}4E?}JY(#!ggdOX_AhaeS?=+hk=pW>m8E7ou_-XZhOuh%PD7}St9*tu1 zxH%LWF#juvL1^sVAtu)yG0AttB-s&@2p*HthL|*Kk6Z`KWe1CkgC&czbZY_24hPF_ z2TQJlMbBBLwSXnu!Gdk^cnV{A^$jUp*TBMT>!QD9e{4$QB*-UiI7<+%0td!zb}&tF z#0HPZEo_vyS*xsaXmZ7&Nw!0i5+0wv4VthL*Hn&0mbcw&G9qE^v6<}PIKerJ8#tQP z7Y6lBCqueGkXK`x*%l{^QM29YR%Nm7W9lD(6x;VE$mmeK06^AN5eB5;FWF{M@v0m2r>@6wlUX9U!&E>!;bkR5s z^f6i|ui+f59(QW4U+k>kX{`M;RtfK8V$WMZQ%O%4W4xtx)-ujNHBMH=!=?-K&?eP5YxS5zw)=+L zra5-hMXSePV{+J0cP>kzVw|HLh0#B@e+Jr7zIyr_&KkF)SeoC^GHaaXe=)L_)Otv$%B|Ecu4ktP)p~8DHx8s1+-Bw~Pe;RD@j3Mx#R%odcoC2z|INSpNaiDG$!e{w##(W!iCR0g8!=)8 zHjqek8uHw8&z(DWu9B`{cY-QR_oYXySg|4^-6W(}SxD}cjR`yZ9)Zf#UbWZ@D44C` zrxoIhG|*&He0tu0zkW(E#(nV-rgSk0z1vt^a8Q6;n370R#o}=9+ zqud!gP1c$OSqjBkbipJz2^KZ^)cMr(tPa7pnOLa&rZbz?|1K=dDFMBT87LtynUvSg zojLNyF_Yk<3l;MY)tH2k7>hb%#uFokPd*fkg?T$^9cEWU&|D0~@;v+%{`ePbb4C~q z2o;4)&a$={GTS8lcresJ5yIqLd^rpI^|Z~-zLt44`$}HkbvBQGEjRZ{PEkH(LetCG ztx+)vb@id5vura7)iz68Utj&8{%t)wbZF0JPx}N0w(|4si1BqLc5=}0p6u>?$K9Td zX6_!?1XrCf_I2j0U$-WUKgsmMM^JvYrrG&!WTHF7$gW|k{qBEy>oK3|w zjE~-|1M5KueK-eeNoi`7J2648O8x^Ck@LH%);Z3YeM_>S-i{2 z^76{ctk!@wox65!(-F_~_##+Auz36V>Ggho_ugCUA6OOWFW!4EelZGJS=f)-%S5V4 zC@#Kjo`|PoEGxJoG5*0$qL3J8S!Tcn3+&*7m0G-9_klg`gFVK>9(|b4ggx4@l9q7k zQc}V>Y|eW=HZ}#9$Wx)N)zy-Sz090c7aaMW>iqeO|1=2$TrDVD{J|KDm?4aK7_RdWw%~mmJO6wtxKNEy zw?tdu0F&(n1AR$f%E!_Ksc;mwmz-^T#5M&!F32{)_M8P;;_;}AjI7-J>p3~si;6OF ziFzf})yb(IE1PV1#-s{`t*YA6#>)#U8d|q*>))}xpI>{u-UpW$C~D$Xp39lHV>_X- zm0N$+Vz7V(W&I`gS35_>wwREuxxaJFldYYd@tm`WkyOe zBTX~;GMz5$^$lFw;qMzu8%2?CJL17bOPHvYi;91}`N1K{;_nET_3ffq<~gcKVz!Nm z>bR&|L#U1xfD`P{s6QaoN3n6_Q8fbQUhw&Zbb=JeincNtMVo8P+9YgNn2k?3X8nv( zeK!Pb6xyw-?ndQ*s ze(3UJ9CH+fli?`1F3rj2M+vBys%wPKT0B0)t7*H&3CBoydNMXBXpnW);~DdtY>*v= znuMfuoi^i-*(#~$8R`jz^UXp^99#b=O~zkibE~Y_o-TQOPZ!kBY)_XNd{38yY)=>L z3}-LZti8xQEL)-V*ITm0s7UyOMQlTZnl2b!$52uldM3Dxi>EKxaq<&*2AI!J&j7vZto!w)|^CM3u(GWfA+&rS~?xZ%?c-~93`cIy23r=Nbp ztU~B0n~TjE`{_R^nO3j1jKc1^eejKoIYj6vw4+iwLLUo@7l+WNbQVp)&O%2M`=tx2 zmJ9T`_RITpkgf|pLPzGE3_=IjfkT4Fj(<3?OV?fl24k};ilj(4?4xuoH}85$c|~bq z{wVs;mQEH5mEKVDU-nLCEwFpn&3GO~^&90@z zm4av6wtlU|vQm?4X{pt<4R+*mvz3)v?)CD*v!L$Y?K;*;E*>79iiZdGfo1<%`?T_K zacPHVNIl$LUA(?@IcEcW}LJi1yK5n+v z+H;HM!vgpOxu8u&V+ZO~=3_h%Qll0}lQXJSTz)8*&*1VpE??0g?LTNG+a}eJE{(Fv@{%E@WyqHr;)Fr;Vd5P5FTtP4_0EqYNb~}`B6}QnD1?< zL9{tBZ6<6Ut2$EIp8(VC+ROi2i3gw+-nosPBqh9QMFg;lHZASX3+GP#ap>3?>==6X z*s;X8D_1V%V58vk=g-r3rcmd?aw%X)>ynJiv1VtFiqe8}l2vl^u%LBq{M(E3UVLHx z+oSOACGCTTiGjXzTm9ij-yZ#C`_^y1`G&f)B0ACb5GwD5!l$2(!eI7;w$8R5=23zl zHlD7hgILcwihe}9qN~tOD}rQc{oYqUez$FN*q5Jt^2wWTe)al{C*~}Amo>`Y!#YNM zjOdtzjuT!N#tGfwQ7_^XFSrR$3$L5USz}SY+S)qV+TqjD=4{Qdp0Oa7#X0%qR=E;I zsIaiQI=kR72&jN6f~>I zHQ^d2tmWWl%WWA@&3TOW-?3K1TupMSCb{<_YcyQ* zbM!8nlg#*zwHd_j0@@5aI|~X4_grL6hR6KSWYDZV4JGqmD47W7>fDwU_+}T>-;E=< z=hn>?ZtECs-EN^dPjSz!+b`VKbKJTiLvya=h8*&4;~aA6PPsB8U9*pKmN#`o_YA3$wDa0v@09MoXRjf0sjAtyr<{r^DZ2pN+Y5=k~gKChY%m z=8z;gH7UQQrUoq_q25-1hg#zJyUiQkUfT*u-O~15C~yZm7!76_i$Zn4M!UXOy%a5+Icq zE~8?5`IO#WwBXqgGSX-4V_WK`ZH*H4!|uZozeb!g(6`tvth>oHN@yo^B^&*y)#L}M zUho$D%%jjK?~J>Wu6RzDf&U(h0fd+m@tvc^yYiljB#+q4)e_C+E?y0Ey(Vgs$hQRB~I-;SB zmT-wwSyOwxSQ{U>o}GR^`D$KKb~P#plXFQKYV~`(-Cat|&iy-g^vFMvUhmp!Sx09i zOm$gV?*Mn_)~&SBg04NgIJ^3{$Ky4qk|3CwoOC`v>tZSsyk|&1_ZcIH-q-5Jn)2pZ zb)kRbnhsqr)@nh;`x~#T)E3lrXF05BVPUf-|V8OBQ5@>LI z=uz<4SN%5KYW|Kjld*Kn1VQ|{$o}}Oa(4PZ)O`nBlt&u(`)=Q* zExp6iK~b<1jY_jbP0lovm|S|fB;KW(VsbfNmY7~ojhbd`XVk>r8xjkmNbkM(y0m2% z_WRE}i-1zrE8q9~SY;RGKQr&lGtWHpRCuMMpoVB~9piuu+p**&TH%bOGo+5XjiSF4 z7xdD8g~N1&yG?gc)c+7^c!s=0Kh!IN-!s4nHFQ<#pl3Z62V zR>PPhQ8h`%toTYyY5 zf^O*9I8i4WQN-FS&_*YNODU4=w4)7FI*?fzYx#{Tak|l*51rH^avcwfU06wTK2)Ri z@uY1#iq}!R)iC_^LHx+l_C}g7Lr=lyD0mX>{2>D7Pxlu=Pp^TV$mzZc^z;nq$z7xA zlsMSqI!(U>hJNjMBv#Fd8b#1Ms)JN25Sd8Sr)eCQ=BBr`wX;fFd31C%N)8ovx1arP z@3>@G&M_<2GIKcTw7U@p`S>Fb-Ue0rd3)Q~Syya7c<>+++<2R7FCQwn4ML6Pn9tY3 z;aggLZvX!MJHGq;Mp+Nj=+-hhIr-GDzakEgensF;`jpRCSH>mh&78qD?QI1i4BCRD#9@bVh9`RENU{valqSWh232-YV zz+IF8FAh@gsQ5^Vx2E_sich8ZE)(z)im#&h0~D{I_+S(8VHCfJ;_s*UjTE0`0=|;s zZ7F^q#TQb1zX|x4D1IKrCs2GI#aEbsH}UyL<{loDdqmOBYiQ?NY3JKW=REfy=P%Ls z4nKcA!uU`rUTsch4@X!HnLfO4CG&^(ZDaze#&^{9UpZ|4W9Sf$(IFI=@ccrG52TOq zJH=n7_>kdvqA?q)ZR0g1Rs`%mn;@I{vZnl_vB*i$4ECI{y3U_&3qu(2-xIBd;}K{H18iJS`s`pyiSI_{PW^d|XdXVesv9e}JJ}zcrHuEAr z!JjkghCk;*8V5b#&q>BdS{oV{BSfh1=fEKiaHxT=tinx4KRx4M{b zl^AzPtIPRTgN>~o<6G@Cwt8;RmoriYhqSR;eU@)E+Suv?e5;|xR`>9&))`yfgI0~+ zoCs^a_HVbE!TU;*4ALZmXB%2I`g6)u%mrhs_&6wks0J&?#Rzmneu+ThmqHe<;f65A zfo*2stCmIh5Iet;Rna}a1ZzxcSU!*Y&r;lf7BehkiMwU#co&{hPCfpm+V9RgckUeb zMpMWM6H#9HHtsPFjg2c;-p0knffM={7W&`jb;Xa&!?<^oEtrULZzFz4wyGTW`T?=h zr2973?7z!5#AxwWtLd1$OT|E996@R{@y>tyJ>EWI)br59F)qNYtWb%Vr?EE{U~eqI z-tdvvlT3zrrJdc~YETCrg;U#DyE!P`Cr_Rn5D?&Q=iscAN~Lkm)2BC{+DyJqHXJ?L(Ig8B3X(N< zZQqXCQED42m49&fz4zXWOjq^h{^8G|4s@K=-o2rL;g3K5_*{1{g(7!1lBTaFFG0+n zeCkEV_;PA1ePz?7n zZ0XQ-ASDp~v7@`Ay%Tko*f3{BU*R-_?^?2!MJr-&?EIh;0i9yT>;~fEy zpf@3GkGgIHLmzqV$k0bxJNPk>uTj=d1=OhPN6zLaF@LXP{+3|=?#BH24XwsE>O5ZI zhSuU6neHv1?Y>cy@ATq#z$WG#j~FtMQG# zKz>6@@!hn^4A@Tw<0czgh;QT#Cxlt*1cXPGon5yZiZYJ6e*UO3JETDQ=xaIh>2-x% z2hNgjzT{g1`R30J-VTP!@=^wQbrMN^Oq;A}b3Y$Yw~)E@W><;)z0HeqsbF zvJLrL#yy~-CqPASgNptHDq4niZ0IgDMauhVd?_#J`Fvkd{`1RLnIP$L?B+y{Rdw8k zuqU`4X&J$6XLj04x^~}&&^MW5SF8wUwh9Fv9yZ}{{~ai3Ynrk;Xkd;^kN`g#UjpQt zt8MKm9ajpx#(iRn6!;3(m1Nu}o-40mU0=hxazV%i=P1hEZVe2a9N;RJTRVW8IN4iSc=}Hc43za-Pna;lULdL5 z``Kro{aSXm73D{_Zr%F**I#e`@yGA?Fxgxz$_!fmjq3FY#t6i%LU;D;Z6*u0VSu6paOjT<-r7=Qi}afQ8> zns)X4xy_q5ZuG6)gDRs3n!OiB!&ko0Oa+E`&PTrF{FV)lz(ch{F7)!;Jn5`CDhpY)G#W=4MKy+9QET3oZP?{K@c$d zZgp6GetLT9AE`Ap&8U|l=&rA*!B6Swl^wOlRyDh|!^&T#gRyZG}yw5UiH((9+V`o0Esl4habfo?wt#(wMc` zpf!>O^e)iajVz$0rDj?k3bJVG^!fRy3p>Bs)Hd-v`+bLP0zrJq=9l)pG+iX?7{ zRb3sBl=`F{)zxKf4ampX*j83wpPW^agDhfdo9^}=xF%?|m}ExR)76cS9Aru2msr>< z?C$QCi7ULlojolvkyf70{{Bwk3tSl+Als_zJ6lRhN^ny5bk~=ZfdCMCq#Z{lTT;i% zWC}b#gt1~g0p98{6ujA>U877kvbDkt^NruDreGHe7E-W|fT_MoHOigMlJ;Fp;b$q_ zm%S>B6@w^zt7ZjgH@!cli%_!cB;!jb08O1L(0q;Wbz7!u%@iL0<9mJ2O zAC%M){h;e57P{_JQ;B|1c9rM{`)vmFgQMtb!!hl~_y|`T%!xL$MR=>Ec>q#U&mUT0bj>(?8PP6iz~4g!@>8%!S{Uz z&Z;qXWIXrDs5_GB0f+TUSQzRud2;m;I0~&>cetvsu(?L)ICy}_v;wAXxP z1y^73I1_=nvy-V}+b93M{nw^~kt8-BqzDkSpkxec;C!MGWGMNKhtSwGd{J~vcr_&=&ip4cq z8Vi0%{;H+Z+t2*GW52B!AW3rPbB7b#Ujbudl1Bs;L00D=96nMtZ#Zx@MvjXk=0_IGkqqgNjLFu}FX? z*Moz`f`gL3YHuatBJc&N!P(y4(H0k4E2RyD2&Id&hbMFbZcNB{ZSXv89@ZlY>#-E; zF%Ro859{GYcf*)_;iFOag4cDK?7FXS;hs;)*X{zGtwmZ4Ra;!WZr!}F4I7Y3Pd5FK@r_J)DTXzt|DM{J{JmUibB1UPY-qVPax* z^w?Bye3mh3{-aT8-iy-w7zY-)fR0V^t1!c_V1_@&48MdKei<`7y`?fMEkEV**_4*< zZj?n(SZSD4#BpA{n3`LVn_F5{k(zq#^5x50w`AA5POyS^V09IyZK^Sg=fIS$@5!uTP-%FY0m%oUne~4=0PXPJVuV zpAnYXvMMN{FGUEgi?4^)(={SLwoH>@$|jwChLCqR|6^&E8M=uTUcn|pP~#~*+E z^X0NSS@4`Y!^8aSB{Gd2izb=pp1p(wnQs4B z85$rw`N=1r?8``PE`m>3rp|5NGq0@Ly4&C#9fc?@UbGawz4X!We?%a~^XHOPt5&U? z2j-7zNw$j@AvS3(vw_*c?B-t|o89H9O;0$p`1jm`E!AyWL0j6DEAX2+dLTI-E|%>&U2AO>Qb!jQwYGL(OL=fJl1|DbqhihpSkKOjR5^&2I%;`tf| zwmcYyw-^d%Dcp&`$CjdQv-hF?79^!7-+K)mx*zSkgZ6DRe%~l$Kr#_{QTRm)@1tY3CQgN3km2FFLh{-6A8%vi2(_S%o&M4C!F1G7%*-SfX%O2_VKocQ;R6JN}a9b`}fwZ(dt%A`ucWEjx zW-zF}K2d#sSto*FT&(+8l(3SP#K#xw$hDa1)9ShyxUp(`da-?-qF1iG(_WySUL*F# z?OKqZ-D?%KWYVNb4w~|lKm0Ty*iB!Xa}2la)A84~t%Kfq@3k65U`WX3@AoESPS96R zedYOWTMs8Bmxw*x!kz{93jvo9fg4Kf{ldcUoD(doE4%zNxaE$kxw$o3f!bEwnTsHY zo&P-4o>Oy5tycfmteh5squ^_cO9?3*4zr$k_0?A&jDGrwr~zw(`8%OzZ0@0btKL}Y zkI6%+I7aSmrSy-!Xa4*t(!yg=xZLwBPZYaz=jE1Iv$nh|zyGrPOhUTW^R7i`nKH{A zV4Pr|g2lGrgf+#*2!@b)+m>J7xbfV%a~Bd5^NVs*(=t*MPoKf?5_=k?0>~XLEj5>q z#$QfPN-n~ZaUmk1-of9~j4^0xT7`yQus*NwaBp(+MX5@u)LbA@T1#a_0Z@B-=gtd_ zanqMg4@4II<*&XtH$W={3-nj{&Y9@rHJ+;Ep@t57K1iVuqH?C!eg zj_ojWY{lNG*2K!zv~+fMw6!*M##(^{&bO-WzyJQ`d280J@kx*KMC?$3!`pAqbh+a> z(9No8nwc{->EC?wO}d6P$U5F7P z86XN@et@K!?AZq(h(FE$6^n>kOekvfwGb*1vERdqRQ1YUMU1eRDQ4)oHS)P*Wb)QV z_V-2%^8k3NAe2#(w@yYe-npZpwP|wr2H@7i!y_nsK7kMKefYU!Jl?7)crpe1P;krm zeH&=s?i8L*;eHg}HrV%Q=Z>DQ$m7bNJ1Gy4n|Gj+oICA`fpcdxr0;S5p3Ea(~vV-MD$J)4?({s`1hsj2_`=i0Rn z4!8pfM2}5~l|Y&G^5Y^Xw4l^t-c?&!5@RrqOneCm(FstpWGVA6)`>|+ZXF?`$19y( zh@kehtQ)b1aMAQehRU^TKzvW}f9+9P*wx>|BfuAA@8K~K8Apycc1Xbcsx!l( z!Gacx(W-^udFR3nE3kk1ype;+#l^wF&JJq2nbe*k0XJ>#m|eoLB_$kFVwBqk_6ezN zPby*z*(bFH`IUQOgi_)YQ2pxDDC6q>q#c(EOo>UteuoyZQ6yrXP>vjBZ2eNZ=(sEK$Yio0IpkqAFDTzwD zvy&zIQGG9z!ZMC9LWs}K=h~oE1iSq9qffqBzy6>9^6=QXbJM1vAW$k$RVjUaDF>hs z-mng+H5~F0aLgLDDB6J1sFBFNfS2H%Ctiz0;PXaiqxiiCv$H?=AcFj}@a$QTxfxLf z&z<|yYwx}H9(X~F0NP;i0^~@6$sE~Ao>n`{CE&GC#`?~hhFXcX7LAR*zEI7gz7wY; zdR>%cAv{AQfnK)<`BHi~q-J$)tive*{^T1l-P6X#r%$D-uU9I2d$F06L^-R`gw2Ks z9y%Gy*X;-#wxh;*eS-xNmaDge(wZ2Hl7hCn<6vFV#5B?-wR#;0!)nPribK`FV##hICV zdEGe57o#jU<5<;a4IOk`T-=U~sviHvix(e1e*E-xxL{$ltf7ySHK+U-6BF}eN;4c6+2!Ky0U1fu z+pF)>aWXxOAQ))Bo)hZ&^u1K`9)|f?g!y<9^DzzcK_mnlYqF#)pX6p>R;=?g$=8s% zIT$NIB+AeU@_zPNzbBu3va+(W$99ytvf-GgV-Z{;Bf%x3%-RCpDzbte;RTR`Gg z9^Xvygbv2TH&T2d#eYfhM*sE)Ou!2$zLDbhP`rTlZ)pO)m*U$gzHu17%LKd!#m}So zP>MfF`)@Y^pG5HrivOJAdnumWHExn~hOQtnjqD}M<4GZbmcqg*t==>J8AcZ zTgE=>v;RN956junaqIYvaVh45Ge0o;q3h1CTsTEjx+D1r>GNmN=ZEXq0Obs{Fm&zs z7V2mVo^<{`qj+On)kA~$(Iif6JZM)Mud%)iNq2=KSx`u%T+6BBNx69N0ETViku^M0 zR!Him=%)_UO&P7vs=XxhMba0+fDRoYv|L;=xpf+ zpLSHdJDsh$6dywI$0%Mih#zg_AU4dFC;!Y}@Xu_;&zAhgY>nD;4(-{F_I#Z7Tt|CW z4)#1MKJXyV<7LETNK$L_rFWW{4df>eCirGLxrNe#DBgj`^JfI#!X?^5)2%JsLTNd) z1v@%|<8%ad6t6S^-%IiB6yHek0*db%#E&}n2l=_TA(Z+>E}>Mb4TMtV!2^^U1bHMJ z@(8iFOoTi#3+JN+F3&>*H1pCSQb>F(@m?@WX!6`#+&gj_U=JWV0P^~W#c$`*8AwI& z>)Wsk6)=R!lkX zKO!z4TgVtTW>#!oIz+sPj~OPpAyKGOIf_)F;?!fy6YdOy+>Rn91oP$22=JF>tjbHX ziwX-XvI~j|E3z_3#zkp+C&`4+*}j%_vJ5x8EpKeei_bW@<5%C$5W5kB)R(c5NWjpH zs%J-!Mj96NH}~ULlX|0ZH~br_oo-~l6g_hHgRfOYFh#5*G_pBdYy>-%Nf6GNG-+l8 zlfX`0Yt`DW@9gSo(RX)twe%U77IbTAhJ}-wSsMS7F*G&}{ZC6|Z^VD{CnL$HOgz4a zQhhAOPAeScV~8Kdu&;qQNO%TnPq&b_bn-?es{xuLv64gc0_lzrnv-q<%`tG(${|$e z*g~kTs%3!c?nd8#Mc-?Hy&JQ*9J3fuTAGP)^}Ia9_~+-BIouoxY28A+?^}wvZuRzw0q^r3(nb(u7;DD{T zaPH)h!xt_joH=Dfi0V}{X?}iA_VHu;53fZK41A_lhAD=Jh3X%Fgmdr1_^g6M$}Rt< zj$pPhTZI3(`|h_RkdJOl9NW^n0PFS<*6j=E#ny=*ed>i(uZzQ(^NJOZKK0zo)5B)Z znSU*UxyIy)njTqpcT_m@p~d~|e^W>F z@9S?BV0e*+_xdaQ_r(RYw-cY1UR`GwT+%hVuI{cLt)`>1v$IvNZ|mym?&$5-cI!!c zKe^IM)~C_*AwlKN)I0&wyV|HSRc!_v}kB!y#_;5QyXoptgEw`G4z@ZG!}Yy zRMg#}w6THDYBr(~*{Ma_wsAcXRJ%UFn}<>ve+Cw5X^gBlpU+yxhXVx<>3Y#6R}?#C`oW^0X*i&surC= zrEBfz@54P*-X~^k`I2s-%WsxbO3#Pe&wNg(TV?* z7}O~Sb#kD-I1WRPMo`+=eZLD8YW_yOYwj5W*;nYkH+e#?-T5OE?$ zu`fiN)F3mPHjQKrXGC!VZ*LqAFInvU_2{Y2Ua4f!_HVys4&wKo6zKtSs59~5POglqBlK~+zSPRG6v7nZma0R|N@rsKLOm9z=;RVBA z3=bOaVgBHlKh#9gURzpUi>fz?%;_B`W58lZm|8fkWn^7s>$lLXp8xY>V!`qD~R4Q%Z-#MX-r$Z@i zVT{v(@OGK%mO4SKFDXa>W%N|iLIkrHM2DZw(nCQ&Pr5N@!Fakj&=HJrx{NxH##2^7 zTNvY%9aSFW>mJ%3WFZe)8V@HY{&1=$htt@rXso#?qrFcD|Kg>DHhMZX4xWx9OS5F} zBW(w?MLZS!WdLtHpVJ5NL0ExM?6o=AYeCp+L1eFimyoaF{3IUi=O%t4*|4FbVW{5AV8}uTYnCJG-&ji@4|D!JeC03_qq@*bEudO>73a+LcQD;&4`@!Y|&$YUm1x z@%+SZVmSZh>rxy9(#T^YEvY1FkIKaOG!XGVDjk}0#9Fab}PUA{GcHEqAk(6dan>gcWls8IaFt5^97pX`A zky~0FtOPW?PUGWzt4d?5+xS*Vd_P9|d?V~t{75ZSH|h`E{M4lG@35zC9jJfE*8yTf z(E@7rV0^9kgqrCHJ@3M&ao53E{ji_zDA~Y{63`vVzftP=PkGwtvGAoNMMMO%({PZF zh5ri#UOpDyhKzxo-OQ@~cl0^3&EHsAcP^cj4+bWM=PxmWef-=IK0O1pc_((+qu??# zz-4A&m7I?s51JU@9~c_7w6s*EI(q2H!TmeG{aB?Ui9_TzZuU~vAV$m%Zn}+adIPJ2 zpF^C5lhc!r%nk9Kyl}a{|MBAysME4a^z|=$e}Yl(E#cwAOeV0qo_b_S)GMpuv&zXI z{_vmsPJQ*sT1dZaWo5YGb=lhUWmVZH_cS*zWwtWN;mrz|OT4&!f~mtH4=ZU539>>k zWduBYUS&7_7HxPD`R-s3g+-Lv6@g#=hJEq=Xh`2bl5k=K6gz9R4ebhhxv0Cnqr=+T z)vm9THTMd(w`U|6PghoXuUJUDW;nTGqNlWvf37%ZJG))K{`lRVe{Ejh({uc|liSpY zNfUzt<4I-W82Q5T^5UYLqGA|E<;O*4&fY;j&@LG{(5T!Zisqa#!#v>hx59#~#RmcS z8+ofh%O6r28MOsl+JYZ#p>3cAZ`uN1c6gi?q_l+++Cs}f3-f3T&L)h(_yvqb-x4?rBOr(6l&lD$NATGhncm&!&yQHLECPx3 z4j(?;=s7jQ1p1eJ^)2Eae!a{oQ>ys3N7)auBS#VnnzG;!97(7i-T@EU>eFv)liZR~ zS^Bg;&R*^9?42Owvu$bUN2W%5dwUy8rR7+ANdgyOxOW&hr9qF_CHP&Bytjf59N78w zk3Yk01;J|P&m7z`#$J;eoLx{m%TL8Ay;01-Qp$|E_XsB>wJnF=7<);c5?;YGC6iR( zKyF#%-s_@tpEyYOVzh9p%uz}NjZ9wVSW7-i$T!rmrn@Z)m<6D?he2^9^L{ue&g10C z^C{(8d*ActFU7|nKYsYgvEwIBoIH8t;L#H&_V32Y|1KxCv9U6jpu|-lIH{FTXYT51 zBSD0HPfJ6O&_->UUQ*vwS6*D~?EJjHKk_3WczliJr$6lbBd0v;@;T(6rkc!qhG`E;o_YMa*B9d2`ifxgOjsF5NG|y ztFONP%JcVxnVET;n+FF6J@9Tg?ho(00neB9%tq;|xpU^+{~WUj_*b&CIm@pWE{wQi zN)Vj#{d_%p^z;_`cg*-2%=jmm@imxnc*`+!>gwB&lcBAyx~8@muIlM2*VFUziyIp+ zZQY-vW2@lAnUVsZp8E1?^4}S?y}dPX=WJ=IEoXLejtYgKyS=FfRW>Rs%gd_r(h%yH zmUt3+q4%X*;2LmXM?yE}(BE$mE8t$E(^nx5r4bP-O>MobP(7zfXAZxR#w(f9Ha$Mr z^=2mgabVLw?L;M)E>{*DhVj7K+ELb6em%1b|EqAFHp}bUH(z}5#m{FUIH&{yBu%|? z#wi=%km((4xFf=OdP>EELq|&7ku;0QRxzRAuhjXX`~mcE!p>t z+V1kD>$rX=ojQ8%#9nyjZar3G8MZ0{!B6YOxo7^B?)-2xK7WZ@G+s6*PMrCD+kpuk zr*`h#`OD?LiIe89jwFScm>-4zSjsHsoMkdYXIHykVQ+0_?O^!C zup*_#opX>#nmc4Kyf9zhb0YpqN_tv)ZinO42VYRnG4!_}#j8eQ##*c3*y8VQtJK2p zvQ}f`ZdOq)M~aG|Aip3ti9}?F|MgYsnp)bMFoWITu$+omCc1iw?T#2X2gCr7NT1fCLPK{;Q&UrYhpxL#D^}03wY5U9v6X|9y{$WWt>HCg=fce6RGpo@2CyweC2M=N z-5RY99cqPkL5rtA8{j?FDw%UYl95g%W9ig1_w{ynYjrw?HQzj<1j85si^;{CmKx10&@Yv{N5*DQte9d;C{|3d_QSA*R}Js?I>E+LWIjbID3Uxa z?bQtXhvak~o)<2|04T>es8MUl6AjR`a3vXNgr4J&4Ft{Cgw$ zzlXHD@$@6lc_*Fo96EDlbmpefnTz@#Vw-8dsl!%mHtl!GjebX7vChF2%Nbm;vcVOb zHn?I@x2)LBxs_rBrj2Ff`TMV9XWcvpBlmk3oxeNi{OzafR!Xt$H|B3-tQp1DP;5WN zT2O4?IM{xQZKqf*#Ts{7?>JbNV%;gWf?^$MznwR*15z;2e+;#*7tkG?wd>m{c z#pco{o=UNL+ON$x*cOWQqS#XudxB!M{}n9JOCr_)e}`nr7q*ZGUK=CyBJ+j!Q{<8H zeu6wR-nHbRjT!PsrV-;hJJ69IrXx?JBR3moIQZVM*eq<{D+}( z-*il+=r-E}SF(opE;W-g769 z;p^;0^m)he_?k}_(`jHru{kHV%((cJ$2_ehoeXH zdiq~^Wr_CauX}#qfAGlJ5`)z1-dAAo=NPqU4Pv7{xC?^&w~&Ru!n_zQB+NIGr)FCf zo;~y`DJA`jbahDU*^`iB7w~25K4UgRQSu$@@xTH=p=rVEUkqoq)ZJBHWd6LiZ`o6+|Gjw&>ZkDWOZYD16qG$sv=2 z%-HFKcEdV|ukd?zXu2Oja)qUh zP$HL0#UcZ8*~sNo4jQ;U4zCQiliU`0we@21$%cHJ$I+Y2$UDh&`R~><+_W?873pi+@Q+R3w76WuAGg(l$?oh!mOnDgd{{-CnR1vpKv8B1$goXRLlT+Q?JO&6PC0m@ znpKE8P`tuC<40V*_FpeD5E{@q47sayvsp)SYqeH($jHKZ3IzJL+MKJ&xG(m(-T&sB zZ{8!g5@?->U3%cc-gEUrxUom!@|4rBG0gElio05UZnYU=U4qTpQW3azShOT0V%S~o z`nG+MtPHBm3xel;1!{SDMj9?}scBV$DC9wVz_8Hpr1_SsU9+FW*BzZ#_N|S8Sq4vgpSj@@OAR0tKZw<%m(>W5zp3_7Zj8* zRh%zwcZeJiI{5nf&M-d(#q052>uk|-q(FM*p6O3TI^n`oyah%H!J=ulv2Km|g($RB zTCVquc!9t0nNU&&$ zrcP4o7@4`1-LyMHA*l4pFy{qLZMgaAyBaj;QY3PK!XTGi6%`h`)4!E6J)PS8QzuXj z)=#f*ZIgKVD0TSiYK2o@V~<&TWd}Hd!0mwuM?~VtI?~UDGcl7M^8oEy1qNEd(+D@Z zP7I*Ew6LgR*hO;k6Mw#(a^ZTlUM-i|+v3tV>7jR4hcicArp#ZwFmm2ZM7>PAbHRf7 zbLL0gIdkIVxsj0%zCkpN>n6XxXpWb25Z1Qa%MVvaR~K6+CBXSRW($gC~z!bAt!PnhJ^ z1}#c&OAi^mrdiW7a0#7GDt*lV>l2F?PgL}125(!p?t20D8E%;Kc=L-O7|V^Hs`+}p z0u=@{)_v2QVr3M&n_@jEcG(T=$lQ&uVyPoMt%`i>CEu1Ax!VKaGWUYZJOeIsAGplD z;4*>f8F`?p+|=v;+L@G}oJZr^nkr}mJ zd3gy5oSjCKRUy*rH8tg^Op}&Y*;1ZerB?^G_n09h&8(}rpw-pi-F@RmH#dJ5O=)J8 z82>D+$V~hd+wotQkeHe?Q&=#XE~4ShT)$pz_wvh=h0GUU?5pT^zIXNN)hkr}g};8G zRvo@Tej@l_Rr11{pZ12;`@S8IqPSXw&2mFDWe$u3qn;4GX&@4C~;+TGp5!_Cdc@2;ixPR?*)KoS(G)Y{q_(bAJS zi}rT7GF73RjHp){6r>ilwOPvBIdh>{pznh6wyr_oXgk&k1$l zLiHO;8#F}q8+Pt!Y?yxI)*WZsSVlhK|5sSznKIVg7t{I6rt?=!=WiaJzsUa~Hi!0` zGHl%z(0=c_(eKFXRy??F*@Np=Jh*Q22G=dpgmp7*EF-Vme;pfn^Bj!a?^HT}VRZg} zqwCf{v28czZ)9u-#a2_Sj$)lCRzD85hGN?&wu@qo`%OCzww_|$D7KVhy=lK4H?X5= zIcoVD&cX34v>d5?MFkrNqUCU(Jhqm@IAUkoYZ2|$kM`Pqqt}sV%9V~-NwFyuTS&15 z<6y5)tR=#jto1n9T#8ju>~V^{K(RglL#%O?d}zN1Xus!a zzkcKN+epXUNU?WPtZ^Sz-oV~xlzHA9D)Xdt>hdnZNfIeGlJa!g)a#dF`@EE#`bWam zt5?q_q-LchB_*Y1AZ4dmBr4C+Xw+_bd0ll7e3G&&JK(XaugWfOt%AgpQ(Ru7fy9$m zmb_#0u3aSO>E`Vz)#+D`z@&5JN~6{Z>6ILN8jDUtP&t{8u_BfGq?cb-yRBcJNusp7 zG>nA;63?%N{az12;Wuf!60R6W<^rke@W?Y(9LMNp_C5ywm9tnJO$S%u6A$^GD8^0DDOvMEWkotUHlu znJ~k`LhV*pcOeaOkld1K?Su%VG#7NWLIUcSK^TGvB(1Br@N{rcDxI7>-Fh1(9zn1* z1-SWygpjmep+24-@Br}iLJ|UTd%MC$t5uu(_=vTTgOKf8&O!vTbrjUsLImp43&g^k zd^(2i9p0c^zXe`UG4Oiiw@)u62?vZ9n<*Hb7(F^xf+ki%4vqm$kP6U`Kn@NrC{0c_ zoi5;yAVJbxQ)nZ&BJkU9O{Wj$We$H#A`;=WM1$eKKq=vd)iA8CzHNdDsw!=7H=StU ziy$$WMmWJP%83(ACmu$_Ni-%A5bs_0Cf1)+fZU7qe;w;j@+gPp8pIW;<)o@;X=a63 zn4gn^?S4Hw^Wu5D$N!O8kcazFF1afeD|(pzpuH*15}sDBSi>g)hc!Hx^+Ak>Fz+u zPG;S@-;4V_?*m&{>eyd(c%An|d%nY`pG4(?yC!;rt88SBFsJe0yTDrxf~zbdsgLo~ z9;ly=gMGw^UV~oot#Bqz?ozWoE-r3cmCG_>vIY~v>l4Wm%!YW$lXt=Yfc(QIY@%17 z?A%FSF}P{+L}YHRHZLsf>VaRkz^b>Iu$}G9cG2HFdoqv{^da&R#a`_RL_gaFNnc%! zWwmY5NLutZlKJ8#`1q?Nby$D$;nap=QuVa7CgotF-rxi!rZX$B3BY@xouIoJ%F5PW z{C8Dt4fL8#whjW4IY>#l6UGo+U(a(VnFZ6=eE=JZo zq`Q_DqsNkRi4&L@$+}_NTCTVE^6>ERwDp`6iWQg=;^|EEQ;sA!kQFH>y0`?)4Vi$e z9L^fTy)0W>)t;D3Q%Su>_xtF?3xHa0=Wpoa#gU+dr06)TroRaIcjSvmejdo_`` zh-bG<1^vJ3MnxZ1x`nl4z|Cw4H^cj%2s^{O72#)W=S+C4^VB?=#MZQh*rnWgiR}O{ zv3WTWiOn!`Kw=vWX}^W|bKo`7mLW zF5BAF=0QQy@lMs{phFV<^&Gh3a-()AAUT87zA4B^&MqlRyLjnJN?KZSegQN>1$oJ- z*Djnrd;VHpK_>Gf$9ea)Rp(uY#`xTxjo&0(x|W)ekx|uBU08$YLoG7QPYv;~hzn?9 z?Vtj(W1F;^+(TRZCQS>FH`SbkHt1wclPqBBB)?w{<+k@As>e?zgMV3{o@jAP70aHF zoJ*vmr=~~?eqFzQ{lTjV-yiMuSIN|Nu6-q7!zF#L^IrO3&0l~0{bHq=*JIIOWheO8 zPZp1PDay|8-u~k1HGi4QL~uy{2I+-0#7dg4Z~uJlM@Y!|9x3I5q8GonOPp6m^ER2U zz|%O%d+)vV=V|sLb%3U>4AW3nrzy^EunzLF?X0ZOl#?{yWfhvrYQ3{>pmjrTDdbkp zySGQj5<$tvv-|Mo91<$oQd6(9AcbdowEoX65$09I1=zcxz>Ct^&E8z0bn~{ex3#fx zRG7mn%iP@2*3sS5&EL<})|#2kN%Z=j-hPod)>UF@@8D+D-+}f@D)bI6(0ZB)G&PNl z?cI7KCwagq*Sv%keAp=0Ox%{0wZr69{LrLToMJ^!`K1xm~w)D$%IJA-4|_m4I!X!k(Ye^-p~IHHZjZj znHT>2%(E~2`Ogo(`nPxAd@9I9`dY#o8j7zDx3tkYk7cy ze;5;PcrFGE7eBkw8@(6|Z7gl5ilUV~T2M~0?G)=Y4z`kFD=9XCVuLC6o>8%4?4KAx zE5%Nr*b5ZPYrtbiMUpI0B*2z`-f?;z@bFMz*UGSK;UdJK)+>2jcyf4<=Uq!?i+^PN ziz|6%yolZpzivk`hP*#YNDSz%DXiAF6|WNKLFmab}go&CN|zk$Ht!hjtCO4zWh-&?I(yT6mt;G=QkXG#Dqei7c8^sXoP;LDU z>ySc4Gj`mf8OfxYk#MRR$)%c+XQ^hyZBR4vI?k8ZalU*GO1*J2zLo-mJFa~PfBNNe zVq!wF=@j8kk~OxpuzUmusUKt-g@m1MhRtoumMxpV{Awd=|NLq?#aNLyC@(gh28PQ)Ayk2o#{q!_bpehLTJ2t`BEYjT!~k<43gSdM)5EZnpEL1 zMWvb)H6s)=Z@Q6OK-4FWgX)t(N*m8+PV~K`;_+WWX*cw}7c(;AP3L6rLNoPz3oe>G zd86qZi-j@Zh7-hCJj&O4!Gia1n;k`vo9*o%c)`>B{liRWJg8@5>cJQ6W@hGZI)h;~ z8WRsdstx-ZYyAt>dOz0sYsgw(fve01C$bs0g+(_ngop%Bd0%7cWE@mKc_p=O&;G!1;1TNT7H3#&E+Twqt#S!PfAO zJCi4dPSjuc9&)PaT4tAObre*_1O0pggC+!r!iOw$JW7@g%5R*rffd31k%h+^%jmJJh(&_>7K$hVS>OTJpRo?q z`ED*miUWIdcXNfl%M;E&E)KTV4vtPP9$sEv9^=u!Y*6|edpv_oaE#-Y?hFzwFTu_r z@e4%HEK`vToHN2+Uk=Hku@cQOiyr$ar7ESfdndDq~P4x_dBp%p2wuIMJ z_ubG{58PCT%ETmrHJ&*JH!djGiJKm8rJqU#V&$-B9+`dd& zBon|JXn|O%zQlU{1)S_fv>-llg5%Dei-4u%ZFbu>;*R=4z`hIVXPe z&GPcU|9t^d%9OI7v!5fGQ1NZC81@OwVmcSYJQj%x$A2^YjbqkD;!5)^^KHy+-~70# z_uV&p_R5v^_V?X49v2y4B0d}^fSrl6={JLWpJ^;(x{uHD<@WaX-fM0B$Rp#ihXL+@ zbRRItc+ly0pwsneYyQ#Sj$BO2LW-^Y>nYHj!@(3^ z1<2Qkx2*K^s|g8XAG&5kGz-T$H>EkL&n&mGbhC$VfaoeJs-8dJD-yy+pb*6>k+IM; zVz&Ey?dCs<4Q{xzw_ms<5L70tv=L@bVFr83jVUh?|0b1k~EHbo+?952PE8E$@liHUE% z`OZ6hol_WXzn_3|V^NCs#2+_oxy>^-j1#C9Ah?)Bm46|7@7`dui^zQ78EJTeB)x<9 z7H)V#T3()UsZ^osiIrQrt8V)Y4qtF*=wu(9bRH9eU=?+8f@_D$-QCR{Z$AEr01llz z<_0nwqDE=tI2Ur@b~m?IHjun!-4MW8R-*6gX>ae+$0||y-n4x+f6jur0Zul~P$*3F zwGj%FlDxfLTr4duP>#^S!^_FWJ6JX5b~78Ii*e!|TaZ7Knu^uHCXmkieqeH?1EaQSYM8t~cB-Q9U zEAjuwkGnng*i5me>KIb`e!DGM<9tU%`0*3z%_u?4`N}ON2&@_i#z;@^vb$#>bmIyQ z-AHH@#W^apWl$(qG4^vFeRAc>mAScw@4h?S>>Ry-S!=8iwVP3!&9m-FIk4-om5cqE zP-4pK>yt=DvF?&i#Gb zh8n~#H1{!0*_9GYH^<&;rjiSg$@;tWtRD6|8oQv=3Hmz+;v6(I&S9QCs*MEsOjN;y zXYcAJ(3yk6Crt2lclQbK@g&aOPF@p({k(hwgF^y5$v9Ez8QJ&xW8G1|NKhbB_NB>gIzop!#{XV0D;=5Gtej3>&?z4QI|SNPeAa}MI`k0#qY9(n4?+`M*6 zQW(fYN)SB1zayO4`R@<}u>_h%5msbUj8suS9#yDKCyPn5XP1}X25AuM-+xRSo>Z3g zrL?h!(#ADP8?85JV`MqlgJK;hHlJcUDAsEnY&pf&Q|w8KolLP0jf1tL*ku&^7{#8Z z*v=8Lhy{d14LJqEGcUV4QS5Q@+p!cGG$K-Z1KCWGp%i(V_G&}1@^P?VQ0yZVyM$t= zQLNuM*#BgvW)7RFPiVgdv|q_M{gzPdBs%6}6x%?tD{sLL+*PFfEZ0!1KaV9frzqN) zqAhMgOTlvnXWEvcm(rderPwnR+x1_;j^@2oz~9X(>RH^)4uz7t*^El|GQ&O|I&e1| z4GH(r0EX4ov+!}Z4JDEMmX)OTf+2LE_QEhro@&67S3?LA60$#`NMgx*N z*0~Zwx&81mL=n*!Qv1;rks*cr6f$NzuAPQ7`|BS*cIH{gM3v{F6U zPBdESPw#-m8So~{OHYQ+$@RR-iu}x+!qWW0GE8g$ zR1(SgRU~7SnVWSR+{+5G^C}^Cl~-u85$l|oc<%VgPd6VEZ>iGY}^fja_w*Jo?%b@ z169?2YI~=o|C~4ZJKWD;qSLhUx|_?@V7)*=@-bWhY**H-H2J5aX{9F(L%Qw&mu+cV8^- zXU%MEZ0uy6mTu#3FMm zbD0Ri_++Res53=kL9Ba2c6z0il~^pq|C*ay3##qxNHGX=#O2F{B1)|ju#y2-$!S>0 z0IZ}x!!lCHN>>ga|NcW1+?MY>@bjTRFiE(Y#8}p4#-BWSG9|aPASETGzB|rl-n@C< z`qtF_`!iEh3ku-R!koshd0827hm$MbSpy%wm3PINV+C`x-XW{jy#CVTeaLl%r7+XcrlXU2tV}4VuR|z%`XL%gIUP?*GHpiUNkj0Y z)9|EbJ9g-Fs4j6~-=PiQQV4hIxsLi6AaX+$i(nAeZAh(bp#w_gOl6^>teby5MQZSfN8`pl+ET%U53&i)YN3 zG4D^0zVh1YzoU-fTJhf=oj-p*v^<{y)7Z$}JX!SYnTpv8!EDVn&KA_4jM?tptl$fg`&&uHV1!xBc;Og4OBNYGxl7V@nC=Y(hpFzgZaQVUyGrV0q;ijvqU4@Z7Q8 z+qOlb7$IJ4S64*;=Kjqwq-KMXZl9ixmtW<#53`D6R;gvsq?ND}<~)Un_%D%r^KBSp zzNW@hu7qvO-Rc;dMy5r3}krF z=_XEO&Y@EjF~rU?=o*@_v$_nZ_Xxh@Kmzjbe&mT4|91bQFRel`lDT4WKme%#%u|g* zEJXoqvc;H2j)4k;+)2qSO~))n4V@)QHNSno@zB8oJUhwASi2UvaYvaY8_wTK}~e=Im$_WKIcZ;=Rv&@YY`j35N|2N5vebD291%{OQ3kbI)!2 z*U=N-|FC`6aWI|cW~GwZKI-&P`nhxA($KJlJomA-e&``4V3g?@OR6(4u^g`UGq5-C zmIP|YTMIICl!G4GHzhRUO2P_YkVzPh8xsqaLgdUrfy@^TG8o zP969;-#J?z&(`7MW&>ty>~+9CGVIoXbfBc;4~6wJ(DR4Fo+Nb5Kev#)^`raY&pm*q z)|FnM|Bl~aS$uewAacP!@n#Nwh=2CsnH+6_cuEaec7|EQ*b4x^NbF&c8SchTI?YZa z_OKvE#M{FjM?y~80`Zl48*@*h$Oe3+W@Y84rc|*#eVVL1xJ8ge3h60`h~V=pY`c_m z`7(;lo=dzXxx!D}428mO&hxUCc4!wJY%?;Bo`vqbx~?#tq+6)dq^2e$xLYd}85y|< zKguS#7V2}8k}}k@igebF?#GVjG!zl{xd8X(y!K8x&OJqEb78Bax1-~R4I2(;XO;`` zZ=%Y~%e#nk-DPYW&Rx@@-~Z)@_&G_pbg5+>#4SAvCUrw3Al@sSP;4$^B9OZDee>_r zI_#E%`K)m5NZkyYQj47RSC3zfK<1*417BO{sDZV3^Ty9M9K8rV02SO{ zBS%Zpu#n>%ET^pqo_z0Xuf4X&UZW|ffRNBw+v{Y^+Q2Cm&30zitXb*VmA=jxttj4{ z*X4Ns8ucu3c~O1w#Y@MM#da`F`^|CA|EOU+Q(xcNZw>+NDA zYA61ABF2UD6pNi)Max&tfC!`%Pnz^VB&0}TuS_X}f-~)Mdpl>Vv$7VuyE|BzTNp?> z2fewIr@MMqfSK!5S0`@=+aL!=M@OIO_uf6(gNPd>!NXh^8&_BKG@n`19*aV7oqu30J)l;nJ zIM`N-oldbi6l+Pb2Gg)BD3+yI3B|JGVE>cnRnalK(0)5ZM zr`U!Y*yZ4UcVmr7L=%ZgyBplkKf9>1y1KfexF9P%Ed}9e1%)}ekh=4-a;|4(WtWvD zCnsM|XAZ-f)!)}vmVW8Ng$tK(7$;qW+Tm(?X-hXTy;P7m_p1Lx-G9JEb)|p6__@<( zXhZJ;B8UhUL?u>0Y_Z0)O>+}dcg-f7wq~>0&DtRaK1k9fS&^wA8hPwhN!LY z>=8+23N(xAF&Jr~_a0N96h6t04pQtK#w#D+Ki!UnV2&g zWinzgN_8kl21|pYr=z>8qf}};Dnv0d8uI1sRps?|s0caE-!A}V z)|#SC&jzVRMpx?JKHs9k(cI@~rr>Z!oxpQQcCgRoN#Or-LHt6-XR_Z z?NNJ01ZZy=XfFb^7XjMyV9Cj65>6gJ@;l-_VTH;_%goLqdw7zfQ(U`t9)cBOM0wfe zOz?`VY@G*7KK=zl*6DLeNlB|$uXb-M_;nZ2^JKM7;pa!Y;2P~hc-hRA;0I5x(V^*# zkXU>t2<0;*$nQWR>25f%51_z5E-9Fabkd3#5>NJO$OaxAc0_u8rdFhH6!F}N_ZC3w zpw|X+KqG~KAVCwJ(9*|uLYi71)gYZHuYGCUAc^E za%r80DqskW0xns>TTxMNE+(m>T|taOjtPgl2omK0T&m2tOv}ql(}hGXx&PjKA9!rt z{lFxS1d$c|W@pDBEBGH8OkTMA_D?_i?6aRzc`^Eg48Xg}jQ2@tX_Is|Q@rX^Qwuxf zO~CR(p|Zk3FuoBJgtVyW>8G(!D)a^6UVm-Fh7EsO$BWS?#2S%E4&;s>pQ{UNX{~8$ zYD`MZ1&M$p+N}^yNl8@|5TK&nKt$&5AYya}89+=bGch4?OQH*NQ8b!NE!XQBKuS1J zU|t@Qz~u5r9sy2ONZB@phN0xmtSDZL79j(OPGu%K;m!>E_W=7e=lpeGe+}kz7qA}! zEp9-G4T}BT9AVcU2Q~EX+P%Dbu=bL|s}d51*vHVg2A0sEc>nHOVPBsI9rW+(7IZcl zR5fD&zWevN6r2KeFqREf2csY3+pzs_uz(*KMGRyCuku4E5n?D2)^HBSKv!r$%?pa} zs%n8zpf|OlpHbxT>cL9D09^Moju7av18Q1OJWpVZWE}9`&q%zO46nM8hA{xe1Nq9! zz%I=u`wG~#SV*D<@9mLed%!+-#y%e@_M-;x`jJz^fZgwm-9Iww7{Cc)8Qx{D{Le^c z0E3W%LEKtHWf&5Q8q6|APErHdhcnp6$Ve<|Fe@23$qisDQm_@82{bGT-rz8In>oxq z;4mm`LPD=nD^*q&nx*98g%&Kil$dbh$O)xoEx=>@g1QXupHZae{oJ zAum90Q9HDsc(-4);0}Pd6cjXQCC!bZ_7w|JO@LMR`3V)o!gx_wlz)6^NRS)$y$#fb zMwq>Y;w5^8_c(tT%iR1zLMFmaF=h7b*|VmC?ei**GqtxsTWGB7(Aig2bz)`udb>;# zRN9d6M8u1n92D`P{=VL@CHVOIdLY3S3iP4jspVx=HO+W{ zVx%djrKes=g~Z_2fz-l;egP^Wp>xcmyP7SD zcVUh0!%m>O^eXIxmDma6Yidj;saQo%4THMd(AP)nBUiO_wc`wFu5YNV0rHEAs6!}F zucslPkTY}>6(C&609j*)O#L8UF&C_`4gelmK6c9_n6oC;9H;+--+}6 zz1>hD#nox-7*{1PR?mL)pNrum%_ptq>lFD&@@kBqpI;2=CB2$xat{6u2ZwO21nYkA zorSFAFZ<0Id$m%PyyW0b*x=TLzBS&?uCr6$THk^-?5*rGbaeIf82TI}MvkIErL?sJ z65QQMCnoOTKp1X%2^_G!y->38S;}>{nNg*BYkX6asjWCaqn`d@=+ec3BB|76R>YJb z4`9aL2L3hFzks{Kk=Hn(OBF+0TRkm1-QF&eXl)wHyZp>(RGZ_6o&_GM-(f58_$=_a z9(ar_t3(0sk)X2Z;xE4y78T_emXkAl1kKML30^sK2DeDA)OmfI2SdRqu>1~uDbQTy z@tZbnd}jwr?+4K&?cveRt1M~Ubl7OJ&*^Dw$M1M0z+Cry`vsoBR&q zxXR0DruOLQgI8Jdx_jDNbdk<#RN)y3y|{O!Yrm2j$wM?y*Kp2|DVAd+4y7G_&IER5gT8AWBi-FqBm!LweF!+)u!n= z#0ZvbpfQ4W`5dD3dc7@CdRAwyU?bPFkz3iwU2J5B;YU8ZhvuAl>N$VH<i$ciin`ScDexVV`!KDCA2gFM^dkO>lq#W=Rx5642Y&-kwHU`_tKFNlG zH8I#`_SdrpjH~a5wZZD$nmN)(d)V+~JW@pb>u>`Wp;&kaZX!e|!htP(dkKHQwc=(x zna1|Ig>I-PMG0(B>n}nExb7p|c*4~sgKoGxo&&8zK1}8{WX#sP07?!vbmosGXR8~yF9QIpupxBv(kxy)APB>Pm|zcNGnWx^S( zjQw>lgRNwrJcohRGuS-#*98pLOx>%7femD^a~SNu80-P|sbLt{R0gYHf1Sr*TiB<7 zVPMUBa0UD8RScH)O2*2i^ut+I8-VPFdwtP}fd8G|ihpKSVJtx!7M z@GLUepAK%`cSX1fiwu^?g>MUV8dMF&&HDQo+`RAT8KYzw*_`2sP`OjscSUsHaq?uG zG1z?}*nM$kdEO6PU<^3lc~X=;cFZ~dYEv$um|pKdXt z0UGgTLwPl&8DL!_?mKc=IPo@WB>u@Qh@IL9Ui;ii;nc%-5?C^};X4TzGJ&iypPVkZ zlMC+Ti90#qPWB)(89jsPPy3JU3y|-<2mjwIX}^+irJWafDm@p#FR8=-?@6|xd&(AG zRFGS+06~XG@c;X0_ilO!(W(zYY>-vAR7-j#^%9$QuH6RpN4!)Kfe~|vNsB7b43|T% zAVnqE6h$LmC<^gH(T5j`I_7n7@F!#)=Kd(=?&2>Z-axBFOnhNiDJ{-At$_>?gW`m5 zNOtVpxpT`h=$LZ6ZAH z)KQU_co-#^$Bqp&6rO}}W>-oK8bi$!A3K|`-xNcN$@kKiA78ibu{Z7~2e^8!o)nNq z;c6f?cz+E>7+l;I;o8dmoeLK}Z{jv-qPcy%cXf3g)I1vEjYxTYml66+Cmgq{qrH2p zH!9?u6tTR(hP(*P?F)-*Pj6HyrNEIyEJZyEB~(##^0HNX`16Fji6`F@5h&^t;_|rd zAQEDU2Rw}lP*2WjwzU4d*&{PFUd)X(OBr6wa@`n)msS0E`QO8ij2?y_cVw_C?1P(g z;lXYZCt}`EIQfMAgiLtzcB0?V-Goh-j)I2oe^8dAV0!;kl%wc<$pF@#@-If1r{u4 zg+(vZf{?%t^FSwfi3IwQ!*z=O3ODmd70A&7Wr=MPMEaH=xOt%N0tQ-0L20>80Mqa$ zfHU4~6@C(fS1@=ZgD+?B?l<7Ao$x(BP|3zY%i(O#{2P^Qq^GF%EDpD5&(-(qMXJ?URhE?Ex-vy z)d@OLNl7z8T1B2}QH<$9(}P@bF^aKDPI!glAw^f$t_9iF#B;y<#*3Poa-zw%qOvS~;I3YcCZ{CM?-tM@gy2&|)x;Aq+kt_2q7m>E<)9t*|5~=fXv-3_ZtH(T zf{{`$3{L)yQtCRvsj^Wp2OtfcyZD?|N*xgNODXjlKnUJ=I|-tYNe~m+*<{wgOW7x< zep!C#6N4(P*a`ORuZ>LlaATjw3V>2jHbh%{Q-xn3`vKC1*CiN|YO2Pd0;n!oRfR{r5lp{PP_@n3oHGS&_;s zE%s9UrDVAro`~gwK))CL8(u(pq#c4^=p9H-@Fa;ZUc?l|)CS5cEmqC$mW{5JWta@i z{=~%zHhg`BdGW>6YRM}tHp$-3jjj?7{0)MA*yMTRy}iTmR=|#!)`aAhH)=vMOHB}- ze6_!X+y}e|QrC%>DDZZYaf!vwv`T2SI>DRBo^@DaA zyj&zvD8*6R|BIhV%y3B6;vxI(wzgx`P!@a+J z&IDg2{0tKH*zg!~mYlV5pZ}M?{N=$pA#w!TZ^Q}Lqj0UsC@3hX_gk|NA!~W04Yp!j zHV6)!dDne^eDbL$m&}hPJ@h+~w>O30<*0IS{M^~A^;$0P%NU513somhe7P5XzFjA; zRJ`rfR8d+|S|Oy+R8&<}S2b1CI)Lq*J)4+#F5%>fvu6^{Cu)f*z&$7cFg49yezD=;0FZ- z->$yhW~WBCFh+kD8=VolXv#kQQ4C7R%uw6hy)QkrkWT8VnIuP3m2RW-nHh~B*9B&B zjaR?C4mexAV$F&%l_xefc6h`tLTw>r?cmyXgL-+N+t!iW6jWVV%80q9x(YF)2%f5~ zEG@cTT2@|Ji{MGYvxyygyBceogWOyk8J)YkxdKa`2!e4@+u5icom@OX8-gnn`}?*x z5wur&woysbVxN_p^?m!(oIMgP)|uvUD`BDih`WcoKsdu`t__HT7)h#!JC%mhSxM4c3xQovKllu1X? zEyxy13XWHKKfJXwy+^Qf1PFGH>w=v_O6?q+D8wQS&^lGr(rFJ$wgDyEV8$A8&o~UL z;oRoIb{H@Z!wFhGxgJe48a#l8D4^&|_X+<{fVZMLOa+D$X1BIBo4_Mf!k912<_%#s z`;)1JQ6FNFFswn=9if)_>|`zgzn`NO2ZE9*2JZ>6R2&G#b0C9A;QEyJ1>-p!TZ`C+ zEU378DRI{U+I3E>P&9V%%KQrS%xKql8sYeMn80>iacA+#U%&jhne67pvgYHz^5ks< zxXbx%AARs20}9ee{%C_8wA8$VzJYu2ZP@z%iPx>7*U203mW#Yr^4#JV<5&?W6hzxB za zz};TJ+}_09?#JC|-g_8a==ibmK_%q{3l^|X!tdN%djsAe{<@RYRhFDc>L7`T&QK}4 z+PbuyvF|^(qF?pVv2u9OJKLHno3&EByL<$_YlRA7+{ei=$mx7Za=jDZn-fPR2hJaO z2YK3m5%DLn%Fps+mM>pAV~mqfKFxbvON-OoXP$XxNw5j6nU)VlMk2MRS}XC2nt$Iv zVv$dZOA1E6`iYll1wCBcE6*(8CWEd^$h#u^%bi?^a82M`Ia`pv39qii2=j(5Z*%%| zdSjPrVsv!0^!r%EE+ewlAukJ|G!4D7@JaJdTO-M^qUK&KR8w(Y#<^dA{S|LJOb7$% zF%*f#N3!q+@z6Pmc@27H?-kO4CG0lH6;iEChI;J&?n|(r>vkXpmY(O(#A6k|*cc5+q!vIty z12!^XBLx;_Zk@Mx(_~Z1+oJ{EUPgJloMvqZubF0eYdo<}=odO+H%B$SH>jp3Zlt`O z3%WgT?;8g;Xy)w`=3(xPw`WFE-u?zYQQj`zhsV$m%G04YLhbBBqvCadB4O_gsR37l-W1YSVz3k&9FZigwc=QJ)tV*H)%!hz!9m9&R%< zNS0wd?OuNZ?jwPX2$4PhBE08_w-*tlYR>W_6>(w-{R%?1S8;Y$YBpluavB>-E3Ri| z6e5|Xs0fO3)?tljLV0=CMM$ZaDl5~^B_xmw$%*7bQgYHcf<9BFw<(FK_I^YpcGOmvf;g zys_>M7hc{JiFbd9niLO%*4?N9@fFUhj}g+gi+n?Nqo4P$9#VZtNlAHi zZ3-_2f9}zVksl-Cl|5a(q^G;9+ejv=)uE0--tL|ruHzJeF7|4AfU4DKHm9<&NBEO7 zxQ(NeE3Mc;{IOWJwn_y;?wmtIz3uR7YiG~PQ9o3nP^xS#?`-TD1^pc~_bM=c02u!( z&Yf3*@!19W6-7nGQbjv##i+`MR)qtxoSBJlf$GA7{M;<)nH1sqc{z~$5r;J(xYixP zwXoL>qyuU%;pHHNt@b9W+b9Rcb#-<3V0*E5y~g<2UiNJ`>&o=4 zAR*A5@2JReK*+5KA-AKW&-=|B=_Sx7twc#Vl8-mrUN_hhB0dD3B*6jeopHUe;(C5z z{`m_uW1fk|xiFsP6%-zpCXm9ydIK-%C@5$|u&jwBV4vZCy3Nj=TexV+;`x!&rg(Td zySRJ%xcV@O`M1h;A21}arSvYBDA1Eo5?A)j@=yb|P%Ps^^1uHfS_L|U z2uk@+WTcN0A%8Y&)&$A%s&>$l$KnvOD}a)BxhGcgqWjRO$6rmNX;0)4AI9po@1aD3&m&$=@xfIWX zgh0HLh~mM6IvjeWN~OZ4&!f-Q+1|mP@*uDrH&;jSADV^dU=PNFI>nR+wY7D2L6~gJ z%BpI`Yg=mzFQ*+dyE@z3@UD}-YinUV=waaaA@CrYxkF{&hk)ZTzCfvf;GwbXTU~rR zJoC3h^yJm6ui~^B1+w3A93is#1>_2@{{rK0{hu&Cnf9O_71rMx3Dzg0#VOqJqrm)I zqr$vJ-W(8fC7O&=1cD}2rQXzVcEU!$=Q7YLUK~MROyV3G+QgtE8FZ5cnq$!2{m>S9 z(_%xhNZLJzsb^toJqI^HJ+;MC-$DwNIQ#r95~n3f{Fa=+p|gu9+Z{5SNXQBtI(ul? ztiTba1Pz-NICQel?Xm)gPUabDRv`5}QEL&co4=Q$Q^6|HM3Yyky(o`xt_|P}G^=Y3AVZQ;Q1u8&mj`xy4jJL3&?t83b!TR871wX}wuXn03inksU_UA0 z*I&<^JC~3~O^-w4g&D~E1BxXBv-tY6x+M3iQCej=%hiN9`WlQVyM_3Rmh$|5%qAwDuWK$q-U)4`l9HTaJzq~F>l2VF zG*5@(EwPH(d+xdK-ajmd2^iSrA{XCh9kUwQds|S-{JRUh48kMsxq8UFTt!MsZgo#7 z5;J2NcE!(yI@jFD)*TT}2{_4SkOXxvZ{+^|A}@nDh-A&%J(*X_5q|~rcC~f1 zSx2o#CN?K!${^rD;Iope7a2Ldv(o@|5$N^tunV+~TDb`Vk-Pg?e_jR=4#}BAqn2rr zw6~}RyY?gC_Id2u=do*}E+k!M+cxpSMWEtb;`#GQm#?N>PPvqH9{Rv}>$tw1BSB}C zwK}`by_@anCf8Z03lwauI`LMt>5;&Q*U}9g(l!|JJFU~gNPG@*b0?gk$wd0X{7!Iy z1!fNLuKJ(rAHdIj58XGuDe?VIQ^2xCWGC_o==IC!7y>`$w$6&%@%*Un&Q``CkbXlc z17^g|*5=06w)Re%kY)2)`V(dQHd zGnr!ylR04RpyEb7lRDJ>QinyZl!JmSF)#h{it#3Sg-S1i#L_hkY%LqNg~67xaf2D` zqG4diGuSW&o6ca*G1#JEU^^IW6oV~iu&Tjxwn)O3`bQQ?xOK0Pq0$0Lm@9de%E5Md zR1WU(vdF;}$iXtS_g*1SghJ9h5drp>JZdMc@EXoeTDF(kNo!g-*h%wWrFPQrJPvl! z6G7a_?@qDITWLbS+*a0k5K0{{Z2?l75p{Kz7qtFK-gZo8#T%8@Dk-NF|l{89ROR=qM z;oHzEnuvdRc%Y=ANhf#YDzmaGogCx6)ar>7y_dW05+DOT4|v=l(dz-MhRLHfiax@cZ?{#o+wd<{|M7TjkU9Y_>a$l@hA2YCWpKVwzu(%?j z4wguUso|3g?mbY09?0oh7_5Q8`VWBZ9RN$csT7v_W|>xJVz5nsg&#skeND8~naBx4 zqeK+G;QH_I3~1;n+~Hri1MN%pG-xOyH8tblrj0-0=ezH|+xOl6lwpbL3tpX^l5*)1 z%*lzzjwPI>x%b18Swv%FeM4ho0}NP5t3P!4TJ5lOSHO$ch~U~)Ow7sg4xcr9?($*j zQ0ePyqjHcpL)3|-UBk#0w>Y)C`>3dM7Eg}8g@)X=!3J!qqobhI2)(}1Wo|U&c6a15 z`}lcw3`@GD{FJs1$b~S^!T3&0DB;yHEG??z`8NmMGW={278Rbb`1lYz+jyi>}dvTWUyhwz%F61a~W(pgY99kj>Eu? z?0KnFpzud@lD`c~ z#}0f!BYul67|YFBOBW5tc??}Oyo%_u30XPODDyxUPxxS+J4Ovy`w!R}1zAtn6? zDd|&i!|mXP^G7WuUA~ffcn2#`OG|5O zXJac;*y`qwTGR^<4IVeve~h=c+R@8zY_KXM zbYl3F$v_ea9J|zt3>ZGN#)Bj(+IXe9w3Jr2H}yh%>_f6k@vYSdsN7+(V>Emr@Z&)Z)7~(d}0rG>Q*pV1%su1 zYrx1dF)Ch$JN?cN1#8D(J%>33pw!e$mxfh}xAWrS($eB#_23|H?^CCSReiH~@#SgLE)T2ky4Ba;dFQCK z+en^Pq=oTi#P*jUy}rGDSVgv)4@56wZdlzl$j>h@a9DLU4u$D(w-ITm|0@nKG7Aok z03Dp4iKf3Wh5n6hcW+c~!pm-wL0>`v=``B&YWOG?tvTP>@sq`2N- zAO-}$locjiBoq961Y4qqS3qUu@2{{vaWZn zog0U!P9bBSyeEBh?%K$iCDG&)Nn}LiOlOUqNvtqRBt)m%TWD0Qje)OHqlqROtz>E< zjMX9fTAJ!pT+!5;mDSK#2kU8VO#x8F*sQxj19_=!zl;RG6x;oX)WSxgl!P%gxoz#nsUh89}ao z8Wf2{b-W(b^dd4X8h>LknGeK*=>f4aLL&ZDiqtIf6JEGkFnM32pQ~9ps5r91jBDx_ zqqu$ryo7q(SA&xQC7nWAF_Uhmb`}W&Ucc)gSCH;bC zF25vQE4jSq(_OpjyFF0O*S)L$`tjYncOT0`xd5%rhaYCp73pXtb}Fq47ruTyjJe+x z7z6HyG50P{W7&`I>JTZhNT@6M1^J5nghvNzo2-Qy9sI z*<|8N(d2VoP;qf}b^G;`X)=X>R8&-eN}hc@OJ7?na|sR)4rRA?gv?>QEWi%`MQ^P%j%Z0c9%3`^anR)nh$8 z0w+%n3JRJsCD3n-KT1~ypb@v$X~G0~H||tlL6jUsIhDO$ueEh{7u8lnX6)z{Ns-$t z?BIui)dzu9EwK6!u=)V78ipF)Z6n3(X+9L|p+b=Eh_D;hcGPx>zdveWe0HHQ`OJP8 zPxqfm%02)2mQOz|*Q=2^>CjxBc^E~m52Yb-Myx5Hlrl0jOA|w*u*Vh)oyWc>`%!fC z7}-g5l2_0T@wLSWl-Pkl?vud(_cV@1IadD@vLg>Rk6jaowq;c(tw&A;FM|qbedKRJ1uuaL{t7CH0uLKd zVPT{cv6G)MxbnhCsUr%TzxL>!kG5~$w)yRk;Q1OkFSw;q&j_go{0P;vmkw3D;eWw6 ztdw^cHEm6&CrWiyOMQKVvzrJ<%1G(VgLfEQ7-EDp7lrd>K+TAe(%+0*+UtynA>GDG z5`zN-XOfAPh>;8Ot*8dP6JH(&!@He@#gV~!GT0ghD`&8~`(cNcx^_}U>$F+hqP!X( zXPEV_yRm0zY~kbBGgS5w&coBm$yo_s|8Olm5BSPWyZY@n$$1wqp1ztwySXB6n-*cn zEWC~>@#9)sGfTmvxwi5wp|x5^Zf-&Xa#N~G3*i^OR?%FVRe|Gh`=W1$r zw1vf?r!eC9Ywi%?DAa`nj~U}K{?6r-y&OQ7b{2?;q^!?v6sx9v;q88Eg%5 zIq<_f>Gk&1guy9EMLllaEuSz!XDdeM8S0y=t`)gEGu@f;J=zzBR}NXaS+#3NU^ zP4)K=Uod^j80@tXli4jj#707POzN_nw;Mrs{rw0MlOR4I-eKSN9cK{ra3L)niBm$q z#=XgTmoFzIq@>U`j<>XI+{OFR@*GV~Tz{PM4X#oxa24JK%%%+OE-cKYyh&g#QHLwR zM7D3=zU8fr?{C|-<*Sx1fy;C><~Mr>`}%I*?&A~e)mofUAqDdhmt|a}%;p=)Y#eBz zkH;RnQRsv3=R34#qt0dh`blC4;}+&~pTEF-Y{p&(dB6Vp>yIt|!wZ7yH1_eRBa9QJ z9Q=6eZZvCr{q>zYzx{gar`Gy4#_?{nScsqBjbb7FtjQj%Nhblj>QD{Apgt`$GpMMD z$zfq(GiJptqBlGOSi9 z{OG=jdNesW+$cYCgLS2Wb?IE}>>@)XH~1IYSW^BKrILfxKwwj+F9=6Mw%a7e$k4NK zj)$YK@0c;j0}5KY?3T8TlXzcR#ze2b5f^=fvl%F7lYreRTljtkS5rqB9HX>wa5&m6 zc@ZdWA~@VcNOrEp#TgkX$=`kZ!;TL>-1_M!KSE>6A|LP?#{?+hG@ADGspLx_#@vD{ z@vc+^+x5e#D`&TC-u$0)MVAlJfTWLkt=wSfh5eFnG02#FONvH@e_j$nGboqh^q|+5 zNJf7R%JDz(`lk5#KRoi{0x(+WS!6Hm2&_f3=`P%$OTP%U@E$PdKR#pn7kN7#Gpw&~ zYIgT<4VpMHEO@*l;?Qf6im!F3s?x*cZRlxkLL;VWe)IlFy2y<^ z5djLAdGnt5>8BGX&R?jmsX)sJQ)gG{1qATnj)(*lp^uzaB^J|sw2qp}wgg=a@^_zi zZfa^rKY?_qt*tWsXmSPWZa( z(*eO_QmGmh8=d^dy2QKAo;`Kyv}rzaC7R{cwstv&qC>W@51O01;foRVG*iox+gLC2 zK3Ii)Kr2^1ja8tw;g#42!6(na6L;*`vEA=)`|a4VBfp)pvMg*P*jP3K%Hj{TPLgej z*sLqC5cGNx{pg=TPw|&=y@byjt*_#sBpKXZi{!;2)>ZPbJJN_(pzJ5Tc3{!A!lH8w z*Edl3*IQrpK}j(vqOX-($3w8l1RjFr^O+KciJHf?RFu@kmfF*bn62E4Wp z%}{#KbuvSvO1gxQH{?H^Pb4mfV4)gQbvzfOY@@%h|LmDVhY;h-i+rc8i=);}Tv`&Z zP%5PGn)OP(P?Kc0JqRVg!zk-(23;vt_r#J9k)X6&gP!m_*}TZK9`zcY5w539>lA3A z0?>xM0-c>LHX}8@3g%Z4Vq(vpJAEzz*&Hb;`!qI9X!1_?Sx0*tafyR7cIp@>RYONd ztF5nk`jWUfau3ftT+Tz6v8LAUzV0@pvm|e$eV_&Hajbb2W?>bA%&VY6VOoqP$9Q5T zxLQF@4k}J%XcUR(&z-{}BoP;2!_z(7xdx8yOm0leaeBh#@fwAfv=_6MDwJ^~9xEWM zglOF~-*|hd1=;TF);;yY>#slm)N_AZU`eQ@kFQY_0xB2 zM-M$cG{`4ih8G&cA;l_Nyd{=QL@k4?XOPhpQp@0a`{9NjGoL|rF~~wT=5z-8Kka@BaQv@{-jtL)p)KmYvOvb?;; zM%ucx`RlJ^=6?GPfOPgQeqksOfXqdcU-a|OKhJ|-$2(rUxNP~~|Ni&5IrHYZrlSE= zwr>o~l$)g=Z~e>5aKY(pnljF!C=n_Tc3#;VYkGlRJZEDQj|m>jL}|U;GnJKRQ_zL@ z!m*l~luPJQnSil6dZ_OKje>;m*=`zE9gm%4aD|HJ$)N>3qBrd122W3WJ5Ns%1c=7g zcEs0$-*mL<5i-GZ#l_5iG6SmTC<(XG%Zp=5|VtKUo@4ffl=@M@0 z)TuahGE3@tV#I&cu_G*qt8X?|C*5`4yg6}LujR|Oe*E%Zut3^a8ir5avw0{&iCHXN zx^!NcBagl}q+N$4s;N140c(?TwzBd}4lP7@>{5!bL|S{H6cc1Jn%F3oDtRKpe>yCZ zr>8-V)v*=3xp5v?FIR(|y|74Hd!(m1*rC$ABbc>~9mes;e>z5i)XVZP)`{vIYq3si zu}?7 zl(Am4XRCvU^VD@HP8s8FyqcVNDWj;eJzg^3tNa2M{sZ!^dfnZgpxLsv>MVHwuJr{k zrZ%L#g)kv4}Ur#jRhm5Zmh`DvaTJSM)IX2k#=0xJN{As18gKZO;|%&MRi?Or-&AeaF;cdpv!ixNi}Zb zxZnvPVKxNOS{)AVzBtE&-Qc61s8YFjIE0U*3b(I!caPCjTvnr}D_7l+nuP$M=Ei!3 zR3=BOArF-<+}p+3RXt&1kQDv2+{gOZI$;pspb4|4g-n^?H#1@aES6yrlPAK<7CwPY z|(h0?Ge$u>On zKcTZ=y*}u%==&f0GYD$F<9sS#A2~FK*2O8 z`%6&p22k*8l!D945wOw!gOX3>qoiO+$s+C@A|4gl=9iV^q~~2ZleYK6GXed6!aS|G{P6ay(eOXnA6L$wyKp&rC%#X1k`!{l=G8g&xo6Mi{7>jr zJ|SZ1qksMDU;h?OG+bn$)+2Py%CV!OSRA|+1AfX!NwH$NTq=4eOgTC-_4W0nYRiqE zuu+mJI)aNu|3+h)uuG#NPj^lrrF2F{UH#Pmx7L9_|fW1+;QF zg+*nRO$`kYK8P&LLoSg>+R7`63-a@`GSja?*+~3R7v|2r?afR3VJGJ7?&?h?U9YiIrr&YLR7KqhxOk3aHh1)C z9oj33vQiS0uNvKBAO$`)RVT^$&GEi}-HC9S--T-r>}?y7Ua*f|l0SvXi%`|%XZ+J{ z^%Ijl>X0N*-8D^JQc^Nz<-cfd@@@^*hx;&98)R(COF4P=Qd+n3Ot_}+jrhaDF?bdm z|CzD#X3m^wTXhaX;Lj&?VKw!|GUxHIWlj!Mbwh74+4)QeMAzRSbx%!YWo1)0=Lw}j zEvu}qB5FRgt{fJ})|#?nv^DPYM*5_UP4GmumkTw01_w=)%Vo;?#%6edvr~0pit$p^ zSViURfbs6=7Uks^8sI)Q(8Jj$$WeiYHc|;EgRrj>8C{Ux&4(`WfjQ04ikw>b)_Wy# z%%-#Md_gnh0=ctFC6$OIy+#9`LSm8oBZ2uR{3YZc{{8xqrw0*nQrw8zzo=afmLQmd zP;yi3QCwU^1;J~na6zYK=H%wXR(1V)Zq@-*{N-IZBaqIaa4C|WyE$>6NFtXYy#?97 zzTMTihd=)K?Gxv9>lOsT#u&0}-MVF=JvfG(@3=P>z9C!)kNgZHHnjC>ZN!}=En-JI z7YV6r(#C>*aM|8JMFJ%VDzMA-vtPuT9-|lc_U?ErNeSe2eUl#Up2j+qpDVAfE-S05 z%|Vf5-o->0(L}HppnaM8#Kzw4UV_^H@Td6(_yxe*Hpbh--PHx*0nYAT)2+h@)}1(D z>uS{c1zC7$;`~ShT`=(`PsR7xw-lI zg+@uY$08rtCg zNy}*@*a%)zX3t-^d|8lIT$=OnpJS=rf^4>XeD(6EG18W%%7*s3v@NmdFMx|2)&(G( zskpKlg(sU(3ctDn#RaRXDo|>$tPC<{X+>pC27-S~C{1nTX(?2#W1kTvVC1MQNRe;E zVv+!qB2c@b4!)0{FD!ztp6+h$?yjy*j?T_5F3wJ_Zk|(zy$6#efjZSmVHS{gueaFcZYsIS6oc?^WvPG!g{TutIZ&RgQY%C=Qs{hWnyN^cRToP7fZ$C z#*s@rZ};=3nvb@BxYa1LQ8s3s(UC+;*X_-=&;I3~FaGnM0Q<7UM6wvMzzd-Q2__3# z?pmg#-JI2vbdJ;$mzP`AX7aRaE2ZK}sRyq;Sve)O ztxy|^czHr{a>C%}j4?fDdQQ~Q(GqKVmR_Q5Uw^v5v;oX#m1&*yql4F4S`gg4lGkw0 zKC2!4{4q4FmM4`gaSbJjz!0=lxLmZdGLCEm6a89=LBCz{$VGlpD%*_ zN{3oOMP6QEWz~sqkEbobn%!aYG_4gKyIe5Rg>{mD`PJ87f4yas(YWzPki88VVxbks zk6$WlJGb|zatDq#-@Hq+ioCkc;D2S=R=M+lu*I6naYpXfXO|<#LcNU;z^Lo==rYA(7bacc}J_h&9 zY81q7&$`H~c5T&tvg5N)CALn_^5j{aWO8p+Rn-D=UbuE~|9XRRL=E|ndo~V%bRUzC zaYDWW24Fjt=cYbxVFI*cg1r=5NocF4vyF@d*9FU;6}{xHhoecn{FUWBJ)xoG1zudq z1x4|yxET)Jov=!^mLsyfOJ{fAEa?1`CQVxUNjeCyvw;#${>!oDKdG@}j>O zo_G<7A{@)kVD7OSXD!4fI=WYNJw;a)&i{(?^6L5~eSKYRc?q3Ukt2>{qeNs9p%4oS zhRG!+lwuLFiAsMf>AZnD#n#^nVN6NhwV6s8p(c*Y8WU~Wp#0*~@3z1F_S-vJTVrS_ zBWJ-W*~R0Q89*=C&Fv17-HLfOP|V_M;MT);Jd5My#My(~v%jg-PhNiT9a z1dM}+ZIs+=2dpnNWBqFgI=nrM`_Di7!n1Rw1$*sWXFe8(GnRFRzr3Yy#Mm$K3_C07^Cbh&Cg6dvi(|AWFW|1+1X@52TPNMI=t*vlsLfcogJN=>{W89 z%Es2omEu6`XzVkIdX1dXfTE6lecinrrdOAw1ZBSfkFaHuyae++1H?)FYw$@I224cGXv2 zPs53O{?zHymmnCYXB8Azw}O52aB`#*i4YlK(mF#Z&dD*-E)Bh=9s`}Il;g$1rgbi; zX@%9drR?g7y*s`L8Yj+6Ir8sVY+GD5Zy(QX_eGFl&JXWvV@z+Do|E%GrDRs!AQkoY z3)jw7WdFWr*Y;n2`Q`Mrev&xM^AjL{^g#^}iIr;gv`4`yS9uys_Csg+?8t=<#Fe=y z8tPyP?kqaRJD&U<;@7)pPoF`q^Q8;vjqnkJCoCsvdQ z`}9&|Hl&hn(#yGX3xq3*lUoRk6VbXw#3Yf5wbGLBR{zBw{@|{1KYTH@@oJ4@WiW^-hbb+Wy{tqhzXQfh;Y7>3~W=VSmf>(=;`F(V2jdy zN~KEe>PDjx&@dF4KPo$wg4R9)D+B*A8AWo`h{jRrgpo+w`{l* zldfDmdHC?*tZE$`Z?UF7;Ck2w`5?!BIGj~ss{a)V(?4lP6C6NP=GwMR%dL1Sn((SW zKlA6m2=Za*d>uGitWXl{fDMpYqmXxqI{nu?6$?KO8VKU}p&}QR^|jpZTjQ8B5dk`X zl?k$AbDGZ8*4D`>7#@SL@bS*_27^avP;jsyaw!yAySlP6L$9Gz5OS-kbi8wHYy=#W zekz%v&6HhXaF2)(BrX>ht=-uI2yU${f_U3p%uXHZXI+Z5S&g-sjD1CQLlLLm?6}S7|634QCwVq@4Rp(| z=u0|`MrSZ&XWyde$}rXt8iH$VEb=1rm1^*8X)V>VcyKE2^4}<*3YSqn zb#Ls=d}_$2Rj{Y6XHVP1p0h7+Dg!Tp+%~98ZGOA*|9x3u3(;~tV{sCT<*XKH0DA&Gp>D(1jM9)i% zMp0`HFHbr|6}fl|)LoILU!gUKVp+ho?8T)M+hER&q4l<91!Xs7~bI5FYQ5eU;jMVVP@0$sD1odTVQbekRi9C2%Ih@Y3@3t% zt4ar{mB@4+6-6DeQh;l@+S}8JZ)B--D~+|pn@46&drKEuQqi(G2u9I~U0e*kOfsT$ zXz^@HK@XIkv{(v|oSu>nhk4?CJ|iPO7Bvw`{fuuQQZ zYe+8Q-?oC~!0PG=mLF>f4?&r+Hx>0=3dL>k9tJ!t_lMWeUkptFc<|@kTm_E{UFyn;$ysR#zkI<87E8lWwA8xwlEQJI#i@O?Z95_ZWi z`Rxu-tiFAIRcUFXFEqJ@3-1^w@2I$VXz$)#yL4K`(bGS`8S(w;qr4>P0CMHEvf@h! zMR8PUB??F6-NX|_;8}J?yzK5M-zF&Oi|7u*h17eB8@sN0xPtV0MM&JzWskqHK+r*G zpm;Rd$!j@(WZ!Q>;6A-Ja%=o~^2!33g9Xh~Yd6;qrY6~<3B*b+EIQAy87O!a|B< z_V#XGsv+;ePJ%hWL=1Ms3hankz{D)<2p`xRdTgC(;VrCidq*qEWwlmUH#aL3_9&L@ z(pXv9Xl)NDgRm^ZE5xsr2_>Qs9QgxOL^_G^DenSq3SBi zVPID>*rg1%ox!#<*xq4a>ly6J4E7HU_8NmdH4N6@Z7;G7XUD6M0P2QC6q4K6l&y7R% z(G65T?#`$3Cg**#ys5+qH8>imdk;6BKt|E?h^oq zFiEA{%}4laP6A&4G2?dBz$0?#?|D8n^mqmx%AhkX(B}m6jHqxJ=u8G3!l3CnBaB_e zpiviUh?(Ckg$grgxZz?Mw3fkE^}|}$da&)rkV@$?A|G@~EjJ%i%9IsR%Ixj$)=n+0 zRI8vK?nyYYY1W2&`gK~D3tB~PPI~HPL9IA{HVGKes=FH#5*pfs)|G7y2?>qeZ@PE8 zn%kY9ZRnPv;~(w+bg=-4f;-4meT7$?KlwH4i+z3aJOs*WlSC(-F{cX{`$W?*V=n@plb90}hEgHTRu5H|tb9r%Skc&*`-cVl1w2G?g27Rlb zR`m3BVx6^|+=*8>+fuu^+}0WXJNcXL4pJG@DC`yX&i0rY!j;@Ti7)z7cC;HLyh7a9 z1u3MfPt1!YC@;n|3JXOHq{V?}r-)_tn9oi}+>&Vr)N4m&p*2`p>b0Y?kT;cuItWb; zge=tC)6vo_2t(DC^$;M$R1_L&i3Ks}XlC^dG00B4@96nPK@QrqamNiQ$jXYF#i09U z%OM8E(WZ&G4ic^O{%|8)Nef7laP8$@0Er#r6;u$q{qhS>LOS$J7D5#@33VwzCsV8F zlTcA;C_H#mL8v0XfC)n7h2=Hnu;@eVZD_K{L8HgVK&il-X=RoQD2f?AhQ{T<6SkD$ z(k#m?{-3~FcIRN;UnX$x8i9M~zam%+829$QJ@+1X&i%w@Y(D2Y`e!T!_1#VvHc>dU zF3j~q4ZTJKr2zvQ(JV-!{a{HYk%}ZfBn>Z|NO&qiFB2*XJN8FvTWD_ZiQk$?PyA_V98P@XeEx@? z_;x~7{N7vQ%Lk4;o(Lm{vXRp*BcEe+a*O*%w(OLF(3uQ6ghAU{psN@(B8dj^UeP53yQ zxmgqL8xra__xLmZkR{tqdlYko&*)KW(Cwh34$;IdkUB znfYcWmeSf+k=pxG-nBT~yXzRwUDdwpXud<+&)I)oE!*f$iD7Kgbi6J882bjgP4dMy z$tF$VA|$?$YD2(VzL09ZkZP*iH3b=0QZ5$e<)oh9pG-EfS~=osU1vP?)C^bSg$s$Q z$jD9ED2^$SDTML6cT??Ayl!hweSOZ>b!(a9*|FrPWbxy3XFoD?N=$JafF!rHs7%G` z;(!AFod3p@DR0C9II?N2LwinTqtVK*YwRR)AC|PE9dX0aUw=K?AYQ8yOH@)PuYj7S zZfl>^fjH5BY|SVr$Y>L!GE~`~vxR9UK5){;(myb``?Lk4Ex%k9Nt4eQQIC${< z?eeO`V3CWyvGU5Pj4KCsjXE%CY>@Y`AgT4`ysAZMxwS^7zMb#tyPnpnkGK3`xn`Zl z{Q7IW^MaB0VPj7 zGI`AKAwvc?#FCFCv!>06j~N!|ofSvUk(;s#xwCI*VQ-fu6*&; zS6}}SpIG|W>Q`QU^&ju!lYRzps7sMAYOZxSjz{q68SBc#;QBUeOJldOp{b*-xVs!+ z@*+c3MP^A=Wl3p8OpzKV$8TnQ=>+W9yez0qfb2XK zX;VYo1p>9LDSvBKXJ@t5w?EHLf8OPOX=RgkOs#E{z$|^XI^!sFW~(J>u_a%ITaI3U z<00y~rhM??Kt9;HW%s__2aoLCcj&-@Yu8e)-$=QVifEymH!fc~fA-Ar<3|r3+-JK2 zW|z38IrGOKuh-PzQ$yO1Ki;gXVUc#Fu(Xk=R7|6x@Pah4Q(xrwbk`Ch^#8L@j-r8Peg<@ zKc5xnXZJ=#^yTL3rBw}WZHP8$YphBPnzwY`?3vRhJ@N4DnX{vZM@5BB95sB{h>)m< z#*UjXaZ*CU4GDc**6SOj%VTCrR$l2hd3{Tfqm7 z93PZ&d=SL(!G{C*;QsWE{q%!5`b>`g0!Q!mxAdxhdQXm?;ppo)`e}blU(!!Mf}=0z z=p`I|)Bt*Hj_&bmL*!AnI4ML2P-*y31N1v4*H-IOo&i}%#$gilOT+s={muTXkLIp^ zlDqmJ+|@@6xcWp~Jpr?5CT7tDNYgk-Q{STE;-ah^l!MR9tIR8;2-Bjxiqg`Yoa;GR z6eFO@LvD)erHJkCDZPH-LQOR*?ku^IoP4FEGm+nmT%xhorRp<#cJ64dYc3wY{kPwC z#AH>q_Ut`!;%2OMxfa-AEpu%+E8cvvvZJH&EEiL?w70;UHEfRT6o5eEw zpJ&B~Pqj*=t)~tr`U3hZQ=8|UXQ>n;Z%Xv@3msw ztBndd>RgVRS~zXgfgE+i9cm%QmRbd@>~$jwMIn!1g>MmQUr>zdlKkMBN2eqy!AD#-6@ zhG|ZxHWQhJq+Cp)`Fnu?KYQU+_k{~7`a$ysbu@MhT>||510tu+U#LhK8#>69baZuf zRHvUkA$>Tx0Fgmj@s=%zfBF35k3ZHq_qI2!CSh|HJpYe>`~#R*i-VhvC1HBi_29+t zuYTjHM^>$RB*Idh8G|YW(gdE=**{Vwik!=3+t|fStcWH1cyZ(-wE59Tum1CW<%@+3PXfE7z-sIzLSePJuNP4o zy?s1OtRQm8(75?l>-R#`XDw>C+KeJ^2501IkYtc-QR@f zR7!CthAdCdNlof|9fNUt4C6Eg<1`-Q&mTebj;vF_)aMMXEatYdjuRi-3KfiPs)Xzz}zcI7!CcI>FpBP%D9^P~jF zHR#RrCYbRkKp8mEJeQv!E_NO7jd&pdMw`fI{1sECAm8cdxIbQ8jK2L8~cQf9o`PLofu4{C@U;>bdjjXw}Gxbt)L*S9dvyql{%ng9;DM6Ii0qh z(`j97kAg|6ooYZq2!P>_Owm1CK>coP>bN zh{02^lUdItq9Gs49`_@EHNv8*lb34(AAK7VM}-THtGhi`Q(3Pj7SG(c}rP?;Z|mKt#^o{%sbHOL9B9))p`h; zvk@q93!V*}imYDbub3yYv##dW`SYo5H8oudYfn>SYuAI=$%3b8>#^vZC64tp+`6)+ zo%}6-#eqX&!68e*ArrwNvEUF54QYN5Pi$iK51hD~ZQJ7F9yI6$NI<-7ne4BIHxA(y znX>fs|9u_{j(qXOk^gZnYdk&E(jGKO?I_#*S7V|o|8`dUbF*5-&1$L-ev5g`GjX%J;?AtT|N33dQFGQKrmLSinxppm zFKU@)Al6*XQBR@Qrm9{Xf>w~bjdy_~KSs$PB$1jq`Y`U^d-|{L!I9tiudCmyJrr@& zLpkd7e(Dn(b^6~>YdC6Oj=H*^I+LS5c!&D0cKDUH9e%EDhriJ7rR=rs@H?gbI_>@Y zJe9k$H+N-C|CKwrD`(%i^8Kk>Icg(E{dhmMU5cN&Lw$dpX%p3v3T!&lxAbE>eUtX< zO!vRydG3l%+!Z_euPEZ~x8cqe`>Fr$^2q9bESizNp{*~gCGkWO6^ zOEzn58c8z^)?5h_+bZ6dXL?y`Bzueh`N@;e;@`Bs$nwr!D5dIw*1MvjS6 zolPRf(q5HQ-O<`6@l&Bo#Nu9ab5BP$bPS_&%p(Ad8(Cf%ONiE98_CU)Q3So@rBh0>$C9QguNaXL| zE%6V8rA+KMgoJRvaANtLPENg0dpUH0Kb|E3&k}*PpxV9R@kzik`BKehbdK3ct+C7Wm|i+^hs85ur4mo7c@P+J>HuYS{;o+4w^E;_%6Y_mhw^*dSDoOU@}ItKfi)2P#rW^T7Neb79jJVtv#N%#iCQ( z?TuDI!t@Tv4jmYDPq&DII!#W)Py5UI4ofyA&>o`)?~$I1dSu;4RiV4xXP^jG^dK)y zqttP|D5kxrlAF`?YK=w{8GE-ogMv}J3q8u4=q%H5y(*@?Du)hepXIaAFR!3qwCERV z(VmGL47%GTM^BzPclOkYV}}nOJaFjH;iJco?BDzQj_vyn9pyMi+}}q!Zs9Uu?#VScqV&ufKlSI?EbwosK~6p*Ug?|4ma? zJZ+tiIPi(qSG0Gzj(J+A6L|Xe7FOtZ=wQHcWuKkTI)xNF>p!h%O#~0#?dN#f&*ZFd zr9UGZ{raPbrmSNWI6@IzT}IN$FTe!6k3=d*krk;8w>k|jp9VS_;M}_-K|!Mko%MeF zy%-1GY;I66IA0>u(mo)HkRbraUUGyt$l0QIzB@+dp3e3T7%42QBPvW<%!ttz$Q%@? zZF`sX8KaYqj~H3LUV*`riNsRG$6GB{-kRXM<2Pd5@Q88o4=-7+R=>Pr(dd!U<0g)u zG-8-0FyihsVM>%bfzn6gD$*%1vd;dY;jU<@tBZ%pKsg_XhdbyXs`*bu*NFe~1+e4m z)*+1+8c3j{8syep(G4@nY~-|DBn#Zl*T)G-|O;{Qw3f3_jpW1~m~ z9-G>b6_*&;kjv*#f7(zZ^{0uJVAZ$vSU>Hbd~w8q(#|K$5K0?|S|v8>+eR88)v}~N zLW=rf$7m3{ja6ZchKCi7aa4g z--gmv%jy$g#QBNVn9)nyT1cN2Rb-GZT#sCrJw5mPihC{^3$+k;^8)T>7Vc&`?nVjA zZ%cEnt@mo1ktNTiudBJ$j9xRhHaAru?!N+g(OA8|*(_C%jvk;gdpd|hYVPRh=_5Rv zSWe0(ecc`U;PK;s{<*1Bip+De&ZeqNppY`Fnw%%hnKNgCbJO*o5q$I0Ra(YAW$f4# zRv?j)Zp85zyNOgxXzb!gf?c)5?Xh?)FgOJBJxLvMy?vy2+qv`AS7jChOhE>V%q@E6 z%$d<{vbyi%xtwBx@9Sg{*f$)c2mk#D)HLD;7Ze1e_G~9=M(Bic2bii!JWcCQKIV-j zmnXw;JlA%t=B1FaaWHM}(+30FnvSBa;3CwkyLJ&7pf6s_NJ~aa!{oG#%tEkOVJ2dH z^!^fwv7I=GkQ4+q)^?-B(NV@HmM(N-XCL9q9reM~6xeRGiGE{yLnU$>R5r9Xl|e-+ zYijSNS-5%Kq=H=|=tM&@omz%=I*FsBQbLSvXs5M@NK}rT{^^J@bHtc=W6T^eW{!jr zizqMRB|3ZQ%%!uJ5=BNLRzk-YNg4#rl7x zn|&DU^{iIZ(`IbzX{!^7iIj(w8lup^u)xs3nW6yFTMP@ZjeBLdwhY&%+3RJvHi|tU zF9p4_Ah#^ra7;@OJ9T2_*61cJc~8eI`_wiYl-gM9GM%8NxXy4zOL`Dp)fVFDp(VvS zuGPoT>Pob_2CdFRtMkBxA?MDy^rh|o?YG^jy)Ng@ojiFkt)#G_GVS+IPo4VD@0oS= zg(aB>QNf1J46WQaZ=9Q@Prz%ux=+liY2Gwzb91-LFe@*;QRU&pn1S0(WRyyHWyP!K zj(F&y5s$3GXDmadrPD2iaON(6Epsij#_3jtrINXJZMvn(s<6(6Cb<^>vcT$QDdd-y z(hU6a&Eal6RW)ATil)5hSrHP=tz7Zsi_}$dlo*(Lw>Yc6b-Gx)W$WjkK($I_($eCf zi9Id+;c^(e3ufT$z16bWm z8u;zmbYU|VytyFKgmDywK({OK4w06 zdom;fHSOX^9NgW#^+b&f%2Dm9tw`8x7S!YxT+ggg4nck;W#!Eq1-X@o(Zse*D;Mv* zT*u>^EiR+y8d)_3JTp#C(#AB#@{mhwO|w;i?oEU)TxD?$jEV|$?JFNVI3gn2!#{BN z)M>Mq$H%XDbY#SkK!0^~L>MTTqj@3E20fPbp z0_=MeYKNE)PIwudFa?}21)LBD0?a|Yqog)w4Bkuf3k^UY8w&GF3TamPyu#w*f&!%a z{89}#GqN)=2nOuyLo!LEDx-zT^ zDP$}b+$N?@Jh9~^6uccyZX@Z|)7&BW^fn29JC^*+Z&j&U^e1Wj6Uz8EQHSg%X*R&q@U-xI_Pt3X=STzWeUGP2cZ0=p8Z) zZjIT}?dDJUr7mtP`CDu~l6OxUptg?v**8Y@k{LPIv!@4b?oL=MEmTZ2RP z`rN4ti<>J`ko1vGNF0hMNXi={4F?V!{nx*a(%PBwquajzY6D(heZB1{E8Ugt4FzPf zclNF%e_OC~Yg0{Gk*!=BIwA>91`f`=A=*}W&wW<88%eN!(!kN65>FHQ(}ZFP-2`w>>B7!`_?_sHOVGhAJq`@ z6#8@}`g9R^e<66^%aa;u7=|ylVF_%XZH3bDm8YS&m~Oc-CGraLii?p+vMirg;3_z- z^)MFH8!bp5WvnkSk{%P52q~W-rXDiH&F!(r62dSS)bifu@~o>DFJ3&n`IAplQl!%1 zk3atSNO4onZ`jQ2x^;^b!HSWTbSbO613MP0PD)eoca&#cN=jmd+fFqJd>6$d1{Q~K zk*}cfUTd+m|pUavLS?A#wAcefHUx7e+@v^9;kBi>E8P^&|fFMvut}&|_>- zw1U?mB!gXD|;0<^b@jX0@yT}IKsP@w|Z_fftpulpfbWxT2@|Rn;_Lt0jsOa%8;S7u(-6MsutA^>ul3Q>n0WltHtO_ zn}e5fHD9a_7K>S>L`1B8G?!X;ACeDC5{K}3|M}0A9=g2o1t_N=fg8hR%10leECC$H zC=JLHN#wLzhFDDG=qq>kp;5jtDw_LrQshBp#J*8O-Q8KG#?RYH+J~IHeKIF+KTRSx zha51695CJk)>6XT+kzGCC2XB1>v%h$E{B7THtW6;wf;J=NZ5F&O;H6XMX2INoz@e18 zS#Y;t`7+AhEZEy7!~fhOw&{d__z#Y~S+KXDu9fmPi%}MM)lde9Kk9Ca0=-~zJY{h^ zI=5ffS70=-8^_+0A#`7uZe5_Yb9rLC^#$T*eGPxx_&kxnGJ7(f+{WmM$c$=jAirrj zUQZN^)rhpGvr*{pG=RL`>JiT+8KJ0fJ)1&;0 zUhvFP%Jm#Ny8qtqZT&U-3=P49)7F>4I6>!VjIB?=`MkLt>vOGt0=Z9_-`09$1m*t! zFRlNxYql}k@kCkPW=I#FzIvZHb20?&7 zoenIfgRRYeuS`GomdZdauM?Uu3}Rv|;5emXeZ@%*w!_JSm5Q^H4F~tXtl@IE+cVR6 zxKoW3DI;tKADn4nMVg3%=WZw|GZa9*D1w?%Qk<6qrK1R2K2mq20mpCPGWLBYje*029+duqLO*yPDmqMc2q*B^Q zPUPpF+Mu0@hkXRTgJp1}zJl}f*2iGnehV_@HM$c$%*}pDqltzMn@mW#F0jyiu~2S_?VBEx zF)>i12?z`f2+(LW{+htxu(063L1AGq&<`0B77klDGI-AVQzKCKdM)=fVjDMVFX>;c z`d3qaMhjVsRR|hhBGvGG@tJ`z+CVitCJ28)!(&wB20lT;>f*w(?EWoxA-0-Dg#~mICu(Y?Wv#6`v8B(_*W1(9#40T|YbwpXYwk7mTJ+@H zbZZcJXDL#Dd)bZ=*5$IK<1m9&-9Ec>Yr`~H+upJ+w?1W`RVyV)-|XDEbI^0d(pjxkf#rE^ue|q zckJ!1c0ousg0h`mm0oUYr1^JuQ@gt0<*Fv(|66O^9s|rmMrz=x)$oZ1LV1{mQ7Iwf z>6Q{agM-Ic8uBsB1qJ!}*`*Z~#YeS5vCVtN3Q+(Z+(SI0qYoU|y46y79wz&96_%|$ z&vE0%dGfYGLDflDuSOY{>zPFjy{rTya<+hpkB>*TetH#q7ALxc#r1YQisfA}f7Y~_ z^YKCKa6zlpUU1+;sJtlj^$i}3tZCdfUO3;i5EXirhAQ-BWo1=WT}feKS(;X8L_0i& z1t3>oF_Zo$p{b(iAy7KHp}W*-H#aZ%@>LO9VIy{IA|5MLDv7Cq+`I2`wXf~S|1APS zn7)LC25Qgexe}yVT3kqN`da3#)w)>gD@?52vQNx$Ftb0a6IK|geV>JaA4v+RiJvE^ zDzRHMsV|253#c!~?k^yMaM)9_*tLR6e;1OA^HT_V`v=Z7W!dB;&Ut-hz&F@+vuF(6 zc(m{YTIjDl>8Qd};)Oa=+!*py=_q89ntCzy^2M~|08c9zM`rWH4txpNcaklK2TVEOXS!mODP zGoQj?cZfO1++=njiAON`kl{hZd=DxW%ZwSr5_gvQ?z==65%Mp0cUAI4X5ynzS{0oF zBIJI==d<*;y}gMp2E&yrZO09_^U|_XT8?Mtrst5I_)#~DWMI%RZ9lTDI8-B~>%+Y$ zv@5{%V@^@=0aETjt;!|4VJ8pEly830R=whbo0Y;urY2Rm${C^~xU*6Q(} zW4AB*Y;4Kat-wp_kYtSo=!l7lNo02IqFT9*nTWiFr%-|C40Cm;?Hh2|LHu;yJlju? z97)6l>gr^SLZx7&b#+jR*I{NP!t@pr(&psb?CWF<2|@4a7)M8zh*$<*cJBE?aJS*O z+o5>A5ZrAD?$$XkH#d*oG0IV8WtPCEOb)R8-d=$~$C%A5>9a9~RK9T|$`Nhfo^6eT zOW+C7#?e=|Zzn&q{G6P}AJ;Jmki!0idG^^v>5QdIXYAVb!3RNS<1tGvkxaqy(I0$( zB{_xVKmYvp?K)=hVisE^=G$)*ox%Rq^l7S43aF_R#$0Io(8ZO--Omj2Ky!29{VAZN;URh0RU}9L6 z&uH2^Q}>)m4ti$Su6N#%pxgw^{U7t+d51(GTH{@1F{*#nx8@$&vPBb6b83Sv1_#VA z5!yLII}&|sYinw2VVA9~EHAIEG34dc&@#6bMKyJ`wTMG^f|^Y|yl~u_c|2z?7n4ax zCkSIQCHlhP?db`-r>EM*#nZ!4Dn=b8Pfu5chZhRmMm><$)~liD)d=(|l_jC*)ll@R zD^|e=J<+S(pMQ)=uI4Ihean z%vilGxgOcUTc9+paiwZVJ^pV4^1TB^2$~z}(IZr%A_hdI#~n$4ido|drPdoMI(^}k zcN;f;T+|>Re=@-)MFXsP>3U&FiC88UJWv)G=;SY?Z*KH$3w^7w%YyswNJ}9)AZ0$J zRv0bsTjD}bCFCkSsmVy3s2mxE9;O*CXzra+cE0s0DJjawyfkEGr)CtDmetf18E)Oq zMm=MS;1fcx?=d0|kV&bD7%^h3kBLPY257LSk!CQ|=G$GU#iP+3aL~Vv!h;^+Hu{;5f zp;V16<@GI~YHAdC_J}2arjmh*CmQr3oSMvs9|A1NjIh(%diDto=U00UJv$#g`zS$H zBQJveJv0fr!N&`GeftTLz^kdkP=Nwm<~HE&IxKt(%5l{c=M~pW=E1YrmuP-tX525B~OB za)up@RIn=2+DI4n##W+&(u?{MM;nRjjDO-a!<951{f}cyi=$qxY`F!e!mSpia>#U~ zs+v9o+HpNfg0G^7aC^N{Ic#M-*+viX%3<5Kty%>umlHOnaNo&bp@&}!4!w?!cy3p! zSQ0=C+gZG}8%O|P#M-1VFF`#V2=xN56KQo2XLV^OyiVLKM_rHdnrX$y-P2I+Gl%cuucBt+6d0Ps ztzrGMY#<(3%gZ6>>4fsajKpG}2(Gl{k*6WGxh?#jxu9gn6+Rv*VsiG;}s})pvC=q)CU;7+7&w zndX%-gT@Aq9Wyy00rq8<1hCB5v6vu`5<~2Aco^<}2zYr2c-gI}nEHPpIrHHfMj@+` zva;MC~t<~g;e%-in7RSTLi9-<=7goM&*O8h{o8EW>K2pK;>*-}(tkn0>Wy_Yu zxmm7VPjW-ma@+@2s4BQ;OG;4Avbd&(T%m(YJ!lLq+nBQyb%O!j0;40uD#pzQHl~!ZR$$~BaaQle&M{!6s_h5dR zqGODbFoVk&9>Mxdy(WQrru+)iMi`e+ymQ>v-{T_(_BaKC<>tapP*hwb7o;j;1}HoSc&1!Ad)wVq#*VyuLe_oFw!eH1V;= z9-{?6aESBr+s3@{#`7a=Dj*-;dGz|okq`OFSZVp5AAkJuOdW6Z$Rsz4`>LtIzM{OW zf+`45p>Qwg^#P*~cRL;ZKB*e3AHIiO4EjKXdq@aM$)VTp`RV(^dG=l(hq*EiYtj@v z`&e)r%?Ib~g!zFvV1I#<$8$o3lM0k=2RJUbpfIO2J^zFg@CS%76#<+Bfk}k$P_f10`+q%2n8f?s9nT!&al-T!S z)K`U0!|dfe4PZJku;+L&0+AuR_;yj&K`qaSy|f^Dt6P$hqK|*jVhoaWWY-j;v*wbY zt({2xyGlzjOZZ@&ZTzhqSxDjHG)#Oq45R9ZnSS z&i&m8TL!-NC8O;#hUy|?amN(nPIZy77K-xZY<=jNuSW*1-vV84)s z{Cj#a`mNX8XR#(qshhvo41fotM<6ip`pS+}1W?~>SB@IBeY-Mz>eQ*@0+iSnu#lRP zw}6a0eL26uoG8TrpUSQC96x@%UX`7_e7Sd-GGMDeZJZEgE68;`)=iVe*Ha^H&TU)F5X=v!^Fg7(bVPKjYn>soymabNa zr^fa!BQCBNgFS`Ro{%VoWI~Z4y0YtiG#U?&fJlEwFpF>8QV);FNIwMzOXT7gK)m4R zU?o+U<}mh|@vMN7DEm5UVX3i?HpSn{G=HjUz$9by`JR3A2}l*3!pvfHKMh=gSKv9pn^QWUFzz}im(*rm6TON&AERUdw7PpQ2?0mWixWU+FvY_@9jJ@Lf zcCBsg8;o9Agr0p(!zY8$E01d!7iZ!GHlx#%Zs2TEiSLX!(4vBz{KA6U*Ryg;nL#My_rok|Mxm{xtzP)6+ zl{jDpMo!SvR!>&=)?0oa4uZevMkZj0(-YF%P^-^QXsvS2a)rY_GrO z*`)h3txI(Do;^2i?Ah}})s5|6eDTG$>s3mn4Gt@-f=|uQ*;w@h_Lw?{D_6#iBll%v zn;GR(Yz2~&<4|YqQ&r=k0%s(dcgjDc)y|)P=<@{Hu{i!I*nFrF8%v$TM;}2?`O7ZW z&__XC9YbT8p{%N^svH}!nzpvOO0YZ(7L70hB;B9Sog5u~eCYpDH*pUbOBgnlb#QYN zbz<%|^@^l~hl{tg6c@ zCT**$fcLEmrJkGHcof&Bz?6WKre!+qTQ&90X4(^htv@qDQ!a=j2 znl)?bWc=<2_%?9xKa3*Rra){sEZWA4gNO3*pJl&&$1?k$UF9{{8z8o;`C0&ciR+FoA&A*VEZ)vVpoyNID@x z@AX&#h?I*6yggz$=`^Bf9p9`U!gvhw6kAc9QBK;+3V>A1FYV+jpkx4t300z_9s^#B zIwE{NQrWOxbvfliq1+Lri(LgKqCj1vhQ^j|q}lE6)k&pj7fw@=$Y@_tC_puYbBupV&hR#OvIPS0G*!|mo@I&mY(pRYe+P2pq zA4^|ZI;rL8(W5PsmQKa4HH{I|18ys=mb`+n#xrMTulYX%w1?3Sqnh#m0BWbmME?JP zY!Aa02FsVDc5RnoQ;gJ6o z&CI0HAf!M`p%%`7az8gAP*MyJI`r?+VS&DiM0g_fh+X6D+qZBkFxmUzHKfOW^09oe z+3e)n372V`j7N91$C$#3SaM2Bv7Yu&1l8K(nYCU}=74A2aWu2ulE^3&df)oe(z*fz z>MWKP0+g6@DYc@aA}6b=-qO*~1>g?r427BkO{s>Aj}}W$TX!PWC4G4f4aRmpN-_#M zj4druDw(&px5|AK&(wW06RHF23>Ce!q^=t^C0j~LN}4{VY=6;x_XBA!+Z1P1jIu>!*=bWrDVm_P{Sy4$ffC$|#l&DJ zZG?>m&`Ojnkqi1NQCPkpud+`dlPesh5)t3p(b3q*NpOh+l{NwaF?9l>$=L>JHm-AVM_Z9-IVM2z!cQkSa9`{o@{+KQ|6Z zP(Bd;oZ7_;e8(PlB3u`pq4K+b;&xYEJ$(4*Dex~n zXBXTb@~@Go4<~a%9l~8FTM9uxJBV=dI~m&#VBxcp&z(Ac?CAc3hYp{*SU|&_stYcj zI&%2nzN5!ZoIZc!MphOlzY&>(bq&aV5rC>v>piO>(ZSeIT3k}pSebd{R$U$1g8UYAp<$`czqh-qx3{~6;w=~jF$o~fT^ylP zmF3h9l;590es9KnPR96uhWVU?`5e>S+}Z(~98ekUZ7od=5V&>qjjbK6P%{i=war|t zQCVXL0J&Ip+FS7(a5sH5G`BQ2)Cy2y7jDNGJRUS#GmrNfRKyf3U0NO{$bOuu+_n0ffEKGZ+z(SN+YZ2HKNBS#E>C^7=IJtsZ!?7V0% zCvXy?m$-1Ho$b+2O+j$R9za&;u|d7yA^G)f+qRuERJHo}L{FUPsZzLOF1VV_Z5XAr zMpSsk3^tm2T>^a+Isp`(ZX;}9&8GCq zy0q?70L;x0KZ)`Wp9*L}=~q7A=dll_!OWRYI&4QbsUsI1-UyYKfgKSHg}|o-I|528 zn-rQbBN`gx@Fzc*i)AH+u0BU@18mYkb~4nA4Vam;V_;asU{5FC zC|D^*3~~+#@o~@zIy(?L1+hlMq~7ISCc~-jK?A7vk*^iYhdNj6glYE=gM(+>n^O_U z^BDqy>G31k%HwzSB*tW9q}{l7;|8DtH&fFyZ$X91zMYkJBMn|C8eYZ4Qx#|3xOwv? zp5$g4egjEQU$-*SGj8Pwa&BcpBE?|-L$enEGGVKTB(mxGR4DWj2UMGadO~AsAsS>- z5iD)k%@R1turA2J%5nz*#a!rPf`eTYehNo_h3JJwvhldRwq>;dD#Opw%pdLPc ztoGLojSPad$R{8ubSNrPk3@%z85!M2h6sj)hCo`f&aEgNH1KAG0-jD_?Spc}`nR;S znMkhy3nbJfA=3?c-wofg3Qj0UZEqjDh3PHKjn6PQ{s6zeg}Lzt=Ei72ZwsbFQ(JpW z2kJPHorRq()#aG#m8}Aiv#-Vr(HdewFZjBp@KR^z*^1s?QqP9kqVl>r&3Mj+ z%loM_@Axev`lGjvD1`X2iRXX-ccInH%B~_6S|1JYC_SiG{=WA-kOY^H{r>Z}8xRLh zai2?teSv|!=YF0Fh}Ar6g6(+R`Y++{H!PlhuCdmC3D*9w?@(7f;8{3iA8-8X+i$!tEj%*2 z_^W1xdQC>ER{HB|lA)ajK}wIW!I>YDb*OT;V2~~r)^ZkZF&?(eI;p9(IXQXKq`2n9 z1RIafwZ6kUZyq-pUL20gzZ0z(@9C*l2z0XH(c>mfnuMKKj#)oi;TC{ks6qaI0ltpl z%n>7C#dq)t2#=&XhDIT`cC>a21!9?^7rezN0(^W1!#V^LN~l24-A-h3#9f(P$2~$l zmeV4{7$G5&+4N$>M3#=$*3Mo=z{-qGT^J?|mQ|%6-Oz4@T}FTq1ve$WYHNdZGV+KD zeoYrAL8q~`%23J2D%m2$9gG}1EIfShV4;x6@U(4>7KvP>^YNYXcu$X59N`QlqDAN| z=;)(dipY5}ug{{BH389yBGWxRYF7?tx)412GGSY45qdOf2ANolLATTl*|>BGjSl(J?2GY+voV~2U*GomxvY@MMG_X!SzRs89v z&u-p)q0cA&Rj6ezO>}KJyY;8vCymi4B(6#`ok~Jiz;#)vqfTf7m_#B-6kE;ZkU|F!(qGV!lZI(R(DzhG1eo46tw@OB9+sG2^TJ$b^3s2vZTkqc`*XSkFK&a%?qN>FI899 zx0`ivyi$iwV))_XspzI(e+4)+^W@>(zXRsB^XSzZS-JT+S1(__a`o!D!+ZDcJ$UpO z;CkSR{z-e=K4~2U11BwJH_ESJcI(5LHWM6MYPE}}r%EI?wPP*o7C5WGj)4N6SmExi z7Sq`c3oYQzKIJEOAxzbHtBJa*dYjA+JdAuh(n}!yHBTp0x$t^AO^KZLEyQU5Hv;4` zb@LvgZ_DZ175di9atM$hP|$ib)LJ(P{4t0y#Hp!?y6K_XmRFS-ZZ#D{d`nxKp()^j zI@0NI1m2Xclap>P!%8+%hNL5WX@lQ1@8>7;-jpi^t|%s zr>2pu@Wy6B4Nu3Smd*q+$tHMUc`M(<3U-5KHdpErtPk6c*GZy2j$9-c^-e`aWmN!L zR9DwDw6s|&I#3jq>Fv`Az~((BJu_~c-l+oa1ME`aL7+cr>w%M-0j#tCCvy3NP}g=X zIu#ipp*IJUSb7owB!iO}R#Q$Bjz>?uq~TE4@#v{|!f5=do!Dl&<1u#sA7{*C;AQ}R zf#+XRRZe65@G2=Qud1%7t|)<}5ur_GC515a9rtI1d~C8A+7O_qJcPI7WMxdfG}_(T zONdGlId7QUTLWz-01Nx9S7V9R|J_%nO`4Bf4ekg_>*iv*()xV5noPiPrH>Y-`NRFTcEGbYSPX-+ue;bXy=m+bonr zdVIp0kKXgE|Mz)xiMf|F*Ff1*j+ruL(U|f-nq9`kjS3HSVQd~mhvj2Jy>mBj-h5T; z8x9ZdTD^a1ei01{#jCWqC_k?-udo!BVnoFu0IagMI>q1G+ebau_|FO?G~iqePK*`L z0gEUjB>qk^1!O2D2vFJznbbk*;G}kkZOK{ba|nbemb0qN|t!#6kgnS;KI$+ ztthN9^l?h+JJ=lO+5KhM6_)n0I|0LUd2kG1lwKtZd6)XmL0 zX59O6WDEI12LJn9AAZGN?3rl1$z6}YI$$diiEp5N#xQ}jH? z+O0qW(L?ZczIYh6P`2fo_tKzlJ$z!BHQk9(Mb)tR!}eI|K5?w4SYF=O)-4$ns;FzE zdxEBVgTs(uNmskMT&fJ2JL+UeS%F>y-c<gb6$Gqt+4YEV~Q)y3L(7jfZ?i4l^s1zB?V(5 zP6xcd>d_Dpd@!s+rm%E$!st+wyiu>8#5!Bg{&t;8V>5DMU_ypJJi*EmD^0zzhPQmU zlU>T*e?Q%Xk%z!|7-NIe*XS*rq<9{jl(-rvMea{q!O^NX+QA%cDo5M%H?+z3qZM(q z9)CmI!_mfb*X`o2Tg=fq{0(g@M>~?E&E{y!INCvfLwn!r+Mn0;4lP~H#F&i(Z5}%A zs!!c>)sx&+eeO`+zn6zl09IcR}9rZV~g&gf%jyB_- zURK?qy&v*e%kQSFu~y5Ep}c|f&y+cEE}^}Ja~x$4JM#E1TA&PazZNKu&=x3@Jh%lq zT(kwsCpiC183pGO$|u;bINby}PTio!X$XiPBxTeb?HrDF z1xI_3qwT&!`xjh9nV8-=8s&8?ayf zRq)3<@MREzxF1r=M*8UYz)h&2mST89^Wx$&%vyoE+1*7hagm z%gxQpEiB2snq5>hj}_ymX^2lLD>=rn3|Ld^2sNE6!nHroH!Ans#2+c+{%?JCpdol z<)@$M#rURxA2j`+@vsfzP<-^7ORUz`^DJd7rms(W_Kl*{%-^`0xIv-t~dqEv3q4SygTAwa;u31M0*J z*kaTaS9aHxmzI=Q0RvWASC@&x^kyq70j#>kY!$hyQAo%mxJx3o_OUR;^q7TYG;|I( zPgggZm%|Nl2gDh6Cm_B9g^dKM8eC{rLMH$u)h>XFshnH^{|1uT4aRx2EfDHtRseRo zJNm3bsc?<2u^XWzz+^Ig?Eo;MX$K+S0!7Foup(uF-KR!z@FC!_G2k(ZgAW6bx!f99 zdFgUy=9MeR@Tm|CtiB{jav3(T{L;>0!*=dO0#MS6obvV}O!y;-$wx-cKhb2B(p&u!P=IkqKWVx!hBf3HE=4PPRjTV(*k@!XHU4%7N9Hz+euvLK8UfPK_p( z-#~hLxc%vSc-DX7S=ZrNsg3O|JgZ|-ku9$b4c&y_xUB3Ht*A7CId&}0I>EXs-~`}p z^?jYW$M;>pr@R8>k-+u>8?Jl`qtDF;){RvX*jo*r=8czC+2;nN&r7Q15jT*c;BUR^qiTD)2c_Uz%8ojr8uP<_Y< zNnc|b(pHqUm|dql|NQe))#ma8@IoG}Kwz<>MB;$#G-3rb-!7iBw*yZX2wnZDiIKv* z8(Bq#6|F?9aFA@+aI`@hGy|b((}SdqM>psn3RTtPuIp8yp#eQt>Bh?(BbU#b^3;3p zz4z|Qf4((;-g10;J~p)LINW3>xkv&swr-|dUnYs8Kge5$UjC@aB^*Os~ihR~WS*c(`72jYYZ)6zzc zjPO%RoaDMd?1HR(i=(^3+}(Ej+~xABP9heY>TB(3;A-nlV*MBwSAY;4o#2|VcJlzj z9TdcPdNTflF~c-L0UirqoL-%hh(CdlNi2de_M;Jbky!TC{g>3N?1$Joei!zwg=h z{U6(YYLq(~@sGDJ96NUMz>nLHA3lBwyS>buy?cq49ceaqwYE8^B|yAW6L3!({4|X% z?VTuZVlFSgb@_T}7Lx1TIJIr_wNwOW7gkiGt#bY7vT6Zh3ZthC7oR+tk+L1|oum2P zt^vV6|9ZAiJS1imOl{6><*Bx0*(SHr}Mg{0giEdEUjk6$CWRmKaQ6;C%tWR38}kxxAZ{~;S-G6}o` z)i9=F7PPyCPDlICP8}KM%n+H()YvYU3w6rAhP+z`a7TxVO|YfQ^rM9g0v~E_9!YML zNL)|>#%N@uGPRGJCcs;D`{;obcth(ge1(6+LxV%ydFHwXIJ{sR6R{&PrJP;iOonL) z1~Y-9hYxmoUj6}$%1OfKE1U&&opcx3)!v$#jm;)(@UZ0a^`k>Wyueo;;nS8NagzTi zU|46533rjG<~;WB_(fq7B|I;Iq2U2TVx|pJhYp(r2Z9UCx?~ZaQ0DIG=IjjgH*T}F z6%cx%km;)FaC3)kqNgG6dSM5;RxVYla6<|!LYN%;GcVm=pUVe_Ky@35VZ&)7=Y<`N zhr?g)xFV;|tprC>t%9N`-nL7@0UY`1rzxpm$W!~atpDPRq_yid_A}(g<2%-`OG^6U zi!ZmGyJ};}Pd^2i??rahy-aDg@nr6`jEvldfjpUh`*ub~Rn-8l9Al`LfGdZ^3>BX_ zlX2qHPd`0nXmtz@{^jSLm&M@|qI~`Q6m4ZU5AXQpm*5b`j@pagZdiXjBxuwq$Oh60LF}R+Vvc&`E?2g+kSVzAK(18Um@;0}(ozc(em_$>^7*)I$-V(> zIY!*q1hz~+b+r*bCqhA?{9dWAZ&0uc|Ms!Nsjx@3SOgZKTTqa%kEE;5*w)e2#}5uh zJliOV#L>;wSW(9DrL%`GH9$yVz3m;ymQpW2B(Ag$V9YUNB7wNFG3L`xE5gB-(;xB+ zLKdcHr#xim%Ry0N140s>c_unKGBkXgjWeHqn!L%fZUZ^9&c>Kh2S-OSi&tq z1|o8bx*qyj)8^x+`|SU=8f&SU_&j}^L*GR7Z4t|@%B$_GGR1Y#Rhddi*IR~y+Z6?w zImNkG4P4tc& z*)~DhEAj1YZ-IDhXzOW(u!P8zs2$o`yTFtXiq#c`V8zsHo41{Uz`UMSdj0aPa=mLw z1VpBuw32oVpk0 z$A9a-!Q-(=oMA1wx)mdT(qIjNzyy1G`#H22S(URhBx85r3^3!8;VOklC*mu;g?%Dt zz1v`cAw6})%S)z?aKmpZ#2zgzFFZW=RXgjc{}KN}ON&Pp*p>;N_3G*?7mH*;<6ep9 zk|zsZo;Hl=v)V0DvfJILwXt!8Aeyy4=CdHfMh zS~{ti0DmtqqmPFp@D_dGPlKV$)!6}T3CUPlUJYicSN$W3QwH&1sNq$L=gu_|yEDkah*>O@fb^BQ{krKJuW_6{W8zw8ok z1vt$4WGXDNIXSm3AN_y0d++$Ds;zzeoasF?$xP~m^biOkp?655il_)GcCp@z?b@&9 znlm#L3+lBZVgUq|E>)2l5JCwM2iItVoCLv(aNqa+{QmjnFquin+WVZn z*Is+=Ri3q(fX;naTKaA}`xIj9o);l#{?p9}Qw}OE{pj;gKmGLEAC4ho5+3UOBip|J z82_7&dVhDb_v4JgNxvpwyq_-OIL-4{j8;>Vmv{N{g{xRCpsZUL7W%~(Th5eGh{_Z+ z`h0u9X>Y~62TtC7VUN$(C>%04-g)WNme0R{_AhNMIkWwnZ@yib^)V5ELpvMV+KzvT zS>11y^Q;yUDsSudKmcB0wZ+5Q+T0@HAIv67);ARtkfY6q5wFgOkpm&5N4s$0gEMA5 z`qo=-J(2$4!iCz3BS(((1kQeoz_xrSMUo zG8Ku57q}b#isdvR1n1BH52oYf;5Xp8pTKj!fag|0s(lHbi~s(YLqBi%c-fYnd$w=< zdez$Rzh3))lXKrP(@vh;_4Aq13)|oM3R8I``*`+A9CQB%l23X?^Z_wM{LWWjef!1A zIqX7s`tO9bd_GX*Q`wp9M0ox0{Y%*xc~jY#%BC>7i4%v0M@v_)`SO6xW-Bga+%J5B59Vnuf?68L-~wb2-$4GQ?531ImZ39MwM1qBUD2@H=Nwr<}|BLj#zOH+L*L8b%T{n@}b(?rym%gIwUY~Zy^=M;x+Nhh*+IiX` ze82tJ^9ZaXI=11uv;;kYN8~E!X^lKBhaHdNFobg0bt$hP-V3hVYyZ=R--Nb_?-e!t zNKgHU^~HR@aW|pO;c3V5w3qo&?tj|gE41Sv>mH5-W@)658i%L7Cz3{5F-&9-OdPu zE|s^N{Y!s6c)~qm%Hqu7$rGOV+p{mgQo>LGq*|_eAtrD4xA2Mn``O5s~;7P3MNtZ#YIQ64;|S18-k+&dXRtda$y0rfkC*1 zB0D=T?{_GvIj2rDlHALPA}c5~2d`U~knq!wdo~_AbmaJ{Q>T!%9n@8}ZyoSesN2o5 zhzOEl>{BN&l5z8327Y|LISppnjlcq#13T|m0A%_K3GM0Y(aLyQ z@l9xbJZ(DPuZQo~%F{Y;LfgyJj^Sy`cv?G8tGx;Bb^GmqUSJg5aI7SJzYko~??Jv_ z`Aul6d0LjI-N@hb8J>31O=!>Xv?89?&eKYH+L)Wr+IZUAdD_Bj#@Ta)_WItlPQo1U z6nP1E;JuZw2j10Wyzs6f4ASpG`im9_i(Ib-!X=~y!X`IvflL@_fp7}mTM4V+T}^m} zv_P2U#w`$bAuSMoAuSMwAuSM&AuSM=xp51Bbq@pMKp5x7EfCfr_ds~(>U$vEL+*jF z&y8Ck3`ANW97I|mEQEJ8;UUrjVWJziK-h@1K=_EXKp2U%KsbrCKv?OZO~Hw4lQLE zw3IoKv_Vf46ak5^gZC7kRPH4pRm`kbq<7=XZZ9F}Wd-*AS!AmM$e{prF@ym7@Cuzi z?89-j?WD9xsJy+l;&-@icK=>ci}29NlXbNR;jj7l;O5QY;lO2xk;&YB0-s!rqp4W# z6ZE;^pDBtdPgt^KNkVx{(PyL2o*iwb0+fAaFcO?C>r+NdLg}PiCPgUEWe^wyatY2U zXV2;{^Sc_=#a2H3RTuAn5r!R`;z&mxJ*k)Gm~t!( z-WTDSF=Nh{`G-UnC%($cYDh|0L)i%=VfzF?C4{$)JRo^cq-9fah-l}&5OGg%USu+f z*n<+=Jszdxt3ujKO+g6IA=6*6+OHX!osRK=PxA*#FW}V*<7>M9zw)R>N?fZ6D z8}&USl(Ze#$58JgXjIC4`uV>Hn+|S1e6gUVY8RmKgf4Bel99)c50goh8VP`< zbzb63g1X=@H-tv3-0f5xfxPesB1K#noPRQTdnfQOIZMFx*IWsn56jDd2f$k`ZV7K= z(HY+2(Atf(0}oF-ji+7D(+YW7Al%)MR?5>h@w9H9*2&Y#uAwDX+^eIdP$HSons~~w zJY^0~*?e8fKTbXu|4eD(>D1TI{W(4>OCaIzb`;;InD4WQr=@Q~%ks4GJgtMLE#ql} zZ$f+Be*2$QeiK>^-|xI@`rX6#tGfwpBTq}%8ho&wzvp8-ZN^P#FY&b9JZ&>i%ks2| zH=%9dX%WLo`n6s&&ZyUULykJn(<*q{GM=`b?>F*4Xs^X8is5OeaRgw<`Ir1Y?f$y<(z8c)Z(9HBmX9*oCCK{Q#JP$sJ5OyQyTjwS{z-2=Wl0!5 z!rz~A6jymwg6@h#KYV-sg3T?X?D?flbo7K&8rdCrr2H-^%n&QO`<^8)J^pxmFZLv_ zHtxp_pR8Q722xih6SkGUdjB}C6t6{DaI(TPC@xG?(wF+c4CG7fh5@bu2Dopicj=db z4Y3Rv5dT)s85!78aB0MtF%wcV7Tk-VkE~Q`4UV0?x$84<41NjFz0b%|M3km`iVsg& zoPmax;q{JG2k1+WEIy6Y>wQ-E30JT81@=ixLSZ-6;Z+Kit<^=f9*q6i&Nm(%{8=|EmrN@KvXMKH*-GNTSB(9FHB|#Q4 zHVjcB?zrS>bJ7R<2LN!&qdWZB_h&9$YM^~oZ%=#5&_J~x;FPjr{r48PcXT;*1PE86 z5T?dx^})JU7mk2#xahP=DDkUy@;)r$<^Kof`VGwWbC~PDVXl=VYc2mW0!zxuex3m% zKnhDE7evAavkyl9gs_I6EhbZaJwaqd_AtR^EG~pwwXoz;9_;r7^_rrW`q6aBq2uK) z)cB1W87pi8oMMlZ(Uasi0VyCvVyakD*9x^UEowyIKXF_7c;xyF6`%Vu9i>5EV_%J3 zc1#4jw*ali#cnM>8;_okH#8P>Ac^MfQGW&r6i^-U^YuEnp!`DCUJaFK^(OfIg6sWTO zX!WaIcKbKqBqu8;J^AF5!~BwxddI@mI-=v06OInV@MH-Z+(NU))rC~Oda~PA7oIqP zr22nCnfVvWZ~X(f;l%d(lJbX(0=VIjE7NSeHG}NlP*EMx@(}M20=5|J3)6#pLj(DA6 z0C$Ms{!RivjRil243CAH3AJO;;9#}7z9oo2#>#=hfQ)b0e*1bZT`FzD-S>3#dSw(+ ze~)HoA2o+~gMfvM0RCKi?&j~`|E5GHGnrTx@NDlf@MRv@7ZnA~=@k`>T_1nwVr5iR z6t#q*mY79%3&FtC`*Y^7)G3C3{Us8xu|n{CO-&jsGTZ3hUJh9VD;*FRM5StPpSC0u z>zZ6kDT~BUC7^(oM=IFhWlm*ZlD-QRFr@eqSC`*=->?5|&wB(wthxE{Vd_T=zcxaG^C^txtr*R@7|l3nd1VC`&K!rc>-V$g+WX2*7ZhB` zIf_lE?V?|ZH73-m(yvpA0F~>nG z4ffXM??b`MJ*O^I!K02ICLmVd=49i8nRdwgCbFRD@#|X3_&Z} zkf2~hICjWX?YL)^hHh=b((G{qg}dJdd40XPj)OH62r-z@R6_CCcx%F4;;j?y-$-xH zxgKpSPaANBRu)N75^#$wl1IstMG`nA0V-;A5_(#Fg!WMQsgCqbtAoL#_$mGh{q5kk z`Jg8#o4148ZU?s|AlB~3A9F92V#rG`%nPveM$*A2zIqFs0TK zsc#wNZLcXlhg0C;&8z-RLT<4yI9hAU>WH9s0c|S*;>f6Mrnvw@y4l%TwClULq)~(A z&9(a>ocGr@D_RbH`|Y=gbQT14c+3$Bjnqrhnj5T&xlg1|A>8)J2x;k$M3Qg1+!kaG z!5(BU3iBc~xS-M-HudlSdi|N7w*78bM%|wYE+ds0ex<&V-yh`=O3D*$-4+KN_5F{HY(HH`a ze!ecWS6S@|42GdsIVdHG-^t1B-GPnvnXw}W5XE-jNN(!{OdAb~HDapE`p5^jHzVrx}q*=;`h=g)Uk0;*0mhdM>VmzklsTPwb)> zUwr!M1*1a(wNi`P=p&g}l|JKh&%J28I}@y>5xW9wEzmox}};06<)= zEl8GLQn8o2(gTt`~4TV;0>27ytC&W%& zb%Ues_|fyw1(X_1Q^~1QWXdX~r*b zY-s;t#PXJcqwpmuUwm=b>a5RKefEh@tTc;;(MWb>=D>IHBbCivKXI`-Um?$Ujo|qy zR4Vp4WN~YUbE69XEk+Q?=k!ZkNfiNSzr zG`+O2f0V=+rT>i8+Q3*CSkv(BW%8|*d<$pz)p329EaL5l3wR4&CFmfGJ-h|a09sxq z5A;UPaAM2}@rjxUrHQ9w37v_j0?yA3C3!dBw}OJB$N3gtlWpNav#LX zO~wJXtSCPZ#i!1l_&pCvMc5bILuQ4zE3g+?9p!o1Cy_9bcaBGJAqQ&3nKL$Ed1piAK)Col_Gc1F<&*#ifK11AA!X;K9PG z?TE)a;0jBb{!%(hM&XjbFd;-%5A6jtak3IoR188M06#Qmrxrr^_k<+}p! z)amg2BPW8p%)%MU$}gt#IypNX6^%reWMr1uQR@ci{T(>1Ry#8tKum_^+oBw|OVJk}E|{7x8Miog$^2(L=S`P$Eiv5W4Zp4BlGWs~3*k`;2 zcuBpqfsWuV1J#S4jd*<{T7==cHw|iXF@25xCAiyYOv7IkvVBjyGrEhbjekKfl(=3{R&uhXD7mZ zo0)i-jCK3OEL7$mACUN2nT)7hYF}F;RNdCbHlG?u`!sDC5*ZtgsSl5hoSk{=qD6~t z&72)M`}T$ST5$X9$Pl7~K?#eTJsVYCh}zZTlS$2C5}6OiG7p+Gs6CA}L<_5F^r-cI zRAVEr9ZbUPr(yQ*#O#wepmCV}@QNA-xEQMm<+<|<2(TND%}@X*qzuwr}$6qgYZ5aGNuEIn_N9q z6?G1H_T+!gVVY2tQ_ynpBqKOUKa?@BS_Kc$v-U+vrkTV>XBamjB8VKeZh8Dpi zHd|8@DH1A>L`-1hBSK_kdB{RWFoFpYl6S&G{rxcF(b3T{(V@d)qj4%8H*Va7(ZfPR zQTcf06|xxk%%oCXOg~u`LCt$IlF!#ziBTmu62!I`6^Rom6jZ$6YlGVo?m7I3U-0iJ zxfMxbtZcTzVqlS1Akev@%4V|`78Mm16{Cz>S!wCMtRSbo^3sXyQ@7|zROpmonHgh~)_uPQzRcf!BLG5*xs;B3W_rKv;R~p(ANULXUqs2##6d%Qha)}{O&iM7Z#gec<%wXK? zTW`HJL1dQcQ z#br`H^~jjO$Wdu`K9Wv73y-KsDrjn>8Ihx`OOw$bF;Ax=P)LwE5I>)xX}@fPA*rEG zAQPLYTQaBudJH`V&}#E6BXjc!+DakP5K582*-(D|=$<`$4jnr5^Y@+S5tQbHZ8Og57?6<)`(0_ffT=tM7H zo#h{xIcRt5Fn~+5=tAy+@Usv-{-Py$7&L5yw--KGq zQ$No4E#mv$GC++TsQ=E%31q;>IB@4Cp7tJ|*3Q%J7@$QdlN&zub$h<@)beYnQBDKB z3sIMAV8s2nzEdf2Ugbj5ciO)zT{DSuu!Z6FS1U7!` z@EbK@nd`*8T-63)n(MW}^9^Z(FwTwJAQMR1AiRV37Q#H_K6vgSZ4mYuXakaX0LfwC zJ~#>7xer(n$%KbU8)ON24Sxow?P<;v_UwGv_V+x+L<9t*5Ag}c8NdMSHDb3MhjRbEocA| z%{IW{e@K0L@4&(!3|_fEZ_emzW>~T{@XyzOH^WU z{_`ka#aD!$^iLWf{qrr5{z()4{bzu$h~4r5@I(gISq9M(%FAJjLQ;D*s<}e(LZ&tH zU#-vvib(Q$K?!hE88NmHB(^dtR7-Y#Pc<*qlgQsK5^#QCdMZ*$$ws2Vpf`{E;u#qz-y@1JgG#193W z{NbC+7TL>503EMr^0iNgjq=}sbcD&9S}S||<;BydPoKMZ*i2~E7bq*$2bgOaE#sWX zT0-JkxgD+>mUf3 zcya)cTWwa9i^J~SP(zeEF)I+^XvnBwRpTsc_$C!HBDoZb)Y7Pc0KgHdH5xUluR;sf z2axn!Y|f|}2Sq3}kQ#imCODn#Da^+6n2n{FjVCZ0PhmF3qHYMZ39G%Xxvi~^9G9)O zy0-S7uBN)Cvco$L6HnAhRC7K9mGxv!&gqLdJ{Mkw%VC*>7Rzx{5_2?)Y)RRGNs~sX z5zO2L=e7sFD5Rxy5o}y1iWfUQ=CRG)TtM2OxZ&d_j~F>&!pV~*npEvYfVyw^@Z+EM zqwSnajz&QW#&DR+cJ?QnsJ9oP5D&54dKxAhyKR;A9oLQt=ePv2F-fni6?H0l~nCmW%di^%+CeqPv`f9L`Q{&r6b;IBemRk*Q7MU zuf6>8YcD^IzrSltQ0s1Vwxd=v?(Ga-v7?p>GEjJqQ3>?JVlRKYrBvakQwW%V-`9Nc z>8GD;LxScp=vzfSK9>hai+8DaMUO^1^FB^zpC*^+%L+jR&Pwl^0kcW~gRNynmoUsE zl+0RDbm9EjJluONQi43D0DxS!cVoz$Na7~hxb=;cm02!E&0=U{W_YC|Xh~mrd1*H^ zF%^bFgo*$_4_6_!O(jLKY^lgR78OzOzgmUJi&AY$@-bm_q*4<%eCW_{oi=DNZ?z5# zfesfQo-lF<65C=20|OAi_yLN?z?9^I)GuqZKWEu5JCIq&=BtEam_b! zDgkDp&@ZRfAAcO2_h$nl(*O<=GDQZMrteq>%SXojtC$cA2x6k-zfF zSUG^*k7bdEMfNBhz9rUX;v4MjsPAY-A-d)cKr`0k_}SE6kK*=bh1ctp=yY<{33wG9 zLL-DiQtb`-5l$D22RFxRwNWt;W2qyD5nsG6COSmHAqFc@3Ni$c{>G>fN{NQlYKc!S zN3|nV+x(*;oT=mrKSbN`^Fpe56KSo&+U~FFE8-4e!jb}OwJ--Je__$OghJ{y6E!Q9 zLNdcNqV6B0w;j0q`M)H-xb!bZ;DL|X>f09ES5)WYCBZ;(PoO{r& zIQt@LS-hZ#v@K{u>w@=Z^|w!lVpDk?X_oyY2YFIr#|4pb8i@PTB6BCTm%Cw#!A+j1)iTVKtOWSFBjI|9s94Mz~^y6dCCF`L)+}?AT&v zBt5R~`jY&UJP0dLO6cb1bPf`96Do8(pN=XDA5vfAOurkE0Mqf)3g8`l0tn$<)a%$c z@H5>_f&{paLX+wBSBWGE!QIx^*3{J2z`5OCSJ&BzZ!HKQ0aB=xML8LTJ~9?TrxKLu zBR{)bu?7jkEWBRSJa@ZY#0i6%7*c%0Bnl}3{tCb%tS$E(BMc7asDguQYtL0nczz-^ zj;3QAXJQ-&Y8(ZX+sZE%5mQNi$@w!EO7lsz;-WmVw3U_F+2!T@tCFFstI5kEaKGk7 zeQH6l!!d`eOfj6TU}ySru{Yb;5#%=lhXY{Ek7Aof%_UN+_}DS-MLtfNL|zuEfPgq( zppFWr^z_U@kXJqiP)&L0d#GO4htK$ppr>b2DrYIlMKcBl^?JD%`S{y!4 z9JSg}FCxk71)gf87=PXfn~>cO#lu=w0(Z&U=b4Ul6*cfwxCNe zG#smzp=J?a3)n~kpE`nUP%xgTgF=idTIB0!?eX}`qNJY7MWt!jg4gP9i|;_(LHDSj zpiy$9Y6Kx7 z>?>_-EOkN@E7_jv>f$2cBdJ0Wyx?hS0?OtZEcM$Ueq)v@%iRtApow0e%dej5jSyvCz0EGSfuMx62 z9Q+Xv{s;ko1V_@8ObU}5F|i4y)`m)J!P(1uvLww^c41*QrT!@;W&95$I(_xfpHRmS zBBqEc0$}k_cIeVnYCBTbN*3qjWMX1A!gi*lwvz-lCe7F4YcZ$M;dJ;?HHwcGLo;k> zXoq zQb*{E4{*PMR9=HW9X*<%Y8iUlHVaqCyRNFKt;tGCU!TK&lB(Ab5+Pks!@`3EoosB6ogf2LzKM^;oc*4s*P(y6Si<_|wGd=g`HkuCo$ zo@p-=ZpT8d@5MG78tP%?G9;*W+3ghnpm<6;J3EzKqFwRLcL0fihyR2Pur1-DU-3(B z0QMNuNL;HB55JqyXJXk~+r7SCaqRd@M-Ej|kyA1=xd9=g0SND;+y6BKrp8`al{dMgH=(HIQ1Cg5YvTW*BF692j`j|nJ)sL+b3zdc)4C4IV4P@ zMrJNI6J(A!P9*2>0h#=Vf3lFP>qi%XKb|Hs`66WUIeeh%%@E8XDALPI}i+=ahC zVOR-5WFgd4{Sg?0@x-I?#H6O`Kqibo3HfjXkWnT!wRTF zXis3aO$gF_mb|WBnYf?MPPZEsYfMs_1y(IiZEYTTsVi9a_ zaSYXkfB(5whvKOd$UNhzuhdY75cc{Ym%W`pq3P=Ez)=lqXO;{Kd}l*75W^S|5XJ0W zOZ7cHHLT8Gqcv{YG-AY>k3QIXE|0iEB`Apb@poIcnMJR?)=<~Al#%q+6U+^MQ)g1oFj*O#Mz{i--xE<*h-t(~33`g{OZB)DoDt!V5(@kJ1VSO= ztf3ocVNa2$1H+9Zj%<)owNx(_W18hSxFpt8HrCX)giOfekg8#+ z;8&FxJ(DOO2}YZ&NiifNLv1EuJ(ux~koAA0LYO~!2(In1095GVdC;N9IRib`a6cnW zEaXo;im&N$59tP_0vPHo^6d=ymdN}eH^}H*+uvorzj!Vlfs-2BN&k&}e_>ZD#*>{| z3+hnx7aS>|!XjxR$@{-#QcBE!l-kOQ3#^wb8>HEeN-i=>*817RgX3doNuK@5legZA0p_Sz ztzNTc&Cll<$$5wT;FvK7-&2l>XwT2DcB{HjMLeC_LoU(ladG#;Z%uY+vismy!oosK zkD{Z)Nx%8}tFONLx5bQPaeT*&BS&Vu7ha8ANNeT(eWzhoL;OLF9o0P=kYkV7%__{N zq8C;S0jawNja$NcdJ#e|crQFI1n^A47LO00`aq&e(CdQ$X{yzRg`o;oBBRh~r0mdP zh?Jfa{XvB?;;`grEO|FfUA!^N12&;Fq@UUQq1l%y*h*pC)qSWwwufoVHF!FvF zd1573P_$UYafJv>VBMR=9o0n#($^-~vv8bFM=s@lA}AiloWE`oVUDe*vv6V;1p?wH z9tFtxGdo#NBtH1s_0$4(CP9Jtj_^LPwvT-C@8|D3K=Tx6NL<8x+zW{b9)darFjG}m zmtV|13+4K3?#1$pCr*POPM^3~-r&Hw-%(#)e(@qUv8&=D&T(-OI_zGJWi_qk$M98- z(BMXTx$H_jFbRrSz^1X2xGN2xpJJQ&poqhV4}ZxAMKsqQ`26$Fe}f}`AShxX9~9x@ zf+7HPhoA@@iU4gwP{e{q@wEU!5u3hPE}J~46*^Vxpvm*;5PCfQ1pPL5J%P_5)MUIG zaLE3FyY^EJ_?yYd3HXX64B+}ouw?S&H2g9I@iGgq1Vxx7NVXvH5$O<4xI}jZN;-cg z@x-1@Kh~f43^L5%PVf(P!$sU69B64mK5h3Ct zB3Ka@5dnXyR^w~ygdK?OZ1ZWf(&a;BDh?V{1u#(=P^n}}olK_^VT460olH$Cg&SZS z)#)JMu7pDbOZjjJ0={GL)HrZT>2}n341}(s= z0T94$nZMbus!^z+1Z9`Xuv66^{qBcU_A!B*b>VbR|4=^@;V|=2B;`(I9C1S@`0zI< zUXL?=BvJR%&!ER{-@dnc_~Jzt>Nk@#MRfVZsamPt>?d-TpFDYb=icoAMBcOS;P6!H zOF_Yp^{Lbr!SKB=&w#!DD`Xyj0L2KG@UdaYZZRv~d<%&^3r0l+2B^@z1zBGviInQ5 zLR11vv;y@)3<_hQmYmMCq5cMx-wO1{!iNgboHlJp1memw zo&`FMSve>iHrKeJBZum!J1o>bvp9BE#*mcI8}4iQ)Nvv@f+&)bOYg(J#^ItXfc=VJ zUqY28UZM=_;bfQkL%S`Yj*veKL+n1NcY(SRAE*YR+Xhp^|3v#|S+-WmQ#>-TRmTWXXVI zY*zR}${=EFHmHr3z;-tuO0&DU)X~;fc&2>9gk8I^u?kaC6m5*6Ef*o|2ab&yo|Lj& z^f-0{XiMarx>51)Ffy8`oas;}?9?W<4#9#SWC3W4yiBNFU)5-9I!n@=C8%8=>=&*# z4kJk|p*N~spAR)1PgzcVOrE+LPtEaP6HKi5wed{nGupLdX z4Vi=d8=LaMwU?S2$vM@264(nT?fy7xN(nlVwYJsG{drGS9->ZpTk8aj3CRSTgE1M6 zF(DCZVUVKkUSDe?%)O1RfJbU???YyUm+I?<;e5Eb#4iZ!j9(aj%67BRxGtv>Prg18wDlx1f`<75q`VI zY8&t`{cs-61|)m-_2)+1)3bGJQPC|N7}UK$^}L;(b>+I9eE{)=>D)&7fapsASwSUw zfV~$pw3t6Mo3G2=c#1qKk3M*iJ5dVNv1u=-!-b7Y^m1A(`8CQ{$zugj$_24%l12&7 z!*26m&J>=KuQR1LL`TE)5*=+Iwu3MPf@!p2ddkHR8}Zbs2?qN>CFbic2VTDY@5WxljXfj#|M%F7xUpws|6h&$ zz@B!sMj+H^h%O?Or=i%vQekgCH3x4f1aSawE)j6n_#JHs*36_x5j8gwAt+cglOkz- z0B6wRcQ)R%g{?KFJAt_|PoKpSm zZ^j&_Kp<8QsIePZaJ|$ zKzJ_qbnZDe2fNhy94kC3)`q=AArK<$*kDjd8PQZqubztgt*msM$8DZ>WMnkKCzf5= zM7DvTAU)KG&`@F#(hxdV56t{nK0ZXV2#dSAih2Ef5k~MqNS$XPbrxX+7eVSIUO1I~ z{6bk}WpQ3ElK7wm;m6#w=a8I#_Q;`=7tUlOXg&K3^z}mOYla!D9+W(i_7U0Yti@T( z0ss4jvnR6uy=wDeNPX_0M%iput2sXB;IVRA|IrHb2U~N_xv^8a&*g0Wz_j8cgP=V7 zK+f*9z>r?MJ2(wsfv-Yo+`|l!$s*%jd%wy)vlB@RKb1;HJc6k7yQic-F;`)2z{GmI zQjJD7e*E#{W^ufz_~eiC$XF=NM$9g}1nJlIGDVvwqf zg>xQ{ccRXyRI7G3)WV%c(}k;d_eS``x9_FheJ+IAqpEpg+>pT$f%>tdMkL3hm?btO zzmSl)QIn=j4IVT!B`GdE3RwzKg8=p`qb4#&4~jiBH8uB1{lOQ50zHm$c$}O2n1c1f&UR=_9oSS)NM&8!sY0j>B^Moz3w7k6eX6p&uC9za%rH8{%oG>q z!-Weq6V(xo5TCPwQ)j26+eW?}`z z;AXjsqDU67!ST3Z!;a$@in;J9dmZ9VfcS~ih1uY$FDfjB)vpu|nA}VFP@IpQlrxiK;b&&P>P-g~}V|_lkzt#{Q9Zl8L z^!r<`}MDv;OO6%23b5xPA1~=W!&Je zjNVay;+KY|YAAeghLt%Qc6_wqBG{Op3F(fa4s0*_vv9>CeHq_%>v#XKapUTbzdCRr zJ$>z3%)}8UV7@)OJ}_Yv!qpBPI#@*OdprH7nP^Nwz$-lm)_%Y7Sbfjm|K1Zm|LLcn z{`=pbOC#A8^#1+4+KT~CX983U{x-}g%mv=X$dMzajyD>jk_VgkfesI8KeuDYjy=71 zDin80vwz;X^HigTw-e(DK4t=j#?P9hbh%+_lgkwnu{v|fBU4OdjKe!#cxd#9n!+3HckIA+K9F8!k`ge64_2-)_3z`N z9c_}3yE6D49!WB9V#|MEltF~uyv3Y&#hiK_JO+D>CnPpO*H~nAxq!n$w)4LLWzb@K z?{mgKK1WzwO<^}@&573nGkCnLR3831>H$FY9QnCYdM>1ibea8FoE z^Uj2P`pGGTjrw-dkf zTEaAb9IylGusD7}wDi_$<@mergi;+-dvx`5_Q8IG{B(B7pwCwQdf?ot(%<$SK2q-& z-Er*LvE#>&9y$nxw%8p&?PnN&t)9~fkc&7>)Upp1n|`if;-eP6^wLWUqi9e5##LX! zEc5wylm~ZGc0R8gfmJvG2!)i^pt2|)j3;?itzi=;yfhQI)jS}?T38pppf*bXVTA0U zXTE#~wT`N%b>xD^unonwzs27Y+x_>Dv#t2u7u0h393^c;w%V-6U<6){$Q3Rl3#^z$ zKM5gLLl{q`MqVS2WIz;&5lgN(S4B@HyK*h*?7id?Gue%i4kZQjU?Q=$6`Ma!T&$?IY9H)m%eS4VX}mBy{k|(W8@+!gQfpRArO&Hh0Q66ibO%>go}p z?iezr07if}e~nZql`4_qs04(gRA`wdNlro(lSUg5z>2|%v4QnFw^iUb+JGP<5T7E3 zCdZ78FzV%@;UlLe42cXSU`vL;ATC;b>@5h9G!oXf+a)R)Gp4!OS|{pbA-!A;jg*%Z zBUGsU0I}(hh;xlV=&_a1lPxP88d&e5Ke?`5(z$z&~@}yF64;@=;~`W(T!1)YUPuzV5nmQU-h9#-H<=Asw0;kU*M`c<3fK zh4~gLWa9^GEYi=9^zpodwH!@bVx};>cc{cvz zQFaWg?Z2W>p8OxeFZLZilz~4Q2bcRM&+@hV1BTFQDn*K1gq zUZv6p>m>rR^9F~>+S#Zf<3<{2_-m7UdSK5)v;}e$dptA&cSV&zzwU0gTj1-4y|t-N z*@U)c}aq5n>6k8p_1Mkp{dgw7T9lq%0dEB2bkp*chxQ;dg3OUJDHj36F|3 z>Y<-%y1MADE(w$1wXp#}N&)nY5c7(sTdPAFMP+2I>h(W&?aLJT+_i~&^)cyJ_mFcMUepwiaVl{(6*tcCeS zMQ8FZUWP7|rOM9k?aj-H~@YS_dDM{ghz;iwN;|6LN1HI-B_72)0Hwj^^ zh_lc;Nli;}M#h<(IyDIyc!y4VEe&y$CrKdy6L-Rk`_l z3q>AWcU4IPI8b07s#e44+!LHI^s`Ub>@Rl)^a@ikPXM0Cfe4Igor6t0ClKkeI zZ$38O7$L6A&ECCk{ZV^EpTI1GtsyZd+$_gBqJp^fbxK|W|DYUU`TA}-_lkn zXGb_MW*^+NcH61qc9W>}j0kKwE-qzNIDUOJ0DA@4%&v1g2l%iy)P~`E5nB=D8 z8#f-P!%$0PQk_;JP)Vgiifo4B=+x9Gkhxf4TWu#xDI?+oJwlNM1;u8~3h<8|JuG$R z{Io$qQ3#vxqr$XF2oW4Naa794iBl(!iJ3HQ0stTHdvbhqPb;z3jT;KK46_@J_P)M` z1Mj~3V;KadmlkOJ1YV)nUo4xdaNgbEyu=eHY_|VSyM|_MjtdP9Qc|6DFzOfozH@V#wWa~$o|RKQGy+2c48dV@ zXOABoYDTT5oSgqow}xJAj+4p$8^zijJOt2U*DBWk32&ypKT1(`bkp~oY7Ldzd_||e zKEN5=H9Th1I}{^%;|)%$HYW;&wYC49Rt=5X97iMH#3S&*N*NH@x96)OfdCp#9n2g$ z=m$>M;3sYomDph(i;s7?uGOip1v?7}3Xd>yIyLlZa~zUl+^krFDV6#K28CQ)$JxJuYn2iTQ9Z@+DTV56sCe#c^dIS(`D*HBo7gHBCZX-Nr5tm9uWL}v+I0=On9 zfmw>3knDy&;5z<8pA)O0?)qi@nl*`ud-nzekQanZmauLpJQ4KJjf`yLPL*nB-@d!<`u%tEqUJGKf`64jvs}T@B_dcUhgMV!B`>p~o*dUp z?2eimlG(_=AW^b}5+5Z&$c-6b<@`OF4Iz3&4J-79z(6%1%J~-+d)>d~?IRCh42VV8 zf-!giJboX>API%4ni_0Xc1LBiv)x+V(_CS<78Y08x|Mnuj!^a4W<%gc6+GTQ|4jYP z;0)w!^N6HkQ)u0>ZKrD?J=H294hKNkMFn7|%ZkRSbv-femUEqMyhmy*#PpM<}|~B zcmCHM$80u|nrcJ7L^W>WlTTo1lL9qEEAlwqrcjwaOhhc}on2n2;z0F9Nnfc1@j)U1 zBSdMzS~C+OudvqDcX)+}15yf{P9UKvV0T~EFqzDlI3RV_+A?W}@ zLs2opF2VW5PjGorpn9$8;#(g+3bzKdP+^YxEB z(%1LQ2$MjnfXCg0V=ykk^Uw5|sTnj_7usYa0Ha2ilBe8lmTv;0Id#w zP2kV@`3K^JWC#k1zuuqnYmwxZ-GQ}u2iD?1)MO|OV-Cc)079n3>L@L%gc%Z1lz1WP z1$n*1XV03{EKAlIC3N*s0#~nDw0ZNSNlpSS{ggcGqoq58CoxAl`bdrMf6#*laqogb zgQ!&YZL|T0e_jJZ+TFcr?Au6ATz@8&mC+v-q~YWcPjBiC@n>-46tNstfdZn6$A>xEb&703{@eV`JG^dgspB_%s3t*P+}M zjL%Q7PY4i$l*&$JQ~?1{0{7O+hdqGMQj0|HCl@ILf;oMe3N};9`ywOfj?_Ro(M-56 zjXF!6MF94FPtSz3x{Tbj%aBM%58#0fr0E{mkM{MgqY zd|sBu&Sqy*Wo8wGOGRZ}MNM5zoppx@<+D6algQ)h^-+EZqQe>%UU_OwfYx8iSTt>s9vFD-)gsi+hsy((c` z2iOiK6v2HE5G0m@WrA3MFj1_8NHv*+rhINgq93F0*FhXjM?0zbfc%Rs76_cfOVhsnE+^#|^LDd1q#vYT@k* z?@WtFh+}-(or@My(;0;4NrEVcgNm?Z;ylf#{?8Rz3VboIP6tRae|<+SqwBi>rPRHQ>)blXakU?5A885EZ7jB575C>qlamqd?C0(hY-X6 zu0`(5n1R_PUP2O!GJv8GT@IxKyL^=mdPfazxf1`%F|oy$5qwu(0p>o41qWm=N@Nz( zLX@nNnxwc-g4-!FVZX4XvTqo^={R>!W%a&7X=G$%ZFNU(_4fC0c)|bB%-U4eU$}n# zd)v}rBpVKC|4+uR`RS*dsZ@06i5j=?jW^yf$pVtbjF^7^{rAJE`PQqJpoO=GMz+Ky zs+)Fw^wCF!>d-`W=)^njxZ}A@*!tX58xA!a#CPA`(nI}&5m)cnQB8ek5(-;RecBHL zZIUS@t+tk`Z5ss)ee$3sx*Ez}MO9U01@yfVaBda;!BJ+fMOtHRwXLE8b0Y@U97}o3 z$Yv*dvq_4HB9IXt6G`KY4nXRQMu!!ogx^!`r`6#fRC|3;80rKE_#?If@t!wqf?s2P zm<4~#LI`HT4{Hp8d4B}k6;%~ki>$w738fmO153V1+-TfrJd#B{o=TO`kH5+wf{Oi( z%3_OPpDem7D}#N;mk-piCmBgyWu3Lonv*4Tb^tzA66_bO4b~>~|H$_|5GkmDWV|Yt zDUF@XhQYk~tnUC~nCMtYpI;af#iSwGq<9}gy~*&A86=Y~Gg3s|h87}l?-3O3r-7m% zf}%hq4f)bkSCmvDlhk1^ww2mSPXhe_Nam~0ZJXhZ+|>HfM>P&9PQ9|4 zI&MdjOC(#jk{7j}LC*2G7vY%1!NE9X+^7Zr{O3QZM_KmK=akCl%p@9w%faWA6r!$> zFIoiv_oLKN`Q!h%e}uQL&NuSG>Ch1dQG2=95k_`+!-rT&x(2Yxv!tj*mZ+lQyYDDX zYgb=qS7&Q;i?gk_t+$s^`P^t3iSj-iS1C=m%ja}CyF0ts9&eAg^H2ZHk1fgWB0jG) zj7$PXCIL!{z62)w!osSm^A}P7u@JHo;qLnxA<2NUhy+q8CW;oamNaZB0NTdBjQ|N+ zEKX->)}IZfKlucUg+7p*&Ib8Tv+-CuLmA<|!-BuP&ekKt{v9W6NQCA* zjL0JJ`9S7(2yyk17qY)A$gALTWoc<)l@&3X3amLNtYlqOXD35T1 z$ozXMdzavcZ!1#Sxq_8uXfCtZ48h5N!w&O^pq6aI!x-6bNlCx$A2w`1T#kd;!Qg9r zH>Uv#uV+~rt;dy;GVp#YWrJ4#E%h*E=gtg|(F!^OuH*H%+=9UqsZ=_XnF;qSe*un! zTv=Q?E5k+ko6~&TeA||V5CtxYTor;A8BCd(Zfjcx zyp{h)-Fv`Ab*1m)=icdk28Lc75Co)%y~PM(NsKYQtQuoX*-bXdX45vsxLgKn{3e^k zY>KfYvBlU;Vn;+2u^`erL+=c2V1Qxf_nbSRabptAe*gc^?>Cp3fy=$;+;h%*+WS85 z^HMMzFg-8;(YLn`*rB~NTDT(K^aiJtQO)ynsMB?*(`O-NPeaN^-})|Y-@eMqefw^G zCnvFsME3H!x^m;_Ti?xDt$IE7gSWn;lgzVN<~hFm&SCsxyE+Bk)eYm{|MkvJq7Dd9 z_b3!SMpWyq?(RPQw6gNkPj7vPCt;y>4)F2{xaD0Qlby{85cJ}fcY2b_{{Bh?k?(cx z_JScdlOZ>AAUDB~8-K`+2dq!dS3_YgR>WmRjWu-@Sx`Hzlc?Q9Sdsgv6T#58aU*@J z{kiu8_wG4Wm}cr~L$Hf@@&zmh1qD+g&YzD+oWZCWB{|I8efJD<6#9O3B9osV%P4RR zefK5D;*wHP@eB7&ne|>A^vVek<*xV`Fmgy%W7kKc%O%FzGaQoyh?r3n>$uaNk*J5Z z(fMlV9k6tCby@3Mnh;*qjEC%K>c@7drXz8crlZfrTdL4t&!Dv0U=HB_o134XjsH4( z&P%9)4XA6x55Ki= zVQsBi{lynHL_zHC>UZ<+YdIbtAAg(@84MU7AUTYRnaw=PJjC1&o!?iOMGLS&X<}TN z5%hq|OPBB^z!n~jCF|kbw0t=~_~Cm7{GoCH6lXz`D2%+lxM zm@!N+6T+zCW0+4c9Ah6NUU#a|Z1(g{kXvAsu}OoZ)g?P)9DkNnR76K-X1cj?GQBP^ za#)4Ld*Z?wlo$!Bmj%($zS6`A)mc|c;0+F?Rdr1rU_jNi;QT6s{>5afqwi|0#7)&z zbrc*`TU}KS#8g8ad?=*7y-lWi2ihqCG&yK6G#jS(UV6rEXBbY4HHCn|X#vlkg9&xT z1U>6edVA}1dN;irg^B6iH59(A^U!KN=vz-+tMsn0h#?%&^z`z8Z|Wb(%qgd_ zkke-%r%ypnVNG=ra1yIm&QQ?T*v@92J})Ktm+PC zIOCBhL-ejj`u^dE7_v_H(T+p=_V3)i4bac`-rMokvkPCGP2!FsBX+!8Eb8l}w z_)04C>YS|6v=(%Vfy(q2wO>HAQPv?>xkL`I16>`7G>FUKOQ&-<)Y_i5ZY)f(Q5EY{ z{pR3c0~UwGKob}}>CPuqQ4=F+0%e7@ExAR-)k7l*)uhrn_#bGY#b}{LXrU2xbs9~p znUZwXb)vr$0jqU)mD+mis0FyFvbCj`5|;`S1U;?bCD2SyOyC~8Rltq^v z-t)@74x$oa4b*FcnU5M)ZuXaGEoN*#6(3e4bR!2a2q~(&YbD7h~4(K@T>C!zEk4Rc!%gATsBJ6vXh5G$yT+t z0{t<`m*|G_gfls|hvNQDSOU*}I1}oT`}yNd_N;I^)e!;aN%qU$^}^orp@%}8{ zwGai3H98;wmO#n$K>IILlgA8`)J~1z+w>! zd#$#?d1c{Xe|;U&un^KfZAYU}82)8z!M4*>(PAS)sCf~_Tp_g8P?G2Zgd347R9c9h zz=6qZRWM@2DF>yY&(hi1!$=_w{hdT5hBWlp#R`Q?;IJ9PFJ0=h(YLur0eMjL0B+ox zm-*z9C+_w(m+Xf;>?<~V-@RzblbN|K2@r-AlxPIG6Du~f^|>af+eTtYb!t$a4kI}m z24z9AH6L0R!${9WEb9Gz>04wigEUmwM8jeqdi4HS1i4!-+L}#tV*t(Ng+a7@w8ozK zE#17mDSmMA%g;{49(awg$3m2f1W`|I`j0<;J_`&Y3kJpD4fYI~Fbj%xUJS5_|3LKT zd+u30FDUU^m8E7=%wl$9cyd5jM=|`=b_}QrFrRDPhcqUYA>Zj z!T!>A@41VgTAWqXDHgR``2oY)E)sVZA%u}v#ll5c&^@(i=H|^;t`rrOfkNt1ArUYk z*M^6sp(OX(@UXoAwBJFayb}fo6Sh9P)JWVE8Cj>#wU4}O?%Z9w=FXixdz4PVs)A;~ z;p_>=KZM`rG`fFZ{)ppISA%Y#qot{uW|~{o)M`PjU<>Cl?Z+SC zn$Q-rfKEnlzWMIsAOxFgKl#l}geF?(RLBaAx%MDvbDhfAuL&o5XkvF)k8MaH3mrde z46FjOuC}bU7HU`^u*$2NnyN|=?iwaJUQ=69S(W4_7_d2DMH35If@QkZYG)Dm8O-8X z9l~WJ0aYC+Vp((`(2|P8bUjZAV4xAAhC1YeoM833g=C!aqK3SLKwiQiFCGS#sHxtM z2$2QLicO}>`uvI`3Gn^^-<1wbYy8XKfBW_?z6cA08CYtXJ=;_i6I1jKA{D;Fk#~$h z$PH?~kU&&abX0WS@))eolO7hqKjYjvs6&=wSFkjmWd;2HafknzEIb_Fth5wqF!-WrU^wbg}JK-+r$bQ*x#n5#2WPoGV{ zcA3(%%}cYL$)-=N0-Y}1xA z7xsR&bLY1kzQA(xv(LWS4x{>4-)_g#zRGbv77#lD+F6Q_5Pf}`;NY6Njse))#1aW? z2jafAhWg+TeS)f|0lKuTqQ)M~S$S~7uBvXeayJiV*j!fG=nQ6PszNYBkCIMIoVu>O zQ=9*1?I)X#b-|t*B#cuqQ|k)O>96tTTIHs63(K1ck||mGD6S{m6^%(?O4v*W@vI?vm%9dt`PP z3#e3UGZP0ag5P zp~n$u&0Yh@l^$Qh&RT&8B7V%c!6OFS$yb=Sn7=U3FfTGM!W#R%@ULKX`O95!y)R;9 z*l|9?A9ENv_J(qflerI%o`XSY$={Y!eoGwkCr1U>6dvD<=p`UAdfV|P+h9RJzJZ?B z`d}TKpc2Z|px^TkiGX9m*n2q2|Mvm&r{Uaf28*1Y2I4eRQAu4pSS}G{q?O8eU7O9( z-(!K!0kqM!&PHekD%%*D12Bi4)>;_7OqJchqrq5mC0TP4!o25n+T|)#Px<+?pTob# zZT;RIJ5nx|!5gPCJMGkuhz|3^39$QcJ_>glJaXi4PZcD)y|~!NOD%x7_x1L|{v#G^ zJv@sGn-f$rk6<95Lfk0})5wXOz|Yr3nsC!_l`bEbi3V)~sUMgvZTM%IuK z1K0|Mj~x*b0jO1Uuv?$aH*)y!h;ifLQ!#N;+gQ5L);XU&>5e>_ui47N8T zco-z^QNX9Zyx>8jK(F`KBnZ1IDy)J8wtK)GgRI{}_~(5v>%I51``B3W4G1a=3ya$I z1kCHNvfh!ZG8Yjv?R16IbL8 z%FsM;R)XFO1YZXwV~m2uiysV55JpU!c86aAEAGkT*o>Vk2CpFJjXn)^)iuzcR8-eD zni`v$8W3!$5eH0^4Hv;vL+pKh11zG80EsGK6GV2LuoE9I54{@{Av(RMx7ORo$IHvd z#~TOCkY3@xdKW*ZOhMZ{j5YES%qc_OKz^o7%nq0H@^ZoBoNp?|V4jf)mBx8+gI@&0 z5C@-329q#o1H=hHd7*EB{TOFfkP$Ost}-}e^k9L2rrVNxgr9vjqO|lLew)EMA}OHN z@qj%A$7y@My)!<>4zRq;;qGd&1l$8w=LC=;uU@_8$8wf6H?AP5_b|T9OlC3jB=aO+ z?;Bt&F6W^TsiQ{SLpl&bYaUcsGXA*0Twv0f{mdE+S|4!Wf-0+VbF0}qSj7oEPcKh4 zDw^wOv-u5Q;b&^YI02t21OsbmFx9}i($NOh1z6au7PuaBGFnzdx<)Y*z`Tn>gNz!& zy~0mx7>*a*f&Bd7B?MPc|3CwP`2Yrl29rs@ai?=W_eI@LK);!Te&dVz+?OzF6?zPo zr+hcTqCf@HZ+US(WfafLH~kbZ0}Uto2QFa}h|g+Qy&Ge?a%=;vxq7`DFj-2%vwbpNejpb`G+~xYhXFWO- zieI|>14xUlt;MB@NTwm_U-9wrA160((yD4`2F;DtRjX96)>hOsP#*cl25?m&YGfbG z<&slO2NGn+?~rT)CiQk7+?62236-?S_-gE0kArz^1_0O^5Q~_ zI@L8t+|g&Si~`UbVQ`=s7BIXTn2=%HJUq6ob2!$C#p~B+)d4>s?k>wZxBJU4!@|5e zv-V=y@g7zo@35P4TjO}O=NIIYco6r%35>oI3~*~GD=Wy#x|)N5px6Wh`q%}3`qNmp z?c%1@s}thm6O2SQF(hQ-Gmkv-Om}xI0T-wIE@dModSUqN$Cg2TOV*1@%LLvc)LKONMPR9%g;P5fh2ze{)I*-MdrN?`uelYHp|=Pg+>WFtFrRw_%=Dv8+ym zsn=3nkyl%RUr1+dUS7Xc4waqRk_RFrK}k?hiBTn2I^;5YH*4)zxQXqY40@?{r5?Al zSgG_{DGB+Pty@VY zw@Rh;(s>EN7h|rjYH#i4Tvx7KdDN_km@)-U)#qzuZV$fm&O1gA8klPL?!f4Kmn>Pb zXzsXi^YOjp?x}ajkOZa@5Ia69HoN9{SuEB0u%@%HeZNT$=1UW)kT6zy6MHLmnt=qlw%=Q_M7wBP(G@H}XV8yKl6?$9NiSy@^vjKH( zt}kya>ccMumx~%3&mPHWhI7lsMi_SlK_w+YMpbtg(`^;XSg{OYwc(nu{$eF(3BG$l2;x!96Gho;r*sFSMG4)zq<{57=s5GyvdpXv(=tX7kWg)6V~ z`_|k4lUr)~$CG;|O?%!9ARKYhp1xB4Xr77%J35-~XkE zd~$mtGP%C~{CSfpDQW2YHpJzVTapw(b+x-YCyI*Vzr&-VZbNE5xix`tjuJB=F*6}C z4`4o+jZtDIBqm@`(~nUVAp>n)&8?mA@s^R!)`k)=C6qLf5)OenMN-l?fU#nrkAN?~ zySpD4LtG{T0<+r~P|!vd`U-Qw>2I*d{Q6WuBjsL}H5Q!P2H(nU=MWu$6N)6nHjv;p zU?YfruzK~5GBW&efD8XLoRsZY&BI`@*oB&jPU&=Bq94MrmP?nG&A{fYfE*UTJ4V>l zBz$cq*}{8CZYEpfB`{H98f7uoejJusYCxTq>w@3gKALf)W(3WF3PdkkDXI9 zAYKTni9Hd0b_H0&Gab_k*w!w+0yIau%_{_VGcO~#P95W_}r$k;3HOK}cNsPtpc0dBVF4!K) z6C50!9(Fv;7&VFTCv0tPYHLddMO=aaz|sJEs-vSFpn9KYqNdkuOU_QTpM1`%a!baW1F08V1KwQ}=h@6&8a1JKj%Sn3t7X(uj~7vfkpe zXY)EB3Z%UvE2W5q(JIj=C`cDI4ME<+l-AmF2MScHaOeCOD)KB5Gf!rL$`Fg%)DwJH(ZGoprz&@JQ>&+$*Z`s&V|$D3SZ z--)9$J&sk(Q!w6i;dr0C9}iZ;crYJ|<{27Nf7nd1ll=T3TCw}|>7p*oR*gAVP8~W( zov}goL}Lz_>skQIX(=x$hu3jhT5e@!iwz?|;wVRXdQ>emMx6NPyKswHsq^YfOY zPk?GosOTw!%THoJRFtN>hhpZuLdVC&)bXRdY1|ok=znO?Bi2z!p-L}z8#7ymG@N~L2){gt=Bms~`9)O- zjcO``g@x(Clml2@Z+9mtTi5-!P%DYtl!#`TLC9SgG7yD;<+2!cy%e8v!xxG7!PkpmtQ*fZTI{)ws!6Rd#)zN)s2dSZ_20SbMeKr0#W#^ zcV>`{^bowUbm_Y@AvgSAfZRU_NDU`+n>>I13zWTWGk>g9KRQugcIwnAsAqjzGxjF} zhsYoA2{#=*db!1@=qNLp;p?W-d#DA86ABQM=gj3SQ%zw>1w7vyDzPKa%ZJ4UTotu# z)tQ&irI$1{bhYDuWx2VPbhp%E0-k1^P}p>8>(+B!aJ7^UbawSZ+lP@wBv%W2F`kG= zOr090gtS@hYNi*FJV+0G)ey+YQqUk33YRI1Ir0uLXJQXmlt z#L^zDDd3jS*MjfnPEhr80pi}WvYK|<(rxuERJn-kSczH+`4DLWM~(86CMZ~!5h}4< zs<5{-SOAZt9G1f&UzFQD8uB$9@)clUi0;G*z}dj3CIyW~PY;i%V{Y^|HSDJkvJ_KuG4jvTz$B9l``B>-Va83N#H!zN7{L9u-fsRqgeZ||z@ z|Kd~5G$m2F`8Wx)uAR<(Qjesi%-4BcmDmEDyiHAP-4mXij#1afzu6fx`-0k#X(oT?v) zrcw$YuWRb)btHy1^|%c4bk#|$t+f?c&KBh4IsyWef!*EHCr=Kq+4bFtJgRD|h0W5< zW@|35tZW^?VC1m2H&YVHVGBlbOmA;2Z1?f%k*I{f`6@Y{Ux-Dw&FfqDDw zgj;KAX;)WX-Wb`HLqE~|YJ%p4Ib+;w!F|!_f9H&6|8aIlEL0Gj@Szu8c=DeV*|`M@ zo-g3tMUO=GG4eDQ&$ng5ZMS#C4?i?FZ`r(c$Ijimc5dCWjmFMBy6f{@Yu0^xw58>z zqsKn|eCz&G=P_0v+e!Y7A^}r=YwaEp9c?glI($M5K3*Eq-3ofhTHw$mVPX1_cKe*z z*s(1K52Ths#xSaMaq*s*fN#6~Uq<^cL;J5s`@cLmCyp;V{$n~+-?|EgIaRrUISO}#m=-sgh7;Ce+hY#y(@94+8ClUz+YE18-dcfii zFL29rcG*B)L6I^1ctiTTJ1s`#`E%t2BSPQRBc~4hi+U?PC+%R$PJQ?J`t={6n+dq} z8$XT%xZrhqC|B({U(|sf)R2C7`&zgbh`En;olYZE1?S?g>qtFboDhPsOfUr-<5`6( zSAMc5IXOA?YAGlbR;}8cR%$iCs2D6UrEgALWmL=#Xs2u4(q8}3f0}`X!B#{%zmPXD zdj7re45K6<+pz@N2<_`3avVAmC{l2&RxF+o+yQ0=vtimJ|D1`@7%NO1pR!MrSMVHa zLwKmC6wY}ysYj1~9ybVkcvW@0yW1whNU8Q5CLaK%3xkj9FQavxNtojg<`LH@;QQY< zj_-GoHsYDeElhux!`rZPYSYQ0L`&*mkq!7UVs1N$Jb%C70>?j?YB9M+KJ-4)vNRJo<&vdjVDvN_!)o_ zVi4wH94{rx0>ZrRhO@x}F?j-Q~u~C&E?8k`BXvM|FkFRqR=Vq?}JKf z7OHKFlZ1f$OTK8DzV!6z)0IBco}P)}^(23MBY%EcKsRRO&SBG@plaVO{IQx{2ud?f zDFXJ%4hJYphqd3H5D7lRb_+Gai$PFE{{HtTrvL z^H59QbJHQ-umnL1>`Shl1sG;Mks2|Ef=a`f)Zc|U29Zb^Gi>!w6OP9eD=&3t`x%UM*(y+QxlG;C>8&*xlCIsQWuB%@Y4;$E;t4n?E-MD^waV}08*p}Q_tMb3kI zgjaD2{`mmLZEzTQO^u6tE)JtTJ=P7IiFor?>@eEuOx-L>-BX#JSJz{+J@wQ*Ap;GW z0LAdG%c9RRS`;x)8OIMW*6O0#c9G2C5*BRmcNcZFQy^t-eSdYM6yvtuVd*9sPdM>u zMAqhVs3gi8odfp(OmV%?wK$}sM+XRC`eZrEX zc1yR#I9{c3K@3-!I@A}W%Kj?9>G#C=K?+%+obbW)B>ad?$9joeB33{Rs1Re|9xD{W zcHM8sHs}J@D(5J|vZY_#YjxQAS-BL9==~r-foiC~lkUK}x_f~@a!MDC)=HVhpD+mE z)5GF-ppyG5EBbCh6XxEBFp|Hc4GaFVLwEcZG-870a@~mEQ;T_e@QV!WYyML;CY?9^ z{%3zjJ*E_k?LrI+zoH@wF1HTU+=7~HINNQ9j{A31Wx*C@f5|PV%LFznLayA3&MW{e z6@ZqCz&H?qmKu(h(pFS7Tq#D>C#AQtxjZKatU~ef4~^TaWOV+wT2{i@RNPVCs#Hqh z%{KeN7Jn*MKVSiCxvr-AI=EbAu03QN(DK-@7CGq5OE`Ao`XsVy(hZ?8cd<{!5IkW~ZEb5WL{w$kjPs@%)xa~s-$lkEg+&#qn7 z)&2cBIY~*mSSQyuw7_-E-DdNhyJ%624ws00hK(K@8X7uWht-9wDQwips0f2zXfp;? zHVV9lf4J(4lyWv8EG*1=?yN-e=RbRR?A^O&&Du}k&3%a06{nL(ZMMoi*m3^Y+#7Tc z!a_8{ZiyWVCw&%#W_Gqu2sgPgBQ&zE#{T)w zFUMhV$x~4X-oBeW1I>yrf_=X<6QFpfW@Vk^>Da)gw6wI)>#LhTODYW`$3+=bOkz+@ zQU0YfR|_kvinB^FSTKD#Iz&I-w+}&_B{twxY)u8AC2^NZYk%6hHMtE2``+5N@(Qd6 zEl@ggGPl02)*7%B0~;OG(Pozm7>QEU)82(iN$qM64Xv%MtLyFts9Ob74nl65IYh=Y zmtmcW9ei6&RY4vm^t5gyC6#2RCZEplWQQ;s)HjqeQiTmlUI1E4bK=Cf$)`?9rO*xb zz&d=6qZ$zLkp2T}$A5HC6F&SNLHMi&1^1x#ZKDu4D^>#KjZNh>_5lehR>k%VDB(S>9O&r) z$3byT2V(L_;6f}?X#EVFFD@~Fz-YLei_C#75DTCZf({0Q|6a-V8bezdJtjxX&V}bp z^_6*9d1p_7-sD0_D;qg??%YVWwderM0tbo^0y=^1s<}ezNW}R@MtV`wGjju7#=Zak zuHr7)l!XfyPL{M4?t0%Sn?1IVu8Ow*_&NgH2*ypg@7>v$=r)p_gG;x6%b%SQMoG5> zVKWxb#Il#41<49_F~Ur7qOM-okia?wp}%*=9n+>wi@kG#pBTFpumCAvpB*YG!Wyqo z6f_fwe`u6vUClsV&6e-J{~zqteAn#1d@;8WYpa~BY{cNcdNn6EJM#*@FK57=7wUO| zz1Q!q#hmZ9ZAa;Py29MiCy*)>jHRQa9xITBuI?UIVGQl6Yr~+_SW)9LX6~G*fjr6| zQC!i|RFj82eO^tI8PSI&HZ$r4s}5J!{_ZLh=W8}Mmm;gYQd5@zHWml=#Z@p^RJV0> zIb?&o58ARcX3?L~vJVfnY%i;%eWb0c#|6}Xs?IL>aGI*keO-Kjs;)i<;{3WRX|yHe z#`Ig-oVl4Pps=*LwVMH(p&hvaWs1l%9bI;bG1Od=j;3rFpWrcI0jgi(;g5!PCFbJ& zzz^;(HVf!_J%U9EI02*d8BICX`Z?e1>^g)P6zM#an0wxMC#a5XRUG`F)=hTOx!{zy!6z@Dd)nEnm(mIT9Ri(70 zu*{279y*kiiU&KDa-p&h&avPKQLZ~)U>yN{a!6mv$#tMPGKPXS4sCdNZ=cmY*t;XA z;K~ImP+6H5F9UCR<#J|r4!#jw6io`AJAtJ7$Tytt!i96jK%+fOtCk|Rt;L}Pk#;~p zfQM2pb6AX_4VD&EPp8d#@%v9c+iSv&XklAh2iVPBz<%1%T7k7_1>dALTT8P@?(9Bp z!_j@h0}U#=Yhn=b+%E*Q>soki^f*@~v(ci|HupGMl-lOv&{qR&1Km*8W16xFv4s`% z*%&n(x73WShc<0v4}h=KHYd5!HfIBjhZ^UEKpEL!of~i)jSetMKTvxf(E!q_2Q*P} zadAX#tBo$U?0wJ|?#Z%^pi{=&U|S*9 zjlAt$xS`Nycwz>%$RpU)69$?Z-TgVhJpk1!i#`WjnD8Ye1am#YTf;!N9;v?si=8n* zIM9M6oY^i!?E0V}Z>3P8LkV>fA!EfjZv~2hH`0l2G}396q>CEq%w0sORU18l2*wUX zNTJNmZz*)cK-LR6dOiV(V7&pym7m$@lv>i(fsIf{8__BUZFC-J1AnxE0d3%cHo)48 zK#keoUtQha4qDw|DBDV>olfQLeGq*Afq_Bu=G}?Y@yrJw7~w`)7NcxH?bEHglMsH ziEIb|S;2pX;!7}nbk{KtvK1_AGfQuwj}PrSe!HIspYoVsx`n^H$^ zuQ0S1<=G2Hd%+M(XuMe(VYi|Mv?=}({`Pj5E6@XP*neOpbnswUn7_P7928`5b@TK1 z>MLAC2MF$leTak(5^L9f0#p7gj`!rBefI75hYsz3`e}MGKVEP*+mYz$&;d6qcR4)2 z3NIG+iUC$cZ@6JsqNf9iMk7#)Z448s7YY!Bn_hI&oyko{ma)9_Pv$?X`A_g@Vq~fO zou2T^5u%5`@ApOsA)kku|4iaP$ME-ozVcEBQyiDZfq5MuI~ zkr<4`+X$La62Sk?YgOr8LjDlolf|1sPzKzk#(NvyV!T7}=C6jI3nJoTm_Qyx)?BH`uaY2Fk2l;HXkul&wy!A##iU$dB$KU0H zX^3u8@)#12qS8t`jCV5L=Z#M7Gp#3DJ3^HFT0KdOiHV7*(?l{E? zaqS}9MNx`aYVohoU@Wre#x*QtL})-jaOlwa5h3(s0CeIbMi|43u$7~Lk zp@k~D^UAaCzkl*%W4Jjf>Cn*!rue{?{KE53J=|4OUTbzZthH(T?!Is4OjlRGAb<7l zEuVir3!r&p_{NQ&!Y%E?q+Uc44+freaOBYQ~2K#Q@h)Cno7Oa}? zuJRu14`b_VAiVzYVDwmjK?|p;%Bb@@x@Y%pS66#=X4d>y`5lU2&qELGF~XUUe-+S( zqvcr1<`)(fQ1#}}`69YS$)|ydv$Bj*4-5+eDBu}R3RY>*1Ts-J6V_gG=C zLC|5z^|WCR$BtJB-bOy{UvKN&0_P)BEiD2((-*uL$_9%D&jxx1OclAs#kpwfB2&HD zZ0YIlhPu$))DWroq&JJq22qV zq73v@sHGBnM(A?pFH;`MJ8z zoOS=*`_gKyj+wJgCKM}{(t9ZSzw7R;8p2}>ET)OnH^i-Ur3@4Ww zB`qxi|FO{rVFU!FG$-{R>s@s>njZ^cY-#kb&dHyi@T`!u!{+R^Y!w@A3HEGIX`% zS4JbEG2V1MqO;jA#%+lamnTxfzF&+<{Qlp%@Alj8|FiPZcm+RS8?wB-aC`%N z{GZLhk6=`)hx*eXc9RUsCEu$UBTM@OsW&1mx@!K99`TENulES}8M3rbT)+1}dc`m9 zy)NA_xMSy!d+Ubs{g0mU-`q=A&~#OKt-Q{BuS+>;_?2A$*YCV83AdZ)E!_Dlbwux^ zZA1Iz|IfLW4z{ZK_UiX%WxXbcwJv}0LFnOd>FJ)2sXZ>l(T2A*~RrR3$o z99L0QR8)ecVr5H9a~)Vt^Yc4;i_2@+MhYu-x7j6aWlaK&mqE`^mT;gO6b4_G6SLt{ zd=U2rJmpH6))o%z4iLv{~kj=bt(C=pc&}mKCh^$ z^AXAKiN%l4j{}5-M>$LWd3T7H9L{NRJkKMgl$h3D(GK9V+GYd7vA5N9#qQ|J+P^N& zNyF?|CffYdnaepvoO{uCU+h15$qbm4wd_j9nZrLEuJj1I>)lxZ9?;`+q_~246O7nv zM333?E?3%&(`(8o21DW5aCOH~m@Dh6D&P;=D%XIbqS{@9hXm5I1{lz$Gso)Num@=< z1$d>ltG%qoT#{K^dX`NhRUFi}B8SC$tY3fQ#e-?)HZ1HBK&&n`qmK*jJFz3rW~GbH z)|&dXBiY?>jcRZ0M^osA&wJ&~h(1=YY(oGl7o`HggRm&@COKdPp)|JC>pQ~3ORE_j z2{f0BE8=}o43f&-T@QvcwE)Hyn6bRP>^*KSO2AV<#_cX-d$oaX1MPLDW(SP|&`$&w zQ?;EF+jGy=IN-kGZS4Y8mjyoby_SxC$XS1zMJ&FWWU@Q@2G_SbNIB)i)kDe$IdO^P ze0pZ)`E>F*DBIK1Q&Qm0@jm%X1Rrt_ctufw=t@Bb0_g%8#=cA*qcBcT%dM1jbv8F! zx<~~s>Fr%92_0cDgocp2oPY9jQ7Cc?G&p1D1R7W}0)0w*Hud1aV@av0sY%BUA2@K} zF!(6m;K%_^c>vxFoo#4`w$5&wQ3f>yMNH!*9dqZFJ=&N{8%u$>VzY%G#WyppGMT@RC*ZiAKK?^?E`Fv(>F7v9 zS$^dPq|C6fjyK>0P2w>eALCZUl2gob`d2%A4OftOW(UeUpL4SOju@<;iAaI!@q$r5 zx-+TAPbQy6{hUrt0*WSyaGWMhC`?OFPfIO^{;^Qu(1GCUKTImYiG$!xViGNY{*)^b_5odweW1f?1ocS32fD=4ODfbgJ%XJ zJ)@%_;xlZ?-3azB75exHdwTl7i!Xq0aF0=UG3(;hi~`hBK}IU9g{fo%Csmw0si>>7 zTG|o>Z5C^tQ*$Ik38{B5WkQyqgc6hxL>yqX{1A13D*BRJ!B-lp=}lk<+v$mTb_e;C zRu)D4FGUJ3AO#L}v=k{UMGB*U^obfjZVpHiMh$mqFDk8SZK&^e(fi|kppT~y9JdWY z*pdbr{9rfsgNZuw$~f=-&VHQ-NC)w1xqRx>X4jAJ|Ceml{Ch-FIliWiQ7$!*L+YFPfBKUSbk4|8%| zsE1A~*EVME17kJMWbb%g_|@67b&dpg5Or78RyB3XLUlKEiczq;rC&O+9#t7A?gIgSPmjx(Y178I z0Mlpf>C8{B?zI;kfO9WcLg|R6kTS+mkf!>6A8Ph;46ttg9y&2d*2I!NocE3hQ)5i+ zLzK~Ormr1fyLQsbMNVw2Z5yx^71cH&a#+6{BnSvh2jq2aPd`zqMV-|XZ|IP`q|n_s z)pJr=lMTrl>y0sNMLcs7-R_+sSv$eUeHm;C;z`9NN^0Sd&+1WY_E~m(CeQ zKrn1QxEuX96bpk5XG%rmW=`Mt!`b}OE_e;9fy)7@fmh*S0Ba5x_Mv0+QDK$rZXf2M ztgCFy{tl~!qpbr23VDL2x4G)v_h&PU(su)55q|IAW;g*eg1_AxZZqBk*`&u&P>xoD zxWUO?lSJdSJRYf(!~u_*0(s*KS)|%uFo4ajfED+2H zOb^1DNg-_Yi4Wsfs-?Y+)fv>?y)Y#s9lcgQ;mEOC1a+Sztxbhwqyi13@qFuz`@%*} z9^*T5{tGX>5ToK4j7$QhPaquFIq?fGKKqxqJeTtU8b!-7NFDsw4960B2>x}D4pLa8 zxU1~VSWhwI?tg~j9}bcuqP}kRlm}m$OSX{<{EG==6Ff?@01h_R^A#_SBHyB$e5|~qN8{2 zjJ8<3CqMq)d+$9y+1p~&YQz!-PK=H?xOM#CJ%j~}!}-ZTiwCm25lE$n=Q4Bbrs0MHRRVgVtCfHfij zcsrVwI(sQxfPGL9GWNuY6F=s+JmLGNRF+nXwYf2gcB3iWORb7L&LBVQFJ0I$D>dMm#qY=rZ}22LA3VxXR3 zBthfDT^p)E{NiU6Z{NOc+xBPw7Kf1sp%=*-$?}(;-M)Q08HHsC!cig~tsFW9oeRfw zbsJs8Vug$QFdseE@2=j%+*M*C!F9csK2B_Dvl6iuJxZf8y3hrP`=|*Nr89QYD0jaR zDFmBcxv$EO9w- z&%F!=ogQ0ndwp%Y(Iv`{awL0&MIG4v8HV4oHd zDA&H^3v*D+wa&ug^L?@=o)%WDlG_QmHaG;~6q7z{qDy@nWTd;ZrM}%l;rKo6w5(zm z#ND&DSvV|BtVE_rbn*A}1+$fpr~kyU5daKDPNYQ@TRVD*L?Pltj0pD6p?wGC^j~c z?{h+dkH|-Y9fyPy4x1c4`QCV@#ntIS>4H2wkcS)c_|JGBqHQMz^AUHo@;Svg-eStH z=O!M1k0=eiiV(r9)okSpb2UJMD11g8~!l@(Ll80OgW(h?{<;ML=N zl_6pgUNjy@NeR3^ZqF`?aCQc0JG^y0=QeHn{u{iu9&O%qu17~t8hm}5k8Y(Wzu)BR zD-w~@-0fLL;Xw}ya)y~QA4`%{l8)mnqINisGf*SUz?oFCQaXiRg@7D0=HTR+YnPlQ z>Aa&S;GCGqzTy6m?KF~B4_=iubge=wt*W}Rp`nJx0Hm)BCiJr;0nsXsD^8zmww_`z#+?Ff#!?tdi+wkGGiw6)d zZ0Fb8h7U)S;9o`h`4|i1pt-i4s3_u z!r9zR7qML(?Va7-Jg-NGxfxs_-H?adv#>_``{*@Tb|@uS#Cdo_f#~Pwuh;XDQZ#xW z&`;Ad-X3~?q0k@l@CR(HCooE!!YEMyc{q+y;si#C=~dk}GuDgX~;XtXh2POB!3+8M($y;H9R4%(bq1 z7s?{-6-cz)bi~ThyRdx&GL*_wS-SI!1LaU$hK_f4S7Yrh756eSlo_2>sdN)z*0x{X zlq1qo_df(jSE;~>L<2~O2&l&?NVdJE3rV}k+bddFuM9yt-GAl z*ERC_S6^QcIBawn-09Jw!k+u*yYJ#AOSgC?(5UnDW=n6y$*;fu`ed^S0)1zTBIWeZiK_(8synqS=<0uw)Vo^D?d2Vf!uMb@d~jefHUz z%DM~NJ~k=>-K?ePc3E}$Df4F|hD|yp*28g*eCYoC-~aT?KbJ3G_P|}SVOZ(;M#aor z_~eo$aR@<3j}JUvTKLdok3IItBa5C}wD9S_{`IfV{^{=Nad*xFS@9<7$t{26l}BO# z*`2-cuZY#QdN5)S_)j4JN!)Zm>+@^PUAnO#NO*XzXX)`{g(xIJJ5W`t$z+zoPc&qN z1TNOffF(3U&$}Okd%^w|Cu_hf{1&;=#=eB~kfyjL9$l)YYRY@*}7gKfKRru$oU%7In zG%F(+8kgkE@`_FdQi^4Q4?xgf`u3*g4ry8@2!hY2U$}JnDz=noPhzD$T`VU30>iO6B|~j68hSg`k9yg1&dE6jgpX`|qgJM#0N zd8XF!&?NYL8wE$_%sFZl9G^4ixKRf+O0B7)rh4cbfvt>KG71I;KM^(5-Ce75(+_=< z;2ST9rKPx@DbkSsM^<0tj=W#K&uuXya`q3fq>uAjYFUo3{e){YmtZ%J7h{V74% z0{T;S0|K3ddD3B9XW@2 z-f8NXvG%7+LGr!wpi6tdY%9QKmn~@f5?}FTI|sKV$A`n#t=mydf*yV4l~*1OBE>t_ z-DfEtud!_WQS;+Q%YEaEEk-hK-kdweMvaOZd&iu4_dovBbANvBsmJf1ch{_$GiT1a zYu>yEFq%Fv@8!79dXwP-Jy;qT38klXKtBe*3H6O~g*`mH%!-l#?uJm^GJfDj_^TU;gDvPG|l*WRjF%rNT&i0t?R zPSztd3moHCi99_)I2FpRa_(M+Qc|Jquced=hI6>`uM)lgK1RIPYP5uq6Ir|^Q+2_MSjLjmJT73WPQr z0GSIqp~2u!m+k@JDGrFa@qgFkO^(tkASpMr_Q~XUzO`wg56@<%o|yst%6R*JzRAzv zpR~z`8)E1;pAy9#Du31@m@?2JLdF!&5q8m{wZ<4I&EmlaIG0vB&NA8)1EUYu8) zhv^59f}|BlCxK|p%a&cex@?)b>H;+H7pnSs#K&}MJpm65t(GQyr!|H5#xMijVWaX>M23) z=I!Q9!LV-HK>oz_mQv%nsSTR;9cN34Xq({4S=d@>OW^~7w*RxOZq~0ky4>)`sw0Qv zmuCJHcFCWt#Fv4BF0V<*e$d&^QUz9EXVDiHTec3Xi1mXFalsps~t9x$exwI%d)#JJvu4y5OaR2ttKZla~6^_iu4^X=`2vz-7JlIAEkR5Lr zg#wHpEF-^@I+ULjP%Kcqa6s-!6g0s`DbW%?7`yM!A=K^$Oaaq&ziPPtRdv@29uE1W%jXa#ixl7dObmC-bn zo%m(KP%0;wx4C$>fFe%ff%0tr?KDzw>zH4pv58rTH1fdsjWqnxGCz9`CE7)e8XIB; zXXTfW#vRCBeh|OJa7=o-k#wVu;$xTxFeMv|g60&|JV0JXXKM5u-vV;f4PB#9rg1uJ zQG^I1M=A)t1LU_Huq62NFx?~uY;|Oqk!>d37$eZZATI&VG={7TIs-zIA`KqlsB*a+p9Uc9&|B4gO8`YVSY9h9gAlKgfL2F3<9t0B8hZQOhy?A^ zQaOk;Ica1~wddj)tlhNkBj~sD`Q!$IkJgy@@xX5~MW{)PK#5@Z z9JK;BZ)N;_W5{--n96}EbOo(NGQiPF#Ml3wbvQIO4b>q(HkBh_E&}5cA%2XacEhT} z0636`@naO_rat)q;sRoSLJk^56$sOcaI9c&2PeNsN2y6e2LHM)({#P~^PL6S2=({& z;6P$G+1FqY7zuq227wspcKGpR&d^vew8I(dyS!(i6bZZzgsmQ^8rpMfQT-SV4MtWA zCMNV;z-ji=znxEEKx?|=DT2_N{=^wESviCcgkF&117GJ9Kxa_|M7_BK*gzQ8=!#qj zOyE}~jPPF=K!FF8NokMf58*_ucH%$cpt0P9@S%I97H^;j9{08oRWB^kt(IP%E*y`e z08mMZs__8N6Jt~r&@gFv1%+_?FT_JCWHL8TU<5+EJ>7;-e@Z+r9j|CVp}m-nHRv6$ z+v_3Ni)c3}IfA)?_L9BKBJ>hDJ=al9^6*Xe<5A;d99nLrv-EWDBSqBKpHu(XPG|zx(<^%=@iGdhK%14rs zxZDsEV@Ra7AwU_i$X&xr70p8jxzzkTkAfow8GAFX=OGdjes`qz(DOjjL@e~^R*%L* zIfq>0#ReiDDgRk6Fu!0obS)PVl{@4@Of}?f3B0cr4E{ zDoltYgE6eWwiX&xnN2K&2Ry@w?MCo_(x}lib~FjT;Xiz9(C0Pac`2XgwdV!;uc)}V z2oOO2dD%YtxI$Z>@&C~F9)N9CY1;5rZE{?Jqa=a>vN()r5Ch&pX=%|db#-6u-u+cw-7Od+ z|BH@)Q%Z7`2kymKX^{~ruR}w~fi)(E7~_%=M%Y|hgiu;VMuvjK(~=?n^OGbcE5sPD z!Wd^_jD>({Az&)$N3S>hVVoFgKgKC$Fv4M0Ad0l#STq=;d7qV(kiX9=(amo;Zi`r- zeTGqsGBZE^cQaoyIf7&_Z(efsIf)znv<^~S|3 z<}XHRS866LF#*g3(y;y-6Ob0eL&J&kS#cP4U~Hx(3aOO*VLF(Q0@L~FHxf{vo>A$K zXGCU$DFHyREF@0M0yLM*22&z^0Z5ggx7c_CGvZ)EnNx=_7jAdQrN+ms zU(*sY8%zlebVGQG8F4TnYvW8zgtK`5ip9qDm>ex3lfjHe^?D#EK^-yXgGgEwGDQt) z7N17pam3ofrkj=q%WiT)SAHY8MuZj-?Tttjm*r%zQexm@>$Ey;4Lit)Vqe`_8Gn(w zHU46}nZ0jD-)$88;`0}uuRtv-*5ry0DYnj{v(%yDCJT)vq$;UuM)G+AW(sw(lY|mi zP^g)dO3k<&$WN|<-WrK6oSc!w<#r^m$6hXax8a}H5O%3AzJ_$8`zUEgY7WcEtk*zV-9hJT;HdwVPXW# zqv_4em8U7@!~GS^#)l}TSy#sdoS91L4Dy&9;m_9W9n|&r^y>zDuyeRNI(3xx|oxujw z;iAI{69rFq3eu&DEQK^Z3+VsDn~60fR?~%QHnmp$BdaNZ%VM%g<^`rv{Y>Kh_= z12rkk2nwe%VKNQEdMZgJ>yTKBB{O-GEKJM$Ag)7ed+tuP>@y+V|Z^4|H z-=dyf?!yoF_ZOfskd|4na{Kn}D+^e7&MH`i=c_Q6^t-yc?)v1@OLjg2Ci(VDpN4`Z zm0K=XjAw1BED6#nX81$K%wy!vSeFuGM<^z-o1$71m7;{lL#~vQyNnrEewKtju{IE| zf*g2D>;O{IGj9bDP)ipBe3^xFAbg+n%UkJZ)V~ z?O<^i-%N2qflNAc9hVb_WC!sM^hP07&X|6kfO93TsR5k-4REf+H7hAbfttYvy-g^m z5N2V*V*}g-p2-8x)w(b$IvQrvA}TeyMm$`|YmeXA>7rwqO42 zQvX=A2vPcd-|hm+F?kJtSp(YeV*HOKFYP&Y`0$~FpS;zbj7T0YMSYv%#n){@3fj%9R?YOmQGWXHo_F7U_p@4Bts5OfBRQkVfS_zMSOi$U z9t_W9gj;P4Jfn_>O0*o*{AE_5+J6jHq(LB&n@1pt@QiSjQcGp>G^r#*kuJ+nD#1hG zPJ{00&AahAa(hO;(=VCtRW|RvO_rx)I%& zo>URWGWXZ)kfLw~YAm1*BGwqA!@B0Gid5)ef^}`zO{iua{%n79pOH~mCYjRiCu^5I zZ}JF2LBH+-sYTnUzX0zpcdw5JisPycdSF4uquB_RLf-{JZH2=4z^=Jns>1<}ed zuZjNy2>uUo39%aFO0J(?iO2&9YcF^Skq3m}Lhu2C`XbAxhGJE*=9C?3W-Ps+VRgEG zTfy4D?gGVXzo4z7qG(Glk<5T+b_tcF4`dCe_rMjlt_TH2q+zxo0rCY6b0bQ&y@Gmz zz^c&nLt+mR%^?f#7J_jlxg)iVlb>`k7+pzhQ(%mwTR}eHr2xDXu>T^`AbB}d3WBhR(Lz2wwu8^YcUG;9 zA&6i`Vx*&r(LnND_7fe;DGYA?KL>YtfP6B*w9jVOQUXYf7$M}>3p@BxDoUL>!HiPq z+CWCPLAv3tey5)x`9~J=b4C^_Q&Ba95P{QziZ9cFIQ*LmAbLjE(6K$)Xz@gAZuO z^cXbLquME#KxRwDzeFyVB5*}2m&4QbGha;1`x?yqTFm=e%=>cRYASPhq+?W9Uq3R^ z0cCB(K0NIA4-MEybTFhVg#j0X8DoW_Qohf}mqtUZ7?a@&2x$p3*ybqmcnYm;LFStu z)FG?xS;nqi(4jed>OOcAsea0}d1be`Y>Jey>x72&cb143b7lEsa%VsQ!IM`y|y zN-m;W(*z2JEG1W#tL11De!l(GLvB)?&k_GH( zZW^ih(vX$M35UrgVu*@C)l+`l>TqzVOjQPtd-+rZ5ICk!tU{2HTuiZ3%ThulVpm)i z7lv4@U<##zvmTuYAt}bV;uJp8)DUDD&QYminW`&b`{QA%($W2SraHGUtxPUbq@tiVLtYIKE;rvQtEV~fL;D$ zrNXFXfOKon4MIy8JgrFyk$STnF6?)aFqNH^5Hc^%55G!0!<`BcQKqIw2HJ=3sVq%B z^wwL4QcG|DjZ-%kNu^d3ug{LEwCuI_-uw6;{%}vx@Ru*Y{MN2rH6{&XN8(eJ67Wi# z4D|8xS(%JXa?(Oo%&s5&&rf3Ox2%u#ANbng^2aQHQ9Y;h83#vzGZ+4GEcb@3TbC*n z*(D{<;S(~2#Nd+?!4#5%Pfmm)rn3~0P=p?|5W&;cKlwO8Aqb=%0i-tlpF;}T*%ikR zkV?L*z|?Zx9*>)ySD60Z{(g7*O;0{~Q@XqV^ETqkO{YXqi zwL(-*xB^G>^Tt`~BWG>e%flVZS~NfWim!j7vN93!#40a; z>M0GQvQopUtfZd8IamFaYSU0{v}(*~HjIuL$Iy0fbkvAaI^g+);n(3ytiM9GKvgJ} zJVQwepvdJ41)`fWq+s;qpPKQbFD2&oAm;Wc=Jo`|q8`ND9)xVO1ZuGkb`SCZL&&fJ z4^9##Ov1iwg9G4Fc!4hrBC%(1pbth8zyPM`Y7N2*U`Pc$1uZ8>loHd#gNQ@OU{rzb zzDh-3L$@H!!k!*<97V39kO}_5WXU7wyHi-ei7>Ri+VKE+6_`p%85r=!+ox0(cWNpE zK>9V<#cWNX3-;oP5G6^Ir%4cDk3%3pmS{<~faOHG&t!YMF52!I<#K)N#5NC0qS3Av*6EMbn@pu(N z<6fm$uPuR&kJ^oL@&MC_ECmlt5_>F_rpdVJ83;ng3w!|?Ny_vLCCnyRe3;@({$E}o z$^Ei{5oMrCi$RsLff3mhBUOx+G6|a*3EpHf#X$Og-H?tv^lFc3AoNwijER&7%|E}2 z?~$(p^{c67s`X;E_Vah%(lCqrRT_4&ClhO#nwT^BR`Rq|L3eNbm zCUa=0wRLQ)wN)XpnbV_2k;vtmKQ~6rTg2E|#VIa!xn#1JI18v!fuQuy8o}blm6c1E zR#q;os6eXl`~17MJ^kQ=fA~ZBU2sG&U);8elmLPvR&mKQNdi zdasi{!ZA(4w5q{Q2Q7G+Yw^}w1%oDyy0w+TXlo->u&=Leh~z>KwG9roojrRFRe2iP z669;&>jIq8&t!{XXw-1x2}|osT;|M;N(s<*Ix2^&0C|G;z%{Sk= zWy?cbw?2IDt<=MWPQETc0UK3>vLN;!=Ln;lH{+#QP>b|LO9D8N3yQ-C3X08&0~^v{ zfM*N$MnjT}zMh;!azcPAHt5TXYfp&_#` zHh!peIDD$LAMJQOhW^%58H)aWAAGRSuApS#J9;7*!VCcZ16&?G#{?iX z;E$*{Ksxt@fi)CiA89f<@Ky?JmH}a9ML+kwBsX{r5PKZh`Z%!lF<|TCz}AHmsOS<5 zx}6i_@Ss9e&>4+hPjBr7V~jD5?DfGxz0PGHH^UyT^F?hwWZc>jJfa8&{r(_EfyQjn zIFmG|4p`6D8>&>3rb&_4i&LV@S18VkLfgaF{GkB7Fvyl*Q(1<@Q67q(WINg~e(_Pd zaI(Kk-*xoE4?k2WvP6@;dImB*)6^x>!^mqqh49Y#kQCj{=K8+j!BMLtV=me(-?S)S zfYS$@ws@Z3Us}rdfB0cdaMra;7R{eq%9v>RkX|^dC+UnBz9g3hpt zk1j!>l+~+erLaa#D6~Bh$e6vFo)?R_k)|HzL`Cp*D3wCqgwA1ivs0K>J4dZnXJ=>T z!vvOs)+h>YFhq)UlPo|5iamwHOvv_8mOzMZn9*qWMcrhqN+pz17Pti<9dkH=@kt&$ z7GZxZ>gUi4c|0!Sy2zannM-Nec?D&KX~l~d7RsgB2*bk1?1)^HLg*NeM|>^mN@V2C zN7i(r>N@Tw#_bL;9%qP?Me0hi#epu;yw~FJdME8>o7G}>I9zVG_p%M-IL$Z4OX3!Ic3Adxeii7rKrgU(Rlnl)<*g3hty@4fflu~BM3lkf9|V?nnE zYmKVqv54>QfB&XGs9K9gQp=s(xE#uUcp37a}lEWHB~JS{3J zW={-ao`VzIVwy@D9$vRDHHK{-Nu7aRFoW8H@96bmNl`^b#SBSQ4?FhsMR+ z0|VtEYWgWoQBiSGQMe-hWPBI({Ga&CM;hxJ2S%=QQ&oSWWX;G|zR*arkuNmjc}okz z=oq2UoBupnK`zP*6waqY$!J9qfuaU9a{$fDaTxHkGr*#lEba)*Qv5zEOU z;AK$@)}k}@zoXVE?wMaM+_jT>zKW6^J*rgCLNM7ZwGv2(9t9+Huj(}ni>rsR%c$F5 zMTM%kX)p+7NavI1NiOj+MsN^2z70@#8SD8s?D$oBZNr6OYugBExSco>`Xst>*Y)7# zFlx7I>n&Qu{ru=KdjjDJqHjW}mHp`bzl@&o}K~g{^2xefr3-WVihhGTtLx* zi;B%uiv}eVLTuwc{PLpLW|yom@FXmuS{0fYx7fliY;&)8_OeGGeRL)x>h7slcqc-x zp+PTK3^g*0W~H{~j?~GpC}-7g82JUfu)X)tU+7io@s6Xl+cqzgOrAb{x?9)gF?Jz< zR%^DIkD|WSF^lxZB~>5Rn8ep!d+k-F%N6-oFI>3rYPBkR^+S(8{`lrOL8;@^$6xF{ zaqdKV!Mb}k-LP)WavH2ee)aM-kFG)872J5gzjJwxfcXK+IXxf$UyQ0Z*CP4vSMi(T z*T>fhc6`|df@{&XeX)j=N{BzhdwcKce!J&^=dY*!O1*)qHGhN_vzz*o+J`m&8|RT< zugPS-fH*hq3+oGIOs{zjx*$A^WWK#E-MT40z3S41OWNVChMHE)B5|}d<3T$zI7}W} zT53+z_p~=BIwB`O$Ni}zr%zoXKWJ=hlKt@R^b@2nXxOOJ8qa<;C5}ea5O7eq6=5A! ztb%+ditjUC636ip?d_;YAOLCOomEIdfel{9;*h`tav*#Ycb@(sG|pgxbp}(?g91Kc zJD4nTM~=9tFdz0XQqzJ7G4Uq{#nxy*v^X(P^%M?d@T%ls*r_r|bQp|~5n?845W-bS zCk7OnbXp9Pa6ZM~Nl=e{*f*E3Z@RH>_F>=bBl~7x1U3UiBOn-Mu)j}BIzc0Py0^C< z`c}VoK-*7LE@GM)8PsYKt~jj2#v17(Vk#mRwL|Gc_}M_8R@>E$mdGft+&?&!Z0;R^ z69F+S;6}hqpdT^u!(`1u2|pGOLM>i_X2%hZgMY+8z(+3e_qu=?ypquZkEYt=EqxY=Xx zz5C^v*4Ui2^Rwkr_53V%>-n$W^SDROAANHJkY*!1d@rCo@o#{OOXIh4Upjhz#7(`b zVO?nW{&l3Au&Z8w>#qpJ{$0v{f<9{9RO+l{%a+}_hWZl_@?GW&k3UN+Iq%N@*W2;- ztW?U~+d#S6V1Z!GDHO)o69&nK4IAdAQEBt=$m?^kW74^+#Q3~zl4dTy zCTP4Si_u~tN-uaeyrb~qnvA1~coQ?xAB+&##?r^|Go#sJaX`ki+HGc&VGQ}>t56LF zo*0DlKv??$Z6I@oq9ObTp>6ztVo5YZE)EJTlc*^~|HLUY%Egm#G_2u8gHde&|lI>_b2wVw=ABUUd3jl=52gp9ctPn zYnpydy(4^NRkjDt*Pg*Np!zEsQFivCO8Hq7K!4|NR+=L(Eh!O3su-c5Mkve4e*2wj zz0u0M^>X$ycm4RejFjeg=miKTODMQ7gAqem!g64O4JQ#_4uzLsH^E@jSfb+B@W?0( zt4KD7Qcsl05OiOR4kVmGL;BUEL(W9d84=u}HE~gb8K#sly#OI+3H=zhGh#hU7#CnU zBib@@oMDFmo+WTko+$m5Dv?no|MSZvrKTca&tf$?&OvS~0`?REdt`lmLqlkZ)Ysn+ zDGBzf9+H?8LV-Yv%SG-UY-&iMCzghUu#Crz@!R5a;wvDv+?Xi%nT3G*5dL%G9APZ> z^7B-uFZRaRZ#5~R#O#TPjVCuU(x~H%dl>}~2ic69sU7GpyMu8v??8hbvXM3*Ld+ObZ*h~>WC+|i z@X1$Scl7mqhT~i6BTY8&3MFQ$s;WNy_P5s%9h>+3?_YfJ#mAo5-xsDc3MIO(Hf=-4 z#q&70m3RE+h3B7o;i9UnR$((SKVu zN#`QOc-S@OPQ=|CEp{lhCa-;b0%khbIC4<4WwNZ|Vl|4=sZ_-!sIQ%yk(CF-XmM$# zLPqC0ed1IZli}2pQfo)|efr)TZ+=nJ-VKh`@1VZWz*(qJsnsa=CZ=wmS0#!f*Z@b+ zTdPmMtW&B1)kT2nB0yCQsHy=~$*^&(Z+HOY3v>=%nR6QWx*$tHCKIq&$m!a99rdTU z6^&mn#M9z$BfX>dyR&C2;wkZz9pH7V<4b6&6!#k3Qy-!!9`0$R{4hLMT}K7MfCo?! z?oAj-Ut&D^Xa#kFy08O|8RGn6G#=7WjjQ7o$c4o{Eg2mcGLIUu1S}??A7%clh+n!Vr^-T7L-j2_PoSqL>{b1_K`XBggc!^2gRda(Wy8|T@Za;W})AoY??mt!E1 z)(F2$4vM@+$z@zhz!waxj&EVV-p`-?=vovtKY)LzbbmsICZpp|mGQe#9OLfzv+QB2 z{Pz@@3yCWo&MK4^>^S26F9dEZ z0B&54lfa2bv?%M)H!`5>AL{Mt9U@*0Ci<9am?4Vy3M(aT|C!bC-=x%hBN5=okv)#Jg=&frbe!mfD&t|*kDl=A&H$Hauw z#m+^+xbo71Oa`n?K_){c%gf7^T)Y^`op;r&nKRO2){BrIB2}~FAunmfVdWGox&7Yo zbIOGciy67F0=XFf?^^K=iN}jTS6*^{K8g3p~8NUF~PioVnCB zV5SSdKl;UopL~*?{qe^ieOZ0*_#vF<;4w27Ood~<@adEgqO%~U2Du4hq(!*t<&k3YWs(MN;9?b{#S`q%@v+_-7erkih5$KZpDsVg3N zH6J#xCC2h6&s;mr4YzB+Kc_7u2qzR5J&hiYVCvM2U` z)=+=u9sd5T!=zr8I%oX(5B`i=O@$TG_yp`s>)``OBQdu0j6kHLvffiZh|XhH0b&;>^4 z1oo}V2?yGk9!2#{Ca4?3BPO$R(r&QZO@KSh;9ig0Jps>w#SusePeNiCn~Y;^d^W8; z81`L>_v?R*_kH~aKz+2g@6f*E-+#Y<|M%b59;s2Fer6 z$I;Qz0=!3y7vOzIS1<6M&k_LcT!w&>=jP;m{Bc%Ro|4n$0kLy;9eLx8Hx4K9K3y8O z7zp5rS5PJ^EK~yT4OCVF?@fo%AC1AO%Hgrx7MI;-yZde{XuNfS3^S80xC4UIuaam# z>E-4Z0rVtiPv-UFgh2azt`lhQ=4JuyQHd0=Pe~Weyr!tQc;-w9PG!Z3#wNL{44^%U zlmOcM+);@zf%k)C4a8z8(B79odsf5?v_~@nK;K~YQD(Gc25Ca;+4u;gWZT$C4~S52 z673^#_rM_F=!r%N`a@O(+I!p+KzoxrVoSn*Bk0aX(489r|BawK8$ox9Pqh*Fo4U@Q zIS5hHWcp(7-XryG!ytI3meVH=?%RLt0J7l=O(wIhtGOPCf2yvty}h?r+XAN3(B9pS z(t;+7RTMJO#gj3D`=YRYqzxt$gpRIQ!MF7tMApu`r=0SV*)=CeD6OUtFcZc7E&$CF z7v$y|3=YAnRjW$k_UtImIiNLiq%<>6%Bsp^%L|vRtE{Yqo-{DPDp+(U@>ZT$o6EKt z*R7gIt_tm*hWZ+`vFzI~s+^2#e8eS|>8ziZ?&bp#vLWL~-yog|7SGG|4FlSV#N zL_TxD?f0&&Sh02!s>9xTYeoijzeeuui-Ct`sk*vhgto@L%Jz084Vc7ZE>9?C8nus) zS8?2Ki3EW+ypT5xLIEhDn=wP22S~AFbd@Z^PvLX(^77M#n1M_NAvvUx2iRe3SQpwV z1J8z=0zlo%__!5w--@|Egt>3U+_z%xb9;J*Y#uJ4?Wt$dygGB z0O!e(3ztqFKYqOS(h#V_Ai}xJA(DtxE++sL2x_&h5I3FU+B1Y&z!Xqh8=kOF*lfmu zj=H)!?SRfk&((+1vz6af_l$<1Ul(OD1Z+g>rg13&OPZ}xee}^s-?etTAWFEq3FSBf zl3kvkFK75|V{junMa9J`$Wp3whRp~Zc8rZVoe_3={;5+2X7&v?+>p(*o}y*yY|81o zW5c3+35cOyZ}L)Xbctho?33s;4FBMbbLOWZd`vo@P?bAS0HoY%bp!+ z)MK&Ib0>%Gu_>2-+-(KA`(g~A)dc(>GfiPptyTi+pblKXZqyNK)PtHk<&do6E*s&@ zeikGHTsmDI(G6o2(H+QUV}=7Ep^(FbbQGForU{e#fJ95p2QJJ9E=)&DDTzE}v9$HH zoISSZlTSWD*yr)OP6KST8OI0{sI4VsK^q#*Ss)C76Q84%AAg*ky?ghkpB~)v6~6sN zQ4xGC7e0o|E+e0aJ1iClE9Pf%WwN40Xw$ubR&L)O3~t%-#P)mde{dU!3jmlsXZ?1# z?J_(>un`FOSb!8Z*Xy-uakU}T>I8|7#pEKcKnQOgTy;{BfWxHZnv6u0H-q8D%zQrF zp)xP_53SVe9S)17Z_qmBx1!|_q!lV=D zNpV^dip4?*{u6_Aa`@u3baY}%oCq#dB>33jurnF0W=pfc7Jy|#tL^QjlDZDbQ6Ra< zOR%mauVV@Jc$N+59aI*F!;%=xJ%rUDj}!N1WVNYkYx`}aPz;dD>Ljy5HR$B5q!NrB z+HvA8;q{b!`stD3h=7G&cfO2^Uw>UIaSHSD^HHaMWCHVTpQ5q?&^E5mK@KBV5D3gw zLwn7iy9yOuS5XgWRA?jPHe(mUGoK@jL}Ud70&@#w5n4q;Mm!_haM+H>2Q)s%kqDAR zz$;+ka$|LY;cf>)pvA%jRHW&2mJ>7c^Z6#zI9e52h?US{fdFA8OhIL{3DCzJp{7$) zoHT$5a3YZEovy!93|P&^9+(YSk@YSHtfVGW-|=HTgG4bnHK?tj_GwZF+S_Yu+Y@?# zrK8zGv%mW4{j}GhopRqLa({XqoEFSiAg-oX9zE)Gs+7QdnQZZ}jAq|^?{>}~S5trE zCM5gt^#}!KK1V&LNj+CtsZ@?Tf$?5%N0FCCjZMMm7#GogOs3oG_rf`w3ZHi@CL-br zM09b8mJ(SUeb0zcZW!z9f^jC*2b;gmg2I#P3o)L;>+6Sb*OW&|KL zSeuzBy~_MJyP7F8Qq1kaEljVB!V=@-7(CHTy`z~KfWaeS1=x)9{0ir}9q0KKAoMFh zs1yWi)M)8zxzy5fzTxcmpMCPypa1-4(Cznke+_PV;>3wMgfJNl$m4oWQ-ZBBG}Hu) zs_S(+d+W#(kdHtV!|&@6ah^G!8T3ll7qWCE(`4AWJDtBkpxh9iN+tuC(WNmEk z(&|hOM_*@KJ)l+J-r4J*^UX;uG;HoV3&wc1%gliRrwA|iLK$tOS~a;+sTh_rqJxrt zKVoT3jU}F14o$RN5;xb-%pxhHDw82CTC@i5tyxqQgIsMA6)nFXis*W3ttJ;74z=cq z{+R~zWyrT`m5LY9!)>mQ@)WD+e0DU9#Scf=DXA!{pPIrJ0UP-Oc8r4D9YcUwF8m>} zXo8x+PaLD-z`t06(89!tc)a{^ACa}azHyTk85uUyxCD@Y3+Z^Y$j{+W4-_5oSa+*=LhYdot~SO z_3=;j{`~xc0wr|6Y3(1eu1@C_dO!beo6TXj6763B?H@J|tM%@m>ix*}m~;Vd)7pO; zx-%=U)cc|67tj5t_Fp)^5ZeDw_5M%@{yi+rwDyk%0Kn`k^nNJ%Hc$Kq?LT0$LHqxy z-Vb{13jo`vwf`Uk1uy@r-d_e9QwADS1{zZa8bfrz%U!?z^(`x}y^S!m-)LlDXz{odPWFtn@2hd%_8~8w zPAWWexEWF|(n8W(diE8S;HgNh=*jRir7xVfO<7D z;G54kffp7BHwOEwadcYdxEHg&7qh+}v%VLzz8|wL>+BzKPB{jeFPwlT`Q6zLV&`Z( zd-BwmyT7eDbG`}Z8EQCw@bk|8y60p{)Ft!bKF{U*kmfWnY(a7}T{iF9ITEK^ zJ}X}WQB$5T;{zg|YZCIaJ?qL_Em z1@=q`$K5FC3e%(!MqDpTuE;K&nJ4E*{B)U^0a+_Zr7Cl9WTwKC5e%}$Ik`Fc`MGi- z`iMptDH$r*Y_sL4PdS^x;H4I-ghWB$@Q4@3J%x%jVXs?1o>(uxKMIpe3ZD(9ZP|Ez zb?>k5EMvuxvWpHBdLZ)nxI1U1>Mw7cnVcf}j@L7VY&e_C1HPp6c&>dE9qS-ldncSE zfW&6AKthL4>h3hX1psn7oa6o}*EquNK;n~fA+e&&#ad5WQOdE_3C#=<45>0VB4Q&M z$cu&*g`tsOy5w{LBsNqLaC^MA;e-_>JqFk6JKyYn`>h@C)OPA)vA_L|qt3~_QVOXJX4j*vLx*&jHjP|`tIx3ZMylPn`Rc|Fc^2<2|LO&8o5%LIe*o)%T}$9 zOgWsBp^Stbg^Ec`#Mwpu- z5(iS1Vl=>Jv#~b^HFB3LHRSOHJZ_gC@dJzq+3Wv}WYy4)4rJhShW0vAH0F~}{_#om z;gdvr9vvMX8m+AzMH2$^=s1)|9J*0jnVFq~9g5w3Za4_l`HL@9IR%9>qCHEc{3yEp z4qk*&rb+K~%4C>@lvW~66sUqde$(Q7P_QSSNa2Oz5~4l(d~qH}sVrCk%jz5`T3#=c ziF|jZ5RD0lQo-btO72jgvlIv(Alfr>9TEn&D9#x6K!8MVVg_1-v^7J+fQo@8YZj=^ zeSJLxc9WK9&+ro2#|h#i5IpM?U!@YbtpEfq$;*7Zv99sbuzpOZcf z$DUcgu3}~7>g7vCFQPQS4(bmYG3eXIwX=l6(u$3kD=iU6*+P+6tjtkn6_zTgVvQJ7 zE#S8e4=04;5G^qvyK#8Te5nOdbIm>IrYi<5bB>t<0f)_rxg=NicFbZkW^prSaWiId zGr{Z;FOm}b2BARqo;dWu2k(9G&fD+3N3V|moA~Ybj~qT)eY~c+`b2FX(DxZl5hmzy zxi}oF(}QQ@ZVpH1bdxo|*j@kafdl(bHea;RGt}Adb+os3X#0D+r2Fj_gUK=Jo|v`k z;~eZji&@*-)7`ZTE^ifpQS~23y1I`2tqRXxKi<_<{dYW*$tvIgeu?wjyYIgHt|u!H z=>vB#J;Ofv@WKW2%ge8tb9L-CFyXxX>};jw{whnc&igfWtELFhd+g?G zN=vW3=P^9HVQFdUs{8TG>;3CrKCi8=uKoNkfA!Kc#={{h=5zU`#_b^=i;4wdM0R?n zsK|(M+!L4zFhvp%<%XwZi@$2$bYT z(Eo2-IHTn9TCK)ft;Sld!CI}xTCK)f<+(ar-R}O@OZDx|7tWpf?DK|8pYLwy?CGy> zZtV1c$K+V{grUnBD6yF37|_C8`3)>v-~O$t>Vxlqk2`V^4XHVG?C4>ATYF!(^sRT^ zeJ8zrV8nFn^vTAZ)H|9S+4*Ho=jm22muqQjJneKYIgiDd!$;$p@(SpjLO}}m*_&>- z?bfmdix)4E|LgCb`J?QbMKen_-L-DRQyAh)njG)-=akAtSH+;ls>>EBm323Hy+d~O z+_|%?;L>@D%v`xFRh2g*bJ5cBx#eZ>WR%Y>%tAK!8cj~9xC2_AqpP985%VGJ zuB$i{;$<$l?z-Z1UWm>c?rbs+XgeAk^uyg3>(5rVbe}lY(lKVMJ<{1Zj0W5}fkX2F zPj>PA`7mN%WcTPso&G7O1w_v3*mzEaF1E3Gc%SkC- z1^S>;QlQK(I+sx>&n{Fe(}Yo`P{iYB;yI>qQ^z=F$HiYQ9JsnzYPuGRErrv;3!axn_Y;ey|JQ@{%xjOAe zpU>q0U9k>X2&+y~mpnkVP@RnC&rvfG)sN~pcKg14TJ7%L?d|WpLm}+g5`+#}TYCU` za~3*L43Y$dFrJjv`93HAs;cBJbMI<6faB;@i=hV>YdZpA5tUc z8sI>Mnnf)H4qO8q$m}9aPS@24&(VdhR>WGhcA=zdAHsSxN}~~!j2NdacH`)%-D1SD zn=H0bI8E>NmIqd3x!w(5sa=OFFy97MxllmM@`YGDR1bd%yu+c()F}o z!~`$3cSG~-Zodej@e+b7A?G2sfv`)ID=?eAfe6f-k-(HWxw_L)YB&67&*c6RynM!E zbp+>s31$X|9XsZi;8r~}S_9Z2MC|8b$6>fUG;i$;07&6|OO*y%D_B7j3ZMIaVavMe=9 z6l{f%bN0-^FFyYq@*AR5A>_coDB+=gj$R*65eN_iB@WHFO6oD2A=D$Z211Tj+j6Pl zQdd`Kq#H(ga+QE*l8uykI7XaMUN6hJ%fXt-Su$x zpEx}j?dbUC8#G_bu{Z{MyZUsfYhda;eYy)9-8|5KscW1w2DcHd%F2q$3*gh_P4?B) z%$t`w*$89WSKnP6tw1U0-?ADSkTjPgt8TON(lblpfL6<7ew->ON=eTzTX^u$AsMX- z1e7VnvMbDGSb~V)Xfrc_vJ#!Z6_x!knv+`UZSHR0f*>|BOzKJI0BhAxaui0{dK( zfuL)+6}6ARKSLpN-v}tzgdf4hs0ti13?XcHAU+!=LNl2|z={eMEFtML=|k8O=s}DD zFOEFT;dD+zcncs*V$QYzOCAL6+ydG;r{_|)$uct3)X@SkxAY+txCg7$+SaZgGa0ol zh;xCjv8Ai)0*vVATB*-9SEK3@It2znh4d!ke}V>LG}vI;8tv)nC#2R1JJ-0)=^C?I zfaP-r?Hq;jz4vPRg78Cnx_P{A7hJAW+LOCrLg!kv^?RXheDTdU|9JoX_rGbRzSUff z9UpPIx}j%u*}3p*!bRd16;@PK6rv~-0B9NyvpLLgBsk?~h-LJgk^TiiA)v z(^vEPtC8C2PAgb=*FBT2m~zH~o3LWHtY3fMgAYEqX)*N>3E5(0&z!jso}Sq`DZqAE zC%uMVm`SxxXGn8`-rY4#K#j6tS_h86G5!MmTIi zD!jpLzh3~BBIQsinoLyiM2zSp;dG>wD~U)7hy0-D;VNku%RA+EdC(Pj3YjnA2tCKt zF&yxX=v_7wSlMv1-`_ldCuVoh>G1ho)^Ud(J|eg>za}@9%Pekit zU@2nK5Ew>(k{B9{@=~b~QffSQx6hxX6VowIS7gd|Hg_8>!+lMy4TQb)A3eHfkJI_( zm$+O5R^U_%#It;(F_@-MM6u?!2~rxz0$(PG+oaQKcl&+gM!H~NoU2g2`&LbV9II?; z^m^ycmB~K&1l#Qstm}8JSXa1T$+~tSZKT&O0{p>56012W7Kzk?Lf>g=vznVDAAIoAOOc4*|MJV!pEQzr?MCBWceQtS&y!`?37Nip zF5=Qfm$SH~LjJ^ah{k*25usZ4`R8i8*C>!t!m#410`@fp1tsd0!U#kQW-NrLFI*!M zF{0k?z`)6q=&#$`)di=H#1abx6bgSRWC?I6;O=EUqrS{%)QG@^tVr}}c!9}v6Y~%Z zMmTyfn}+6h1QLq{vye*7D4UaZ)4)d`ktz6muf2vTh{+TZ6HvhWEhC;}9Lezu;umH= z^c>W%=N=J&9Aorj0$!po2)PPE2>D_6c;K3X()^WtB&c8(VJ9VtL^uq$`{c=C)Pv~m z#tg({28r?WV4k=<{u{6kq!z}nKml*SI^2Nq7oDqXyjXwc+?n$ykl=f+`Pjh|)t`Rx z#kVJqpF8&5-mh`gAd;p4wO_pPcmVXyj*Mu8b9C+_TL3&AXybv|*hCjK42H$xH~+S` zVGL;1Ute=%zc~HfcMpF0$=4sf`RCXE@|VASgN{@MNPC}hIC>GkGUAsbmvZsS0;6&5 zS}r$CPVVxsWOUZe%QE?*-~X>IH!Vi8{)$a^Jg`kJeCCB3fGz&@>(N!%Wu zXz^lrTRf7og>yh=705#`h(uyR_=7;PLLo?$eybKa^5O8wlNZ5$CIQe0T~vLhYq+oe z+{qKwU}^bibaauV_Qs0u2RAzsPt1j~h*4v7E|w!)Zb zrOPjNf-NZ&Q|-+s{_!W8!L~J3?|%Qici(>VjaOen3evtyqhO^&^@rd8>pP#FMthPn z!W2Tb{sFKb4@)E}x$U;wmZexpl_vd|b(|}!t{(Q1#u!;qXInL`tXQm01-7adEZey4 z`QNcQxvOs3{`=qm;h8l^X#F$wn*86Nd+xc-t6(h>&R%`j(=R;teL}CvAfZVpI1=%$UY#fKf(6();_TIQd8$Ir2KQo$#VPXK0;NopCKq5q zgPB*&F2y2ermA4VQ>OA@v7mTVmZprv#CoEB5~b`%V9FHueD1?lqMu3l1BYOtrLv!$tN)G*XgbK?B@b8X*#y5}UkGTmDEkotRjnp%2#`Ic5ID{IP$481S`lrGXSCXboM`~LBphZW5hC5tLDB~k zDTH+Kkd=jn<_4EuIypJ6J$>ZB9;8zrY8oQ#+C4*c2r8;aDOH3*X9%YZbq8VZcR9wu z=?3-oNQ&(E@xe*-IiJOM4<4t5%NAu}QAI-6hMS1KvXgp6@yBi3e)Hs$Pd<3V@=`G0 zlI5F_6@44^TTP+YJMQJnOG;%j7}Vi>Dkv(*I+y1?(#=bXR0s~@k|7`DrG37*IUVEfJdbfa5Bl&t#<8@ad5G+2kYXf^jrBD(oIi@q z@(m)yNYvNCvkkTIm!E|FTd(hEIP>kd-=1zDQGu=0>zc(z5a>4FiS_EOHaOv%EO>9C?&tlZMLCqj06P;f*>_2T?|o=1lrrt|Rf>7^(!0@MsbSoMI##URF_Ko&$3P7ECY zc}$YejZ#$*g>fKZ9hU$if_=brv6#FA(>-Cp>@Yl(FlnGlN+3iC1Im{W;}vigv<4`Q z7(qWVdQABtX)o>TMp0|sFiha2NlR}x62B0P+J5xt#V#0jy8HY35oQbfKSB|@`UbGN zLwy|}MV)BZv>4}cql%9Yv86&(+GDlnFSJ9^ zH}-)MU5yMzM924oO1C2#qY=Y3KpCtSHAvGTtmx|WbS}tV7syzxT_Q<`h>#%_ zMXY**X>@EVH7_r{sYw^%7bDywn={@-&$)KCBu+j3)aFH{sUYnfkKImc{BkAAtR=q& z8~T^ZYf6xdC(K)X3uxEP;6qot-IP2ta}HwUHWVXLgOCM}JIocQgQ1Asj&b+AdHF1_ z0rbTjmd>N+h*Obhz!At(__CZVm73+8Br6&gX7kuGFd|ioSe93mhX!p-ED9=@Xs#wQ z0E+6uArC!mVIjs7#cMy1g$ctB61744f>9RHoRGak&lx~Elcl4rXWUP`%_9s6&jmcO zxP5V-j<6yi%Rs-jd$1RVf}S3tMIyEd;hPh9rGsWV1g*szWg)D@?-}biz(*NG1tD-M zU~o7Ta9_rOX>N4OWo~pMXiah7a4*=;>0>tSr9+1rb$UHf(vS*BlwHG659$l`5N-q; zIP#Ds4BrQN%C zU(^Q?T^1nR$Y>lLXUOKSBQ=sz(qSdcR-}aO#+55`xD&YE1SfkX&AVpVW^kTI9$8K8 z$S$4jw3|JAK1y%=KkU5+d{kB1KYs3=-ZGO(?>#_BXwne~p(u(C8#Y{pwe73xHrHKu z-I?4_Ty=ddtFFDHh+v@$8mUsGBq4;$dm*d;gzbZsyLt zckXG=dCqgrc`9!nHRZ|`D^@I?5{vN~M{|+Y8e#QxR#rfR71&3!eqF0Kz`B2bRWEJC z482wRg=B?Sfyqa72aTC@-Qske%S{8<#l&9pKHOwsD+MxdPsIL4E>a3Ft~lK7J+-$WhdIWYJUs5qPQ_(Mhr%oN;yK~oJ_|E#_ zz^1n7F0;8gW^_tgS$KZMU?bkGxC|C%ZwR{WQ#56-^ z#6^B0%tByCM~|1kYx&BREAP7PF5cAv^BS|(5Sfyc6lKr`L_$m}s)Rtj-e?UAGGViC z3J3}_>mk53!!J`)XJ=z8U^H;{tji+=wzoHw6_vuNK~+P2X;Ec6J83uf4)l`qU{!BR zc}-_$hhJ(vV!N6V9}=EUn_Fv&u$C`xrFQi+R5f*SYNyi!tD*YF9w89^sDT%e(VISf z@AcPTe}5CTD_rGKYqd~}C~t*uH7HyN?CE!6kKEI<^ya(nz4zWbmr}dD*6;wWJ|Zb8 zHOi>fDZD};S^UGESR4?+4%8yv5!%%;zzsMXYoTA%bk^w^=nw*(&BW$uarSjXaiprX zkJ{B)UD49Z2D-XBdR)-U@{c1U`s*UGn)3D*>|LjHb`10|Wz_p0zQ0B3n7l8XDZ6i2 ze#4E*q|$}Z4ujjO0IRaIr}->Xw!nAkNhJ#CWHK&XBJY~Knp5kP107D}4Q~yvZBg#K z%z?alCx7>i%7vv#qPB z3Fj1bsOyVt?ONsOIps4LpZxofdF(0nmU3a&0%-U{AA(aW``!I8INZOz{OwHfkIHUu zw^y#aUklcN{NaX{;!4O`uswZkywl0}HQXA0PC3hh&l@XylW&HgOTnr5))vOS4};r3 z@@CZ_6etc@;Dt5qba%Sjl(4*VfIvu-Yinr*M9dwe)Ncj6H{_!$iqD+hEw2)2)I-R&=tq#yD zFVg95I#Gmin7CZ3<$tADq;}jySEJ~3^pYu_qF277(|vRbqSO8nQt;>c1CaDV_g;HW z4z3X!=ED9Pd|wJ3vzrqAM1CKQzVQ9Q{{D zs*!LKl5e~tRqa-~($lGI2$YE~4zHP%|KIQ)2{7??44vLNzm$I_&2aiFb^RBV`6F}+ zq|?UpYBoH@7J5aZ*emHu?YljPF&EPKXXuqWI!R-t-FJJ56d%$no9MKHPDVPR+)JcD zr}6L?YT12NbI-QS?!)x^%y|R-|5_$E| z)#sNi;Oeh3vQ;C8vy$XE)eYjZnvKGVVl{{bndP$(N5b$&W?Zr5oIpyZ2QY{v9bN8tPAY2 zuCjw*%^k#22zubIrV?;S%9{Gcb?bg;(!e)`w&{m;LPV+=+xvjjxdK?ee#a7Ow)?gb6AH&$ss37PI z1G5X7vIB^g!z+zO9U~cR57Qcjh^8j+7@C`4=2X$#1a>i$sK7dIY($iI0}R2Lll3)+p)(Q%seV&A z;N@s?iW@eZXhJ;VR8`aQ4PBjOWkO7%0oyV|;>??GzIjHX8I3Y0PQB)uYo?424xb3x zZI6ISG2j+z*0QXuBr`m0l)B7GSvq4_D3k+L*xsx37HCOedkb-qUatd}QP-=36O~YV z6gYki;2VIa3HY}pH(c21Gqnc(b&T-%)CO=uAsb+8Z)*n!zP(N2r#5)|D_FTbh$Q6@ z4MG&_g*FISgvidKyT|Dc;Bj~~T`jWKY5>R>qN{>b92`JqRK2%u^Q>jwMD%xO~SOq7-Z@%zV zv&odr8dsYG0(7oEHPn^^wBC2$1v$1M^0zT2JsoNa-{t4$Ao;-A2G}{bdbPT&EGz+R zhP=F8TXydT90KFwa?A<^7i{tL_Li3PL7xDNP@8JuHB;g08Va+kVJlwW-VRe(LwkL7 zZ!d7iW>71mlA;iB#{yGa83$*f-g8lJiV-&p^`41(N5e}p2PKmLPg4nrsrm@t22=?J z1(3wl*=^xk7@5>Nq3h82!w)2)^fsP8+8o~A*OwC-6r_v;PFZ@(b90FW4JFjw-{sXw zA>kDc2kS-h9ovdqoW!B#J3C7t04|{{|Eb|^ zixy3vjxlHppBruNg(gZ{+kir00V;-YtfTY8t4Qh2xba1d6I@$fA6;+RojSmNg4fI&%rlbID zR=dsE8*T`SR6}~-3*&`st`ct)#=ed_}lR6MaTk- z0U=6+-Z2JSz=*b3f)Evo^ctDjo~}@|ff3jUil&68PK1qDYM7e%$;P<2Fb%pBcDVd3%PRSjTuHMWCC5bSnGFI>7b13euQF(wVdoj`u-kXVQ!IFR0MU_lBA^O<}M3{ zX$bKI?9qn!@#E*9=jM(}i@>xy4Q>--W2-}_%$l7Zu3wd?wU~If-Wp<50BvHUwGaqL z^z@{rMn(olpqqm~|}=`upuV11~Z#EIS93LVSQBG(Ivi z5rHHyt&PrzoHS`*0G)2U@y5j|ASTu@AsA+yea+1+=m1D&%faj}gypYY$5-S7f_0|=>7$$ISE)NytYnuN5psciCu)qy5cdS#{V zlTY4!Q>Eg0Se|XL2P7WO&!0Q@z|o_L5K4mV>S}7L)6+H6rY%}z2NQGgV$25t4RNut zCr{$kz+9j^If0^RK*iUS^*tE#TKsiwwk#tH;dHh)We?GVKrv&nRq zXZ?D)9hf{e*p1W9rr;`#OW z)xN7M2im0l%GlT%csQ!8gwIU5_G+o_I$z!WQjR6)hjTJ_Xiy(LvTxt1rcUDby9>Vk z{@}Wrn$N%9a_9`0o{$@~wlas^DvJ7qi4%cHJ1zA#UAsvO0Fv(|vr(^t*U;nw zM~_1F=IdJu+96SBC^_)$PQCKePdB~y&gWl!wf=pW!EHK#y=WrL`}%u&3L&^X)oMsd zNeNcA=jW%T9Y0sn^dn2;6=98aA-cH9(K-4$0vVBB2&n_u1o zpJYpBvAOm{p0}h5(rK&*jEcVRHp*XQQe!s;z5J|;-b3&}dY79Kk+W-Ud! z2vM2)`?O){>F~4aR)r-b(HhCfg6&q0bHPfd9mwsKZ2J58FzB_yxlj#OJKa4{EknH^ zMB-h&JTYlEr)!OJplnR)j zIN++Agip;49pGJe!BVxp5eqR^X*asvA!+fR)-Di2C7k&NV8}8^YcVpRmeJvMIuY?C z8}8IPTbx2h-tiiS7dn2(JMigWpL6l8r*>}qY~5QQt$XipFCkK7ek~@mvLid+edCke zKd|-ovFQ5%SLx1Ov~q_GOb1}YplCOgUcb*PZWTZM^l(`>u)ym${>7(4M!3caKR2zN z%$zV~#+0fiS47g{#S16hbkE&a!y8dzmoUtiyeZqY%gql6w8h7b>zuqKOM$bf4j ztj~HmHI(i=oEmm^{f&)ns#AN9Hqv5NQs?w~v<9V!rFVwFh=GZgYq+B)HpXhLuUU z)&eCSix!KnPM(F>F=;BaPy}$=AvvUz}mZ6v*}?-xJ&tgy+Ak?3wX^_c+8 zX)SeNS$FCbXK(TiSHj8njO0knCXvaLX3Ur|iOsje!aIi#=nII%MA(HRu35cmI&fT^L&KLpMuKodxBnWn~d~>o^cz9R9iMMI|RrZi$ z$EwjcZqEQ5jbc3l9C5kbS|OpSybcMQ+RBt6lg6md05KARhwAhXC>+7-tBo#B_*vLO!}05GT&Y z2PhQa_T}h|85tJKu3fK#!DLg7PfN?s&&bi$A3a)E7ZGt8LJ!(hXIolgV_S0g$bx(LG&obkNeyYVFhDzt1D8b!w%nw_EbEuY}a9 ztJg)|_C?PCcIE?K(%ulgWL2q9X|N#Es37^9(KtXPA}j(X^ypVZUt{q>s6lVvvSlZ% zU3cv-YQ$8Nu4+7v)k;AlhsD3xSOA^KeC*7o0qeV4;qs@kwh^x3ia-^xcBVV1HnesR zh+lk>U)zn1e|K&E7kl^CI@LfdwX=4wFe55NL^FD!4a3kzF(@i|#+6rIIepfwnORp| zb=Az|D21eOKM{tb)1yqFLQuZH{PN2)*fhI+ipHqd>R}qIM-c4@Abmt|uvKq@W4g4^`y6xfE1uHmsMu)1fmQ=O}ptK`u;ndO&yI+M0oCT;q;VI5Wg#vj=bEsP( z%;;=v1KxJ_z=san)Z7fOLFn85s;V~X$!=J70o~!a4D5r}PGBHp$!AfGPF6zzhDx$V zDMEXvAC|F1T~wk+l1;#Qq)XK4Kvhs((aUrJ0f-HOMuF@H$aqfrEcXDl{B^Z8o5GYd?!+$WVL{~E4$N-p!iapH5# zDmzOMiN(O$%L)-?eZx^qEB3Md=##_X6Mg4|qcg0W1C;LOqP&7r`@TNW8x_I!?aOZ! z84&+r`A>x5rh zr%R#oQnU~krWlkk03&fX`lvpC=g#l3+irC!H3pT}mEcG<({qXhorYW{cullTR-3HJ$;m-9$?&|px4+k8u!lA^b->o70g9>c z{nXplh3yaw(6vHhS3@BtmP*gt8fAUE3BCc)%c`z2sQ;FAX|XcUVw%xle_GMTCf;KY zJuNx3f$PMQg49%S9-y9A*es?Bi3tg_Ft1#H-*qto*Di_e>~o?sjd5czU5$wYGYY(& z`F2fQ+?Zrg)!#|MoQqIjMaK^AI|^v$A4N>gas=%&D@-PY zFEa!v`divPp6)&Y4pvnC&X->SJM{fGUv@$x<4e0)r>M(2bEZb4V;y~n0iYKG^YZcw zP6da7NW_H%pUTSv-^`534`T@tmbC$hVZamubg0APQ&ST`03z`nV+GsL?CI%)-*s=l zM;Tzkgsp{*r^TexMkNEYqT-{p0Tzf?1C%PgL8l9~0$V_K74kfOeyb#{q=9zwAPqCY zu0|kgb04+JQ}5_f1Va0cY4wVZHs}aNTge_QJbL$z&(N~ZcT=k@-A!(t0qz0w8FQA^SU(7t32oP^$uld{MoAIh!lKYBvkvNFN^_7-XEJM*3Ke|mODm)R9~LCUgDV=3 zy{&|`V7(|5pMAORy-e{YrMm-aKT2hC;nrd$_tlG)K$}JBSXHDiy8Fo|pS&9~g{SR? z-rkUmMNp|)lo0|`7*40o)~ey;1v;qU&l%uC1a`1NK>>OV0y~(hny}Vuf)AkiWl%{ZYb~*)Rch{3Q zOI1-Ej*L7ksMPJS>ON>!6l=A`f=XZawF~;diZrh`%~!%?w0s&$n2Hj}zIX~s2;Z}N zAGBhjd4A&X{{7hO96Wpk3Ty{qxgYB6>c+yX1M4%au9yp@c6OabWWw--i28$QNfEq0 z_2{rlSG5*y+xTW3s>WW-6mJM#<0vX?#M&Ck8c%G8zxAC3?T3W0y1Lj5`dhMOZukJ` zt~Wdlh6Y(u8$!#NI*|{VbHfdzf!48;uX^Bt2kyws=oG>`d$F*kLpD=R6+*vN-_?!v-~YP2j4M_-A*bg%j)n%Vy4;IW-!#(%*j{JbT$PU&~ZznFcM>pk>l3 z3oWxl%9TUsT@?+cDby% za#_Lj==dees-#!e%c)Z2RFbMd-z8IpP^v09)qFWsD?Nh7)Fs+gOs^c3Q^9XLK~RDQ z3cN(BT1u5CrZV6EmrV6P*)EZ)Qz_RlT&`g!J-Qe^sTkptq*_uYr;3nMVTcUZ z#Jg}zdg&F%u(G67P>Z+(48NsRU&^Wa<+2j#5tOd}g;f9G96P!0K=6oj?7@Szo=}(( zhI(obUO2}p6gs2iq1dQXj4;QBgjkK_AyjR&hWv;*R*_O)pE5Ydk`wizbL@7lcKhHQ z>r6^=4$iT&0|OBtRf3nsM~!Hus=@q$l`4Alujq2%H0DRpK3YAnqqqer)ZrGB60^vA znvqj~0-Z5A^+aFl-EwLVrJi7C)8*7Ulx+Zo*ZInZGZI)))?re(+rG-ieT56GBQkPq zd^-CT`WhFf*>e2EA3^Y>RZ{&)|Cm-GM&#jJX~Pz?4q4bT#fA3OHue>A!Sxj6gp}#O z!W3plW~*$9)5{sXp*V6c6mnL}@Srs}= z`@)vVzL)Z20e}?lUZXq{bIb+4nXPxxxN6#47rJk!Y> z*AK%)YOMsrAb)PONAvHl&h?5SU@v>0N;a%VuJ_70W&RO4`QaA~N-x7t@6T!EdNsU> z9R5i4LR)V~y{x{r-h|vQn z4qs{nw|m*`{`&dz8#Y3i1O5O}ubGtJ_Dpd;`nFYSFPn?-u(nM0socB4ne27kZKGU| z--pqB9>(a;A0r6&BwjOEOJDCQP|IKX>Nu5hk^1&0=jS#IQ~YgSFeEpN4_63wSUdQr z!cr-}d!g8OUhVw&EcFTD^F62?-je!w5w0<55|s&Cj{HDGkskv-zl;|Dncf=6WG~pD zlQ_I*Mug-XdS8a57%~i!Aj6ce50cN}J%Z#pYBMr6YHSRSp(xKifG27iC)YHQM#(#J z%R7I^9;Vz%e#vebspbC;>&SOK@{QZ{ z`Ns$z@z%Ocn?BnLe;T>lHg5V9K5M@FWYZV0Kzj4572;HJ8UiS$_>OV7pDIokFBh-y z9dpDfcrr=6(sxY2-E=Y8cg&Q6B>z_YE$Uip*T7ZNfWElVm2Gr27VBXlR#VfgFLq@o zyNdPAH7b=MD$S*fjE|3x)Vb243<$YvNOMW|t~7&kP2`tH5mBkCs^Z9(ujF8-B&>S% z%N^K;w{83Kla(S$k&aJM{!)a>eZY786d@p|i!q|^++h%7@nkN-K*|S&haB$(d19uRg*rFe=Z+tr5}U|t;V7dI=fv2Q@f6XPpRi=fk_kNG+rkT;n>p$n*w^9A z+zQk7?FGyc0F9LZ2P@dVCa$q?7ubgzcNaB!BEaz(9pP>~LqI;$=mJkK#0!*H|LLc& zUcB|btFg3rfqlfjV<&yb9`+%gWGf!R-xYAd^a6XIed9aUsiLD-tUy<5-hO+-o@2*K zyRy|?rN@rte)_h3EWBiFhZ4t@W5;Tt9A+Iod2-fm_uY5jZCR5irv|F-yAM&EVzIl8 znmqaH`__z^GMWUDsYzp|%wG)m*$b}BnzCd*{;r)8Ppq8S#~xe(U#<7AeCV-jSq`jy zR{4&#aGdxUdlt{+<2l1)_h6&{z}>gpBB^SAYS%7;6n!?u6xf@c z+#6_0fkKtLr?sO_ir8G&(b@z5GHz9C$7vYgo$hE=x!1(qaLWylvRrljO*bsL9#P7d zU4P9DOQ2(U-I5z_g$v&sZ@e20CjN%cv%cd+#8-U@fsS7Sn#;$FaD7BiUhp0C+iUD) z|Dk;J(I=k3AP0g%F$WSStvVYjJzj7~QJHSsXB!cxq#JUSk$+W6zsfqPh#bl|*l?&-`GB21!bysH(F(kmk6_V&Ct6^>e5i3308J+8 z5H0hE4AieLK2YcWsDXNkfvk-DyCeBe|Gtsz-S4gNb`hgjIy$}Oc(UI6^10(x@27Zj zROIBtdl+}S7I^>cJH8o03LgL0NkQEIAg#Fnd|JUA><7fw{?K=9M%Zo#>*mZ{obRx( zE<8EGw)>6~xa;7I^1+-+iPmJ2IN?HSGxr~%HX~6Z!+(ezseR<=ZZzssH2o9b@e$jA zCkFtMgTCVk+m9#5@k~BW;_m%(ftue{`Uo1NG8ZFgbAJ>;y9B{g{z!`VC(y5diQ-}Y z*o)6L-?1C>%{OcZKKp&gZnjf;vI`N}>7d{Cv0VS*qnMoepQV`p06qN$=;`C2rw=d% z2R$S1{{I#9bcMIdTkS3Jp7b5XxUcd$ybT~n^3mk2#FKK^+R8^M?rOXzd`I07di?x< zkRB&X^!O44`+otwUO7mwy-U6S<+XXUeaD}?Kf{x0-lTI!ig!Am+~)m@@3<9ri@jmK zRX?snfX-#4LfQU8$%W$e%iWuzHr=6#W%aDtcQmjz z=Hj%NuXVn|ge9hn3&b<|2ttZl*5W&=eG?Wp{y#WjB})_5C1$k$Efd<^tO3w&V3odu z;NHNh@oD!R^{hsEQpe`Dk-gE*H^2(Z#`-eZ+sOY* zJ0C{nDfVWfY?{So{(pQ3_e=~w1NcrpW1t!ou9)JQUC1%>VLVN*MiM&Drgc5$@{SGDEGOUEOVI`FLOIVo6j-rHi ze+hXDq31oM1T{*yz?vKUMUwM;j8mp;olgp{r;b|pML(;XRFqL zqeM~%&GzlPcF{*re5g--AyfQ0cd9s3Tnt3H-wur*M(N&AJHq41n+UWlEjuUhL-R!8l{l^$HaIj z%>mP|i|0&6DBleJ8BfB~)~VC^dHJVKp7_vs=2XFH|A);NHR43?QSW=IxWzR)U)-G~ zE)&Ojn~hgi?}+t(81mzJ6%?y{pA}gvGT9cjxy^jfjPM!rpi}U^@vwV*z5m0E?X4Z1 z&5f;XtC-@>nEDUw~=kJ4JTMK!X^UfAO7U*@i#OaNF*!^v+p2Niy#f zbOn=e3DSfE7xZVQ_z2zs-IC69ExUqPYR?&7&TuRUr5C9%B4D^|@|_0LP)Q`sgGvY5 zMwkQFzD3%XpfMHigIYEMh30@=(6vVF+u4P8r9m51u}FbjzN%xWdBuU?{J^akp(g z&QFVJBBx4N-0*EtX_mM|{F8WUKHK`bds=v|Mp{ug9-x3BtZ5u4!-P+Tk9cWZnRR!qv;B-s9Pxx{{ z??aYKz3@jma}N`Y_R?vn6#K%_{58F@pH9u^t{(E?7)fRP(kzm=(Y&Q_RGcmrpc)S= zAjP=Z+YbA$U;E^?c!5RPS(Kn~RKi;>)M_vnDlCB!p}uRm?*3f;9+;$DzxZPP20=l@ zR8RybytGMB5bYFTR*dMS?6-n~sHLFrl$`Mj3ZjvMqBdng6P|!F%HwXtd^dgm$tV7N zm9fvi<1o5^Lx;Zq{?PvY2XL^kjus~q+l#*XoGG+@OziJ*2aKImSLQ%Wsfgr> z(B8i+I$90Q$cj98nlGrT>W>gun6d9{kE^4hxcG$^PB!#w;F>EmE^6gZM+;#G59T3c za!c)zBW8r=O^b%2;OL1Hod*R2`E_lA-%k8>K(!5elqLj+#{t?uSXU_GCnqH*C&nd$ zUMHuFF(O3i*|Y6%_2q=ho{Vc6w3oC*{F(@)4Irs{g$hK4STgAnY?4(Apk7sqr=#Ev z#RqM&Tqiq{a6tQ<94v*&q_c#jD4xXZcA zZAr5W3TA_B1jID;DwObM*WVOlR&Z~KLM zPrvD=6)WyswiMt2lgNZ%JRI4T5ZvFtxpk3rauoeEt(Jw!dDOU>f{oKt{-Vt=buStIyf`q z#8Pev;|_*$`xux14%Y#Esp0Bn+>b(AX%3kdufUwhqQKL-UU07o09D zJAC-aak$cl_q~^-M9;9)+i$<^hN~CNnKo(Cq^YwPU48u>P&;3_a`x=`S6_X@tyf=} zH5*#KH(rbOzJz3)FB$B$QHGYD1~yT8GNMNM;%9>F)e51y{Bt^qa74tIOpU1u@7Q*aHk zk}_HKmOuk2iV$PA1O(+*S*J{#o;el<3-}wGIep^fn1SN_Ak10W;jW#BT9`XHi~?i9 ztO-#xm7FDY&O)V+zW$qGzj3kmJMWnv4f~Bh0`?nyuwTon{wdh6{yVU*s;(Ll_EndJ zJ>p&g*unYLK_kIFSc1KK=V8D;C=_Ol)NkF*t;1nYT#rcLeFE^FVwr>k@Abuw;zBr| zFDOLuTKEV8>BYPxC|u0-ai$mxG8M}{7U-92_wJoFYcrseZOa1Mm%?}EJg`|yfpbp+ z=WMFcgla{CzRpqC)Z%coG*Ol=rp81EdM@ZgEuoRo7E5#_YCGh)L?;tbw-nSZ9d%1Y z-4anZGu5rAun3E?vLc5AxgijS3wi`9_zvOVeMYWj76#bI;&PBfb~;mBzKUMDmTkSQ zy85=bb?f4!3NMfAW}of7u@YxV?2a&6G)|pJrl36zHg_DMG|4 zA}hOgov5jCRdJ)aOm^cc8~cnnvumx(CkN>hMok9A1l>GRSk~RJb)NX7cptlI)fJ$# zvEm}Ek8Xy#(=Wvbxm8bPi8qRHp>EH@O3N(X$UU`ck@z4~oqj4_g*Riw-{yc~hUX2y zOE&LUd}5z%1VKlKuTLUR$GQZ zi{5II$R#Y&dO$wAKt@-^1e&f{l%i;9Z*Vvza%pio8rqu!=3Ha467JZ-l?&&jS~z7} zixcD$MAH-&>}D;8cDm%*{ND`sr9U3-qqwB!6-bZ$e zrObR}SDizl$t)J78n(0Xh#)9aW3|i~BpR6t8ksSaMiRNI14>qbTs6=fVj^>QC?fH9Peg?+O4WHvjTDJ}uwU&0O(ZRd*_ z)!3#Pq>@CIi)kEyEEknip+uJ9Chr`T)T^~<0}MR12tFMkQOV(NoTHK#Z4nXRhU$y8 z=mV)mV=}Rr#D|T!phe7g@xF;V=D_^kMlco+0br-&K)BMI4me*Wdx{`%i*LEoOF z!-kq?8y|oC@n78klRK8L{I6f18oPiUlIZEH>}l@)-^~Y?%E?XUuF0{_$K&r9H1(AC z-}Ufg2ygT*JHu+Y(5;y)NAtU-lc(MH)1Uu=f*#?pVS_|6pe*tCbf}+t^_7E%Hvpx7 z&t2J&B}PIecDd@Awa>r#>gp`QPf<=(w5#iN9H9^A6$<Ujjv%945 z_~FB?oe0?W-R{rV?*NDA=uuxE?AiLsdmnwZEqC9(hU(nh{YOuJwPP>(1$_{37?zE5 zr%xQ4nl^swoVT6x*o*9G<>M=Ex$NpJOj3PZg!1s=!>FBoK6ec_nK{{4-0$vRfK~ge z(#&?qIQG=9|7+#)JAQKiFVH!w0-|1i`Q^7aY~4~*f9Ty;eyYlX!XrSd{^Y}r-)w&4 zrFr7*;_ZACU_Kw(#g_w|kEvF_`sVX%pUD!J1JsdR!<7KNI%mV-gRi`bIZo=7RWWdR z*wEP4+3TojI(znq@8My@*=MjS+FPL(44~V?INltc9B0sCqg`BqxE+06dUzplxmLyG zBBIZrg|fG; zv%H)({=Rw22lHh_jiYm9qVj8eE4`_$?iRej-xLC^Xw2L7jez|X=b}F(RA0Mx z?Q^fa^WnR1y!6c2QwspbxzKlhK>Q8&w>9&{YeWNtPG9A4QBe{|)i1rV`J0U&enJ2P z$jz#szVhy&`kF0UH@uDhAQWf&V7|JV4)t#zcyPwlo1TC@0=GRp3zF^<_P*-ZkN|?tnxbSPpFG2YO6;h3HL41>>jR zSy@uk;T3aYa&rd;V$?r;wBhCF-rv2uuz1hjts6dn>&=h8Kt~Xo@3Ds?aY#~PfK})9 z)HL#Z^n}qN5v*|I2YdW`DIcvNFNla{Amcd&13iL(Yv1lYa^l2iA0Iu6>!!1n5(NUx zg%D#zLbO>ApA*0U1l;H_>&QjpD%Rs8A$t4T|NiT1FF*h1?1UqW#S9RdD?xA`0^#|M znCLy^T}N@tBvki)1#MA-cn!DaZwv6_1AzQoahmbe*k@jPw^T9w|L(~W|29TwFIM%X1tA78$il0CJ z>n!#@Kwrm&Z%0?CpSWr2j0Ye1t*?_-*$Be$a@reUz;=^B>~-~2mo|%>AR)Zy?WifK zboL|25onO8(3^S&cqCb6ivT$>MS{rgGO6|rZ}2OA4n zM9Am~(LCQ+?8?Y&^>EG^-(+0vW25f|GRVNeWi zlFjEB6vJ2A*}g?#;NW6>IR9d!0M&=_3(<>1xfcs%?!~#4_`)G8@yGd-EN>Ag-SFl6 zLV5Yljm+*?D_oY(G75&UI?k=!3o=-K`oNG?{U8e@U6?}arD5A}X+bY7EYDkXo@0$j z+w+j2tdYTGes>O{44OF(G;7z`>O>xVl%Z9dy5 zo(2?t{T#SN z{_dp}CkQ)a4^1sK&Nhm(4tKVtCy$gC7EtgqS}($iPI6)VE`4&1+i=g+fmvAIg&rm?F8+cj)07XT~6XRx$+ z60+&1vGe*8FC3vm1t!yVcigcMY{@_4mo@knxZ9y`6JY0UTH8V~RlF9T zeLhRPLkt5eAq%X8F!2uV`DYi2zrZ5)QLN0C;LTKts}Bi({)wU!h2+JT8sFO4bhZPY zWu&P~1AD%}q(rZG4UD-GlSn(tEAf<#hl|}7WAZ3+A_%`mDz`T{Fn;Q_Hxo}8-cI|v zr=&RZPoMa{3~qr9eSKx$pEzC6Y#KME+c(?z`{9@92io!a`vDL?RZx25WT{-6nl>l; zr2)Mo^#ferS(1~VRwKfo7T4&94fEJ*Ot3DFFv9RoX73#>s_1Dw`rbS&3+`u21-O;e zZe5HeXeGe&kZ6Njm}TNZ@iK8Vge{}-y%6^+@%-md)iA)|;1Js?%tI%vwy|Y2+}A+k zr zz6MCKv#>d7U>|Ug{Av+$T@9x>YViEdc5z*IRT7*hM>mln=Rj{M9vZSP| zstI1Q5%aRRFt4Ea(5_$?b2GO)KHk^|;Z|RtGB`fo&B!mo&&r9&yD8U}LY*|CYqmMqi;+9*-rzDP@z-~(ymyJptJAPcMD0Vln zWp*g%LLQCIxzWCiyOEm@x$z-(3~I;atPP6aChQZk*^L-$C7cPuPA@cbD)H+vyd`q; zg#;oF6&3K8>L|)9&O23JQI6$ZOMg96PxYfn>TOWy7h(U-`~1z zE3^U;xBAo9pML?5HVX+UDPzVYC&Q$5^6Z&Oqk=jrXU-XA34Y}M>t~D|7aAOu%&wJl z;q2iY7d>%IVyd!WUraa`5wq_|OF&BEn2FKYok*1a4$v^#L%f6j`YHPBA@mm(rx-d# z1%kuNIHY4>AblE8Bg2|S1GKK=&AzK6>o7Bco9H)8)W3gWc4(0%7v{nGAZ zj!!&#uNdn+1L5e#9Bl@>ZS<)03FAlERo&P|H+0(*M~$D5K5F!BEaP@=VtP{Qgk`s` z1fD#)^44V&Qj^jrf^gmrnZme5(tcwXWTCHOn3KHp{2d4J{(p&$>dlZ5V6(yJv6Bm> zJ;zlL2>eA$DjQr1@zdx1b>lcyn28D>n zKmYvm<6=lxSIFp@bLY;TIXdK`GItv!in7>!b>L93`}|~Q`S-780l#o$TRm;%KYRo6 z(%?okPaqf%*@yzPfH*<8qOz_DuGgCCpx-cL3%VCD9<~JqBM(j>U9N06KBoZH_&X=A z=Y{dZMaDWc`H;gIF|Y$8;n^+{%6EeT{9q`QB714mDGTtG3eG!#+>!l&3)%qEZu5MJ zKH-iXxbvhBca{_GltGK`)czeiXn>TJSJz`4*H=Svq`ab(;-i*BvV*@JJAS}>Wo5t} z8@#Q{Y>o~$VTw}8i(+;#(1${dAili^P99~3h;kL@x$Y{cJf&d|un!BeRp4B2fKld2 zRu*)u(hbL>?#g3%dB=9Y{q)mMzrFj|VpfLVzQH^1;@#I-1((cSuDlKy>H*o1Nn-L4 zR>t@+bx#Fw2N*OpT-{RyLE~`#$11id3pC`9;uGp;-u~pBUAcdr2XZpalxT)S(mszl zalwMb84qM(HGzYFU`FCRFeVqN|D3z)oloC@Jm`<&&5BLaWVEpklRbX@%@5^mo@^h| z1it!xxic9{m2+$j2qahMjIRb(w7Au(%1W%Qs>;s5Z{L|RP^GHM8Yo%SR8`?|Wu;2p zUFpCZ&~&m{hchS;>zm`UgVD2Y^eH2N56UdfLFVfhk5;#LHdprr1zD}S>}V)5!gF|Z zBzfPAi3|$rt8VUWvt(Xx4hjO&*sQ~Zn1m><0V#LafLHcuElV}eN!L`s8F)sN2{p;~>i_Ap!{jg@eZ zHRB5k?*@Fq^?RWF&?&F6FPb(;evK56k=7}vZId4w{)M!@wf#lQO_PflUT3TPutzS} ziuPJD+QQIBR*XC=Xtc(0rWF5hkEtRL{z{FY3I4Xh`gH?*PpZb`uRA&VHbGPPaAMJe ziFm?oSqwusZt><9c%v+X?^SW*H0Su=lu1b zag?4y$rN~9jlfEjL5{9=j%9~+PW?_;lBsWF1sS(x6y)gQo+f!gWJk(#55}`QYr)#yc zdwU^#RjyWqKDq#mq1AZez?OL*zAeF4mhAU!G;$v0rE(sH+vPkKJc2xOD37WdIS*^_ z-^ruQK@;F=<+q*R!uRTG#ftD1$m1zIsdfFW<8cE zhOx&49&pNJ9)7`7611{eb|$nGvw;*fRn1jr(CBR5WpDu{f~`?j9U3p+%eAMHDP|mD z;$X?;rKjqtGw`O%fRYQh&l3~iLY6uvn_Ig!lQp8;1&>m>PszQM&HdpI@Joeat7@9h zP=BG?YA1@d21n_r-{gC#|H%$C7^PC&2(kl(Rx8s0?p0L0W9n!FNo&k|oE>@P*fMt%hk7lJ>d=4u;Qmws$yekVj&9#e(TU zA^g4ofH*69C38de=XS)!)C$=q zgV73K-0)8oW;GfNt6=xo*M|@xXKj*Ne70I8mzvFcIZ=9qZ87>>i5e==2THPVVIiC& z*Dif5@d(NRjHI}J!DxX6Er8}dK3sr_1rra$_pdNvBVSurTjMA#DK06^wHaNA5vf$E zRjA+q9GC~gyEb5FvoRZIV|1ZZgy5N?;hFGXD}IeLa*)!>`5*^5t(*@tSu93_-e9x@ zhlGX%$DjA>2(3_E&XXZM+Mr*_?{!9$U+;eN4K_ z{sZ@iY=xt^4!^1qf!?AIjhC(@s7ynCW0?{k1^CoFKG2Sr_FwX;+)-8yP6mKd4e!eM zE5XdW8mif5Eb<0C3fhRNlnT#)OZao3mw6PEAA9Vv-%eI|jzUQL)KNitm9B>T@|5)I z6<@!Sot-Vb^7R!D{^1XQ_~V1}t8_Kwmp?+&fAFu$QC(I_bto;Xu0^HlYNg6>IcBPb z!b3Zj0k=n?QVX%NR-AOUT7pdQKo7v1f)F7G?)k9VnVcuA%> z|7KVmoj(2O$gtSFIXwJguvqZTHw#9D#XRi2HgK0;{yzf?qA)b}XyrIaX{0gA5Hhu= z8+Nd3IBW87xO5aM-j1ya$wO8Gf1i(G>XJ-aS2b= z%z;x<5dUR@f{Mu!?*#$f!&^{Dj^xCEeJ)736d@lemp(WS&{4TsOE{?zk}a>+$K{!U zI?O;FvQP)o7nuPXk|2$ZT1UynzLBfILvJhRyi)>d+TH!rZh=72{h;*S-F;8@Q~rC@ zUf`c+KhNQxWxvS5)z5PH=WwN#fc35=lL=~Sl=VX^E(4^eZ(IH}_{fG<3jD+Dxg4CH zr4z1x#$L>k>s;cfmFhggw^V0nh2$Xo@6b~M0mfpmP^ilhzX34eyfo6SJjSj9{FVbl z9tMV71^8XXxa1@jD9uW+1*32JlfH&sC&8$&suEnCaxiuz4jP#m4!_Q|>97;0#3Eu) z={Qm3;D`-hcKzTrWB(l*y%^Rpi=$c1wm05*V_VJm@h4AKq$HFHhJ=)gGU+Y}cZ!6h ziZbCj^J}kftzl78k|x93=Lr)}o}8GHP*NcnQYuQMJEW-qnoxOatPRK_D?40d zU8N^Z953x+#>5E|CL|hLTZ4j3T^%OD(9vZw(H%nOngs<;W)wDVe4>eUl_5kxSr;=U zju#Ag-i5bXyYM>hOeWtQUQYr`k>DXgMuG;}PU2$7HGl%<4*((pFm{~;4+$D}{oplA zf`MfK@wBW6V~BqwiYYAN8l6!d2dpho`{#7qVYt6I9Rsi|#hYsMnNQR65+T~vIgn54IYl7VE zi#ac>7yP+t{JDY5YB%f|n6l)dKm6>LO!krXmPej=VCm&---eO$Nv(4&(ss z2ZIzQ1-LIQEh)@9g%zEn)KS;iP*-0^Qd0pDhd>>&Lq*tgqlFA1SJfJ|ryrbCcRxit z=Xj+s2FOqU#M`w57>!uW;@)U9sCktsDAAF#bKh z)P?&O!v!mU+JU8kdc=cz_;$D&HG?faNkgzlu5)1huNn|}=(n&xXMuzL7&n!_f^Ez4 zelM z4`%!(yF$w#UnUns3&fkepLk2eYC2%$^TVTug)Pm3WNodZDwP$S zDJj~dLzRHCZ}N06b0i{(9nDvGn7mOW{!4HY^!~7a?B* z*kQJwyKDA5+z)Mv;+>n`c_&l6)25>qRkzgFHk{Oc{b30ghWdJ}^wH2l^scHFV3emWNO%Uy)alBdqLwf`&HV2%0TiVPhxZ`Yz zzn$ofg%l7+ZY$)bpGd1~8_7Bkumjv2R?Efkk4TN?mX45DNFIDA%bkY~yo=m=qWxsj zcbL=6gEooOm6{EuIfwqrBp7h-FjKVzhSvHf0HWn26hi=T$f> zdAd}nsQ>v{pakVOATz_ffr-OWg#&-p)ipJsq%ct?Zx=8Yg2{4uHN+y2wb2AqRRf+u zi0E-6{2_#t*oY(GnnLMzBbX%Qv0mmCAxX0%LZF+6AS&C$VIIK~NTJ+rC7Ykcs20>S zP}=~WVxE!eO*goszQd)2pY#^hnI6*>m;jWL=jYZmBkp!hI=-4q_f41(rSEoI4_MvZ z`q2~i?JKS64NtFG|ML2p^o)pJT)ez~)Z*pKt=yBvuba{5&~9cT~brwYU4o#-&%r@lzC6F5SpxVSXj_fDjv~fmv2U9ekCQN$67rTXHB; z0DqtnQdI^CB@}^aHb%H+APb~eh}8*cD{zBh^#RmDqq7lg5!eIKwp|Sb@&Y}wO(O6J zPasrND&1@rW?|YK1_lRXD#l!F3BZ9rlgVfd4i18qIz(&1*y%&WW;GcD@Uw~L>;NMe zPyc+m@|dGtC-FUp@%>BDCI@j_RhWTzwN}tTCXI5B_W(`zu5YZbt`rQkl{{BFK~;{9Eid)$g-3)8=1^HE;BSiNTDO;NlpwPe25nn2#g&4_ zd;2(c=Qx54TZ>pT>7X2C z5)pBAdT4+B;oNZ(QcZpB`Or61eI*xzP*?d*>aiLY|-{MwY0al zomIvlR`j@02@xEE8~5{^)fyWcV?KP?9W!z4n9(VL-bMt2n-~p!pNe91co}r9$eT&E z7&>w0nd3()#Tbh9diwO~ywmzd#OB-I$0sjbxG;ed&5UM#K#R znISZQLJaEeK6|#Y_p-}$ou!W2y2c)D)Mb(`KR~C|AuP5oKpRRfLUeku6;W9Gd9_E6 zfT@Xzi76Ob_#rghM8a;91U!V%?&jST~w+|@TRO~2i0!y&7n;~{D*4G0AXZsj9 zragWA-2=iD8nqTnE%`91s%mY3W=dCgdrNmquR_P1jn%~FtgWjou5ZK-4SCI{OG-*= zSUh^E8$nE)9po{ix}HLt_AA_42FoGuKsicByy3dK9w*3!M&aphtCJ;mw2F+zY#D>u z^4uztT+d)0QG;qZsvWsD6=^}MnN}msHTXq?DA?PYCc2v_Y=~klq7krF+!ef?+vXLZ zJo*?NFxrO_9ftK|STrJ=GRjEOv&N#)5@a!1;{Ep$zEVy84}0GoA7z#Gf1hW1Cdo|N zq{9FKLgRaDz0rx5Eoh7tLs_;m&LkD5e1}4?;s_lXVN?A zZIaCM`<{De(#Qn&-S_kRQgY~%7LxDgc`C0izaGdbU&dDzva8PFgsFMQf42NDB&Lk6d4eg-|$Rh{$AxdFc;cieQ zz)KOG%EK_ug7jpgz)C@Sr(s1osPe!(X_|zQKMlwLA|$iFN-523(Auj|`${+^7_akF zbz=Ly(%u9o$HiOog6x9bsCIz2+8y0EAhb`NO#vqBfS*O3qfzInsPmPmb2RE4&9Lj4 z8j_&GU=P*72!Ambei7me*}Ls3I6H2;O6O5A_G)nX_77Kp^*g}&`CxrICLEn|t_Xhm z>EF(aVih|$+qYLNqv|%NID>;^^!A_(WeWq;!EWH}m*a??OKnnQBn=*;*78agkFG)W zcHkfMr=_NZZTtfl@N=y?+?7?jX~{3F*O^* zRQQ+iFR?JCyD=b4HD5^M{7eZA*ji8d57IiXw*~T`owl6oVn3~K!m8Px$wxct0CXAkt zlR--6X=l3bp`aC|D}o*>oyS%vdvPkyUPh^?DyR|0Wf(@XM4d_tvkS68LJ%^qj2A~$ zL5(l~1O4xk{P@M#qIGzxse-a$o0*%cMpWv<*J z1aXS6ppyoiw7f=~Ng>mxXJqVLv}os~BqT|iRKx<1gt$aCqMA9?Uwvh>C8psB6E#az z#;Cv2I`YYe0>S($*;#4V~BXBNuL&o9f&EX#J4WiynzN{gw|CHo8*tiX6) zH|t=A2y;Xj)iXSy&$pasWdG5Om{1hv9go5I4w^Q-bY6_M>w56x9FoxU zjo=K<=i!{OfC|_E3;#4*U?3GVU<;<#+jTx7;8ZsG1{MY-ha+x#2!XxR!F0Mjdj9tYwx8*&o{kzBtqVk2IWb1{<*viSH@f zmD1zVle7{I%)lM-u}ZrtFek@gmkdu}i(V(fR@^>;P>~5R1`sJ}w4rqhmJL<=? z#?m0cOKrNcqUz!*SJhFQR-*wi+)QT)jSY)2$Bgg!rbl|Lz?5cq^rsliL*2RTb1sa$ zZzIX=ifs#CW-YcfmX2pS zHi1A3yLZ+6ADFJXtl9qCzZ+dWx0&Z~waHBy}87H1Wcen_* zPKKEX2)as>C%>%n;49iz{UFw$Az@cFi2J$h&hw%l;nXt&wyR^Qaj=-tYnW_}^>6 z6Y2QhaSEP6b5?Wm3W=SX$)wYl;cPi=^rG6O(Tg5bz0O{5fbvn-vMJv(gR2xiKw(93 z*pc5z%3mkf<=rxIW+hEc|j@KKyKYoGKt=UsjBHY=W>1z;9S>?Us>^ z4O`LeTz0w-ek5JjpQ0A>Di*^)L;CQu;q+HE#u`y&Xh44iA0St78)D0!#_g65KTaO^8@M%;4axEg}$TR0N?yTv?}Tur$Zj5As^})7g(hb zGtUQBl!`)sWNF%ik*5b+-5_JK;E|g>2<*T|v>WooJZ4igwIlpS|MW0L%>MP`FFgM1 z*%1fUz2SUg-2n_hetMvK_U(_o@YwCMs}FqoI^WU{mYNPGEy7d?(n&H$)nVAX_i(Fm z!qqb;7+(&!inWwryUKvL30K*JQ8bJ^6^iTzIzY=F1U?c57=t8yycK-SQccA=so-iB zKI`^JpL_1PM{l1M?tB-4WaiL6b?229R=w|&SG~I`=wJP0Ulp@ldFNADd+Lor#Dh8% z{hk8q#DhBVwBI5&Amp78>PVHB(*rnF)M6w>wqsmY-;Gf)AgOdSCz0Kw&=LzzWcd{@ zyzs({SCmH{24c>`P8*Y#VMAk+)9E-8T|NUpzA&Ra`Ur}D6=0^2x?GABuSulY<$GMsL0bgQS^QQZ7P z@T&5Hc2eSzbj#@jLDJS=N&vA8iCQQnhi-H(6OuQ$j7+A4yxvFXT?pM8aPyDTL`rgj zZoC3q$eMQ+GAXZ2> zUItamKLi(wVU)O>Zd_ze4GF=MaEJ_oXs6rIEs$Cpi=i8D;GAkynt!auP~z!ydxvhk z8Pd%^1Q)2;l=vgMaqiR2KLjr$nG*kvZYsL{j53G;Kf4SrCDcf<^)hZ;r%SAq4E?kwF#(5+xdhzXP=k#6Vd)}tZ(*F&as zT>cKxI}&uRvHe4EtT@jPP+~6pL$@f-^I4Rbm!YF~L$`BIE6x=$)e@wTzbR#wAA7Tb2xsqsiylgA?M<(> zVn=T)naJ=}&`uF4k+6?J1wMh@xM%zNHNQ}lZY-dhI27f9ef51%zTQv=n^Xf!4W2)K z=K4Cuxr~Z+!EY6IK#jLP6sJtKs_`%4l($)>@Gz#9_2e*+x$ZOc(+3**Fd|hK+4|vO zWckJnu=m4eA8>02+WcW!_tDD-+WnRYVL8K)?q~a}zPhnle~|;AGPW$<(9a7iBQ}?M9;mDv|U`_vUH{1c-S8|xV{t4qw0(=Bjo32}W8E$|UP6D|I zm`A1~_kg^E{x(T}F7W2vmkaSETD4v-Oz6Xf3Eb-;2>R-Pqhb3H=cu21v)yXi0X zpGH!?yfZjU@3>W#HK2q+Y-uEwoV^J3%B>&U+i!ZpsK1<}hehgICsFAF8*O8}x838# z$uB>~fc6?}`JrVa!3nez+^Yuiqkm1fEqN7H`(?WEk96}<_hM@4DO~{F$R-w^hk8f| zZbp(3!b<4IO_ZT~X3hfMGgFTt%4i?544%D*5_2$K4|D`W)B|eY#t3m?v~syRIV1#U z0xzhIz`!Jtfqz=@?D_2sCFV8>=`jBg36zc#tb^YDFL;|5OCyXz60Z|f09T;RL(2%7 zxsA=ITPEEm(#_ZN`FhL#QpH_AVi=Bz#Eq-%QoQqDZ*J!BdLJPOZvPBzg>p15P~v*J z@gkdsEQ9lpTRs22w~1!tZR$Q7X7HMWw%$r}tLHNMNc8hxb1rz%l&+X={NvEn{r}$P z|2y91apE~wMPw@bmlCctxu9qCNJ-w15L}bYq{P&hi568iBm@^iE`QY1h)CSfEsAH) zwb}uK2&5ae0RQ#ix?~omJ483`c?{jor&2oJbh)=lRk(zo&CB?Yz0D33sUStja;W&EZ~evmV~&2-vUAVag?!Bc11#{R`NZ=6)w4_O`hG(%YH}+Ffc9GD7hBn!%6@&3nV|C3keiwC zMQB$?41O~1H}XpI@h7yUAgP{D#j8`e5k6s3s}+kqDpdV$OmMeWlULS@}cQN+xb6*;OF?*fe_Rv0KkF z2Mfjf!5}G=Nmw1Cv^0|*537LeU|Z-@B)fY^*iJ#xbId`hCu0&c9vZewJSJWO_B%>e zjw4R-xPtvr0P#6n_x;{MWaMt)pZeINKDDIczCC zf^hme9^EVk5C5>8>`h8m&fcZR9QG7FG~z@^iCs4&Y(D#MN}Y#!V|Ywq&(UKX3#Laj zR_gVZd$CL=u>$-q(J`2d=*@MI$+rnGoCVP%fGxyhD7@EGvL;OK!DFJ}QxFs{K51C0 z7j_!j;WV^|X^_fk=!2#~J}h~;IoR$lGcym{obat&^RbfPe3sJ^7ZVd56J?2rh{Ud# zi6dg8qM~AOPN>sz)m%9Plb6bBYn8y!*Is+ARm%VNtB=~9mIdxnccso-v}DO|o_Xe( zUrse+lfuZWAA8hk`C)G<_9ZMV#C|q8$FQv8SY{SBV{|($kqN^`)OHV_JUIkQFm%BY zBSwrEmoUZZw3L)!nJ!*fxLj6*MQ4S@INh|e+-bQ`U)NCA*wozIgryKg6&D&B8tR%F z>YSDB67;S?%uD4@6g>%JsqIMuC z>)oA($=y{v8>yjKY9`g6KdWePxUQh2FTr7|HuX)G9#~;joH;S?~!|M8z&dwP~7ZRO;nT> zuL;AdcghJj-+Xg|?82^{E?}-XRi~~!^kFKtdc_s|&elAf_EDLamwoc6>%{x1%JXa$ zTcv;WhVh-9o#StKG?}e(rm&ODp}y`HzxV~t)O-UIjkEdnEqgb6 zN3d(_5pXSG>{x3|Y$Sv?+Jc>%jN>OwnlOIc=rQBQVYl734oraVXlt$zk`e&E`p%=* zgRgok(8Dw+oAd*uWx9P?igKGW9@8H`hDw0;j-^J7X9SzzZN>k24znTiGa*|GAzMCM zG#X>INOF8n9JBR|mFA z?Qq6?vgU+DR-dY~vm$H9+i$mfvBLZ_Cr|GC=Rf~>vMF-j zym_XQU7ziA#@uz+WE@_ISAKro@U({>dMJ5#02EPn2urbR%eHP__0jjMQn6+GEy^wG zzu**~w-4qR)!ETX=QGtcvTA#j9_#rv zV$cld8(U0R;jh+V-MG#fV+=6pz#6UAXbd+Sai)4eAXXTvHF_4w;S2@=mw;Ea1v|M% zhg(cK!NmknFx9HYt`;f;$FcK^m7na3^{;4|G+u?59K8L9@3(K?x^LeOP;>KlTR}&Y zUW?@*F%h)U09N~8>$#X|m+8(P_zAD9tlG|~kca{mYs&)zw>;FJJ!U!D4KC-j;FTy=3JPr>V8A6YHlgTxbE^y1P26O;|sr zbd^=pP7t1McRLUa3<}0_3cLaXB^{2^Q3nyR!a_|%CnRrcJiI(2Qu0~q2hyIf?8Xu ztIw?Y%i1-sE_a$+8tdqEs@CrA#>STV`sQjg7Mpc@+}JFlx~!}V+5;#W^>iYFDlh;P zGMdAUwEYwgdo~1cVzoC^*HnN)RpnSDo7;u9)a7n%!{P{`^KS#S-Uqc-f?D%Ht%t4B z2W=3oL2Rs zyzc!smtbT0E0t(ZHC;-$GKHTCwuDYqHKI#!BaP1oQ8Qa0~G4mD|E5!T1VjIJS?314BGj^PHMf+!aGSuvlj34qxr_~%3U@#c; zAyH9SjBGTT21<$6I#5amerD`4YkUkAQ$~##o|u?u9X-x!jip!IFq|ysPn;&$r?&eO zC%-><<;=I^rO(44KLMKJS+aQSXiHdahi8hNJ%>lN9fKiuh%cMeYwMWMdthx_kFDeD zy`&0sbIHnW_(vb6JdBLcmw$}^0=x9*KRZQ#hNWG2M?IP}b*dL9vY?FKTxo{1k$e}` z85x_J+N8M_r%lyp#7XUTOr3x=9|8;NJU;00y<&oWZpU;yn<}&so}iu08#2$9K6&oz z&C}F3&xyTxCil;idSCKo`}&mJ1T${NP7yY&?B>VGPv8i``ql~O!A&lzXytx$(25*R ziX6uJatIVT)cuScWRU|^3QmNm5OAQ%;6WAf;s9;Rtq?tUh>a8R&#e#%8CaNPd{06L z7XlJvg@|OTlX98V7%0sX_!F$&DpoQPtk;5(A__nNP+&AG;ugzp7H3~3Y0gV6^lcSZ z!wV0_S2m5x_bnUnD0^}d%9g&1qmluOlB>FWCHpAXS8fIE>p1NvicrGWY!p?uS@?2g ztj1Sszwpvd4u741e*^2V0PkUY=%HmvK6rnadv+4$QT2rRd6_Vm_7LWVBp>EJ={BOy zz0z%tM`QC!cl*^W&(5V=wdNlz3U5ia#|L4nU?JPT3Sc+ktxvZ7)@96DUKc&G1NJ+S z6&@O7gGVT`7$Aig(K24Z6FPt(RPZ1)@E{ZhNYFqo0vxosI*(}ak0_!=PzTW>;3gj} z{9af2>1;G-Xq{!d*S^_5x_yY85f9h8-j^)oJA-8*8Z`PGts2CCji>o91Y0a~ML6QEf*VtIhadG$Cb z7L-$!5anPS_R(47bPxptf4jqp8O!N5mFFwO1W^gR#qPvQ@OEDMCFG~$6E1(Vg!~kH0-bcv|(u6Uj0`(0bvHA|2Ax57Q7|>7f^%d zEA-!le1vCzhW?8aTA&5lfo>;|6&@u*3zQ*?OSR%i0beiR4-5E80iW|T;KKwwRR;xA zRq&u{;6YWm1T6s$T29C%T5hrsEo)X2E%Qr!wCvlC`f?$xj6?GNbJIKy`96pI&Ijq2 z|G@(1R)KSXz`07`T+)j(*^L9^_&5q2vW-@Ni04kK<2NjU9*XEajckms#(qnWt>n@- zT7qS(%9RBzQ$s&{9ouik@8UjQvG^|oN_0(U^P zVAalhfPiSjs*ONGwBgP&AaZdV_E~`jpC)u2;&qAS!S-b3d7G$BJ`cBJ({mdI+%KUA z)FO|OB4EE?CwD}=n;DN(DX2>(N%frVWq;kMpGRo z1Oz>r2?;@uP(q}a9)n8;B__!jCTen;-ApVU#mw|*!0LQ2B@EWym{=c)Mn6C~Se(*u zUYXN36LUwIsWNo|sLUnkD1XIyQNxZ=d0&b#8d=DAFj4gq@GSzqO~9`c@QQ#p_QLz) z1BwS9YK{>fzC2B+%ejPl@H8JE{I-anS#n?ae*JOt#h5z;-hsV%_l4H=?ajLdba}se z>jXRo4t#X;SMLs9?@_$oZ}55t@Oo=|>g^YDBT;NrC{gTlg}=x_5jlkVa)=Z;l>Cewyfk#NjfBI0K*LCp!~d9uksFDI z@O?QA!#E9#FHOU6(SweGLkIjDK!KNuhE_2Ica)4_K|_BWj1m3*St`8=(8R$QEphPh zk%1h%$nyy+=9+Gt81mdmem>b8d^kLxhQmeKfzY!9#~#UkXZw(3eU*JYmizr%y+HehgoG2QY;-jb$@Dz3GZo3bjAGy6Z35mFWUy=S zmdsL3IIIC~ma+&tn~S&eL~LyE{91bRJKvjMVQ+qy_2!q>FTcTOKN!UbcR5f4Ydk;; zk03q`03+OU@yY*4u~xtq3D^z+8!BKMF99nH*kJ;;SinXI*p?nxpM?7&0|qg|C8^eN z?fxD3A!S@c+z1Z^H_*$^B}>BD+dh2JB^n>odLvW%)lxV^94Q=I3qgeB#cUpb29aYe zwak!`df^B2Q5sfL=_7Vi>ECkeI4+80p^IG1LFuJ|ekU{KBckqj#3Aw6N1|twgULd6 zu-RTpQ;iAd0C<;uadvreP82vND+^Iij&%yhI`WcOw{fgvIo3rS>uDToa}U;oOI%SM zjCUcyRa0?rfRNyA+u0PnB?+EnhFLEhtJ@6xTdxEUE^+O`7jPphRR25x@bJ!;3E`jo zOB|ts)Px2egaRH^JRVf^&{4rUPJyXa#JBBJNNUX7w>M1j@r`y?BCV!_e@=D`+2S^- z6WZeiw2LFwl!aCY5ONh z`xOmDwUTFuYWf_K_KsK|)duIYM>OpbB6yQ8=IqGglXQtWJ30YdB~o_?Shx6OxCE?Q zzuSfz-oJ72bXp1AxkQnwDkx=T*!KU5i$|7E>i`BtYcIWKhva}v z7<1^R{9eXpNR%0#i-`<)?xt+;JVu0g`C^^n;~x3GTrrPhr|e+1i)uq5ExeZ~n9Sv- zZ(RbQ=frqN6@g-|JfcRS1dat)c-K&EXfE9HfSX5Oe$UrC_on!&4e zl`lyW!W<+CnNLF!pl^2p4*Rw`*hIYX(t_(Sw4#BQW$Pcj?*}B5H*-^IgJR|q&jGT74%sVPD2N_ zqwBAWh!*{7^DWWx@*32J)d5)4=NL*wtkP}msn6hTt4;KtvxJdWBIc#(#k{oko_T3~ z`Pj!7mfCplc|{{pvScZ5(b?3Z+hcq!y01j_#wkl0*x&7IGL2^#bSlsOOLF4Igtz z9*^5Mn?n|H$hkhq!RuNh@NE(J#tM7`1-_v@_zn&mDtgQqG8=4iB~OhZ92hz_c`XIh zhoEtV81=eZ%wTkh8H|x)05Gy=24mma_LJ~8skWovi*+8I@Wwu9DPm zMrGfqB=7$*@Wp3ing#4i0juM%$qe&$`_9Mg3r$`$&>fKCu4NAjB505VooyY?d z$$WVbMF#Q5B%TH91QMVLYX%NP6;lvVMYGUH6%C;Ix(euBUvKJQ|D@iN8$nVZBS;}5 zXb9P9A~>6D6p&rQ{OT44R96qIKMv}+{iJaF$;9oa4sJh5m$09HUa5n(`o_U9_6Ur- zdoUgxwo|}%_N%Q%z&7{5`m3#n*LDW4Z8NW}G{9Q)K@PMIJSR!gXXJRf`Yv%*Dj=?Q zQ1{-CKYgQKG!KOH=a(E~-XghyyjkFkBeuNU^;dGkdsOlSUUEY+d!0%SZ=#Z$LVP8Y zXVAB83(ueua=eseqnq0S@Spi74^CJ?1Gxgio$f7raCnywe!74!7VugDKe-P)*_VBN zQJ~1fEiT#%5!g>BOG3)wy}9-$;Lff<;XdqrobFFI4d$By_e?%PUQ(kp2Ynt%@8=#oo7ckp>7@MXw}k-`A|E)^OqM(WQp!;ody3+N;P-R^_7 z2Pmf%713gBF@MIwS zjTi5M{PJ24tODS~uaN!3ud*AcG7$%AY3?KPmZu)fq&(47nGEB6e#bYXp& zgAhs()>n88#?5=Iufg-fF}c3{GJ5mN@6B&+Z+@x$^7F}q&r25lTr(F0xlC9Zox;+H z661eed>Vj%|F}-T(stP(W~+dW7qAVNfUOp=@dDN*V66hSxd+xK2?MRU8a~oJg-1os z@Tf?Tg<5{hBp)~YL3((cd<6GLipU?4;S~WtO2E4a-XWlsUg&|`Gg!s!G!86H zO($ApkS{CgDMAkZq!Q{*QMLwr7|M!J$R_rNQY04d<=)`!qkn(y7S^=aitzNn8mzFs ztYV*;KGrnw`X}(Rkf+^%HwjKj@!Hfm_z0g-iU$+>_mchF=g%vDaNPUb6JFfC_Jq=d z`{1zs?Flce*PdwYfgQ*vpSRU7`b0JMMW3j;J#B@0#)0-k+X2$ESDXfiSxGW`jl*(J zt)s_w_F>$=UmRdhILYULXnO65vL2kt%JoTRzfsTsbl#kI1nYl12P>|S#|vY4KPdk| zqvJH2^mTg3*k0D#vWVuik38wbKI=y$)#m_bHfcTjx?8b|guN+!Xh%y8XBq;P5Ax9< z(o=pZ5Szs@C}-bOnQq3r%nM?%UpXuHVc-|uAZ8Ioi&;c##Vn#WF^gzy?<^v}@c-$| zo#A2@s7}oPD-^Rp3xz-2a`7zCzVgF$lkQty|06hD1?J5>7$~&D6D|bVR9Fdy$Hm&M zp9~qq48R7O0c(?8+=!Li#0)?k!c$&&(%6BJ{=RF@Y-}IqUH{pR;UA|hS$R~VL9~U4 zH_Eru(z916@`yO#A$mpyvPHfKy*FF|f*mB)a7mBSj^m@KP~SG(y-k@jYWjgV$Cs+FY~n zelD{F5(T4ktbzIb(ZCwl`@k*wS(y$5Q?(0lfww1_230VFNc zbeMXL=U3-3IPHyFQyUz}Gu{T;-=Q`j?L7VR293|LQSMN|^4Pe7Eb5xYN18b}<%$Tum-MRt|@U^KA9y)%m-jSj_ zp**3gZ_`UoyC(X|B}#+qK^W1qEs1ldu5u@9giYu|?l}<1|-S z*VNb4)L?-j7RkArQDGac8VfNyR9dasw9I_w%-MZdIeChXd(As@7NpRa#Y& zNFmB>?xhR0y1shf!E1m`a%h4U8tzJ`gYZCeeiC1yB<#@ZK!!H#`7PQVQTIR3h58`k zkx!QdQrt5|`ID!@mh8EM^hNF#L<(Of7D;#5l4Y(-8cPt*e%Z#>CoA^=MYB(zn2=kv zRn3N3Rah4!GmDjfyW#@&~5*1Dm&?1jB$MQ2yoyyK;U)b3# zcGWFx>eO^WonHcz+fc7RKx%#oOn!-ajU6*)%%sbwFT|1SlShR%U84`(uv+$}tzH8UQ* zIeM67;%$#V{`kT$m8zxHVbq1k&%R~Olrb}I?(mWTq8H>TK9`Au?m8x1e@`k-L;HfQ z*SK2(uUPb7*J4HRkNjH4KE$%OZ-n4VMq>)YsXY*#@9hC0iIE{V{i^EJH?}n8L4N&7 z{o(%o<%%OHKwDQ_R$Sd=NDN`mJd-{arxPANw*76a@cwS^iCVR)yRogc=)~!R+qP}n zd9E^h_wL=BH}BZNKDQ@itvS`zS+ettyUX3y))qSLs;j1jw&T^-bpCKjhbMbO7Sn3e z$6|G4MQ1FnuvNO{h+&Z-TBAXw)w%I~%!F~lEiEk-So{bDnQ%o_S$%g`c6M2H3l@wA zM90QPlD@5;i>*UlfA099gDE&qXr<)wVAcEASa!O=!4;c21?Ox& zk;>j7R*}L4lfJgAAs|I#xqh*0lHxUBdW!O-@+3Iv4A*(uL8tC+8LLKRt*#sIp#&T% zlUb{E=*=M@ZdaEqJHv6JD$a7m!KddM5z1q%jMh^})55q?oz(Vls6M z4yq%C`v1uFm!x!Z{UvFuy_l$xkD6a9+-3Y@C!_^9KP?phpW`2At6}eDZ+rHedW1K1 zVBgdvEk3)(hoiJ$fs=M2!?~JHu3)9lKgS#BSL`#4U%H%9Vj`YC{W@eGBSs9&BQVfU z9z?6Ynx60=QV||Z!YO!8LPSuiFDAW|`nM?FLY*=77&xvRPwXXOtoKqp!HN?nflVwp zH{6Po46TwuFBTBf?7yIzn*4nFKbO7t6voq;+KvO7@kmqd#=_tGmAjpC+OBt#l`tv4 zC{S4K(H70QNTSO zxX1g!og)t9Tim*O;7*9sKcUW%cQTH6+AfN@|D=Yz19{+n=m%hrgJTKxilxbHy>!E< z6t)rQ1N(bq;O|2pmJ3I};AnA%8rW~?MIX3VqYl~U4i#|pz4!+Mf1!WK&h?D|P#jDH zS+@htbf~af=?+gZ-HtvLx1DyO@eul83$1E)omK4qolIx^5Ft4w`2%O0nx~I3%dv5> zvGLY;S}bfyHt*cCbI&(lfBmn2ef`B3U*ckH#O0T7*f4tZnl**FrS)|MhyJm;zNx*U zzOJ&Y#uGa-$vXX4B4XX{?v2*9HT|4*e`4JZr z8XLSv&V%zD~-Iy5{gE;7tIaUxF39XkQ% zc5;11JG)RD?6W5eHctorR5kgBw%M>WO|44Xb=fy65@osKccD0;R7J9@s;sSTP?4aj z3c_@ARIA@!{Wj0h7w;MvwJJVmV>nCQCLM}q^~r3fbWgTaTrO?NmrSve7gh!~r0Znn z|I8k;qR789Ax$7snjkHpOSxN|GS8Uz15TOeDm9j1%bKn( z$`e+fQ>F|Bg@?)!TPQefJjX?g7CD6#>6Ec`2)?Bpai(uijyP@Fnhy$x|Fw1F{2bSN{IkWpR<6eiOE_L zj=Hfj zbM|Gbsu}EkJlc=2Cy*TmCusjPWWLx3OQm0AN1@M0c$z4M;54TdCWjKW5S%7@v3Xv; zP|3uXepDvU1(3PDWad0t--k2*uI}?;**7Xf{O4H0&jgl`0ghz0Kr*Ih#Ej0Tqh4+# z=<7Y74wj74-eFh4>TI_&$&T$=u&pBIYK0^#OEk`4oKy_UZ;2gKLe-|mHESB3yPy{lxgPw}j@vr0O+R>y359O;N{N%TyQbGz(nCAOs{*2%w>q@W<*w_SQYnhe=Z z!+_Ul;58n2S<$M;Fo|h9I^d(W(t(sJm}r6fPwd}+{QUXj`*&>Jy2A;Us`iKc?Qd|J z^bxJCM&t73;BH`ead>#7*&G?J-n2>W48UkM=|(ylVIKQ{y~o~@rm<7<{cK4JE0>ZD zK?y-2t*s%~hcj)U!4Mi6*Jg)HV}Jc3l4Q}fYB z+sLoJ(H_{c0g89NKYq@f*wnwu{~#K1{g@))!*<7un)H2s^VPBjU>ku3JDsRmfv(I$Cih0r^wvM##e zbY9U$V*nL{x{|#TgSy6Gk{RYR`Zy)a#;mNY(-}D~s`YN;%gbJV?Uh$vqnb10hh@{g ze|JXY%x@Fd<{Fg~irm>LXO7Dp#|$@4i+JRQs5VqL1tylmC{K{JAt73-FEhd^V*#ZX zP~&Eo5$9V*4jUF(6Iv6>jMk{=h$+&jB1^zrtMS_8YZoLYE}#yR^=hPlzlJ_;QadAV z4&&TJ6(NW?l(N8;#jB=UvuYKuR?x7SUvBYnl(DfA3vQz0c(qseEcLv5(9nA^vdl2jFY}zA`G02w#w)!B%PMqVL&w_ zv!VF-u65eApYS?re;RkqzM0=PYBifU!_>Ep^HyZ3HtYso4QLkHbr!Idl_l7KEL+>% z$+@JBiK-eFx*$dyK4B2v4D1QH^!e!v$#c0#M}R2#~~)|IO?;tD|r00vOL$4B_~d#rtaK{v7O;jQKP2JnKNfvY^(-b4d(xJ z@ZiBS*rYH@^X<19`ggM7f$jCJ?uLr&?6=6;>3wigbF4O$04rcr%!J`V!G)j6a72QHPtmWRpnKPAl5YGx?Cj(%z;5c z!NEZxA!b~sT2HmIuu#+z4T$RrIuo5L8X9N}3=Gie0s`?03Nb}0I%{ZLTy%77d{kIi zcyx4lRJ1x;EokSngUG(@wS#sOpWXKKbUY5w`yM?tSWiofG+e@N;6q+|v4uv4Zy1Pd z3jbp#mx&Pq4=gp99(u6|?W|aYhC_b)!1_FNUp&VqM~OXIOCenUg@k+&;wH+bPK-7s z@I@Pa%kDqo6E9YqNAVFmewqX}oH}fa=9iRX8m(C5-tRM=Uo|)Ny897eZfLmwp`}kagY)t#X*wdqA6g}sK@Cc=kr*ph)t3T=Z5kDKt3 zuz#PxNUHZm+(seiG01reavlMmlErJUX2f@Jm)u*1NS?vqbG z`TVVA-+YszL@JTeE610aabPo#CwC*9+|o`8nh%*F)~#~8>+6@98ye~vI-KT)2I7A` z2xQYWH#e8JQz)6W2}<{ki<0KXv>w4@9H+s#JlL=x=eBQLa5Shg5mcE2s!;zfsM0?g zkGA$FJ5fV&=x}EW`aY+5{oYCtjAjvhPa~q>x)!6_oQcv~%hIsN=u>n#%>%;kpp@F}b{hihIy?f56k0Ji z)Hi}k4Gqnp7^`mpu^deA(81l%#}cxlKmJeTG9GlIbF3Fxd(N>A4nk=XPTZy!&SnV8 z%5W8A=N7m!Tty|WZ8jAU^f)wdD?Ny&$4sAo=FGZvGeT=|K73vHjCC{S-}T5NkIcvD zh1>2hJoC&apZsI};Vf69-SC&ctayEWjtUV(CsTd()vQ_Kxa}|Ybz^X>d*2sutE4Yp zTcA9k+{mw&lrNYAL32AsQ}0#o)h%7R^ugR?w>bj3x;ix;Nn)KHjD>#j*`W)N zl=`EeEm>kPe6eK7XYZvcH}mTW)%%~Z&+HCeqbuw1*9d^7(8kHeVZ-e4@$ssh^{?9P zPL{%K%%*){B8Gq_{w^6y{Z5^#!*Vm5bn4VX{PYE))O4-KFrW=>-eGqb%}KMa{q2)a zKKao{Nl94Os;#cBiJx=*6FkoD3@R%vaut>37vz_fSCk!aVAEYqr;O9rHCVu5i3l}^ zT7pf%mdGf}n0~+Y<3nHT<>ylJF;MakOwHazdw&d+d;}U}MnzdMgt8o6Pce34t?KMh zsobrK9As#%&OCAA#M#2C%F3#0*V(gY&$}uWqlUG1cGSZjo8Hys(U}auy^AT0G7U2} zpWnYf+l>vEbzO+1faHj#=mLXO?dTVcv!NU#x|}mIE40}8cjWkq6RprE#x~cP^?!RS zIxeCkcmId5i$D3PYWec2tbeUsx$=YU=jx-zk9qsO&-Y<0)gIX0QhTAP8*W>0s3p)b zf(~o9-in>g@0b=R=N&n6cI?=>3p!mhp7jZH zf18Thf5JZ2{B*W`%aRk=Xz6Gt=7m9%s;*5#bc%5kAzFRiG=w(5Bq_$|Mr zEIYrpv8%1F0+!@-wOa0IXk*y-yh951&~DFx0)?)y( z`M}z>yQ(_mD1f-;VP{v zDM0sI4iRptto0c60otH3p|WFyK7R7#ktQ00*9Ys66BX80TT$N%4cAbbpWmVN##_km zALss zdw?!FCj; z^OQd-f5e1=ciFq@2jV(&-bz*OqD%8mL3iw;r76(Op9Y^fdNix9TRU$4{P|Nu;oR8+ zWJ65iO*ds_Wkf%h28}}3O8IK_fqB?QSSb7lw13@KUS8eQ=}_x*U|V|9xpSF$pwkaO zV2A#^v*!-)#7u%84rS$4Hnr3h78c|ozI70@2ab~8n5b5J+B;<2JzBL61addAAPorC zsSYxk(7GX5IwONjIg`STx&X*hU7cn0=wX%+eLK3;&fv(s&kGs7?t)}sD1KM?SWmgc*|7mXT?C4NbDu+7AEVs3%a~(F@%Gm8z%n5-;PXf1- zuv93F_oNzcJO_q0&efGeL!jjcd>3-y=!tWukDfet>d3((M~D0Qw?)G-3QY$({ z)D@2%F=fU~!&W#yIxolF{Oq&O-W*mL9i759v&|}t#pU8V)dzia5_1TenNDK}ieTY( z$ac{>hr{viuH1(B)N8NJOJ@I&o_o$7{6KzLX+c(Yy-XuHOzmVWBLzBto%*Q>+Viia z@&VcV)$ey--O!LiN&vmXqD7=--eiuz`!U#(Iyu_ZmVF8nh~@e7?@hx{E8h)S|6|bnonfu5O$`^ys~T#{YZ|egdi{m!n%rQO zT8D6VcbC+`uS=&3mm6VPJs0uLVn56N~+z;YZj3?dwhgnweA3YR$}J3BY8 zAUETu%$zenA`MIjCR>5C)xj<`rgwN$a(#AIerZ|$19mVAEDO%N_S)3=hTL86 zf|u*yhOxjIb1n<-Y|K5hef#!L|B`}jxSvyP-#&9@3T;QOj)^%O_*g3YoG$f?mud3f zNr7o~j}6h1;Q7|R#s<5#a#U3)M|6_0?s$*ts@HmcXSBsjFMo>oL9+9~~WO36G4&L?`P=1dgt{IE|q2m7ws$ zpztzK_)1XtN+!j__QCMCATLFy=tU0|S)y`W0n}nqUT&t#m7AS;J`2-}^0KpYatqM2 zxVFVfom~nh$f03j(TYbw=&xIWNW1YZw01ykZA2gF% zq2p#I&q}(!qo%N;ZN#~1tw2@rtRv~kGSmD z&r_F4*FV@7(xaPcj=-MTUR4|T?0vJMqYZWWP|inSY1-_%!iS%@V^Zzmf2Ar<)1`U$ z1R{U!4y~%Iy0%;2h4C6gX>ek~@XJRd43x@VfyQ>KmtG&OtaZi}LN(?WmsB7CSXNP4 zo`+dzIFF*F5JRu7191xEtK03y@d`|Vq3?#V0hwl6eA~(5u=&0zI4m49d%`VI(UCy` zL17k4M0g0s>CIz?h84V}nIr>84#leAN2r};irCR4b}xN8B0@DY*u(fl7O@Z77jqNq zDEGis<0~iKtdQneUq-N};esx+NlQSlD_QAdI7G zlbGU+QZRG@G46yn*wNLd7z}C%IElEOTE#1JV2CE&Xtyu>=lK@%sFAQcQ~U7T>XT1);`oER5QdmxSA|s8yB%Q~ zy{hB$e|8jo2^I6tZ6Tw7{PFtxz2dK5=qcA~%kzsH9a3PtBblv}ZYxNaUU&g+f&Qr* z#zu5jp)#pt6|ldXp15jUMCngIz3>7Ko6wD&{*d_oT|JVNH|s5_kIjRVF+-Zc-ePY_ zdOc<`z`ZC!>}J1Qs}FMMp`47R#m3n({OOdK0E#e!t=CFWFmmNw49tht4jX0hn&UY{|~fE{wHFPStzC2`hi0Tn5#79}Z_2 z!>>8;$)A_ju1&=u4dKf3n!kVh?YE!3o`N)&L86lF(%3S)4l|W^9LTQeN{93Rm+hHV z(HH}9%1^p4Tno*30-=)atVs%?i#4(z*gMj__ohl$U}f|>n#Yjz=I1G#asr}JZkP7! z?Gyrd=!RK|!RfvbzvbvD+vjf%2FVJc#DWYn->INcyN9D~^L)=`KcNbQ|= zI)O!_M%R*VfU&L7s2TX_j59X2D4qI_s*;kDw%D1Cje321Ifi>&we6ko@uha79j2(K zy9+%`x&e$0?ryFuEiLuNUeZA4MWFLN^2oRGndF&<+HSbQ3MvGX54i=t6t zK6o*oJT2mb3!xJ_Y3zJPHqMdC%_ltba5#&r&;|dC213Fi=8VwP5cv_H#IZd(9cEmR zGH@b%hnNcHTzx!W`7_tD(6bQ!SH4S89#tOIdoFYX(Z*wkyy=DqlQD&`EgHg~?J6AG z;zsz>6R5Wb>7%1R{`lj~<-t?$f9a){?CK^-s%vs0nwtVw{C&Cpk*5|we`5MNzqYai z#y`)x;*o`sz|TDQ5jv{rW4S)7ITdrV-)3*4VWit(b~2B{ta!R|&bl%wERz⁣dUi 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

=;034=|=!0qfLoVZ{M)EKH3o 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 literal 0 HcmV?d00001 diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index 4653edbca..73f88b389 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -8,11 +8,14 @@ Button { property string uiFont: "Inter" property string tone: "neutral" - implicitHeight: 34 + implicitHeight: theme ? theme.controlHeight : 36 implicitWidth: Math.max(70, contentItem.implicitWidth + 24) font.family: uiFont - font.pixelSize: 10 + font.pixelSize: theme ? theme.labelSize : 11 font.weight: Font.DemiBold + activeFocusOnTab: true + scale: down ? 0.985 : 1.0 + Behavior on scale { NumberAnimation { duration: 70 } } readonly property color activeFill: !enabled ? "#121820" : tone === "success" ? "#194d38" : @@ -23,7 +26,7 @@ Button { tone === "success" ? "#347a59" : tone === "danger" ? "#86434b" : tone === "accent" ? "#315f8d" : - (hovered ? "#3a4a5b" : theme.line) + (activeFocus ? theme.accent : hovered ? "#3a4a5b" : theme.line) readonly property color activeText: !enabled ? theme.muted2 : tone === "success" ? "#d6f4e6" : tone === "danger" ? "#ffdce0" : theme.textSoft @@ -37,10 +40,11 @@ Button { } background: Rectangle { - radius: 6 + radius: control.theme ? control.theme.controlRadius : 7 color: control.activeFill border.width: 1 border.color: control.activeEdge Behavior on color { ColorAnimation { duration: 90 } } + Behavior on border.color { ColorAnimation { duration: 90 } } } -} \ No newline at end of file +} diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml new file mode 100644 index 000000000..f8c981307 --- /dev/null +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: frame + + default property alias contentData: contentHost.data + property var theme + property string titleText: "View" + property string statusText: "" + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool detachable: false + property bool closable: true + + signal detachRequested() + signal closeRequested() + + color: theme.surface + radius: theme.panelRadius + border.width: 1 + border.color: theme.line + clip: true + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: theme.raised + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 7 + + Rectangle { width: 3; height: 14; radius: 2; color: theme.accent } + Label { + Layout.fillWidth: true + text: frame.titleText + color: theme.textSoft + font.family: frame.uiFont + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + visible: frame.statusText.length > 0 + text: frame.statusText + color: theme.muted + font.family: frame.monoFont + font.pixelSize: theme.captionSize - 1 + } + ToolButton { + visible: frame.detachable + text: "↗" + font.family: frame.uiFont + font.pixelSize: theme.bodySize + onClicked: frame.detachRequested() + ToolTip.visible: hovered + ToolTip.text: "Detach into a separate window" + } + ToolButton { + visible: frame.closable + text: "×" + font.family: frame.uiFont + font.pixelSize: theme.bodySize + onClicked: frame.closeRequested() + ToolTip.visible: hovered + ToolTip.text: "Close this view" + } + } + + Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: theme.lineSoft } + } + + Item { + id: contentHost + Layout.fillWidth: true + Layout.fillHeight: true + } + } +} diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index a0b1a7773..86c45e721 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -16,8 +16,8 @@ ApplicationWindow { StudioTheme { id: studioTheme } - property string uiFont: "Inter" - property string monoFont: "Cascadia Mono" + property string uiFont: interFont.status === FontLoader.Ready ? interFont.name : "Inter" + property string monoFont: uiFont property bool phaseLink: false property bool profileDirty: false property real currentScale: 1000.0 @@ -32,6 +32,17 @@ ApplicationWindow { property real activeQuality: 0 property string transientMessage: "" property bool transientError: false + property bool previewDockVisible: true + property bool previewDetached: false + property bool telemetryDockVisible: true + property bool telemetryExpanded: true + + font.family: root.uiFont + + FontLoader { + id: interFont + source: Qt.resolvedUrl("../assets/InterVariable.ttf") + } readonly property bool compactLayout: width < 1300 readonly property bool selectedProfileDeployable: @@ -115,12 +126,72 @@ ApplicationWindow { ListElement { signalId: "Un"; magnitude: 0.000; phase: 0.0; enabled: true; quality: 0; traceColor: "#9a88df" } } + ListModel { + id: statusHistoryModel + ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready. Edit locally or connect an injector."; isError: false } + } + function showMessage(message, error) { transientMessage = message transientError = error === true + statusHistoryModel.append({ + timeText: new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"), + messageText: message, + isError: error === true + }) + while (statusHistoryModel.count > 100) + statusHistoryModel.remove(0) messageTimer.restart() } + function detachPreview() { + previewDockVisible = false + previewDetached = true + detachedPreviewWindow.raise() + detachedPreviewWindow.requestActivate() + } + + function openEngineeringFile() { + profilePanel.openEngineeringFile() + } + + function deploySelectedProfile() { + if (canDeploy && device.deployProfile(sclProfiles.selectedProfile)) + profileDirty = true + } + + function runReadinessCheck() { + if (sclProfiles.fatalError.length) { + showMessage(sclProfiles.fatalError, true) + return + } + if (sclProfiles.hasProfiles && !selectedProfileDeployable) { + showMessage("Selected stream is valid SCL but outside the current ESP32-P4 deployment boundary.", true) + return + } + if (!device.connected) { + showMessage(sclProfiles.hasProfiles ? + "Profile is valid. Connect the injector before deployment." : + "Development setpoints are ready. Connect the injector before Start.", false) + return + } + if (sclProfiles.hasProfiles && (profileDirty || !device.profileArmed)) { + showMessage("Profile is valid and the device is connected. Deploy to arm output.", false) + return + } + showMessage(device.running ? + "Output is running and live apply is active." : + "Ready to start. Device and output state are consistent.", false) + } + + Shortcut { sequence: "Ctrl+O"; onActivated: root.openEngineeringFile() } + Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } + Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } + Shortcut { sequence: "Ctrl+K"; onActivated: root.runReadinessCheck() } + Shortcut { sequence: "Ctrl+D"; onActivated: root.deploySelectedProfile() } + Shortcut { sequence: "F5"; enabled: root.canStart; onActivated: device.start() } + Shortcut { sequence: "F6"; enabled: device.connected && device.running; onActivated: device.stop() } + function groupModel(group) { return group === 0 ? currentModel : voltageModel } @@ -173,6 +244,8 @@ ApplicationWindow { function refreshPreview() { if (previewPanel) previewPanel.requestPaint() + if (detachedPreviewPanel) + detachedPreviewPanel.requestPaint() } function selectSignal(group, row) { @@ -392,7 +465,7 @@ ApplicationWindow { ColumnLayout { spacing: 0 - Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 1.15 } + Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.Bold; font.letterSpacing: 1.0 } Label { text: "SMV Injector"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } } @@ -495,53 +568,87 @@ ApplicationWindow { Item { Layout.fillWidth: true } - CalmButton { + Label { visible: !root.compactLayout - theme: studioTheme - uiFont: root.uiFont - text: "Diagnostics" - onClicked: diagnosticsDialog.open() + text: "Ctrl+O source · Ctrl+K check · F5 start · F6 stop" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: studioTheme.captionSize } CalmButton { theme: studioTheme uiFont: root.uiFont - tone: "accent" - text: device.profileDeploying ? "Deploying…" : "Deploy" - enabled: root.canDeploy - onClicked: if (device.deployProfile(sclProfiles.selectedProfile)) root.profileDirty = true + text: "Diagnostics" + onClicked: diagnosticsDialog.open() } + } + } - CalmButton { - theme: studioTheme - uiFont: root.uiFont - tone: "danger" - text: "■ Stop" - implicitWidth: 88 - implicitHeight: 35 - enabled: device.connected && device.running - onClicked: device.stop() - } + Window { + id: detachedPreviewWindow + width: 520 + height: 720 + minimumWidth: 400 + minimumHeight: 520 + visible: root.previewDetached + title: "ARStack Studio · Signal Preview" + color: studioTheme.bg + + onClosing: function(close) { + close.accepted = false + root.previewDetached = false + root.previewDockVisible = true + } - CalmButton { + Rectangle { + anchors.fill: parent + anchors.margins: 10 + color: studioTheme.bg + + SignalPreview { + id: detachedPreviewPanel + anchors.fill: parent theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel uiFont: root.uiFont - tone: "success" - text: "▶ Start" - implicitWidth: 98 - implicitHeight: 35 - enabled: root.canStart - onClicked: device.start() + monoFont: root.monoFont + compact: false + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency } } } - RowLayout { + ColumnLayout { anchors.fill: parent anchors.margins: root.compactLayout ? 9 : 11 spacing: root.compactLayout ? 9 : 11 + WorkflowBar { + Layout.fillWidth: true + Layout.preferredHeight: 82 + Layout.minimumHeight: 82 + theme: studioTheme + controller: root + device: device + profiles: sclProfiles + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: root.compactLayout ? 9 : 11 + ProfileInspector { + id: profilePanel Layout.preferredWidth: root.compactLayout ? 200 : 238 Layout.minimumWidth: root.compactLayout ? 192 : 214 Layout.fillHeight: true @@ -569,7 +676,7 @@ ApplicationWindow { Layout.fillWidth: true ColumnLayout { spacing: 1 - Label { text: "INJECTION WORKSPACE"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { text: "INJECTION WORKSPACE"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } Label { text: "Manual Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } @@ -608,7 +715,7 @@ ApplicationWindow { ColumnLayout { spacing: 0 - Label { text: "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 7; font.weight: Font.DemiBold; font.letterSpacing: 0.85 } + Label { text: "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } RowLayout { spacing: 5 NumericField { @@ -716,7 +823,7 @@ ApplicationWindow { Layout.fillWidth: true spacing: 8 Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } - Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: 9; font.weight: Font.Bold } + Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } Label { text: root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" color: studioTheme.muted @@ -745,22 +852,53 @@ ApplicationWindow { } } - SignalPreview { - id: previewPanel + DockFrame { + visible: root.previewDockVisible && !root.previewDetached Layout.preferredWidth: root.compactLayout ? 290 : 360 Layout.minimumWidth: root.compactLayout ? 280 : 320 Layout.fillHeight: true theme: studioTheme + titleText: "Signal preview" + statusText: "GENERATED" + uiFont: root.uiFont + monoFont: root.monoFont + detachable: true + onDetachRequested: root.detachPreview() + onCloseRequested: root.previewDockVisible = false + + SignalPreview { + id: previewPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + } + } + } + + TelemetryDock { + visible: root.telemetryDockVisible + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + theme: studioTheme + device: device currentModel: currentModel voltageModel: voltageModel + historyModel: statusHistoryModel uiFont: root.uiFont monoFont: root.monoFont - compact: root.compactLayout - activeSignal: root.activeSignal - activeUnit: root.activeUnit - activeMagnitude: root.activeMagnitude - activePhase: root.activePhase - signalFrequency: root.signalFrequency + expanded: root.telemetryExpanded + onExpandedChanged: root.telemetryExpanded = expanded + onCloseRequested: root.telemetryDockVisible = false } } @@ -773,4 +911,4 @@ ApplicationWindow { 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 index a64b1e671..6e48d2949 100644 --- a/apps/arstack_studio/qml/NumericField.qml +++ b/apps/arstack_studio/qml/NumericField.qml @@ -5,7 +5,7 @@ import QtQuick.Controls TextField { id: field property var theme - property string monoFont: "Cascadia Mono" + property string monoFont: "Inter" property bool invalidInput: false property bool compact: false @@ -28,4 +28,4 @@ TextField { 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 index 0b298bef1..8c68de41e 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -10,7 +10,7 @@ SurfacePanel { property var device property var profiles property string uiFont: "Inter" - property string monoFont: "Cascadia Mono" + property string monoFont: "Inter" property bool compact: false readonly property var profile: profiles.selectedProfile @@ -34,6 +34,10 @@ SurfacePanel { return textValue } + function openEngineeringFile() { + engineeringFileDialog.open() + } + FileDialog { id: engineeringFileDialog title: "Open IEC 61850 engineering file" @@ -63,7 +67,7 @@ SurfacePanel { text: "ENGINEERING SOURCE" color: panel.theme.muted font.family: panel.uiFont - font.pixelSize: 8 + font.pixelSize: panel.theme.captionSize font.weight: Font.DemiBold font.letterSpacing: 1.0 } @@ -92,7 +96,7 @@ SurfacePanel { text: "DEPLOY" color: panel.theme.amber font.family: panel.monoFont - font.pixelSize: 7 + font.pixelSize: panel.theme.captionSize - 1 font.weight: Font.Bold } } @@ -102,7 +106,7 @@ SurfacePanel { text: panel.profiles.sourceName.length ? panel.profiles.sourceName : "SCL / CID / SCD / IID" color: panel.theme.muted font.family: panel.uiFont - font.pixelSize: 9 + font.pixelSize: panel.theme.labelSize elide: Text.ElideMiddle } } @@ -125,7 +129,7 @@ SurfacePanel { text: "RESOLVED STREAM" color: panel.theme.muted font.family: panel.uiFont - font.pixelSize: 8 + font.pixelSize: panel.theme.captionSize font.weight: Font.DemiBold font.letterSpacing: 1.0 } @@ -137,7 +141,7 @@ SurfacePanel { currentIndex: panel.profiles.selectedIndex enabled: !panel.device.running font.family: panel.uiFont - font.pixelSize: 9 + font.pixelSize: panel.theme.labelSize onActivated: { panel.profiles.selectStream(currentIndex) panel.controller.profileDirty = true @@ -163,7 +167,7 @@ SurfacePanel { 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.pixelSize: panel.theme.captionSize - 1 font.weight: Font.Bold } } @@ -172,7 +176,7 @@ SurfacePanel { 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.pixelSize: panel.theme.captionSize font.weight: Font.DemiBold elide: Text.ElideRight } @@ -197,18 +201,18 @@ SurfacePanel { 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 } + Label { text: "svID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { Layout.fillWidth: true; text: panel.profile.svId || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideMiddle } + Label { text: "DataSet"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { Layout.fillWidth: true; text: panel.profile.dataSetReference || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideMiddle } + Label { text: "MAC / APPID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { Layout.fillWidth: true; text: (panel.profile.destinationMac || "—") + " · " + (panel.profile.appIdHex || "—"); color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideRight } + Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { text: panel.profile.publisherRate ? panel.profile.publisherRate + " fps" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } + Label { text: "VLAN"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { text: panel.profile.vlanPresent ? "PCP " + panel.profile.vlanPriority + " · VID " + panel.profile.vlanId : "untagged"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } + Label { text: "Payload"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { text: panel.profile.payloadBytes ? panel.profile.payloadBytes + " B · " + panel.profile.channelLeafCount + " leaves" : "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize } } } @@ -224,7 +228,7 @@ SurfacePanel { text: (modelData.severity === "error" ? "× " : "! ") + modelData.text color: modelData.severity === "error" ? panel.theme.red : panel.theme.amber font.family: panel.uiFont - font.pixelSize: 8 + font.pixelSize: panel.theme.captionSize wrapMode: Text.WordWrap maximumLineCount: 2 elide: Text.ElideRight @@ -237,13 +241,13 @@ SurfacePanel { 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 { text: "COUNTER POLICY"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } Label { Layout.fillWidth: true text: "Confirm the evidenced smpCnt modulus before deployment." color: panel.theme.muted font.family: panel.uiFont - font.pixelSize: 8 + font.pixelSize: panel.theme.captionSize wrapMode: Text.WordWrap } RowLayout { @@ -276,7 +280,7 @@ SurfacePanel { 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 } + Label { text: "ENGINEERING SCALING"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } RowLayout { Layout.fillWidth: true @@ -329,7 +333,7 @@ SurfacePanel { 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 } + Label { text: "QUICK SETUP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } RowLayout { Layout.fillWidth: true CalmButton { Layout.fillWidth: true; theme: panel.theme; uiFont: panel.uiFont; text: "Balanced"; onClicked: panel.controller.balanced() } @@ -338,20 +342,20 @@ SurfacePanel { 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 } + Label { text: "RUNTIME"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } GridLayout { Layout.fillWidth: true columns: 2 columnSpacing: 8 rowSpacing: 3 Label { text: "Rate"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.fps; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: panel.device.fps; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; Layout.alignment: Qt.AlignRight } Label { text: "Missed"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.missed; color: Number(panel.device.missed) > 0 ? panel.theme.amber : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: panel.device.missed; color: Number(panel.device.missed) > 0 ? panel.theme.amber : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; Layout.alignment: Qt.AlignRight } Label { text: "TX fail"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.txFailures; color: Number(panel.device.txFailures) > 0 ? panel.theme.red : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: panel.device.txFailures; color: Number(panel.device.txFailures) > 0 ? panel.theme.red : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; Layout.alignment: Qt.AlignRight } Label { text: "Generation"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.signalGeneration; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; Layout.alignment: Qt.AlignRight } + Label { text: panel.device.signalGeneration; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; 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 index d9c358483..72750e269 100644 --- a/apps/arstack_studio/qml/QualityEditor.qml +++ b/apps/arstack_studio/qml/QualityEditor.qml @@ -9,7 +9,7 @@ RowLayout { property var controller property var device property string uiFont: "Inter" - property string monoFont: "Cascadia Mono" + property string monoFont: "Inter" property real qualityValue: 0 property bool compact: false @@ -142,4 +142,4 @@ RowLayout { 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 index 770e85389..f0cb4e817 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -10,7 +10,7 @@ Rectangle { property var device property var sourceModel property string uiFont: "Inter" - property string monoFont: "Cascadia Mono" + property string monoFont: "Inter" property int groupIndex: 0 property string titleText: "Current" property string symbolText: "I" @@ -78,10 +78,10 @@ Rectangle { 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 } + Label { text: "ON"; Layout.preferredWidth: 28; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } + Label { text: "CH"; Layout.preferredWidth: 34; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } + Label { text: "MAGNITUDE"; Layout.fillWidth: true; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } + Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } } Repeater { @@ -251,4 +251,4 @@ Rectangle { 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 index ca20d5ad4..dfdca9d2f 100644 --- a/apps/arstack_studio/qml/SignalPreview.qml +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -8,13 +8,14 @@ SurfacePanel { property var currentModel property var voltageModel property string uiFont: "Inter" - property string monoFont: "Cascadia Mono" + property string monoFont: "Inter" property bool compact: false property string activeSignal: "Ia" property string activeUnit: "A" property real activeMagnitude: 1 property real activePhase: 0 property real signalFrequency: 50 + property bool showHeader: true function requestPaint() { phasor.requestPaint() @@ -27,14 +28,15 @@ SurfacePanel { spacing: 8 RowLayout { + visible: preview.showHeader Layout.fillWidth: true ColumnLayout { spacing: 1 - Label { text: "GENERATED SETPOINT"; color: preview.theme.muted; font.family: preview.uiFont; font.pixelSize: 8; font.weight: Font.DemiBold; font.letterSpacing: 1.0 } + Label { text: "GENERATED SETPOINT"; color: preview.theme.muted; font.family: preview.uiFont; font.pixelSize: preview.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } Label { text: "Signal Preview"; color: preview.theme.text; font.family: preview.uiFont; font.pixelSize: preview.compact ? 15 : 17; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - Label { text: "LOCAL"; color: preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8; font.weight: Font.Bold } + Label { text: "LOCAL"; color: preview.theme.muted; font.family: preview.monoFont; font.pixelSize: preview.theme.captionSize; font.weight: Font.Bold } } RowLayout { @@ -115,7 +117,7 @@ SurfacePanel { ctx.closePath() ctx.fill() if (active) { - ctx.font = "600 10px Inter" + ctx.font = "600 11px '" + preview.uiFont + "'" ctx.fillText(signal.signalId, ex + 7, ey - 7) } ctx.restore() @@ -219,4 +221,4 @@ SurfacePanel { 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 index 5926c291a..79260a3ad 100644 --- a/apps/arstack_studio/qml/StateBadge.qml +++ b/apps/arstack_studio/qml/StateBadge.qml @@ -7,9 +7,9 @@ Rectangle { property var theme property string state: "OFFLINE" property color stateColor: theme.muted - property string monoFont: "Cascadia Mono" + property string monoFont: "Inter" - implicitHeight: 24 + implicitHeight: 26 implicitWidth: stateText.implicitWidth + 24 radius: 6 color: state === "RUNNING" ? theme.greenSoft : @@ -26,8 +26,8 @@ Rectangle { text: badge.state color: badge.stateColor font.family: badge.monoFont - font.pixelSize: 8 + font.pixelSize: badge.theme.captionSize 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 index 2eb22c5d7..da68a7a7c 100644 --- a/apps/arstack_studio/qml/StatusToast.qml +++ b/apps/arstack_studio/qml/StatusToast.qml @@ -35,4 +35,4 @@ Rectangle { 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 index 47a269802..374c37c67 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -22,4 +22,15 @@ QtObject { readonly property color amberSoft: "#3b301c" readonly property color red: "#ff727f" readonly property color redSoft: "#46252b" -} \ No newline at end of file + + // Shared density and typography tokens. Keep engineering data compact, + // but never depend on tiny text to make the workspace fit. + readonly property int captionSize: 10 + readonly property int labelSize: 11 + readonly property int bodySize: 12 + readonly property int subtitleSize: 15 + readonly property int titleSize: 20 + readonly property int controlHeight: 36 + readonly property int panelRadius: 10 + readonly property int controlRadius: 7 +} diff --git a/apps/arstack_studio/qml/SurfacePanel.qml b/apps/arstack_studio/qml/SurfacePanel.qml index 24790fcf2..e83130ad4 100644 --- a/apps/arstack_studio/qml/SurfacePanel.qml +++ b/apps/arstack_studio/qml/SurfacePanel.qml @@ -4,7 +4,7 @@ import QtQuick Rectangle { property var theme color: theme ? theme.surface : "#11171f" - radius: 8 + radius: theme ? theme.panelRadius : 10 border.width: 1 border.color: theme ? theme.lineSoft : "#1c252f" -} \ No newline at end of file +} diff --git a/apps/arstack_studio/qml/TelemetryDock.qml b/apps/arstack_studio/qml/TelemetryDock.qml new file mode 100644 index 000000000..123cf0bc5 --- /dev/null +++ b/apps/arstack_studio/qml/TelemetryDock.qml @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: telemetry + + property var theme + property var device + property var currentModel + property var voltageModel + property var historyModel + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool expanded: true + + signal closeRequested() + + implicitHeight: expanded ? 108 : 32 + color: theme.surface + radius: theme.panelRadius + border.width: 1 + border.color: theme.line + clip: true + Behavior on implicitHeight { NumberAnimation { duration: 130; easing.type: Easing.OutCubic } } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: theme.raised + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 6 + spacing: 7 + + Rectangle { width: 3; height: 14; radius: 2; color: telemetry.device.running ? theme.green : theme.accent } + Label { + text: "Status & output monitor" + color: theme.textSoft + font.family: telemetry.uiFont + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Label { + text: telemetry.device.running ? "LIVE" : "STANDBY" + color: telemetry.device.running ? theme.green : theme.muted + font.family: telemetry.monoFont + font.pixelSize: theme.captionSize - 1 + font.weight: Font.Bold + } + Item { Layout.fillWidth: true } + Label { + text: "FPS " + telemetry.device.fps + " · MISSED " + telemetry.device.missed + " · TX FAIL " + telemetry.device.txFailures + color: theme.muted + font.family: telemetry.monoFont + font.pixelSize: theme.captionSize - 1 + } + ToolButton { + text: telemetry.expanded ? "⌄" : "⌃" + font.pixelSize: theme.bodySize + onClicked: telemetry.expanded = !telemetry.expanded + ToolTip.visible: hovered + ToolTip.text: telemetry.expanded ? "Collapse monitor" : "Expand monitor" + } + ToolButton { + text: "×" + font.pixelSize: theme.bodySize + onClicked: telemetry.closeRequested() + ToolTip.visible: hovered + ToolTip.text: "Close monitor" + } + } + } + + RowLayout { + visible: telemetry.expanded + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 9 + spacing: 10 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 1.25 + radius: theme.controlRadius + color: theme.surface2 + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 3 + Label { + text: "STATUS HISTORY" + color: theme.muted + font.family: telemetry.uiFont + font.pixelSize: theme.captionSize - 1 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + model: telemetry.historyModel + clip: true + spacing: 2 + delegate: RowLayout { + required property string timeText + required property string messageText + required property bool isError + width: ListView.view.width + spacing: 7 + Label { text: timeText; color: theme.muted; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize - 1 } + Label { Layout.fillWidth: true; text: messageText; color: isError ? theme.red : theme.textSoft; font.family: telemetry.uiFont; font.pixelSize: theme.captionSize; elide: Text.ElideRight } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 0.75 + radius: theme.controlRadius + color: theme.surface2 + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 4 + Label { + text: "OUTPUT CHANNELS" + color: theme.muted + font.family: telemetry.uiFont + font.pixelSize: theme.captionSize - 1 + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + } + RowLayout { + Layout.fillWidth: true + spacing: 10 + Repeater { + model: telemetry.currentModel + delegate: RowLayout { + required property string signalId + required property bool enabled + spacing: 4 + Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.green : theme.muted2 } + Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } + } + } + } + RowLayout { + Layout.fillWidth: true + spacing: 10 + Repeater { + model: telemetry.voltageModel + delegate: RowLayout { + required property string signalId + required property bool enabled + spacing: 4 + Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.accent : theme.muted2 } + Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } + } + } + } + } + } + } + } +} diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml new file mode 100644 index 000000000..abfeca8d9 --- /dev/null +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: workflow + + property var controller + property var device + property var profiles + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool compact: false + + function withAlpha(value, alpha) { + return Qt.rgba(value.r, value.g, value.b, alpha) + } + + Menu { + id: viewMenu + MenuItem { + text: "Signal preview" + checkable: true + checked: workflow.controller.previewDockVisible || workflow.controller.previewDetached + onTriggered: { + workflow.controller.previewDetached = false + workflow.controller.previewDockVisible = checked + } + } + MenuItem { + text: "Status & output monitor" + checkable: true + checked: workflow.controller.telemetryDockVisible + onTriggered: workflow.controller.telemetryDockVisible = checked + } + MenuSeparator { } + MenuItem { + text: "Detach signal preview" + enabled: workflow.controller.previewDockVisible && !workflow.controller.previewDetached + onTriggered: workflow.controller.detachPreview() + } + } + + implicitHeight: 82 + color: theme.raised + border.color: theme.line + + component StepMarker: Rectangle { + required property string numberText + required property string titleText + required property string statusText + required property color statusColor + + implicitWidth: workflow.compact ? 94 : 112 + implicitHeight: 48 + radius: workflow.theme.controlRadius + color: "#101820" + border.width: 1 + border.color: statusColor === workflow.theme.muted ? workflow.theme.lineSoft : workflow.withAlpha(statusColor, 0.55) + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 7 + + Rectangle { + width: 23 + height: 23 + radius: 6 + color: workflow.withAlpha(statusColor, 0.16) + border.width: 1 + border.color: workflow.withAlpha(statusColor, 0.48) + Label { + anchors.centerIn: parent + text: numberText + color: statusColor + font.family: workflow.monoFont + font.pixelSize: workflow.theme.captionSize + font.weight: Font.Bold + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: titleText + color: workflow.theme.textSoft + font.family: workflow.uiFont + font.pixelSize: workflow.theme.labelSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: statusText + color: statusColor + font.family: workflow.monoFont + font.pixelSize: workflow.theme.captionSize - 1 + elide: Text.ElideRight + } + } + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: workflow.compact ? 10 : 13 + anchors.rightMargin: workflow.compact ? 10 : 13 + spacing: workflow.compact ? 7 : 9 + + ColumnLayout { + visible: !workflow.compact + Layout.preferredWidth: 92 + spacing: 1 + Label { + text: "FAST WORKFLOW" + color: workflow.theme.muted + font.family: workflow.uiFont + font.pixelSize: workflow.theme.captionSize - 1 + font.weight: Font.DemiBold + font.letterSpacing: 0.9 + } + Label { + text: "Manual" + color: workflow.theme.text + font.family: workflow.uiFont + font.pixelSize: workflow.theme.subtitleSize + font.weight: Font.DemiBold + } + Label { + text: "live setpoints" + color: workflow.theme.muted + font.family: workflow.uiFont + font.pixelSize: workflow.theme.captionSize + } + } + + StepMarker { + numberText: "1" + titleText: "Source" + statusText: workflow.profiles.hasProfiles ? "READY" : "DEV PROFILE" + statusColor: workflow.profiles.hasProfiles ? workflow.theme.green : workflow.theme.muted + } + + StepMarker { + numberText: "2" + titleText: "Device" + statusText: workflow.device.connected ? workflow.device.portName : "OFFLINE" + statusColor: workflow.device.connected ? workflow.theme.green : workflow.theme.muted + } + + StepMarker { + numberText: "3" + titleText: "Output" + statusText: workflow.controller.instrumentState + statusColor: workflow.controller.instrumentStateColor + } + + Rectangle { width: 1; height: 40; color: workflow.theme.lineSoft } + + RowLayout { + spacing: 6 + + CalmButton { + theme: workflow.theme + uiFont: workflow.uiFont + text: "Open SCL" + enabled: !workflow.device.running + onClicked: workflow.controller.openEngineeringFile() + ToolTip.visible: hovered + ToolTip.text: "Open SCL, CID, SCD or IID · Ctrl+O" + } + CalmButton { + visible: !workflow.compact + theme: workflow.theme + uiFont: workflow.uiFont + text: "Balanced" + onClicked: workflow.controller.balanced() + ToolTip.visible: hovered + ToolTip.text: "Apply balanced 3-phase defaults · Ctrl+B" + } + CalmButton { + theme: workflow.theme + uiFont: workflow.uiFont + text: "Zero" + onClicked: workflow.controller.zeroAll() + ToolTip.visible: hovered + ToolTip.text: "Set all magnitudes to zero · Ctrl+0" + } + CalmButton { + visible: !workflow.compact + theme: workflow.theme + uiFont: workflow.uiFont + text: "Check" + onClicked: workflow.controller.runReadinessCheck() + ToolTip.visible: hovered + ToolTip.text: "Check profile and device readiness · Ctrl+K" + } + CalmButton { + id: viewsButton + theme: workflow.theme + uiFont: workflow.uiFont + text: "Views" + onClicked: viewMenu.popup(viewsButton, 0, viewsButton.height) + ToolTip.visible: hovered + ToolTip.text: "Show, hide, or detach engineering views" + } + } + + Item { Layout.fillWidth: true } + + CalmButton { + visible: !workflow.compact + theme: workflow.theme + uiFont: workflow.uiFont + tone: "accent" + text: workflow.device.profileDeploying ? "Deploying…" : "Deploy" + enabled: workflow.controller.canDeploy + onClicked: workflow.controller.deploySelectedProfile() + ToolTip.visible: hovered + ToolTip.text: "Commit the selected immutable profile · Ctrl+D" + } + + CalmButton { + theme: workflow.theme + uiFont: workflow.uiFont + tone: "danger" + text: "Stop" + implicitWidth: 72 + implicitHeight: 42 + enabled: workflow.device.connected && workflow.device.running + onClicked: workflow.device.stop() + ToolTip.visible: hovered + ToolTip.text: "Stop output · F6" + } + + CalmButton { + theme: workflow.theme + uiFont: workflow.uiFont + tone: "success" + text: "Start" + implicitWidth: 82 + implicitHeight: 42 + enabled: workflow.controller.canStart + onClicked: workflow.device.start() + ToolTip.visible: hovered + ToolTip.text: "Start armed output · F5" + } + } +} diff --git a/apps/arstack_studio/run-windows.ps1 b/apps/arstack_studio/run-windows.ps1 new file mode 100644 index 000000000..faf8ceb7c --- /dev/null +++ b/apps/arstack_studio/run-windows.ps1 @@ -0,0 +1,64 @@ +param( + [string]$QtRoot = 'D:\Qt\6.8.3\msvc2022_64', + [ValidateSet('Debug', 'Release', 'RelWithDebInfo')] + [string]$Configuration = 'Release', + [switch]$NoLaunch +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath (Join-Path $QtRoot 'bin\Qt6Core.dll'))) { + throw "Qt runtime was not found at '$QtRoot'. Install Qt 6.8.3 MSVC 2022 with Qt SerialPort first." +} + +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path -LiteralPath $vswhere)) { + throw 'Visual Studio Installer (vswhere.exe) was not found.' +} + +$visualStudio = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (-not $visualStudio) { + throw 'A Visual Studio installation with the C++ x64 toolchain was not found.' +} + +$developerCommand = Join-Path $visualStudio 'Common7\Tools\VsDevCmd.bat' +if (-not (Test-Path -LiteralPath $developerCommand)) { + throw "Visual Studio developer command was not found at '$developerCommand'." +} + +$environmentLines = & $env:ComSpec /s /c "`"$developerCommand`" -no_logo -arch=x64 -host_arch=x64 && set" +foreach ($line in $environmentLines) { + $separator = $line.IndexOf('=') + if ($separator -gt 0) { + [Environment]::SetEnvironmentVariable($line.Substring(0, $separator), $line.Substring($separator + 1), 'Process') + } +} + +$ninja = Join-Path $visualStudio 'Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe' +if (-not (Test-Path -LiteralPath $ninja)) { + throw "The Visual Studio Ninja executable was not found at '$ninja'." +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$buildDirectory = Join-Path $repoRoot 'build-arstack-studio-qt' + +& cmake -S $PSScriptRoot -B $buildDirectory -G Ninja ` + "-DCMAKE_MAKE_PROGRAM=$ninja" ` + "-DCMAKE_PREFIX_PATH=$QtRoot" ` + "-DCMAKE_BUILD_TYPE=$Configuration" +if ($LASTEXITCODE -ne 0) { throw "Qt configure failed with exit code $LASTEXITCODE." } + +& cmake --build $buildDirectory --target arstack_studio --parallel +if ($LASTEXITCODE -ne 0) { throw "Qt build failed with exit code $LASTEXITCODE." } + +$executable = Join-Path $buildDirectory 'arstack_studio.exe' +if (-not (Test-Path -LiteralPath $executable)) { + throw "Build completed but '$executable' was not created." +} + +if (-not $NoLaunch) { + $env:PATH = (Join-Path $QtRoot 'bin') + [IO.Path]::PathSeparator + $env:PATH + Start-Process -FilePath $executable -WorkingDirectory $buildDirectory +} + +Write-Host "ARStack Studio ready: $executable" diff --git a/apps/arstack_studio/run.cmd b/apps/arstack_studio/run.cmd new file mode 100644 index 000000000..6694f8974 --- /dev/null +++ b/apps/arstack_studio/run.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-windows.ps1" %* +exit /b %ERRORLEVEL% From f000772227d6fa6e8af5d6a0bc83ce037e7d8a2e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 22:12:30 +0700 Subject: [PATCH 122/129] Port ARIEC61850 server discovery and wire parity (#61) Includes current-client MMS discovery/read/control interoperability, truthful service negotiation, URCB model structure, and exact-head CI evidence. --- .github/workflows/mms-r12-server-ci.yml | 61 ++ .../mms_dispatcher_hard_profile_smoke.cpp | 115 ++- include/ariec61850/acse/association_span.hpp | 11 + include/ariec61850/mms/connection_runtime.hpp | 14 +- include/ariec61850/mms/pdu_span.hpp | 22 +- .../ariec61850/mms/static_direct_control.hpp | 78 ++ include/ariec61850/mms/static_dispatcher.hpp | 5 + .../mms/static_information_report.hpp | 63 ++ .../mms/static_report_connection.hpp | 52 + .../ariec61850/mms/static_server_session.hpp | 9 + .../ariec61850/mms/static_urcb_objects.hpp | 89 ++ .../ariec61850/mms/static_urcb_runtime.hpp | 192 ++++ scripts/build_mms_direct_control_server.ps1 | 112 +++ scripts/test-mms-server-live-discovery.py | 272 ++++++ src/acse/association_compat_span.cpp | 371 ++++++++ .../mms_association_control_transport.cpp | 19 +- src/mms/connection_runtime_iedsim.cpp | 897 ++++++++++++++++++ src/mms/pdu_compat_span.cpp | 141 +++ src/mms/pdu_span.cpp | 107 ++- src/mms/static_direct_control.cpp | 198 ++++ src/mms/static_dispatcher.cpp | 40 +- src/mms/static_information_report.cpp | 163 ++++ src/mms/static_report_connection.cpp | 215 +++++ src/mms/static_server_session.cpp | 18 +- src/mms/static_urcb_objects.cpp | 630 ++++++++++++ src/mms/static_urcb_runtime.cpp | 612 ++++++++++++ tests/test_mms_server_urcb_report.cpp | 309 ++++++ tests/test_mms_static_direct_control.cpp | 187 ++++ tools/mms_direct_control_server.cpp | 592 ++++++++++++ .../mms_direct_control_server/CMakeLists.txt | 137 +++ tools/mms_urcb_server.cpp | 555 +++++++++++ 31 files changed, 6226 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/mms-r12-server-ci.yml create mode 100644 include/ariec61850/mms/static_direct_control.hpp create mode 100644 include/ariec61850/mms/static_information_report.hpp create mode 100644 include/ariec61850/mms/static_report_connection.hpp create mode 100644 include/ariec61850/mms/static_urcb_objects.hpp create mode 100644 include/ariec61850/mms/static_urcb_runtime.hpp create mode 100644 scripts/build_mms_direct_control_server.ps1 create mode 100644 scripts/test-mms-server-live-discovery.py create mode 100644 src/acse/association_compat_span.cpp create mode 100644 src/mms/connection_runtime_iedsim.cpp create mode 100644 src/mms/pdu_compat_span.cpp create mode 100644 src/mms/static_direct_control.cpp create mode 100644 src/mms/static_information_report.cpp create mode 100644 src/mms/static_report_connection.cpp create mode 100644 src/mms/static_urcb_objects.cpp create mode 100644 src/mms/static_urcb_runtime.cpp create mode 100644 tests/test_mms_server_urcb_report.cpp create mode 100644 tests/test_mms_static_direct_control.cpp create mode 100644 tools/mms_direct_control_server.cpp create mode 100644 tools/mms_direct_control_server/CMakeLists.txt create mode 100644 tools/mms_urcb_server.cpp diff --git a/.github/workflows/mms-r12-server-ci.yml b/.github/workflows/mms-r12-server-ci.yml new file mode 100644 index 000000000..23f89af9d --- /dev/null +++ b/.github/workflows/mms-r12-server-ci.yml @@ -0,0 +1,61 @@ +name: MMS R1-R2 Server CI + +on: + pull_request: + paths: + - 'include/ariec61850/acse/**' + - 'include/ariec61850/mms/**' + - 'src/acse/**' + - 'src/mms/**' + - 'tools/mms_direct_control_server.cpp' + - 'tools/mms_urcb_server.cpp' + - 'tools/mms_direct_control_server/**' + - 'tests/test_mms_static_direct_control.cpp' + - 'tests/test_mms_server_urcb_report.cpp' + - 'scripts/build_mms_direct_control_server.ps1' + - '.github/workflows/mms-r12-server-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + name: Linux ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + steps: + - uses: actions/checkout@v4 + - name: Select compiler + shell: bash + run: | + if [[ "${{ matrix.compiler }}" == "clang" ]]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + - name: Configure + run: cmake -S tools/mms_direct_control_server -B build-mms-r12 -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build-mms-r12 --parallel + - name: Test + run: ctest --test-dir build-mms-r12 --output-on-failure + - name: Help contracts + shell: bash + run: | + ./build-mms-r12/ariec61850_mms_direct_control_server --help + ./build-mms-r12/ariec61850_mms_urcb_server --help + + windows: + name: Windows MSVC + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Configure, build, test and locate executables + shell: powershell + run: .\scripts\build_mms_direct_control_server.ps1 -BuildDir build-mms-r12 diff --git a/embedded/mms_dispatcher_hard_profile_smoke.cpp b/embedded/mms_dispatcher_hard_profile_smoke.cpp index 37b9eef63..234002136 100644 --- a/embedded/mms_dispatcher_hard_profile_smoke.cpp +++ b/embedded/mms_dispatcher_hard_profile_smoke.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace { @@ -28,6 +29,12 @@ constexpr std::array kDomainContinueRequest{ 0xA1U, 0x02U, 0x80U, 0x00U, 0x82U, 0x03U, 0x4CU, 0x44U, 0x30U}; +constexpr std::array kNamedVariableDirectoryRequest{ + 0xA0U, 0x11U, 0x02U, 0x01U, 0x16U, + 0xA1U, 0x0CU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x00U, + 0xA1U, 0x05U, 0x81U, 0x03U, 0x4CU, 0x44U, 0x48U}; + constexpr std::array kDomainFirstResponse{ 0xA1U, 0x0FU, 0x02U, 0x01U, 0x07U, 0xA1U, 0x0AU, 0xA0U, 0x05U, @@ -92,6 +99,21 @@ template std::equal(actual.begin(), actual.end(), expected.begin()); } +[[nodiscard]] bool identifier_equals( + const std::span identifier, + const std::string_view expected) noexcept { + if (identifier.size() != expected.size()) { + return false; + } + for (std::size_t index = 0U; index < identifier.size(); ++index) { + if (identifier[index] != static_cast( + static_cast(expected[index]))) { + return false; + } + } + return true; +} + [[nodiscard]] wire::EncodeResult read_boolean( const void* context, const std::span destination) noexcept { @@ -307,12 +329,103 @@ int main() { return 16; } + // ARIEC61850 IED simulator parity: when a root Logical Node object exists, + // GetNameList(NamedVariable) advertises the root and leaves its flattened + // FC/DO/DA aliases for Read/Write lookup and TypeSpecification traversal. + // A flat-only generic MMS item remains discoverable for compatibility. + bool hierarchy_value = true; + const std::array hierarchy_objects{ + mms::MmsStaticObjectEntry{ + "LDH", "LLN0", kBooleanType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "LLN0$ST$Mod$stVal", kBooleanType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "GGIO1", kBooleanType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "GGIO1$ST$Ind1$stVal", kBooleanType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "Orphan$ST$stVal", kBooleanType, + read_boolean, &hierarchy_value, false}}; + const mms::MmsStaticObjectTable hierarchy_table{hierarchy_objects}; + const mms::MmsStaticDispatchPolicy hierarchy_policy{ + 8U, + 1U, + 10U, + 3U, + 10U}; + const mms::MmsStaticApplicationDispatcher hierarchy_dispatcher{ + hierarchy_table, + hierarchy_policy}; + if (!hierarchy_table.valid()) { + return 17; + } + + dispatched = hierarchy_dispatcher.dispatch( + kNamedVariableDirectoryRequest, + response, + workspace); + mms::MmsGetNameListResponseView hierarchy_directory; + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{response}.first(dispatched.bytes_written), + hierarchy_directory) || + hierarchy_directory.invoke_id != 22U || + hierarchy_directory.identifier_count != 3U || + hierarchy_directory.more_follows) { + return 18; + } + + std::span identifier; + if (!hierarchy_directory.try_identifier(0U, identifier) || + !identifier_equals(identifier, "LLN0") || + !hierarchy_directory.try_identifier(1U, identifier) || + !identifier_equals(identifier, "GGIO1") || + !hierarchy_directory.try_identifier(2U, identifier) || + !identifier_equals(identifier, "Orphan$ST$stVal")) { + return 19; + } + + auto dual_directory_policy = hierarchy_policy; + dual_directory_policy.advertise_flattened_child_aliases = true; + const mms::MmsStaticApplicationDispatcher dual_directory_dispatcher{ + hierarchy_table, + dual_directory_policy}; + dispatched = dual_directory_dispatcher.dispatch( + kNamedVariableDirectoryRequest, + response, + workspace); + mms::MmsGetNameListResponseView dual_directory; + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{response}.first(dispatched.bytes_written), + dual_directory) || + dual_directory.identifier_count != 5U || + dual_directory.more_follows) { + return 20; + } + constexpr std::array dual_names{ + "LLN0", + "LLN0$ST$Mod$stVal", + "GGIO1", + "GGIO1$ST$Ind1$stVal", + "Orphan$ST$stVal"}; + for (std::size_t index = 0U; index < dual_names.size(); ++index) { + if (!dual_directory.try_identifier(index, identifier) || + !identifier_equals(identifier, dual_names[index])) { + return 21; + } + } + for (std::uint32_t iteration = 0U; iteration < 20'000U; ++iteration) { relay_state = true; const auto read = dispatcher.dispatch(kReadRequest, response, workspace); const auto write = dispatcher.dispatch(kWriteRequest, response, workspace); if (!read.success() || !write.success() || relay_state) { - return 17; + return 22; } } diff --git a/include/ariec61850/acse/association_span.hpp b/include/ariec61850/acse/association_span.hpp index b18d1d7ea..d8446b363 100644 --- a/include/ariec61850/acse/association_span.hpp +++ b/include/ariec61850/acse/association_span.hpp @@ -51,10 +51,21 @@ class AcseSpanCodec final { std::span bytes, AcseAarqView& aarq) noexcept; + // Strict protocol decoder used by conformance-oriented/offline paths. [[nodiscard]] static bool try_decode_association_request_view( std::span bytes, AssociationRequestView& request) noexcept; + // Bounded engineering-client compatibility decoder. It prefers the strict + // path, then falls back to the tolerant association-inspection behavior + // used by the proven ARIEC61850 simulator/server: Session Connect, + // Presentation contexts, AARQ UserInformation and MMS InitiateRequest are + // required, while harmless optional Presentation/ACSE decorations are + // ignored rather than turning into a disconnect. + [[nodiscard]] static bool try_decode_association_request_compat_view( + std::span bytes, + AssociationRequestView& request) noexcept; + [[nodiscard]] static constexpr std::size_t default_accept_aare_size() noexcept { return 76U; } diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp index 7579d60b1..294ceea17 100644 --- a/include/ariec61850/mms/connection_runtime.hpp +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -63,6 +63,12 @@ struct MmsStaticConnectionPolicy final { MmsStaticAssociationClosedCallback association_closed{}; void* association_closed_context{}; + // Appended for aggregate source compatibility. When non-empty, this is the + // adapter-owned COTP Data reassembly buffer. Otherwise the runtime may + // borrow half of the stable per-session workspace while a fragmented COTP + // message is active. No heap/global storage is introduced. + std::span cotp_reassembly{}; + [[nodiscard]] constexpr MmsStaticRequestAccessContext access_context() const noexcept { return { association_id, @@ -141,6 +147,7 @@ class MmsStaticConnectionRuntime final { private: void notify_association_closed() noexcept; + void clear_cotp_reassembly() noexcept; const MmsStaticApplicationDispatcher& dispatcher_; MmsStaticConnectionPolicy policy_{}; @@ -148,8 +155,13 @@ class MmsStaticConnectionRuntime final { std::uint32_t mms_presentation_context_id_{}; std::size_t negotiated_tpdu_size_bytes_{}; std::uint32_t negotiated_mms_pdu_size_{}; + std::size_t cotp_reassembly_size_{}; + bool cotp_reassembly_complete_{}; + bool cotp_reassembly_uses_workspace_{}; + std::uint8_t* cotp_reassembly_workspace_data_{}; + std::size_t cotp_reassembly_workspace_size_{}; bool association_active_{}; bool association_close_notified_{}; }; -} // namespace ar::iec61850::mms \ No newline at end of file +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp index 358324fa9..145d1cf25 100644 --- a/include/ariec61850/mms/pdu_span.hpp +++ b/include/ariec61850/mms/pdu_span.hpp @@ -25,6 +25,7 @@ enum class MmsWireConfirmedService : std::int32_t { write = 5, get_variable_access_attributes = 6, get_named_variable_list_attributes = 12, + file_directory = 77, }; enum class MmsConfirmedRequestRejectReason : std::uint8_t { @@ -105,6 +106,25 @@ class MmsPduSpanCodec final { std::uint32_t invoke_id, MmsConfirmedRequestRejectReason reason, std::span destination) noexcept; + + // Compatibility shims used by the isolated IED-simulator parity profile. + // They do not change the strict current capability advertisement. The + // ConfirmedError-named shim intentionally maps to the current RejectPDU + // primitive so unsupported engineering-client probes keep the association + // alive without resurrecting unsupported service claims. + [[nodiscard]] static wire::EncodeResult encode_identify_response_into( + std::uint32_t invoke_id, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_error_into( + std::uint32_t invoke_id, + std::span destination) noexcept; + + [[nodiscard]] static bool is_conclude_request( + std::span bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_conclude_response_into( + std::span destination) noexcept; }; -} // namespace ar::iec61850::mms \ No newline at end of file +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_direct_control.hpp b/include/ariec61850/mms/static_direct_control.hpp new file mode 100644 index 000000000..49f0f3279 --- /dev/null +++ b/include/ariec61850/mms/static_direct_control.hpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsStaticDirectBooleanOperate final { + bool control_value{}; + std::uint8_t origin_category{}; + std::uint8_t control_number{}; + bool test{}; + bool synchro_check{}; + bool interlock_check{}; +}; + +struct MmsStaticDirectBooleanControlState final { + std::uint8_t value{}; + std::uint8_t last_control_number{}; + bool last_test{}; + std::size_t accepted_operations{}; + std::size_t rejected_operations{}; +}; + +using MmsStaticBooleanApplyCallback = bool (*)( + void* context, + bool value) noexcept; + +struct MmsStaticDirectBooleanControlPolicy final { + bool allow_test{true}; + bool allow_synchro_check{}; + bool allow_interlock_check{}; + std::uint32_t malformed_failure_code{7U}; // type-inconsistent + std::uint32_t invalid_value_failure_code{11U}; // object-value-invalid + std::uint32_t backend_failure_code{10U}; // object-non-existent/backend +}; + +struct MmsStaticDirectBooleanControlBinding final { + MmsStaticDirectBooleanControlState* state{}; + MmsStaticBooleanApplyCallback apply{}; + void* apply_context{}; + MmsStaticDirectBooleanControlPolicy policy{}; +}; + +// Decode one IEC 61850 Direct-with-normal-security Oper value with the exact +// ordered structure used by the live TypeSpecification exposed by this server: +// ctlVal, origin(orCat,orIdent), ctlNum, T, Test, Check. +// The parser is allocation-free and accepts only the bounded scalar shape. +[[nodiscard]] bool try_decode_static_direct_boolean_operate( + std::span encoded_data, + MmsStaticDirectBooleanOperate& decoded) noexcept; + +// Read callbacks suitable for MmsStaticObjectEntry. +[[nodiscard]] wire::EncodeResult mms_static_direct_boolean_read_state( + const void* context, + std::span destination) noexcept; + +[[nodiscard]] wire::EncodeResult mms_static_direct_normal_read_ctl_model( + const void* context, + std::span destination) noexcept; + +[[nodiscard]] wire::EncodeResult mms_static_control_read_unavailable( + const void* context, + std::span destination) noexcept; + +// Write callback suitable for the CO$...$Oper MmsStaticObjectEntry. +// Test=true is acknowledged without changing the live output state. Check bits +// are denied by default until the application explicitly enables them. +[[nodiscard]] MmsStaticWriteResult mms_static_direct_boolean_write_oper( + void* context, + std::span encoded_data) noexcept; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_dispatcher.hpp b/include/ariec61850/mms/static_dispatcher.hpp index c8bd088af..4aaee27f1 100644 --- a/include/ariec61850/mms/static_dispatcher.hpp +++ b/include/ariec61850/mms/static_dispatcher.hpp @@ -28,6 +28,11 @@ struct MmsStaticDispatchPolicy final { std::uint32_t missing_object_failure_code{10U}; std::uint32_t access_denied_failure_code{3U}; std::uint32_t backend_failure_code{10U}; + // Some live discovery clients still derive the Logical Node root from + // flattened LN$FC$DO$DA names before requesting the root TypeSpecification. + // Keep strict IED-simulator root-only discovery as the default, while + // allowing host interoperability adapters to advertise both forms. + bool advertise_flattened_child_aliases{}; }; struct MmsStaticDispatchResult final { diff --git a/include/ariec61850/mms/static_information_report.hpp b/include/ariec61850/mms/static_information_report.hpp new file mode 100644 index 000000000..8aa58978a --- /dev/null +++ b/include/ariec61850/mms/static_information_report.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticInformationReportStatus : std::uint8_t { + response_ready, + invalid_object_table, + invalid_data_set_table, + data_set_not_found, + object_not_found, + workspace_too_small, + backend_failure, + invalid_report, + response_buffer_too_small, +}; + +struct MmsStaticInformationReportInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + bool buffered{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + std::uint8_t reason_for_inclusion{}; +}; + +struct MmsStaticInformationReportEncodeResult final { + MmsStaticInformationReportStatus status{ + MmsStaticInformationReportStatus::invalid_report}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticInformationReportStatus::response_ready; + } +}; + +class MmsStaticInformationReportEncoder final { +public: + [[nodiscard]] static MmsStaticInformationReportEncodeResult + encode_data_set_snapshot_into( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsObjectNameView& data_set_name, + const MmsStaticInformationReportInput& report, + std::span destination, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_report_connection.hpp b/include/ariec61850/mms/static_report_connection.hpp new file mode 100644 index 000000000..dd37f6cc2 --- /dev/null +++ b/include/ariec61850/mms/static_report_connection.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticReportConnectionStatus : std::uint8_t { + no_report_due, + response_ready, + not_established, + response_buffer_too_small, + workspace_too_small, + report_encode_failed, + stale_plan, +}; + +struct MmsStaticReportConnectionResult final { + MmsStaticReportConnectionStatus status{MmsStaticReportConnectionStatus::no_report_due}; + MmsStaticUrcbStatus urcb_status{MmsStaticUrcbStatus::no_report_due}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + std::size_t control_block_index{MmsStaticUrcbEmissionPlan::invalid_index}; + std::uint8_t sequence_number{}; + MmsStaticUrcbReportReason reason{MmsStaticUrcbReportReason::none}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticReportConnectionStatus::response_ready; + } +}; + +class MmsStaticReportConnection final { +public: + // Poll one due URCB report for an already-established MMS connection. + // The URCB plan is committed only after a complete TPKT frame is encoded, + // so capacity retries cannot consume GI/integrity state or skip SqNum. + [[nodiscard]] static MmsStaticReportConnectionResult poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbRuntime& reports, + std::uint64_t now_ms, + std::span report_time, + std::span response, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_server_session.hpp b/include/ariec61850/mms/static_server_session.hpp index 21ca384e8..664a54ab4 100644 --- a/include/ariec61850/mms/static_server_session.hpp +++ b/include/ariec61850/mms/static_server_session.hpp @@ -33,6 +33,15 @@ struct MmsStaticServerSessionResult final { std::size_t buffered_input_bytes{}; std::size_t pending_output_bytes{}; + // Preserve application evidence emitted by the connection runtime. This + // lets platform adapters diagnose a standards-level Reject/ConfirmedError + // without parsing the encoded response or coupling the core to logging. + MmsStaticDispatchStatus application_status{ + MmsStaticDispatchStatus::response_ready}; + MmsWireConfirmedService application_service{ + MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + [[nodiscard]] constexpr bool terminal() const noexcept { return status == MmsStaticServerSessionStatus::peer_closed || status == MmsStaticServerSessionStatus::transport_error || diff --git a/include/ariec61850/mms/static_urcb_objects.hpp b/include/ariec61850/mms/static_urcb_objects.hpp new file mode 100644 index 000000000..ec3f1917c --- /dev/null +++ b/include/ariec61850/mms/static_urcb_objects.hpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +using MmsStaticNowMsCallback = std::uint64_t (*)(const void* context) noexcept; + +enum class MmsStaticUrcbAttribute : std::uint8_t { + report_id, + report_enabled, + reserved, + data_set, + conf_revision, + optional_fields, + buffer_time, + trigger_options, + integrity_period, + general_interrogation, + sequence_number, +}; + +struct MmsStaticUrcbObjectContext final { + MmsStaticUrcbRuntime* runtime{}; + std::size_t index{}; + MmsStaticUrcbAttribute attribute{MmsStaticUrcbAttribute::report_id}; + MmsStaticNowMsCallback now_ms{}; + const void* now_context{}; +}; + +class MmsStaticUrcbObjectBank final { +public: + static constexpr std::size_t attributes_per_control_block = 11U; + + MmsStaticUrcbObjectBank( + MmsStaticUrcbRuntime& runtime, + std::span base_objects, + std::span object_storage, + std::span context_storage, + std::span name_storage, + MmsStaticNowMsCallback now_ms = nullptr, + const void* now_context = nullptr) noexcept + : runtime_{&runtime}, + base_objects_{base_objects}, + object_storage_{object_storage}, + context_storage_{context_storage}, + name_storage_{name_storage}, + now_ms_{now_ms}, + now_context_{now_context} {} + + [[nodiscard]] bool initialize() noexcept; + + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + [[nodiscard]] constexpr std::size_t object_count() const noexcept { + return initialized_ ? object_count_ : 0U; + } + [[nodiscard]] constexpr std::size_t name_bytes_used() const noexcept { + return initialized_ ? name_bytes_used_ : 0U; + } + + [[nodiscard]] std::size_t required_object_capacity() const noexcept; + [[nodiscard]] std::size_t required_context_capacity() const noexcept; + [[nodiscard]] std::size_t required_name_bytes() const noexcept; + + [[nodiscard]] constexpr const MmsStaticObjectTable& table() const noexcept { + return table_; + } + +private: + MmsStaticUrcbRuntime* runtime_{}; + std::span base_objects_{}; + std::span object_storage_{}; + std::span context_storage_{}; + std::span name_storage_{}; + MmsStaticNowMsCallback now_ms_{}; + const void* now_context_{}; + MmsStaticObjectTable table_{std::span{}}; + std::size_t object_count_{}; + std::size_t name_bytes_used_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_runtime.hpp b/include/ariec61850/mms/static_urcb_runtime.hpp new file mode 100644 index 000000000..10a6a9b88 --- /dev/null +++ b/include/ariec61850/mms/static_urcb_runtime.hpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_information_report.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticUrcbStatus : std::uint8_t { + ok, + invalid_runtime, + index_out_of_range, + invalid_value, + object_access_denied, + temporarily_unavailable, + data_set_not_found, + no_report_due, + stale_plan, + workspace_too_small, + backend_failure, + response_buffer_too_small, + report_encode_failed, +}; + +enum class MmsStaticUrcbReportReason : std::uint8_t { + none = 0x00U, + general_interrogation = 0x08U, + integrity = 0x10U, +}; + +struct MmsStaticUrcbDefinition final { + std::string_view domain; + std::string_view item; + std::string_view report_id; + std::string_view data_set_domain; + std::string_view data_set_item; + std::uint32_t conf_revision{1U}; + std::array + optional_fields{}; + std::uint32_t buffer_time_ms{}; + std::uint8_t trigger_options{}; + std::uint32_t integrity_period_ms{}; +}; + +struct MmsStaticUrcbState final { + static constexpr std::size_t maximum_report_id_bytes = + MmsInformationReportSpanCodec::maximum_report_id_bytes; + static constexpr std::size_t maximum_reference_component_bytes = + MmsInformationReportSpanCodec::maximum_reference_bytes; + + std::array report_id_storage{}; + std::size_t report_id_size{}; + std::array data_set_domain_storage{}; + std::size_t data_set_domain_size{}; + std::array data_set_item_storage{}; + std::size_t data_set_item_size{}; + std::array + optional_fields{}; + std::uint32_t conf_revision{1U}; + std::uint32_t buffer_time_ms{}; + std::uint32_t integrity_period_ms{}; + std::uint64_t next_integrity_due_ms{}; + std::uint32_t revision{}; + std::uint8_t trigger_options{}; + std::uint8_t sequence_number{}; + bool enabled{}; + bool reserved{}; + bool general_interrogation_pending{}; + bool integrity_armed{}; + + [[nodiscard]] std::string_view report_id() const noexcept { + return {report_id_storage.data(), report_id_size}; + } + + [[nodiscard]] std::string_view data_set_domain() const noexcept { + return {data_set_domain_storage.data(), data_set_domain_size}; + } + + [[nodiscard]] std::string_view data_set_item() const noexcept { + return {data_set_item_storage.data(), data_set_item_size}; + } +}; + +struct MmsStaticUrcbEmissionPlan final { + static constexpr std::size_t invalid_index = + std::numeric_limits::max(); + + std::size_t index{invalid_index}; + std::uint32_t revision{}; + std::uint8_t sequence_number{}; + MmsStaticUrcbReportReason reason{MmsStaticUrcbReportReason::none}; +}; + +struct MmsStaticUrcbEncodeResult final { + MmsStaticUrcbStatus status{MmsStaticUrcbStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticUrcbStatus::ok; + } +}; + +class MmsStaticUrcbRuntime final { +public: + static constexpr std::size_t maximum_control_blocks = 16U; + static constexpr std::uint32_t minimum_integrity_period_ms = 100U; + + MmsStaticUrcbRuntime( + std::span definitions, + std::span states, + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets) noexcept + : definitions_{definitions}, + states_{states}, + objects_{&objects}, + data_sets_{&data_sets} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + [[nodiscard]] constexpr std::size_t size() const noexcept { + return initialized_ ? definitions_.size() : 0U; + } + + [[nodiscard]] const MmsStaticUrcbDefinition* definition( + std::size_t index) const noexcept; + [[nodiscard]] const MmsStaticUrcbState* state(std::size_t index) const noexcept; + [[nodiscard]] MmsStaticUrcbState* state(std::size_t index) noexcept; + + [[nodiscard]] bool find_index( + const MmsObjectNameView& name, + std::size_t& index) const noexcept; + + [[nodiscard]] MmsStaticUrcbStatus set_enabled( + std::size_t index, + bool enabled, + std::uint64_t now_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_reserved( + std::size_t index, + bool reserved) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_report_id( + std::size_t index, + std::string_view report_id) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_data_set( + std::size_t index, + std::string_view domain, + std::string_view item) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_optional_fields( + std::size_t index, + std::span optional_fields) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_buffer_time_ms( + std::size_t index, + std::uint32_t buffer_time_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_trigger_options( + std::size_t index, + std::uint8_t trigger_options) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_integrity_period_ms( + std::size_t index, + std::uint32_t integrity_period_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus request_general_interrogation( + std::size_t index) noexcept; + + [[nodiscard]] bool next_due( + std::uint64_t now_ms, + MmsStaticUrcbEmissionPlan& plan) const noexcept; + + [[nodiscard]] MmsStaticUrcbEncodeResult encode( + const MmsStaticUrcbEmissionPlan& plan, + std::span report_time, + std::span destination, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticUrcbStatus commit( + const MmsStaticUrcbEmissionPlan& plan, + std::uint64_t now_ms) noexcept; + +private: + std::span definitions_{}; + std::span states_{}; + const MmsStaticObjectTable* objects_{}; + const MmsStaticDataSetTable* data_sets_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/scripts/build_mms_direct_control_server.ps1 b/scripts/build_mms_direct_control_server.ps1 new file mode 100644 index 000000000..f8f41703a --- /dev/null +++ b/scripts/build_mms_direct_control_server.ps1 @@ -0,0 +1,112 @@ +param( + [string]$BuildDir = "build-mms-direct-control" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Source = Join-Path $Root "tools\mms_direct_control_server" +$Build = Join-Path $Root $BuildDir + +function Invoke-Checked { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$FilePath failed with exit code $LASTEXITCODE." + } +} + +function Repair-MissingTrackedFiles { + $insideWorkTree = (& git -C $Root rev-parse --is-inside-work-tree 2>$null) + if ($LASTEXITCODE -ne 0 -or $insideWorkTree -ne "true") { + return + } + + $tracked = @(& git -C $Root ls-files) + if ($LASTEXITCODE -ne 0) { + throw "Unable to enumerate tracked files before the MMS server build." + } + + $missing = @( + $tracked | Where-Object { + $_ -and -not (Test-Path -LiteralPath (Join-Path $Root $_)) + } + ) + + if ($missing.Count -eq 0) { + return + } + + Write-Host "[repair] Restoring $($missing.Count) tracked file(s) missing from the working tree..." + foreach ($relativePath in $missing) { + Write-Host "[repair] $relativePath" + } + + & git -C $Root restore --worktree --source=HEAD -- @missing + if ($LASTEXITCODE -ne 0) { + throw "Unable to restore tracked files missing from the working tree. Run 'git status --short' and inspect the checkout before retrying." + } +} + +Repair-MissingTrackedFiles + +$requiredPaths = @( + "CMakeLists.txt", + "src\mms\association_runtime_control_wait.cpp", + "src\mms\static_report_session.cpp", + "src\control\guarded_control.cpp", + "src\control\mms_control_structure.cpp", + "src\control\command_termination.cpp", + "src\control\control_discovery.cpp", + "src\control\control_session.cpp", + "src\control\mms_association_control_transport.cpp", + "src\mms\static_direct_control.cpp", + "src\mms\static_information_report.cpp", + "src\mms\static_report_connection.cpp", + "src\mms\static_urcb_objects.cpp", + "src\mms\static_urcb_runtime.cpp", + "tools\mms_direct_control_server.cpp", + "tools\mms_urcb_server.cpp", + "tools\mms_direct_control_server\CMakeLists.txt" +) + +$stillMissing = @( + $requiredPaths | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $Root $_)) + } +) +if ($stillMissing.Count -ne 0) { + $formatted = ($stillMissing | ForEach-Object { " - $_" }) -join [Environment]::NewLine + throw "MMS server build prerequisites are still missing after checkout repair:$([Environment]::NewLine)$formatted$([Environment]::NewLine)Verify that HEAD is agent/iedsim-wire-parity-current and run 'git status --short'." +} + +Write-Host "[OK] MMS R1-R2 current-lineage source preflight passed." + +Invoke-Checked cmake -S $Source -B $Build -DCMAKE_BUILD_TYPE=Release +Invoke-Checked cmake --build $Build --config Release --parallel +Invoke-Checked ctest --test-dir $Build -C Release --output-on-failure + +function Find-BuiltExe([string]$Name) { + $candidates = @( + (Join-Path $Build "Release\$Name.exe"), + (Join-Path $Build "$Name.exe") + ) + return $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +$controlExe = Find-BuiltExe "ariec61850_mms_direct_control_server" +$urcbExe = Find-BuiltExe "ariec61850_mms_urcb_server" +if (-not $controlExe) { + throw "Build succeeded but ariec61850_mms_direct_control_server.exe was not found." +} +if (-not $urcbExe) { + throw "Build succeeded but ariec61850_mms_urcb_server.exe was not found." +} + +Write-Host "[OK] MMS Direct Control server: $controlExe" +Write-Host "[OK] MMS URCB Reporting server: $urcbExe" diff --git a/scripts/test-mms-server-live-discovery.py b/scripts/test-mms-server-live-discovery.py new file mode 100644 index 000000000..196b23ac1 --- /dev/null +++ b/scripts/test-mms-server-live-discovery.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Loopback contract between runnable MMS servers and live_discover.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +import time +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Profile: + name: str + server: str + server_args: tuple[str, ...] + logical_nodes: int + data_objects: int + data_attributes: int + data_sets: int + report_controls: int + type_probes: int + exact_types: int + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def creation_flags() -> int: + return subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + + +def run_profile(client: str, profile: Profile) -> None: + port = free_port() + command = [ + profile.server, + "--bind", + "127.0.0.1", + "--port", + str(port), + *profile.server_args, + "--once", + ] + server = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creation_flags(), + ) + client_result: subprocess.CompletedProcess[str] | None = None + try: + time.sleep(0.5) + client_result = subprocess.run( + [ + client, + "127.0.0.1", + str(port), + "--model-json", + "--timeout-ms", + "3000", + ], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + server_stdout, server_stderr = server.communicate(timeout=8) + except BaseException: + server.kill() + server_stdout, server_stderr = server.communicate() + raise + + if client_result.returncode != 0 or server.returncode != 0: + raise RuntimeError( + f"{profile.name} loopback failed: client={client_result.returncode}, " + f"server={server.returncode}\n" + f"client stdout:\n{client_result.stdout}\n" + f"client stderr:\n{client_result.stderr}\n" + f"server stdout:\n{server_stdout}\n" + f"server stderr:\n{server_stderr}" + ) + + document = json.loads(client_result.stdout) + coverage = document["coverage"] + expected = { + "logicalDeviceCount": 1, + "logicalNodeCount": profile.logical_nodes, + "dataObjectCount": profile.data_objects, + "dataAttributeCount": profile.data_attributes, + "dataSetCount": profile.data_sets, + "reportControlCount": profile.report_controls, + "variableTypeReadAttemptCount": profile.type_probes, + "variableTypeReadSuccessCount": profile.type_probes, + "variableTypeReadFailureCount": 0, + "exactMmsTypeCount": profile.exact_types, + } + mismatches = { + key: (coverage.get(key), value) + for key, value in expected.items() + if coverage.get(key) != value + } + warnings = document.get("warnings", []) + if mismatches or warnings: + raise RuntimeError( + f"{profile.name} discovery mismatch: {mismatches}; warnings={warnings}" + ) + if "CLIENT_ACCEPTED" not in server_stdout or "CLIENT_CLOSED" not in server_stdout: + raise RuntimeError( + f"{profile.name} server lifecycle evidence missing:\n{server_stdout}" + ) + + print( + f"{profile.name}: LD=1 LN={profile.logical_nodes} " + f"DO={profile.data_objects} DA={profile.data_attributes} " + f"DataSet={profile.data_sets} RCB={profile.report_controls} " + f"exactTypes={profile.exact_types}" + ) + + +def run_direct_control(inventory: str, client: str, server_executable: str) -> None: + port = free_port() + server = subprocess.Popen( + [server_executable, "--bind", "127.0.0.1", "--port", str(port)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creation_flags(), + ) + try: + time.sleep(0.5) + inventory_result = subprocess.run( + [inventory, "127.0.0.1", str(port), "--timeout-ms", "3000"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + operate_result = subprocess.run( + [ + client, + "127.0.0.1", + str(port), + "--object", + "ESP32S3IOLD0/GGIO1.SPCSO1", + "--action", + "operate", + "--value", + "true", + "--synchro-check", + "off", + "--interlock-check", + "off", + "--arm", + "IEC61850-LAB-CONTROL", + "--timeout-ms", + "3000", + ], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + verify_result = subprocess.run( + [ + client, + "127.0.0.1", + str(port), + "--object", + "ESP32S3IOLD0/GGIO1.SPCSO1", + "--timeout-ms", + "3000", + ], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + finally: + server.terminate() + try: + server_stdout, server_stderr = server.communicate(timeout=8) + except subprocess.TimeoutExpired: + server.kill() + server_stdout, server_stderr = server.communicate() + + failures = [] + if inventory_result.returncode != 0 or "candidates=8 ready=8" not in inventory_result.stdout: + failures.append( + f"inventory exit={inventory_result.returncode}\n" + f"stdout:\n{inventory_result.stdout}\nstderr:\n{inventory_result.stderr}" + ) + if ( + operate_result.returncode != 0 + or "completion=accepted accepted=true" not in operate_result.stdout + or "STATUS_AFTER true" not in operate_result.stdout + or "NO_RETRY_EVIDENCE controlWrites=1" not in operate_result.stdout + ): + failures.append( + f"operate exit={operate_result.returncode}\n" + f"stdout:\n{operate_result.stdout}\nstderr:\n{operate_result.stderr}" + ) + if verify_result.returncode != 0 or "STATUS_BEFORE true" not in verify_result.stdout: + failures.append( + f"verify exit={verify_result.returncode}\n" + f"stdout:\n{verify_result.stdout}\nstderr:\n{verify_result.stderr}" + ) + if failures: + raise RuntimeError( + "direct control loopback failed:\n" + + "\n".join(failures) + + f"\nserver stdout:\n{server_stdout}\nserver stderr:\n{server_stderr}" + ) + print("direct-control: candidates=8 ready=8 operate=accepted status=true writes=1") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--client", required=True) + parser.add_argument("--control-inventory", required=True) + parser.add_argument("--control-client", required=True) + parser.add_argument("--direct-server", required=True) + parser.add_argument("--urcb-server", required=True) + args = parser.parse_args() + + profiles = ( + Profile( + "direct", + args.direct_server, + ("--di-mask", "0xA5", "--do-mask", "0x3C"), + 3, + 18, + 34, + 1, + 0, + 3, + 34, + ), + Profile( + "urcb", + args.urcb_server, + ("--mask", "0x3C"), + 2, + 9, + 19, + 1, + 1, + 2, + 19, + ), + ) + for profile in profiles: + run_profile(args.client, profile) + run_direct_control( + args.control_inventory, args.control_client, args.direct_server + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: # noqa: BLE001 - evidence must include all failures. + print(f"MMS server loopback failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/src/acse/association_compat_span.cpp b/src/acse/association_compat_span.cpp new file mode 100644 index 000000000..9cbbec9a7 --- /dev/null +++ b/src/acse/association_compat_span.cpp @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include + +namespace ar::iec61850::acse { +namespace { + +[[nodiscard]] bool valid_oid(const std::span value) noexcept { + return !value.empty() && value.size() <= AcseSpanCodec::maximum_oid_bytes; +} + +[[nodiscard]] bool read_positive_integer( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + value = 0U; + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded || *decoded == 0U) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool count_contexts( + const std::span list, + std::size_t& count) noexcept { + count = 0U; + std::size_t offset = 0U; + while (offset < list.size()) { + if (count >= osi::PresentationSpanCodec::maximum_contexts) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + count = 0U; + return false; + } + ++count; + } + return count != 0U; +} + +[[nodiscard]] bool decode_pdv( + const asn1::BerTlvView& outer, + osi::PresentationPdvView& pdv) noexcept { + pdv = {}; + if (outer.tag_class != asn1::BerClass::application || + outer.tag_number != 1 || !outer.constructed) { + return false; + } + + asn1::BerTlvView sequence; + if (!asn1::BerSpanReader::try_read_exact(outer.value, sequence) || + sequence.tag_class != asn1::BerClass::universal || + sequence.tag_number != 16 || !sequence.constructed) { + return false; + } + + bool saw_context = false; + bool saw_transfer_syntax = false; + bool saw_payload = false; + std::size_t offset = 0U; + while (offset < sequence.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(sequence.value, offset, field)) { + pdv = {}; + return false; + } + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_context || !read_positive_integer(field, pdv.context_id)) { + pdv = {}; + return false; + } + saw_context = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed) { + if (saw_transfer_syntax || !valid_oid(field.value)) { + pdv = {}; + return false; + } + saw_transfer_syntax = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_payload || field.value.empty()) { + pdv = {}; + return false; + } + pdv.single_asn1_type = field.value; + saw_payload = true; + } else { + pdv = {}; + return false; + } + } + return saw_context && saw_payload; +} + +[[nodiscard]] bool decode_cp( + const std::span bytes, + osi::PresentationCpView& cp) noexcept { + cp = {}; + cp.mode_selector = 1U; + if (bytes.empty() || bytes.size() > osi::PresentationSpanCodec::maximum_ppdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 17 || !outer.constructed) { + return false; + } + + bool saw_mode = false; + bool saw_normal = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, item)) { + cp = {}; + return false; + } + + if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 0 && item.constructed) { + if (saw_mode) { + cp = {}; + return false; + } + asn1::BerTlvView mode; + if (!asn1::BerSpanReader::try_read_exact(item.value, mode) || + mode.tag_class != asn1::BerClass::context_specific || + mode.tag_number != 0 || mode.constructed || + !read_positive_integer(mode, cp.mode_selector)) { + cp = {}; + return false; + } + saw_mode = true; + continue; + } + + // The proven ARIEC61850 simulator ignored harmless optional CP fields + // outside the normal-mode parameter instead of rejecting association. + if (item.tag_class != asn1::BerClass::context_specific || + item.tag_number != 2 || !item.constructed) { + continue; + } + if (saw_normal) { + cp = {}; + return false; + } + + bool saw_calling = false; + bool saw_called = false; + bool saw_contexts = false; + bool saw_user_data = false; + std::size_t normal_offset = 0U; + while (normal_offset < item.value.size()) { + asn1::BerTlvView normal; + if (!asn1::BerSpanReader::try_read_tlv(item.value, normal_offset, normal)) { + cp = {}; + return false; + } + if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 1 && !normal.constructed) { + if (saw_calling || normal.value.size() > + osi::PresentationSpanCodec::maximum_selector_bytes) { + cp = {}; + return false; + } + cp.calling_selector = normal.value; + saw_calling = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 2 && !normal.constructed) { + if (saw_called || normal.value.size() > + osi::PresentationSpanCodec::maximum_selector_bytes) { + cp = {}; + return false; + } + cp.called_selector = normal.value; + saw_called = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 4 && normal.constructed) { + if (saw_contexts || !count_contexts( + normal.value, cp.context_count)) { + cp = {}; + return false; + } + cp.context_definition_list = normal.value; + saw_contexts = true; + } else if (normal.tag_class == asn1::BerClass::application && + normal.tag_number == 1 && normal.constructed) { + if (saw_user_data || !decode_pdv(normal, cp.user_data)) { + cp = {}; + return false; + } + saw_user_data = true; + } else { + // Optional Presentation fields are intentionally ignored here. + continue; + } + } + if (!saw_contexts || !saw_user_data) { + cp = {}; + return false; + } + saw_normal = true; + } + return saw_normal && cp.mode_selector != 0U; +} + +[[nodiscard]] bool decode_external( + const asn1::BerTlvView& user_information, + AcseExternalView& external) noexcept { + external = {}; + if (!user_information.constructed) { + return false; + } + + bool saw_external = false; + std::size_t ui_offset = 0U; + while (ui_offset < user_information.value.size()) { + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_tlv( + user_information.value, ui_offset, outer)) { + return false; + } + if (outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 8 || !outer.constructed) { + continue; + } + if (saw_external) { + return false; + } + saw_external = true; + + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field)) { + external = {}; + return false; + } + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed && + external.direct_reference.empty()) { + if (!valid_oid(field.value)) { + external = {}; + return false; + } + external.direct_reference = field.value; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed && + external.indirect_reference == 0U) { + const auto value = asn1::BerSpanReader::read_uint32(field); + if (!value) { + external = {}; + return false; + } + external.indirect_reference = *value; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (!external.single_asn1_type.empty() || field.value.empty()) { + external = {}; + return false; + } + external.single_asn1_type = field.value; + } + // Descriptor and other optional EXTERNAL fields are ignored. + } + } + return saw_external && !external.single_asn1_type.empty(); +} + +[[nodiscard]] bool decode_aarq( + const std::span bytes, + AcseAarqView& aarq) noexcept { + aarq = {}; + if (bytes.empty() || bytes.size() > AcseSpanCodec::maximum_acse_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != 0 || !outer.constructed) { + return false; + } + + bool saw_user_information = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field)) { + aarq = {}; + return false; + } + if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 30 && field.constructed) { + if (saw_user_information || !decode_external( + field, aarq.user_information)) { + aarq = {}; + return false; + } + saw_user_information = true; + } + } + if (!saw_user_information) { + aarq = {}; + return false; + } + + asn1::BerTlvView initiate; + return asn1::BerSpanReader::try_read_exact( + aarq.user_information.single_asn1_type, initiate) && + initiate.tag_class == asn1::BerClass::context_specific && + initiate.tag_number == 8 && initiate.constructed; +} + +[[nodiscard]] bool valid_request(const AssociationRequestView& request) noexcept { + return request.session.kind == osi::SessionWireKind::connect && + request.presentation.mode_selector != 0U && + request.presentation.context_count != 0U && + request.acse_presentation_context_id != 0U && + request.mms_presentation_context_id != 0U && + request.acse_presentation_context_id == request.presentation.user_data.context_id; +} + +} // namespace + +bool AcseSpanCodec::try_decode_association_request_compat_view( + const std::span bytes, + AssociationRequestView& request) noexcept { + if (try_decode_association_request_view(bytes, request)) { + return true; + } + + request = {}; + if (!osi::SessionSpanCodec::try_decode_view(bytes, request.session) || + request.session.kind != osi::SessionWireKind::connect || + !decode_cp(request.session.user_data, request.presentation) || + !decode_aarq( + request.presentation.user_data.single_asn1_type, request.aarq)) { + request = {}; + return false; + } + + if (!request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::acse_abstract_syntax_name(), + request.acse_presentation_context_id) || + !request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::mms_abstract_syntax_name(), + request.mms_presentation_context_id) || + !valid_request(request)) { + request = {}; + return false; + } + return true; +} + +} // namespace ar::iec61850::acse diff --git a/src/control/mms_association_control_transport.cpp b/src/control/mms_association_control_transport.cpp index 16b1ada5f..865bcddb8 100644 --- a/src/control/mms_association_control_transport.cpp +++ b/src/control/mms_association_control_transport.cpp @@ -18,9 +18,11 @@ std::atomic g_next_control_association_id{1U}; [[nodiscard]] std::span response_payload( const mms::MmsConfirmedExchangeResult& exchange) noexcept { - return exchange.presentation_payload.empty() - ? std::span{exchange.envelope.mms_payload} - : std::span{exchange.presentation_payload}; + // exchange_confirmed() has already decoded, invoke-routed, and retained the + // canonical MMS PDU in the envelope. Always consume that payload here so + // control Read/Write decoding is independent of whether the peer returned + // a bare Presentation P-DATA unit or a compatibility session wrapper. + return exchange.envelope.mms_payload; } [[nodiscard]] std::uint64_t allocate_association_id() noexcept { @@ -61,6 +63,11 @@ std::optional MmsAssociationControlTransport::read( if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { return std::nullopt; } + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error( + "MMS Read rejected for " + object.reference() + " (PDU kind=" + + std::to_string(static_cast(exchange.envelope.kind)) + ")."); + } const auto response = mms::MmsServiceCodec::decode_read_response( response_payload(exchange), invoke_id); if (response.results.size() != 1U || !response.results.front().success()) { @@ -87,6 +94,12 @@ MmsAssociationControlTransport::variable_specification( if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { return std::nullopt; } + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error( + "GetVariableAccessAttributes rejected for " + object.reference() + + " (PDU kind=" + + std::to_string(static_cast(exchange.envelope.kind)) + ")."); + } const auto response = mms::MmsServiceCodec::decode_variable_access_attributes_response( response_payload(exchange), invoke_id); diff --git a/src/mms/connection_runtime_iedsim.cpp b/src/mms/connection_runtime_iedsim.cpp new file mode 100644 index 000000000..44b4bc836 --- /dev/null +++ b/src/mms/connection_runtime_iedsim.cpp @@ -0,0 +1,897 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" + +#include "ariec61850/acse/association_span.hpp" +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +// Must remain aligned with MmsPduSpanCodec::encode_default_initiate_response_into(). +constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; + +constexpr std::array kConservativeStructureType{ + 0xA2U, 0x02U, 0xA1U, 0x00U}; + +// FileDirectory-Response service value: listOfDirectoryEntry [0] empty, +// moreFollows [1] FALSE. Mirrors the deterministic behavior of the proven +// ARIEC61850 engineering-client simulator without claiming file-server support. +constexpr std::array kEmptyFileDirectoryFields{ + 0xA0U, 0x00U, 0x81U, 0x01U, 0x00U}; + +[[nodiscard]] MmsStaticConnectionResult make_result( + const MmsStaticConnectionStatus status, + const MmsStaticConnectionState state, + const std::size_t consumed = 0U, + const std::size_t written = 0U) noexcept { + MmsStaticConnectionResult result; + result.status = status; + result.state = state; + result.consumed_bytes = consumed; + result.bytes_written = written; + return result; +} + +[[nodiscard]] MmsStaticConnectionResult make_response_capacity( + const MmsStaticConnectionState state, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::response_buffer_too_small, state); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticConnectionResult make_workspace_capacity( + const MmsStaticConnectionState state, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::workspace_too_small, state); + result.required_workspace_bytes = required; + return result; +} + +[[nodiscard]] bool add_overhead( + const std::size_t base, + const std::size_t overhead, + std::size_t& total) noexcept { + if (base > std::numeric_limits::max() - overhead) { + total = 0U; + return false; + } + total = base + overhead; + return true; +} + +[[nodiscard]] bool selected_tpdu_size_bytes( + const osi::CotpTpduView& request, + const std::uint8_t local_maximum_code, + std::size_t& selected_bytes) noexcept { + selected_bytes = 0U; + std::size_t local_bytes{}; + if (!osi::CotpSpanCodec::try_tpdu_size_bytes(local_maximum_code, local_bytes)) { + return false; + } + + std::span offered; + if (!request.try_parameter(osi::CotpSpanCodec::tpdu_size_parameter, offered)) { + selected_bytes = local_bytes; + return true; + } + if (offered.size() != 1U) { + return false; + } + + std::size_t peer_bytes{}; + if (!osi::CotpSpanCodec::try_tpdu_size_bytes(offered[0], peer_bytes)) { + return false; + } + selected_bytes = std::min(local_bytes, peer_bytes); + return true; +} + +[[nodiscard]] bool cotp_stream_size( + const std::size_t payload_bytes, + const std::size_t negotiated_tpdu_size_bytes, + std::size_t& maximum_user_data, + std::size_t& segment_count, + std::size_t& required) noexcept { + maximum_user_data = 0U; + segment_count = 0U; + required = 0U; + if (negotiated_tpdu_size_bytes <= 3U) { + return false; + } + + maximum_user_data = negotiated_tpdu_size_bytes - 3U; + segment_count = payload_bytes == 0U + ? 1U + : 1U + ((payload_bytes - 1U) / maximum_user_data); + if (segment_count > + (std::numeric_limits::max() - payload_bytes) / 7U) { + return false; + } + required = payload_bytes + segment_count * 7U; + return true; +} + +[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response( + const std::span session_or_presentation, + const std::size_t consumed, + const MmsStaticConnectionState state, + const std::size_t negotiated_tpdu_size_bytes, + const std::span response) noexcept { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + std::size_t required{}; + if (!cotp_stream_size( + session_or_presentation.size(), + negotiated_tpdu_size_bytes, + maximum_user_data, + segment_count, + required)) { + return make_result(MmsStaticConnectionStatus::peer_limit_exceeded, state, consumed); + } + if (response.size() < required) { + return make_response_capacity(state, required); + } + + std::size_t input_offset = 0U; + std::size_t output_offset = 0U; + for (std::size_t segment = 0U; segment < segment_count; ++segment) { + const auto remaining = session_or_presentation.size() - input_offset; + const auto chunk_size = std::min(remaining, maximum_user_data); + const auto final_segment = segment + 1U == segment_count; + const auto frame_bytes = chunk_size + 7U; + if (frame_bytes > std::numeric_limits::max()) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + auto frame = response.subspan(output_offset, frame_bytes); + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_or_presentation.subspan(input_offset, chunk_size), + frame.subspan(osi::TpktSpanCodec::header_length), + final_segment, + 0U); + if (!cotp.success() || cotp.bytes_written != chunk_size + 3U) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + frame[0] = 0x03U; + frame[1] = 0x00U; + frame[2] = static_cast((frame_bytes >> 8U) & 0xFFU); + frame[3] = static_cast(frame_bytes & 0xFFU); + input_offset += chunk_size; + output_offset += frame_bytes; + } + + if (input_offset != session_or_presentation.size() || output_offset != required) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + return make_result( + MmsStaticConnectionStatus::response_ready, + state, + consumed, + required); +} + +[[nodiscard]] MmsStaticConnectionResult application_rejected( + const MmsStaticConnectionState state, + const std::size_t consumed, + const MmsStaticDispatchResult& application) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::application_rejected, state, consumed); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; +} + +[[nodiscard]] MmsStaticConnectionResult wrap_mms_response( + const wire::EncodeResult encoded_mms, + const std::size_t consumed, + const MmsStaticConnectionState state, + const std::uint32_t presentation_context_id, + const std::uint32_t negotiated_mms_pdu_size, + const std::size_t negotiated_tpdu_size_bytes, + const std::span response, + const std::span workspace, + const MmsStaticDispatchStatus application_status, + const MmsWireConfirmedService service, + const std::uint32_t invoke_id) noexcept { + if (!encoded_mms.success()) { + if (encoded_mms.status == wire::EncodeStatus::buffer_too_small && + encoded_mms.required_bytes > 0U) { + if (negotiated_mms_pdu_size == 0U || + encoded_mms.required_bytes > negotiated_mms_pdu_size) { + return make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state, + consumed); + } + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, encoded_mms.required_bytes); + if (fully_encoded) { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + std::size_t required{}; + if (cotp_stream_size( + *fully_encoded, + negotiated_tpdu_size_bytes, + maximum_user_data, + segment_count, + required)) { + auto result = make_response_capacity(state, required); + result.application_status = application_status; + result.application_service = service; + result.invoke_id = invoke_id; + return result; + } + } + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + if (negotiated_mms_pdu_size == 0U || + encoded_mms.bytes_written > negotiated_mms_pdu_size) { + return make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state, + consumed); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(encoded_mms.bytes_written), + workspace, + presentation_context_id, + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + auto result = make_workspace_capacity(state, p_data.required_bytes); + result.application_status = application_status; + result.application_service = service; + result.invoke_id = invoke_id; + return result; + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + auto wrapped = wrap_cotp_data_response( + workspace.first(p_data.bytes_written), + consumed, + state, + negotiated_tpdu_size_bytes, + response); + if (wrapped.response_ready()) { + wrapped.application_status = application_status; + wrapped.application_service = service; + wrapped.invoke_id = invoke_id; + } + return wrapped; +} + +[[nodiscard]] bool write_outer_capacity( + const MmsConfirmedPduView& confirmed, + const MmsStaticApplicationDispatcher& dispatcher, + const std::uint32_t presentation_context_id, + const std::size_t negotiated_tpdu_size_bytes, + std::size_t& required) noexcept { + required = 0U; + if (confirmed.service() != MmsWireConfirmedService::write) { + return false; + } + + MmsWriteRequestView write; + if (!MmsServiceSpanCodec::try_decode_write_request(confirmed, write) || + write.variable_count == 0U || + write.variable_count > dispatcher.policy().maximum_write_variables) { + return false; + } + + std::array worst{}; + for (std::size_t index = 0U; index < write.variable_count; ++index) { + worst[index] = MmsWriteAccessResultInput{ + false, + std::numeric_limits::max()}; + } + const auto probe = MmsServiceSpanCodec::encode_write_response_into( + confirmed.invoke_id, + std::span{worst}.first(write.variable_count), + {}); + if (probe.status != wire::EncodeStatus::buffer_too_small || + probe.required_bytes == 0U) { + return false; + } + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, probe.required_bytes); + if (!fully_encoded) { + return false; + } + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + return cotp_stream_size( + *fully_encoded, + negotiated_tpdu_size_bytes, + maximum_user_data, + segment_count, + required); +} + +[[nodiscard]] bool compatibility_error_status( + const MmsStaticDispatchStatus status) noexcept { + return status == MmsStaticDispatchStatus::malformed_request || + status == MmsStaticDispatchStatus::unsupported_service || + status == MmsStaticDispatchStatus::unsupported_request || + status == MmsStaticDispatchStatus::object_not_found; +} + +} // namespace + +void MmsStaticConnectionRuntime::clear_cotp_reassembly() noexcept { + cotp_reassembly_size_ = 0U; + cotp_reassembly_complete_ = false; + cotp_reassembly_uses_workspace_ = false; + cotp_reassembly_workspace_data_ = nullptr; + cotp_reassembly_workspace_size_ = 0U; +} + +void MmsStaticConnectionRuntime::notify_association_closed() noexcept { + if (!association_active_ || association_close_notified_) { + return; + } + + association_close_notified_ = true; + association_active_ = false; + if (policy_.association_closed == nullptr || policy_.association_id == 0U) { + return; + } + + const auto now = policy_.now_ms == nullptr + ? std::uint64_t{0U} + : policy_.now_ms(policy_.now_context); + policy_.association_closed( + policy_.association_closed_context, + policy_.association_id, + now); +} + +void MmsStaticConnectionRuntime::close_transport() noexcept { + notify_association_closed(); + clear_cotp_reassembly(); + state_ = MmsStaticConnectionState::closed; + mms_presentation_context_id_ = 0U; + negotiated_tpdu_size_bytes_ = 0U; + negotiated_mms_pdu_size_ = 0U; +} + +void MmsStaticConnectionRuntime::reset() noexcept { + notify_association_closed(); + clear_cotp_reassembly(); + state_ = MmsStaticConnectionState::awaiting_cotp_connect; + mms_presentation_context_id_ = 0U; + negotiated_tpdu_size_bytes_ = 0U; + negotiated_mms_pdu_size_ = 0U; + association_active_ = false; + association_close_notified_ = false; +} + +MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( + const std::span tcp_bytes, + const std::span response, + const std::span workspace_storage) noexcept { + auto workspace = workspace_storage; + if (state_ == MmsStaticConnectionState::closed) { + return make_result(MmsStaticConnectionStatus::closed, state_); + } + if (state_ == MmsStaticConnectionState::fault) { + return make_result(MmsStaticConnectionStatus::protocol_violation, state_); + } + + const auto peek = osi::TpktSpanCodec::peek_frame(tcp_bytes); + if (peek.status == osi::TpktPeekStatus::need_more) { + return make_result(MmsStaticConnectionStatus::need_more, state_); + } + if (peek.status != osi::TpktPeekStatus::ready || peek.frame_bytes == 0U) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::malformed_transport, state_); + } + + const auto frame_bytes = tcp_bytes.first(peek.frame_bytes); + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(frame_bytes, tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp)) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::malformed_transport, state_, peek.frame_bytes); + } + + if (cotp.kind == osi::CotpWireKind::disconnect_request) { + close_transport(); + return make_result( + MmsStaticConnectionStatus::closed, state_, peek.frame_bytes); + } + + if (state_ == MmsStaticConnectionState::awaiting_cotp_connect) { + if (cotp.kind != osi::CotpWireKind::connection_request) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + std::size_t selected_tpdu_bytes{}; + if (!selected_tpdu_size_bytes( + cotp, policy_.maximum_tpdu_size_code, selected_tpdu_bytes)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + const auto cc = osi::CotpSpanCodec::encode_connection_confirm_from_request_into( + cotp, + policy_.cotp_source_reference, + workspace, + policy_.maximum_tpdu_size_code); + if (!cc.success()) { + if (cc.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state_, cc.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + std::size_t required{}; + if (!add_overhead(cc.bytes_written, osi::TpktSpanCodec::header_length, required)) { + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (response.size() < required) { + return make_response_capacity(state_, required); + } + const auto encoded = osi::TpktSpanCodec::encode_into( + workspace.first(cc.bytes_written), response.first(required)); + if (!encoded.success()) { + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity(state_, encoded.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + negotiated_tpdu_size_bytes_ = selected_tpdu_bytes; + state_ = MmsStaticConnectionState::awaiting_association; + return make_result( + MmsStaticConnectionStatus::response_ready, + state_, + peek.frame_bytes, + encoded.bytes_written); + } + + if (cotp.kind != osi::CotpWireKind::data) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + bool using_reassembly = false; + std::span reassembly = policy_.cotp_reassembly; + const auto select_workspace_reassembly = + [this, workspace_storage, &workspace, &reassembly]() noexcept { + if (workspace_storage.size() < 2U) { + return false; + } + const auto processing_bytes = workspace_storage.size() / 2U; + const auto reassembly_bytes = workspace_storage.size() - processing_bytes; + if (processing_bytes == 0U || reassembly_bytes == 0U) { + return false; + } + if (cotp_reassembly_uses_workspace_) { + if (cotp_reassembly_workspace_data_ != workspace_storage.data() || + cotp_reassembly_workspace_size_ != workspace_storage.size()) { + return false; + } + } else { + cotp_reassembly_uses_workspace_ = true; + cotp_reassembly_workspace_data_ = workspace_storage.data(); + cotp_reassembly_workspace_size_ = workspace_storage.size(); + } + workspace = workspace_storage.first(processing_bytes); + reassembly = workspace_storage.subspan(processing_bytes, reassembly_bytes); + return true; + }; + + if (cotp_reassembly_complete_) { + if (cotp_reassembly_size_ == 0U || !cotp.end_of_transmission) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + if (cotp_reassembly_uses_workspace_ && !select_workspace_reassembly()) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (reassembly.empty() || cotp_reassembly_size_ > reassembly.size()) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + cotp.user_data = std::span{reassembly}.first(cotp_reassembly_size_); + using_reassembly = true; + } else if (cotp_reassembly_size_ != 0U || !cotp.end_of_transmission) { + if (reassembly.empty()) { + if (!policy_.require_end_of_transmission) { + // Explicit compatibility opt-out: expose a partial TPDU to the + // upper decoder exactly as the historical strict runtime did. + } else if (!select_workspace_reassembly()) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + } + if (!reassembly.empty()) { + if (cotp_reassembly_size_ > reassembly.size() || + cotp.user_data.size() > reassembly.size() - cotp_reassembly_size_) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + std::copy( + cotp.user_data.begin(), + cotp.user_data.end(), + reassembly.begin() + static_cast(cotp_reassembly_size_)); + cotp_reassembly_size_ += cotp.user_data.size(); + if (!cotp.end_of_transmission) { + return make_result( + MmsStaticConnectionStatus::consumed_no_response, + state_, + peek.frame_bytes); + } + cotp_reassembly_complete_ = true; + cotp.user_data = std::span{reassembly}.first(cotp_reassembly_size_); + using_reassembly = true; + } + } + + const auto finish = [this, using_reassembly]( + MmsStaticConnectionResult result) noexcept { + if (using_reassembly && result.consumed_bytes != 0U) { + clear_cotp_reassembly(); + } + return result; + }; + + if (state_ == MmsStaticConnectionState::awaiting_association) { + acse::AssociationRequestView association; + if (!acse::AcseSpanCodec::try_decode_association_request_compat_view( + cotp.user_data, association)) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + MmsInitiateView initiate; + if (!MmsPduSpanCodec::try_decode_initiate_request_view( + association.aarq.user_information.single_asn1_type, initiate) || + association.mms_presentation_context_id == 0U) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + const auto selected_mms_pdu_size = std::min( + initiate.maximum_mms_pdu_size, kServerMaximumMmsPduSize); + + const auto accept = acse::AcseSpanCodec::build_accept_response_into( + association, workspace); + if (!accept.success()) { + if (accept.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state_, accept.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + const auto wrapped = wrap_cotp_data_response( + workspace.first(accept.bytes_written), + peek.frame_bytes, + MmsStaticConnectionState::established, + negotiated_tpdu_size_bytes_, + response); + if (!wrapped.response_ready()) { + return wrapped; + } + mms_presentation_context_id_ = association.mms_presentation_context_id; + negotiated_mms_pdu_size_ = selected_mms_pdu_size; + state_ = MmsStaticConnectionState::established; + association_active_ = true; + association_close_notified_ = false; + auto result = wrapped; + result.state = state_; + return finish(result); + } + + if (state_ != MmsStaticConnectionState::established) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + if (!osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) || + !osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) || + pdv.context_id != mms_presentation_context_id_) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + if (MmsPduSpanCodec::is_conclude_request(pdv.single_asn1_type)) { + const auto conclude = MmsPduSpanCodec::encode_conclude_response_into(response); + return finish(wrap_mms_response( + conclude, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_mms_pdu_size_, + negotiated_tpdu_size_bytes_, + response, + workspace, + MmsStaticDispatchStatus::response_ready, + MmsWireConfirmedService::unknown, + 0U)); + } + + MmsConfirmedPduView confirmed; + const bool confirmed_decoded = MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed); + if (confirmed_decoded) { + if (pdv.single_asn1_type.size() > negotiated_mms_pdu_size_) { + return finish(make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state_, + peek.frame_bytes)); + } + + if (confirmed.service() == MmsWireConfirmedService::identify) { + const auto identify = !confirmed.service_constructed && confirmed.service_value.empty() + ? MmsPduSpanCodec::encode_identify_response_into( + confirmed.invoke_id, response) + : MmsPduSpanCodec::encode_confirmed_error_into( + confirmed.invoke_id, response); + return finish(wrap_mms_response( + identify, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_mms_pdu_size_, + negotiated_tpdu_size_bytes_, + response, + workspace, + MmsStaticDispatchStatus::response_ready, + MmsWireConfirmedService::identify, + confirmed.invoke_id)); + } + + if (confirmed.service() == MmsWireConfirmedService::file_directory) { + const auto directory = confirmed.service_constructed + ? MmsPduSpanCodec::encode_confirmed_response_into( + confirmed.invoke_id, + static_cast(MmsWireConfirmedService::file_directory), + true, + kEmptyFileDirectoryFields, + response) + : MmsPduSpanCodec::encode_confirmed_error_into( + confirmed.invoke_id, response); + return finish(wrap_mms_response( + directory, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_mms_pdu_size_, + negotiated_tpdu_size_bytes_, + response, + workspace, + MmsStaticDispatchStatus::response_ready, + MmsWireConfirmedService::file_directory, + confirmed.invoke_id)); + } + + std::size_t write_required{}; + if (write_outer_capacity( + confirmed, + dispatcher_, + mms_presentation_context_id_, + negotiated_tpdu_size_bytes_, + write_required)) { + if (response.size() < write_required) { + return make_response_capacity(state_, write_required); + } + if (workspace.size() < write_required) { + return make_workspace_capacity(state_, write_required); + } + } + } + + const auto application = dispatcher_.dispatch( + pdv.single_asn1_type, response, workspace, policy_.access_context()); + if (!application.success()) { + if (application.status == MmsStaticDispatchStatus::response_buffer_too_small) { + if (negotiated_mms_pdu_size_ == 0U || + application.required_bytes > negotiated_mms_pdu_size_) { + return finish(make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state_, + peek.frame_bytes)); + } + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + mms_presentation_context_id_, application.required_bytes); + if (fully_encoded) { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + std::size_t required{}; + if (cotp_stream_size( + *fully_encoded, + negotiated_tpdu_size_bytes_, + maximum_user_data, + segment_count, + required)) { + auto result = make_response_capacity(state_, required); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + } + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (application.status == MmsStaticDispatchStatus::workspace_too_small) { + auto result = make_workspace_capacity(state_, application.required_bytes); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + if (application.status == MmsStaticDispatchStatus::backend_failure || + application.status == MmsStaticDispatchStatus::invalid_object_table) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + auto result = make_result( + MmsStaticConnectionStatus::backend_failure, + state_, + peek.frame_bytes); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + + if (confirmed_decoded && + application.status == MmsStaticDispatchStatus::object_not_found && + confirmed.service() == MmsWireConfirmedService::get_variable_access_attributes) { + MmsVariableAccessAttributesRequestView attributes; + if (MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + confirmed, attributes) && + attributes.name.kind == MmsObjectNameViewKind::vmd_specific && + !attributes.name.item.empty()) { + const auto fallback = + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + false, + kConservativeStructureType, + response); + return finish(wrap_mms_response( + fallback, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_mms_pdu_size_, + negotiated_tpdu_size_bytes_, + response, + workspace, + application.status, + confirmed.service(), + confirmed.invoke_id)); + } + } + + if (confirmed_decoded && compatibility_error_status(application.status)) { + const auto error = MmsPduSpanCodec::encode_confirmed_error_into( + confirmed.invoke_id, response); + return finish(wrap_mms_response( + error, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_mms_pdu_size_, + negotiated_tpdu_size_bytes_, + response, + workspace, + application.status, + confirmed.service(), + confirmed.invoke_id)); + } + return finish(application_rejected(state_, peek.frame_bytes, application)); + } + + if (negotiated_mms_pdu_size_ == 0U || + application.bytes_written > negotiated_mms_pdu_size_) { + return finish(make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state_, + peek.frame_bytes)); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(application.bytes_written), + workspace, + mms_presentation_context_id_, + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state_, p_data.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + auto wrapped = wrap_cotp_data_response( + workspace.first(p_data.bytes_written), + peek.frame_bytes, + state_, + negotiated_tpdu_size_bytes_, + response); + if (wrapped.response_ready()) { + wrapped.application_status = application.status; + wrapped.application_service = application.service; + wrapped.invoke_id = application.invoke_id; + } + return finish(wrapped); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/pdu_compat_span.cpp b/src/mms/pdu_compat_span.cpp new file mode 100644 index 000000000..1a6a1cf0d --- /dev/null +++ b/src/mms/pdu_compat_span.cpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/pdu_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kIdentifyFields{ + 0x80U, 0x0AU, + 'A','R','I','E','C','6','1','8','5','0', + 0x81U, 0x15U, + 'V','i','r','t','u','a','l',' ','I','E','D',' ','S','i','m','u','l','a','t','o','r', + 0x82U, 0x03U, '1','.','0'}; + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + std::size_t bytes = 1U; + auto remaining = value; + while (remaining > 0xFFU) { + ++bytes; + remaining >>= 8U; + } + const auto leading = static_cast( + value >> static_cast((bytes - 1U) * 8U)); + return bytes + ((leading & 0x80U) != 0U ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value, + const std::size_t bytes) noexcept { + const auto encoded_bytes = bytes - + ((bytes > 1U && (value >> static_cast((bytes - 2U) * 8U)) <= 0xFFU) + ? 1U + : 0U); + if (encoded_bytes < bytes && !writer.write_byte(0U)) { + return false; + } + for (std::size_t index = encoded_bytes; index > 0U; --index) { + const auto shift = static_cast((index - 1U) * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +} // namespace + +wire::EncodeResult MmsPduSpanCodec::encode_identify_response_into( + const std::uint32_t invoke_id, + const std::span destination) noexcept { + return encode_confirmed_response_into( + invoke_id, + static_cast(MmsWireConfirmedService::identify), + true, + kIdentifyFields, + destination); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_error_into( + const std::uint32_t invoke_id, + const std::span destination) noexcept { + if (invoke_id > maximum_invoke_id) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + // ConfirmedErrorPDU ::= [2] { invokeID [0], serviceError [2] } + // serviceError.errorClass uses access[7] object-non-existent(2). This is + // the interoperable outcome for optional IEC 61850 objects such as Cancel: + // the request was syntactically valid, but the named service object is not + // present. A RejectPDU would incorrectly classify the request itself as + // malformed and abort otherwise valid control discovery clients. + const auto invoke_bytes = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(0, invoke_bytes); + const auto access_tlv = asn1::BerSpanWriter::tlv_size(7, 1U); + if (!invoke_tlv || !access_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto error_class_tlv = asn1::BerSpanWriter::tlv_size(0, *access_tlv); + if (!error_class_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto service_error_tlv = asn1::BerSpanWriter::tlv_size(2, *error_class_tlv); + if (!service_error_tlv || + *invoke_tlv > std::numeric_limits::max() - *service_error_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content = *invoke_tlv + *service_error_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(2, content); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header(asn1::BerClass::context_specific, true, 2, content) || + !writer.write_tlv_header(asn1::BerClass::context_specific, false, 0, invoke_bytes) || + !write_positive_integer(writer, invoke_id, invoke_bytes) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, *error_class_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *access_tlv) || + !writer.write_tlv_header(asn1::BerClass::context_specific, false, 7, 1U) || + !writer.write_byte(2U) || writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +bool MmsPduSpanCodec::is_conclude_request( + const std::span bytes) noexcept { + asn1::BerTlvView pdu; + return asn1::BerSpanReader::try_read_exact(bytes, pdu) && + pdu.tag_class == asn1::BerClass::context_specific && + pdu.tag_number == 11 && !pdu.constructed && pdu.value.empty(); +} + +wire::EncodeResult MmsPduSpanCodec::encode_conclude_response_into( + const std::span destination) noexcept { + constexpr std::size_t required = 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x8CU; + destination[1] = 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp index 63e29318e..d03b83bae 100644 --- a/src/mms/pdu_span.cpp +++ b/src/mms/pdu_span.cpp @@ -16,9 +16,10 @@ namespace ar::iec61850::mms { namespace { -// Match the bounded static dispatcher capability surface exactly: +// Advertise only the bounded static dispatcher capability surface: // GetNameList(1), Read(4), Write(5), GetVariableAccessAttributes(6), and -// GetNamedVariableListAttributes(12). +// GetNamedVariableListAttributes(12). Do not echo the client's requested +// service mask because that would claim services the server cannot dispatch. constexpr std::array kDefaultInitiateResponse{ 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, @@ -152,19 +153,29 @@ constexpr std::array kDefaultInitiateResponse{ } } - if (count != 5U || - !std::all_of(seen.begin(), seen.end(), [](const bool value) { return value; }) || - values[0] < 64U || values[0] > MmsPduSpanCodec::maximum_pdu_bytes || - values[1] == 0U || values[2] == 0U || values[3] == 0U) { + // ISO 9506 InitiateRequest permits localDetailCalling [0] and + // proposedDataStructureNestingLevel [3] to be absent. The original + // ARIEC61850 server accepted such request profiles and several engineering + // clients rely on that tolerance. Keep InitiateResponse decoding strict, + // but do not turn standards-valid optional omissions into a protocol fault. + const bool is_request = kind == MmsWirePduKind::initiate_request; + const bool mandatory_present = seen[1] && seen[2] && seen[4]; + const bool response_complete = seen[0] && seen[1] && seen[2] && seen[3] && seen[4]; + if (!mandatory_present || (!is_request && !response_complete) || + (seen[0] && (values[0] < 64U || values[0] > MmsPduSpanCodec::maximum_pdu_bytes)) || + values[1] == 0U || values[2] == 0U || + (seen[3] && values[3] == 0U)) { result = {}; return false; } result.kind = kind; - result.maximum_mms_pdu_size = values[0]; + result.maximum_mms_pdu_size = seen[0] + ? values[0] + : static_cast(MmsPduSpanCodec::maximum_pdu_bytes); result.maximum_outstanding_calling = values[1]; result.maximum_outstanding_called = values[2]; - result.data_structure_nesting_level = values[3]; + result.data_structure_nesting_level = seen[3] ? values[3] : 5U; result.detail = detail; return true; } @@ -323,6 +334,47 @@ constexpr std::array kDefaultInitiateResponse{ return {wire::EncodeStatus::ok, *required, *required}; } +[[nodiscard]] wire::EncodeResult encode_reject( + const std::uint32_t invoke_id, + const MmsConfirmedRequestRejectReason reason, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + static_cast(reason) > + static_cast(MmsConfirmedRequestRejectReason::invalid_argument)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(0, invoke_value); + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(1, 1U); + if (!invoke_tlv || !reason_tlv || + *invoke_tlv > std::numeric_limits::max() - *reason_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content = *invoke_tlv + *reason_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(4, content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 4, content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 1U) || + !writer.write_byte(static_cast(reason)) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + } // namespace MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { @@ -333,6 +385,7 @@ MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { case 5: return MmsWireConfirmedService::write; case 6: return MmsWireConfirmedService::get_variable_access_attributes; case 12: return MmsWireConfirmedService::get_named_variable_list_attributes; + case 77: return MmsWireConfirmedService::file_directory; default: return MmsWireConfirmedService::unknown; } } @@ -403,43 +456,7 @@ wire::EncodeResult MmsPduSpanCodec::encode_confirmed_request_reject_into( const std::uint32_t invoke_id, const MmsConfirmedRequestRejectReason reason, const std::span destination) noexcept { - const auto reason_value = static_cast(reason); - if (invoke_id > maximum_invoke_id || - (reason != MmsConfirmedRequestRejectReason::other && - reason != MmsConfirmedRequestRejectReason::unrecognized_service && - reason != MmsConfirmedRequestRejectReason::invalid_argument)) { - return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; - } - - const auto invoke_value = positive_integer_size(invoke_id); - const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(0, invoke_value); - const auto reason_tlv = asn1::BerSpanWriter::tlv_size(1, 1U); - if (!invoke_tlv || !reason_tlv || - *reason_tlv > std::numeric_limits::max() - *invoke_tlv) { - return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; - } - const auto content = *invoke_tlv + *reason_tlv; - const auto required = asn1::BerSpanWriter::tlv_size(4, content); - if (!required || *required > maximum_pdu_bytes) { - return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; - } - if (destination.size() < *required) { - return {wire::EncodeStatus::buffer_too_small, 0U, *required}; - } - - asn1::BerSpanWriter writer{destination.first(*required)}; - if (!writer.write_tlv_header( - asn1::BerClass::context_specific, true, 4, content) || - !writer.write_tlv_header( - asn1::BerClass::context_specific, false, 0, invoke_value) || - !write_positive_integer(writer, invoke_id) || - !writer.write_tlv_header( - asn1::BerClass::context_specific, false, 1, 1U) || - !writer.write_byte(reason_value) || - writer.size() != *required) { - return {wire::EncodeStatus::value_out_of_range, 0U, *required}; - } - return {wire::EncodeStatus::ok, *required, *required}; + return encode_reject(invoke_id, reason, destination); } } // namespace ar::iec61850::mms diff --git a/src/mms/static_direct_control.cpp b/src/mms/static_direct_control.cpp new file mode 100644 index 000000000..c14d958a3 --- /dev/null +++ b/src/mms/static_direct_control.cpp @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_direct_control.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool context_tag( + const asn1::BerTlvView& tlv, + const std::int32_t tag, + const bool constructed) noexcept { + return tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == tag && tlv.constructed == constructed; +} + +[[nodiscard]] bool decode_boolean( + const asn1::BerTlvView& tlv, + bool& value) noexcept { + if (!context_tag(tlv, 3, false) || tlv.value.size() != 1U) { + return false; + } + value = tlv.value.front() != 0U; + return true; +} + +[[nodiscard]] bool read_next( + const std::span source, + std::size_t& offset, + asn1::BerTlvView& tlv) noexcept { + return asn1::BerSpanReader::try_read_tlv(source, offset, tlv); +} + +[[nodiscard]] bool decode_origin( + const asn1::BerTlvView& origin, + std::uint8_t& category) noexcept { + if (!context_tag(origin, 2, true)) { + return false; + } + std::size_t offset = 0U; + asn1::BerTlvView or_cat; + asn1::BerTlvView or_ident; + if (!read_next(origin.value, offset, or_cat) || + !context_tag(or_cat, 6, false)) { + return false; + } + const auto category_value = asn1::BerSpanReader::read_unsigned_integer(or_cat); + if (!category_value || *category_value > 0xFFU) { + return false; + } + if (!read_next(origin.value, offset, or_ident) || + !context_tag(or_ident, 9, false) || + or_ident.value.size() > 64U || + offset != origin.value.size()) { + return false; + } + category = static_cast(*category_value); + return true; +} + +[[nodiscard]] bool decode_check( + const asn1::BerTlvView& check, + bool& synchro, + bool& interlock) noexcept { + if (!context_tag(check, 4, false) || check.value.size() != 2U || + check.value[0] != 6U || (check.value[1] & 0x3FU) != 0U) { + return false; + } + synchro = (check.value[1] & 0x80U) != 0U; + interlock = (check.value[1] & 0x40U) != 0U; + return true; +} + +} // namespace + +bool try_decode_static_direct_boolean_operate( + const std::span encoded_data, + MmsStaticDirectBooleanOperate& decoded) noexcept { + decoded = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(encoded_data, outer) || + !context_tag(outer, 2, true)) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView ctl_val; + asn1::BerTlvView origin; + asn1::BerTlvView ctl_num; + asn1::BerTlvView timestamp; + asn1::BerTlvView test; + asn1::BerTlvView check; + + if (!read_next(outer.value, offset, ctl_val) || + !decode_boolean(ctl_val, decoded.control_value) || + !read_next(outer.value, offset, origin) || + !decode_origin(origin, decoded.origin_category) || + !read_next(outer.value, offset, ctl_num) || + !context_tag(ctl_num, 6, false)) { + return false; + } + const auto control_number = asn1::BerSpanReader::read_unsigned_integer(ctl_num); + if (!control_number || *control_number > 0xFFU) { + return false; + } + decoded.control_number = static_cast(*control_number); + + if (!read_next(outer.value, offset, timestamp) || + !context_tag(timestamp, 17, false) || timestamp.value.size() != 8U || + !read_next(outer.value, offset, test) || + !decode_boolean(test, decoded.test) || + !read_next(outer.value, offset, check) || + !decode_check(check, decoded.synchro_check, decoded.interlock_check) || + offset != outer.value.size()) { + return false; + } + return true; +} + +wire::EncodeResult mms_static_direct_boolean_read_state( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto* state = static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = state->value != 0U ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult mms_static_direct_normal_read_ctl_model( + const void*, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x86U; // MMS unsigned + destination[1] = 0x01U; + destination[2] = 0x01U; // ctlModel = Direct-with-normal-security + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult mms_static_control_read_unavailable( + const void*, + const std::span) noexcept { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +MmsStaticWriteResult mms_static_direct_boolean_write_oper( + void* context, + const std::span encoded_data) noexcept { + auto* binding = static_cast(context); + if (binding == nullptr || binding->state == nullptr) { + return {false, 10U}; + } + + MmsStaticDirectBooleanOperate operate; + if (!try_decode_static_direct_boolean_operate(encoded_data, operate)) { + ++binding->state->rejected_operations; + return {false, binding->policy.malformed_failure_code}; + } + if (operate.origin_category > 8U || operate.control_number == 0U || + (operate.test && !binding->policy.allow_test) || + (operate.synchro_check && !binding->policy.allow_synchro_check) || + (operate.interlock_check && !binding->policy.allow_interlock_check)) { + ++binding->state->rejected_operations; + return {false, binding->policy.invalid_value_failure_code}; + } + + if (!operate.test && binding->apply != nullptr && + !binding->apply(binding->apply_context, operate.control_value)) { + ++binding->state->rejected_operations; + return {false, binding->policy.backend_failure_code}; + } + + if (!operate.test) { + binding->state->value = operate.control_value ? 1U : 0U; + } + binding->state->last_control_number = operate.control_number; + binding->state->last_test = operate.test; + ++binding->state->accepted_operations; + return {true, 0U}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp index c3d35f50b..919fd964e 100644 --- a/src/mms/static_dispatcher.cpp +++ b/src/mms/static_dispatcher.cpp @@ -97,9 +97,35 @@ namespace { return true; } +// IEC 61850 engineering clients discover each Logical Node as one MMS +// NamedVariable and then walk its hierarchical TypeSpecification. Static +// profiles also keep flattened leaf aliases in the table so Read/Write can +// resolve exact FC/DO/DA paths. Do not advertise those aliases as additional +// top-level NamedVariables when their root Logical Node object is present. +// +// The fallback is intentional: a generic MMS profile that only supplies flat +// names (and no corresponding root entry) keeps the legacy directory behavior. +[[nodiscard]] bool is_flattened_child_with_root( + const MmsStaticObjectTable& objects, + const std::string_view domain, + const std::string_view item) noexcept { + const auto separator = item.find('$'); + if (separator == std::string_view::npos || separator == 0U) { + return false; + } + const auto root = item.substr(0U, separator); + for (const auto& candidate : objects.objects()) { + if (candidate.domain == domain && candidate.item == root) { + return true; + } + } + return false; +} + [[nodiscard]] std::size_t collect_names( const MmsStaticObjectTable& objects, const MmsStaticDataSetTable& data_sets, + const MmsStaticDispatchPolicy& policy, const MmsGetNameListRequestView& request, std::array& names) noexcept { std::size_t count = 0U; @@ -121,11 +147,13 @@ namespace { if (request.object_class == MmsNameListObjectClass::named_variable && request.scope == MmsNameScopeKind::domain_specific) { for (const auto& object : objects.objects()) { - if (span_equals(request.domain_id, object.domain)) { - if (count >= names.size()) { - return names.size() + 1U; - } - names[count++] = object.item; + if (!span_equals(request.domain_id, object.domain) || + (!policy.advertise_flattened_child_aliases && + is_flattened_child_with_root(objects, object.domain, object.item))) { + continue; + } + if (!append_unique(names, count, object.item)) { + return names.size() + 1U; } } return count; @@ -159,7 +187,7 @@ namespace { } std::array names{}; - const auto name_count = collect_names(objects, data_sets, request, names); + const auto name_count = collect_names(objects, data_sets, policy, request, names); if (name_count > names.size()) { return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); } diff --git a/src/mms/static_information_report.cpp b/src/mms/static_information_report.cpp new file mode 100644 index 000000000..42971f45f --- /dev/null +++ b/src/mms/static_information_report.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_information_report.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] bool valid_mms_data( + const std::span encoded) noexcept { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(encoded, data) || + data.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (data.tag_number == 1 || data.tag_number == 2) { + return data.constructed; + } + return data.tag_number >= 3 && data.tag_number <= 17 && !data.constructed; +} + +[[nodiscard]] MmsStaticInformationReportEncodeResult make_status( + const MmsStaticInformationReportStatus status, + const std::size_t required_bytes = 0U, + const std::size_t member_count = 0U) noexcept { + return {status, 0U, required_bytes, member_count}; +} + +} // namespace + +MmsStaticInformationReportEncodeResult +MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsObjectNameView& data_set_name, + const MmsStaticInformationReportInput& report, + const std::span destination, + const std::span workspace) noexcept { + if (!objects.valid()) { + return make_status(MmsStaticInformationReportStatus::invalid_object_table); + } + if (!data_sets.valid()) { + return make_status(MmsStaticInformationReportStatus::invalid_data_set_table); + } + + const auto* data_set = data_sets.find(data_set_name); + if (data_set == nullptr) { + return make_status(MmsStaticInformationReportStatus::data_set_not_found); + } + if (data_set->members.empty() || + data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return make_status(MmsStaticInformationReportStatus::invalid_data_set_table); + } + + std::array references{}; + std::array results{}; + + std::size_t workspace_offset = 0U; + for (std::size_t index = 0U; index < data_set->members.size(); ++index) { + const auto& member = data_set->members[index]; + references[index] = MmsInformationReportReferenceInput{ + member.domain, member.item}; + + const MmsObjectNameView member_name{ + MmsObjectNameViewKind::domain_specific, + as_bytes(member.domain), + as_bytes(member.item)}; + const auto* object = objects.find(member_name); + if (object == nullptr) { + return make_status( + MmsStaticInformationReportStatus::object_not_found, + 0U, + data_set->members.size()); + } + + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + if (read.required_bytes > + std::numeric_limits::max() - workspace_offset) { + return make_status( + MmsStaticInformationReportStatus::backend_failure, + 0U, + data_set->members.size()); + } + return make_status( + MmsStaticInformationReportStatus::workspace_too_small, + workspace_offset + read.required_bytes, + data_set->members.size()); + } + if (!read.success() || read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return make_status( + MmsStaticInformationReportStatus::backend_failure, + 0U, + data_set->members.size()); + } + + results[index] = MmsReadAccessResultInput{ + true, + remaining.first(read.bytes_written), + 0U}; + workspace_offset += read.bytes_written; + } + + MmsInformationReportSnapshotInput wire_report; + wire_report.report_id = report.report_id; + wire_report.optional_fields = report.optional_fields; + wire_report.sequence_number = report.sequence_number; + wire_report.report_time = report.report_time; + wire_report.data_set_reference = MmsInformationReportReferenceInput{ + data_set->domain, data_set->item}; + wire_report.buffered = report.buffered; + wire_report.buffer_overflow = report.buffer_overflow; + wire_report.entry_id = report.entry_id; + wire_report.conf_revision = report.conf_revision; + wire_report.member_references = + std::span{references}.first( + data_set->members.size()); + wire_report.member_results = + std::span{results}.first( + data_set->members.size()); + wire_report.reason_for_inclusion = report.reason_for_inclusion; + + const auto encoded = MmsInformationReportSpanCodec::encode_snapshot_into( + wire_report, destination); + if (encoded.success()) { + return { + MmsStaticInformationReportStatus::response_ready, + encoded.bytes_written, + encoded.required_bytes, + data_set->members.size()}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticInformationReportStatus::response_buffer_too_small, + encoded.required_bytes, + data_set->members.size()); + } + return make_status( + MmsStaticInformationReportStatus::invalid_report, + encoded.required_bytes, + data_set->members.size()); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_report_connection.cpp b/src/mms/static_report_connection.cpp new file mode 100644 index 000000000..b65961490 --- /dev/null +++ b/src/mms/static_report_connection.cpp @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_report_connection.hpp" + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool add_size( + const std::size_t base, + const std::size_t extra, + std::size_t& total) noexcept { + if (extra > std::numeric_limits::max() - base) { + total = 0U; + return false; + } + total = base + extra; + return true; +} + +[[nodiscard]] MmsStaticReportConnectionResult make_result( + const MmsStaticReportConnectionStatus status, + const MmsStaticUrcbStatus urcb_status, + const MmsStaticUrcbEmissionPlan& plan = {}) noexcept { + MmsStaticReportConnectionResult result; + result.status = status; + result.urcb_status = urcb_status; + result.control_block_index = plan.index; + result.sequence_number = plan.sequence_number; + result.reason = plan.reason; + return result; +} + +[[nodiscard]] bool final_frame_size( + const std::uint32_t presentation_context_id, + const std::size_t mms_bytes, + std::size_t& required) noexcept { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, mms_bytes); + return fully_encoded && add_size(*fully_encoded, 11U, required); +} + +[[nodiscard]] MmsStaticReportConnectionResult response_capacity( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbStatus urcb_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticReportConnectionStatus::response_buffer_too_small, + urcb_status, + plan); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticReportConnectionResult workspace_capacity( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbStatus urcb_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticReportConnectionStatus::workspace_too_small, + urcb_status, + plan); + result.required_workspace_bytes = required; + return result; +} + +} // namespace + +MmsStaticReportConnectionResult MmsStaticReportConnection::poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbRuntime& reports, + const std::uint64_t now_ms, + const std::span report_time, + const std::span response, + const std::span workspace) noexcept { + if (connection.state() != MmsStaticConnectionState::established || + connection.mms_presentation_context_id() == 0U) { + return make_result( + MmsStaticReportConnectionStatus::not_established, + MmsStaticUrcbStatus::temporarily_unavailable); + } + + MmsStaticUrcbEmissionPlan plan; + if (!reports.next_due(now_ms, plan)) { + return make_result( + MmsStaticReportConnectionStatus::no_report_due, + reports.valid() + ? MmsStaticUrcbStatus::no_report_due + : MmsStaticUrcbStatus::invalid_runtime); + } + + // Raw MMS report is written into response. The report encoder may use the + // caller workspace transiently for DataSet member values. Only after the + // raw PDU succeeds is workspace reused for P-DATA and final TPKT staging. + const auto encoded = reports.encode(plan, report_time, response, workspace); + if (!encoded.success()) { + if (encoded.status == MmsStaticUrcbStatus::response_buffer_too_small) { + std::size_t final_required{}; + if (!final_frame_size( + connection.mms_presentation_context_id(), + encoded.required_bytes, + final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + return response_capacity(plan, encoded.status, final_required); + } + if (encoded.status == MmsStaticUrcbStatus::workspace_too_small) { + return workspace_capacity(plan, encoded.status, encoded.required_bytes); + } + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + encoded.status, + plan); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(encoded.bytes_written), + workspace, + connection.mms_presentation_context_id(), + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + std::size_t final_required{}; + if (!add_size(p_data.required_bytes, 7U, final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + auto result = workspace_capacity( + plan, MmsStaticUrcbStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + std::size_t final_required{}; + if (!add_size(p_data.bytes_written, 7U, final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + if (response.size() < final_required) { + return response_capacity( + plan, MmsStaticUrcbStatus::response_buffer_too_small, final_required); + } + if (workspace.size() < final_required) { + auto result = workspace_capacity( + plan, MmsStaticUrcbStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + + // P-DATA currently resides in workspace. COTP consumes it into response; + // after that source is no longer needed, so workspace can safely become + // the TPKT staging destination. + const auto cotp = osi::CotpSpanCodec::encode_data_into( + workspace.first(p_data.bytes_written), + response.first(final_required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), + workspace.first(final_required)); + if (!tpkt.success() || tpkt.bytes_written != final_required) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + // Commit only after the complete TPKT image exists. If capacity failed at + // any earlier stage, the same plan/SqNum can be retried without a gap. + const auto committed = reports.commit(plan, now_ms); + if (committed != MmsStaticUrcbStatus::ok) { + return make_result( + MmsStaticReportConnectionStatus::stale_plan, + committed, + plan); + } + + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + auto result = make_result( + MmsStaticReportConnectionStatus::response_ready, + MmsStaticUrcbStatus::ok, + plan); + result.bytes_written = tpkt.bytes_written; + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_server_session.cpp b/src/mms/static_server_session.cpp index 5016d5d0a..76dee6357 100644 --- a/src/mms/static_server_session.cpp +++ b/src/mms/static_server_session.cpp @@ -118,9 +118,15 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { consume_input(connection.consumed_bytes); response_offset_ = 0U; response_size_ = connection.bytes_written; - return make_result( + { + auto result = make_result( MmsStaticServerSessionStatus::response_pending, connection.status); + result.application_status = connection.application_status; + result.application_service = connection.application_service; + result.invoke_id = connection.invoke_id; + return result; + } case MmsStaticConnectionStatus::consumed_no_response: consume_input(connection.consumed_bytes); return make_result( @@ -128,9 +134,15 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { connection.status); case MmsStaticConnectionStatus::application_rejected: consume_input(connection.consumed_bytes); - return make_result( + { + auto result = make_result( MmsStaticServerSessionStatus::application_rejected, connection.status); + result.application_status = connection.application_status; + result.application_service = connection.application_service; + result.invoke_id = connection.invoke_id; + return result; + } case MmsStaticConnectionStatus::closed: consume_input(connection.consumed_bytes); return terminate( @@ -188,4 +200,4 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { 0U); } -} // namespace ar::iec61850::mms \ No newline at end of file +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_objects.cpp b/src/mms/static_urcb_objects.cpp new file mode 100644 index 000000000..0f5d4f733 --- /dev/null +++ b/src/mms/static_urcb_objects.cpp @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_objects.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kUnsigned32Type{0x86U, 0x01U, 0x20U}; +constexpr std::array kVisible255Type{0x8AU, 0x02U, 0x00U, 0xFFU}; +constexpr std::array kBitString10Type{0x84U, 0x01U, 0x0AU}; +constexpr std::array kBitString6Type{0x84U, 0x01U, 0x06U}; + +constexpr std::array + kAttributes{ + MmsStaticUrcbAttribute::report_id, + MmsStaticUrcbAttribute::report_enabled, + MmsStaticUrcbAttribute::reserved, + MmsStaticUrcbAttribute::data_set, + MmsStaticUrcbAttribute::conf_revision, + MmsStaticUrcbAttribute::optional_fields, + MmsStaticUrcbAttribute::buffer_time, + MmsStaticUrcbAttribute::trigger_options, + MmsStaticUrcbAttribute::integrity_period, + MmsStaticUrcbAttribute::general_interrogation, + MmsStaticUrcbAttribute::sequence_number}; + +constexpr std::array + kSuffixes{ + "RptID", + "RptEna", + "Resv", + "DatSet", + "ConfRev", + "OptFlds", + "BufTm", + "TrgOps", + "IntgPd", + "GI", + "SqNum"}; + +constexpr std::uint32_t kHardwareFault = 1U; +constexpr std::uint32_t kTemporarilyUnavailable = 2U; +constexpr std::uint32_t kObjectAccessDenied = 3U; +constexpr std::uint32_t kTypeInconsistent = 7U; +constexpr std::uint32_t kObjectNonExistent = 10U; +constexpr std::uint32_t kObjectValueInvalid = 11U; + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] bool visible_ascii(const std::span bytes) noexcept { + if (bytes.empty() || bytes.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto byte : bytes) { + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] std::span type_for( + const MmsStaticUrcbAttribute attribute) noexcept { + switch (attribute) { + case MmsStaticUrcbAttribute::report_id: + case MmsStaticUrcbAttribute::data_set: + return kVisible255Type; + case MmsStaticUrcbAttribute::report_enabled: + case MmsStaticUrcbAttribute::reserved: + case MmsStaticUrcbAttribute::general_interrogation: + return kBooleanType; + case MmsStaticUrcbAttribute::conf_revision: + case MmsStaticUrcbAttribute::buffer_time: + case MmsStaticUrcbAttribute::integrity_period: + case MmsStaticUrcbAttribute::sequence_number: + return kUnsigned32Type; + case MmsStaticUrcbAttribute::optional_fields: + return kBitString10Type; + case MmsStaticUrcbAttribute::trigger_options: + return kBitString6Type; + } + return {}; +} + +[[nodiscard]] bool writable_attribute( + const MmsStaticUrcbAttribute attribute) noexcept { + return attribute != MmsStaticUrcbAttribute::conf_revision && + attribute != MmsStaticUrcbAttribute::sequence_number; +} + +[[nodiscard]] wire::EncodeResult capacity_result( + const std::size_t required, + const std::span destination) noexcept { + return destination.size() < required + ? wire::EncodeResult{wire::EncodeStatus::buffer_too_small, 0U, required} + : wire::EncodeResult{wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult encode_boolean( + const bool value, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + const auto capacity = capacity_result(required, destination); + if (!capacity.success()) { + return capacity; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return capacity; +} + +[[nodiscard]] std::size_t unsigned_content_size(std::uint32_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] wire::EncodeResult encode_unsigned( + const std::uint32_t value, + const std::span destination) noexcept { + const auto content_size = unsigned_content_size(value); + const auto total = asn1::BerSpanWriter::tlv_size(6, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content_size)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + for (std::size_t index = content_size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + } + return writer.size() == *total + ? capacity + : wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, *total}; +} + +[[nodiscard]] wire::EncodeResult encode_visible( + const std::string_view text, + const std::span destination) noexcept { + const auto total = asn1::BerSpanWriter::tlv_size(10, text.size()); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) || + !writer.write_bytes(as_bytes(text)) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult encode_data_set_reference( + const std::string_view domain, + const std::string_view item, + const std::span destination) noexcept { + if (domain.size() > std::numeric_limits::max() - 1U - item.size()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content_size = domain.size() + 1U + item.size(); + const auto total = asn1::BerSpanWriter::tlv_size(10, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, content_size) || + !writer.write_bytes(as_bytes(domain)) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(as_bytes(item)) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult encode_bit_string( + const std::uint8_t unused_bits, + const std::span bytes, + const std::span destination) noexcept { + if (unused_bits > 7U || bytes.size() == std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content_size = bytes.size() + 1U; + const auto total = asn1::BerSpanWriter::tlv_size(4, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, content_size) || + !writer.write_byte(unused_bits) || !writer.write_bytes(bytes) || + writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult read_urcb_attribute( + const void* raw_context, + const std::span destination) noexcept { + const auto* context = static_cast(raw_context); + if (context == nullptr || context->runtime == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto* state = context->runtime->state(context->index); + if (state == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + switch (context->attribute) { + case MmsStaticUrcbAttribute::report_id: + return encode_visible(state->report_id(), destination); + case MmsStaticUrcbAttribute::report_enabled: + return encode_boolean(state->enabled, destination); + case MmsStaticUrcbAttribute::reserved: + return encode_boolean(state->reserved, destination); + case MmsStaticUrcbAttribute::data_set: + return encode_data_set_reference( + state->data_set_domain(), state->data_set_item(), destination); + case MmsStaticUrcbAttribute::conf_revision: + return encode_unsigned(state->conf_revision, destination); + case MmsStaticUrcbAttribute::optional_fields: + return encode_bit_string(6U, state->optional_fields, destination); + case MmsStaticUrcbAttribute::buffer_time: + return encode_unsigned(state->buffer_time_ms, destination); + case MmsStaticUrcbAttribute::trigger_options: { + const std::array trigger{state->trigger_options}; + return encode_bit_string(2U, trigger, destination); + } + case MmsStaticUrcbAttribute::integrity_period: + return encode_unsigned(state->integrity_period_ms, destination); + case MmsStaticUrcbAttribute::general_interrogation: + return encode_boolean(state->general_interrogation_pending, destination); + case MmsStaticUrcbAttribute::sequence_number: + return encode_unsigned(state->sequence_number, destination); + } + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +[[nodiscard]] bool decode_boolean( + const std::span encoded, + bool& value) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 3 || tlv.constructed || tlv.value.size() != 1U) { + return false; + } + value = tlv.value[0] != 0U; + return true; +} + +[[nodiscard]] bool decode_unsigned( + const std::span encoded, + std::uint32_t& value) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_visible( + const std::span encoded, + std::string_view& value) noexcept { + value = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 10 || tlv.constructed || !visible_ascii(tlv.value)) { + return false; + } + value = { + reinterpret_cast(tlv.value.data()), + tlv.value.size()}; + return true; +} + +[[nodiscard]] bool decode_bit_string( + const std::span encoded, + const std::uint8_t expected_unused_bits, + const std::size_t expected_bytes, + std::span& bytes) noexcept { + bytes = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 4 || tlv.constructed || + tlv.value.size() != expected_bytes + 1U || + tlv.value[0] != expected_unused_bits) { + return false; + } + bytes = tlv.value.subspan(1U); + if (!bytes.empty() && expected_unused_bits != 0U) { + const auto mask = static_cast((1U << expected_unused_bits) - 1U); + if ((bytes.back() & mask) != 0U) { + bytes = {}; + return false; + } + } + return true; +} + +[[nodiscard]] bool split_reference( + const std::string_view reference, + std::string_view& domain, + std::string_view& item) noexcept { + domain = {}; + item = {}; + std::size_t slash = reference.size(); + for (std::size_t index = 0U; index < reference.size(); ++index) { + if (reference[index] != '/') { + continue; + } + if (slash != reference.size()) { + return false; + } + slash = index; + } + if (slash == 0U || slash >= reference.size() - 1U) { + return false; + } + domain = reference.substr(0U, slash); + item = reference.substr(slash + 1U); + return true; +} + +[[nodiscard]] MmsStaticWriteResult map_write_status( + const MmsStaticUrcbStatus status) noexcept { + switch (status) { + case MmsStaticUrcbStatus::ok: + return {true, 0U}; + case MmsStaticUrcbStatus::object_access_denied: + return {false, kObjectAccessDenied}; + case MmsStaticUrcbStatus::temporarily_unavailable: + return {false, kTemporarilyUnavailable}; + case MmsStaticUrcbStatus::data_set_not_found: + return {false, kObjectNonExistent}; + case MmsStaticUrcbStatus::invalid_value: + return {false, kObjectValueInvalid}; + case MmsStaticUrcbStatus::invalid_runtime: + case MmsStaticUrcbStatus::index_out_of_range: + case MmsStaticUrcbStatus::no_report_due: + case MmsStaticUrcbStatus::stale_plan: + case MmsStaticUrcbStatus::workspace_too_small: + case MmsStaticUrcbStatus::backend_failure: + case MmsStaticUrcbStatus::response_buffer_too_small: + case MmsStaticUrcbStatus::report_encode_failed: + return {false, kHardwareFault}; + } + return {false, kHardwareFault}; +} + +[[nodiscard]] MmsStaticWriteResult write_urcb_attribute( + void* raw_context, + const std::span encoded_data) noexcept { + auto* context = static_cast(raw_context); + if (context == nullptr || context->runtime == nullptr) { + return {false, kHardwareFault}; + } + + switch (context->attribute) { + case MmsStaticUrcbAttribute::report_id: { + std::string_view value; + if (!decode_visible(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status(context->runtime->set_report_id(context->index, value)); + } + case MmsStaticUrcbAttribute::report_enabled: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + const auto now = context->now_ms == nullptr + ? std::uint64_t{0U} + : context->now_ms(context->now_context); + return map_write_status( + context->runtime->set_enabled(context->index, value, now)); + } + case MmsStaticUrcbAttribute::reserved: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_reserved(context->index, value)); + } + case MmsStaticUrcbAttribute::data_set: { + std::string_view reference; + std::string_view domain; + std::string_view item; + if (!decode_visible(encoded_data, reference) || + !split_reference(reference, domain, item)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_data_set(context->index, domain, item)); + } + case MmsStaticUrcbAttribute::optional_fields: { + std::span bytes; + if (!decode_bit_string(encoded_data, 6U, 2U, bytes)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_optional_fields(context->index, bytes)); + } + case MmsStaticUrcbAttribute::buffer_time: { + std::uint32_t value = 0U; + if (!decode_unsigned(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_buffer_time_ms(context->index, value)); + } + case MmsStaticUrcbAttribute::trigger_options: { + std::span bytes; + if (!decode_bit_string(encoded_data, 2U, 1U, bytes)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_trigger_options(context->index, bytes[0])); + } + case MmsStaticUrcbAttribute::integrity_period: { + std::uint32_t value = 0U; + if (!decode_unsigned(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_integrity_period_ms(context->index, value)); + } + case MmsStaticUrcbAttribute::general_interrogation: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return value + ? map_write_status( + context->runtime->request_general_interrogation(context->index)) + : MmsStaticWriteResult{true, 0U}; + } + case MmsStaticUrcbAttribute::conf_revision: + case MmsStaticUrcbAttribute::sequence_number: + return {false, kObjectAccessDenied}; + } + return {false, kHardwareFault}; +} + +} // namespace + +std::size_t MmsStaticUrcbObjectBank::required_context_capacity() const noexcept { + if (runtime_ == nullptr || !runtime_->valid() || + runtime_->size() > std::numeric_limits::max() / + attributes_per_control_block) { + return std::numeric_limits::max(); + } + return runtime_->size() * attributes_per_control_block; +} + +std::size_t MmsStaticUrcbObjectBank::required_object_capacity() const noexcept { + const auto contexts = required_context_capacity(); + if (contexts == std::numeric_limits::max() || + contexts > std::numeric_limits::max() - base_objects_.size()) { + return std::numeric_limits::max(); + } + return base_objects_.size() + contexts; +} + +std::size_t MmsStaticUrcbObjectBank::required_name_bytes() const noexcept { + if (runtime_ == nullptr || !runtime_->valid()) { + return std::numeric_limits::max(); + } + std::size_t total = 0U; + for (std::size_t index = 0U; index < runtime_->size(); ++index) { + const auto* definition = runtime_->definition(index); + if (definition == nullptr) { + return std::numeric_limits::max(); + } + for (const auto suffix : kSuffixes) { + if (definition->item.size() > + MmsServiceSpanCodec::maximum_identifier_bytes - 1U - suffix.size()) { + return std::numeric_limits::max(); + } + const auto name_size = definition->item.size() + 1U + suffix.size(); + if (name_size > std::numeric_limits::max() - total) { + return std::numeric_limits::max(); + } + total += name_size; + } + } + return total; +} + +bool MmsStaticUrcbObjectBank::initialize() noexcept { + initialized_ = false; + object_count_ = 0U; + name_bytes_used_ = 0U; + table_ = MmsStaticObjectTable{std::span{}}; + + if (runtime_ == nullptr || !runtime_->valid()) { + return false; + } + const auto required_objects = required_object_capacity(); + const auto required_contexts = required_context_capacity(); + const auto required_names = required_name_bytes(); + if (required_objects == std::numeric_limits::max() || + required_contexts == std::numeric_limits::max() || + required_names == std::numeric_limits::max() || + required_objects > object_storage_.size() || + required_objects > MmsStaticObjectTable::maximum_objects || + required_contexts > context_storage_.size() || + required_names > name_storage_.size()) { + return false; + } + + std::copy(base_objects_.begin(), base_objects_.end(), object_storage_.begin()); + std::size_t object_offset = base_objects_.size(); + std::size_t context_offset = 0U; + std::size_t name_offset = 0U; + + for (std::size_t urcb_index = 0U; urcb_index < runtime_->size(); ++urcb_index) { + const auto* definition = runtime_->definition(urcb_index); + if (definition == nullptr) { + return false; + } + for (std::size_t attribute_index = 0U; + attribute_index < attributes_per_control_block; + ++attribute_index) { + const auto suffix = kSuffixes[attribute_index]; + const auto name_size = definition->item.size() + 1U + suffix.size(); + auto* name = name_storage_.data() + name_offset; + std::copy(definition->item.begin(), definition->item.end(), name); + name[definition->item.size()] = '$'; + std::copy( + suffix.begin(), + suffix.end(), + name + definition->item.size() + 1U); + + auto& context = context_storage_[context_offset]; + context = MmsStaticUrcbObjectContext{ + runtime_, + urcb_index, + kAttributes[attribute_index], + now_ms_, + now_context_}; + + const auto attribute = kAttributes[attribute_index]; + object_storage_[object_offset] = MmsStaticObjectEntry{ + definition->domain, + std::string_view{name, name_size}, + type_for(attribute), + read_urcb_attribute, + &context, + false, + writable_attribute(attribute) ? write_urcb_attribute : nullptr, + writable_attribute(attribute) ? &context : nullptr}; + + name_offset += name_size; + ++context_offset; + ++object_offset; + } + } + + table_ = MmsStaticObjectTable{ + std::span{object_storage_.data(), object_offset}}; + if (!table_.valid()) { + table_ = MmsStaticObjectTable{std::span{}}; + return false; + } + + object_count_ = object_offset; + name_bytes_used_ = name_offset; + initialized_ = true; + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_runtime.cpp b/src/mms/static_urcb_runtime.cpp new file mode 100644 index 000000000..968cd8a2c --- /dev/null +++ b/src/mms/static_urcb_runtime.cpp @@ -0,0 +1,612 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kAllowedOptionalFirst = 0x7CU; +constexpr std::uint8_t kAllowedOptionalSecond = 0x80U; +constexpr std::uint8_t kAllowedTriggerOptions = 0x7CU; +constexpr std::uint8_t kTriggerIntegrity = 0x08U; + +[[nodiscard]] bool visible_ascii( + const std::string_view text, + const std::size_t maximum_bytes) noexcept { + if (text.empty() || text.size() > maximum_bytes) { + return false; + } + for (const auto character : text) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_report_id(const std::string_view report_id) noexcept { + return visible_ascii( + report_id, + MmsStaticUrcbState::maximum_report_id_bytes); +} + +[[nodiscard]] bool valid_reference( + const std::string_view domain, + const std::string_view item) noexcept { + return visible_ascii( + domain, + MmsStaticUrcbState::maximum_reference_component_bytes) && + visible_ascii( + item, + MmsStaticUrcbState::maximum_reference_component_bytes) && + domain.size() + 1U + item.size() <= + MmsInformationReportSpanCodec::maximum_reference_bytes; +} + +[[nodiscard]] bool valid_optional_fields( + const std::span optional_fields) noexcept { + return optional_fields.size() == + MmsInformationReportSpanCodec::optional_field_bytes && + (optional_fields[0] & static_cast(~kAllowedOptionalFirst)) == 0U && + (optional_fields[1] & static_cast(~kAllowedOptionalSecond)) == 0U; +} + +[[nodiscard]] bool valid_trigger_options(const std::uint8_t options) noexcept { + return (options & static_cast(~kAllowedTriggerOptions)) == 0U; +} + +template +[[nodiscard]] bool copy_text( + const std::string_view source, + std::array& destination, + std::size_t& length) noexcept { + if (source.empty() || source.size() > destination.size()) { + length = 0U; + return false; + } + std::fill(destination.begin(), destination.end(), '\0'); + std::copy(source.begin(), source.end(), destination.begin()); + length = source.size(); + return true; +} + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + MmsObjectNameViewKind::domain_specific, + as_bytes(domain), + as_bytes(item)}; +} + +[[nodiscard]] bool same_name( + const MmsStaticUrcbDefinition& left, + const MmsStaticUrcbDefinition& right) noexcept { + return left.domain == right.domain && left.item == right.item; +} + +void bump_revision(MmsStaticUrcbState& state) noexcept { + state.revision = state.revision == std::numeric_limits::max() + ? 1U + : state.revision + 1U; +} + +[[nodiscard]] std::uint64_t saturating_add( + const std::uint64_t base, + const std::uint64_t delta) noexcept { + return delta > std::numeric_limits::max() - base + ? std::numeric_limits::max() + : base + delta; +} + +[[nodiscard]] std::uint32_t effective_integrity_period( + const MmsStaticUrcbState& state) noexcept { + if (state.integrity_period_ms == 0U) { + return 0U; + } + return state.integrity_period_ms < MmsStaticUrcbRuntime::minimum_integrity_period_ms + ? MmsStaticUrcbRuntime::minimum_integrity_period_ms + : state.integrity_period_ms; +} + +void refresh_integrity_arm( + MmsStaticUrcbState& state, + const std::uint64_t now_ms) noexcept { + const auto period = effective_integrity_period(state); + const bool selected = (state.trigger_options & kTriggerIntegrity) != 0U; + state.integrity_armed = state.enabled && selected && period != 0U; + state.next_integrity_due_ms = state.integrity_armed + ? saturating_add(now_ms, period) + : 0U; +} + +[[nodiscard]] std::uint8_t next_sequence_number( + const std::uint8_t current) noexcept { + return static_cast( + (static_cast(current) + 1U) & 0xFFU); +} + +[[nodiscard]] bool plan_matches_state( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbState& state) noexcept { + if (!state.enabled || plan.revision != state.revision || + plan.reason == MmsStaticUrcbReportReason::none || + plan.sequence_number != next_sequence_number(state.sequence_number)) { + return false; + } + if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { + return state.general_interrogation_pending; + } + return plan.reason == MmsStaticUrcbReportReason::integrity && state.integrity_armed; +} + +[[nodiscard]] MmsStaticUrcbEncodeResult map_report_result( + const MmsStaticInformationReportEncodeResult& result) noexcept { + MmsStaticUrcbStatus status = MmsStaticUrcbStatus::report_encode_failed; + switch (result.status) { + case MmsStaticInformationReportStatus::response_ready: + status = MmsStaticUrcbStatus::ok; + break; + case MmsStaticInformationReportStatus::data_set_not_found: + status = MmsStaticUrcbStatus::data_set_not_found; + break; + case MmsStaticInformationReportStatus::workspace_too_small: + status = MmsStaticUrcbStatus::workspace_too_small; + break; + case MmsStaticInformationReportStatus::backend_failure: + case MmsStaticInformationReportStatus::object_not_found: + status = MmsStaticUrcbStatus::backend_failure; + break; + case MmsStaticInformationReportStatus::response_buffer_too_small: + status = MmsStaticUrcbStatus::response_buffer_too_small; + break; + case MmsStaticInformationReportStatus::invalid_object_table: + case MmsStaticInformationReportStatus::invalid_data_set_table: + case MmsStaticInformationReportStatus::invalid_report: + status = MmsStaticUrcbStatus::report_encode_failed; + break; + } + return {status, result.bytes_written, result.required_bytes, result.member_count}; +} + +} // namespace + +bool MmsStaticUrcbRuntime::initialize() noexcept { + initialized_ = false; + if (objects_ == nullptr || data_sets_ == nullptr || + !objects_->valid() || !data_sets_->valid() || + !data_sets_->valid_against(*objects_) || + definitions_.size() > maximum_control_blocks || + states_.size() < definitions_.size()) { + return false; + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& definition = definitions_[index]; + if (!valid_reference(definition.domain, definition.item) || + !valid_report_id(definition.report_id) || + !valid_reference(definition.data_set_domain, definition.data_set_item) || + !valid_optional_fields(definition.optional_fields) || + !valid_trigger_options(definition.trigger_options) || + data_sets_->find(object_name( + definition.data_set_domain, + definition.data_set_item)) == nullptr) { + return false; + } + for (std::size_t earlier = 0U; earlier < index; ++earlier) { + if (same_name(definitions_[earlier], definition)) { + return false; + } + } + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& definition = definitions_[index]; + auto& state_ref = states_[index]; + state_ref = {}; + if (!copy_text( + definition.report_id, + state_ref.report_id_storage, + state_ref.report_id_size) || + !copy_text( + definition.data_set_domain, + state_ref.data_set_domain_storage, + state_ref.data_set_domain_size) || + !copy_text( + definition.data_set_item, + state_ref.data_set_item_storage, + state_ref.data_set_item_size)) { + return false; + } + state_ref.optional_fields = definition.optional_fields; + state_ref.conf_revision = definition.conf_revision; + state_ref.buffer_time_ms = definition.buffer_time_ms; + state_ref.trigger_options = definition.trigger_options; + state_ref.integrity_period_ms = definition.integrity_period_ms; + state_ref.revision = 1U; + } + + initialized_ = true; + return true; +} + +const MmsStaticUrcbDefinition* MmsStaticUrcbRuntime::definition( + const std::size_t index) const noexcept { + return initialized_ && index < definitions_.size() ? &definitions_[index] : nullptr; +} + +const MmsStaticUrcbState* MmsStaticUrcbRuntime::state( + const std::size_t index) const noexcept { + return initialized_ && index < definitions_.size() ? &states_[index] : nullptr; +} + +MmsStaticUrcbState* MmsStaticUrcbRuntime::state( + const std::size_t index) noexcept { + return initialized_ && index < definitions_.size() ? &states_[index] : nullptr; +} + +bool MmsStaticUrcbRuntime::find_index( + const MmsObjectNameView& name, + std::size_t& index) const noexcept { + index = 0U; + if (!initialized_ || name.kind != MmsObjectNameViewKind::domain_specific) { + return false; + } + for (std::size_t current = 0U; current < definitions_.size(); ++current) { + const auto& definition_ref = definitions_[current]; + if (name.domain.size() != definition_ref.domain.size() || + name.item.size() != definition_ref.item.size()) { + continue; + } + if (std::equal( + name.domain.begin(), + name.domain.end(), + reinterpret_cast(definition_ref.domain.data())) && + std::equal( + name.item.begin(), + name.item.end(), + reinterpret_cast(definition_ref.item.data()))) { + index = current; + return true; + } + } + return false; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_enabled( + const std::size_t index, + const bool enabled, + const std::uint64_t now_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled == enabled) { + return MmsStaticUrcbStatus::ok; + } + + state_ref->enabled = enabled; + state_ref->general_interrogation_pending = false; + if (enabled) { + state_ref->sequence_number = 0U; + refresh_integrity_arm(*state_ref, now_ms); + } else { + state_ref->integrity_armed = false; + state_ref->next_integrity_due_ms = 0U; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_reserved( + const std::size_t index, + const bool reserved) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->reserved != reserved) { + state_ref->reserved = reserved; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_report_id( + const std::size_t index, + const std::string_view report_id) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_report_id(report_id)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (state_ref->report_id() == report_id) { + return MmsStaticUrcbStatus::ok; + } + if (!copy_text( + report_id, + state_ref->report_id_storage, + state_ref->report_id_size)) { + return MmsStaticUrcbStatus::invalid_value; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_data_set( + const std::size_t index, + const std::string_view domain, + const std::string_view item) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_reference(domain, item)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (data_sets_->find(object_name(domain, item)) == nullptr) { + return MmsStaticUrcbStatus::data_set_not_found; + } + if (state_ref->data_set_domain() == domain && state_ref->data_set_item() == item) { + return MmsStaticUrcbStatus::ok; + } + if (!copy_text( + domain, + state_ref->data_set_domain_storage, + state_ref->data_set_domain_size) || + !copy_text( + item, + state_ref->data_set_item_storage, + state_ref->data_set_item_size)) { + return MmsStaticUrcbStatus::invalid_value; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_optional_fields( + const std::size_t index, + const std::span optional_fields) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_optional_fields(optional_fields)) { + return MmsStaticUrcbStatus::invalid_value; + } + const std::array + next{optional_fields[0], optional_fields[1]}; + if (state_ref->optional_fields != next) { + state_ref->optional_fields = next; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_buffer_time_ms( + const std::size_t index, + const std::uint32_t buffer_time_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (state_ref->buffer_time_ms != buffer_time_ms) { + state_ref->buffer_time_ms = buffer_time_ms; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_trigger_options( + const std::size_t index, + const std::uint8_t trigger_options) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_trigger_options(trigger_options)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (state_ref->trigger_options != trigger_options) { + state_ref->trigger_options = trigger_options; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_integrity_period_ms( + const std::size_t index, + const std::uint32_t integrity_period_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (state_ref->integrity_period_ms != integrity_period_ms) { + state_ref->integrity_period_ms = integrity_period_ms; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::request_general_interrogation( + const std::size_t index) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (!state_ref->enabled) { + return MmsStaticUrcbStatus::temporarily_unavailable; + } + if (!state_ref->general_interrogation_pending) { + state_ref->general_interrogation_pending = true; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +bool MmsStaticUrcbRuntime::next_due( + const std::uint64_t now_ms, + MmsStaticUrcbEmissionPlan& plan) const noexcept { + plan = {}; + if (!initialized_) { + return false; + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& state_ref = states_[index]; + if (state_ref.enabled && state_ref.general_interrogation_pending) { + plan.index = index; + plan.revision = state_ref.revision; + plan.sequence_number = next_sequence_number(state_ref.sequence_number); + plan.reason = MmsStaticUrcbReportReason::general_interrogation; + return true; + } + } + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& state_ref = states_[index]; + if (state_ref.enabled && state_ref.integrity_armed && + now_ms >= state_ref.next_integrity_due_ms) { + plan.index = index; + plan.revision = state_ref.revision; + plan.sequence_number = next_sequence_number(state_ref.sequence_number); + plan.reason = MmsStaticUrcbReportReason::integrity; + return true; + } + } + return false; +} + +MmsStaticUrcbEncodeResult MmsStaticUrcbRuntime::encode( + const MmsStaticUrcbEmissionPlan& plan, + const std::span report_time, + const std::span destination, + const std::span workspace) const noexcept { + if (!initialized_ || objects_ == nullptr || data_sets_ == nullptr) { + return {MmsStaticUrcbStatus::invalid_runtime, 0U, 0U, 0U}; + } + if (plan.index >= definitions_.size()) { + return {MmsStaticUrcbStatus::index_out_of_range, 0U, 0U, 0U}; + } + const auto& state_ref = states_[plan.index]; + if (!plan_matches_state(plan, state_ref)) { + return {MmsStaticUrcbStatus::stale_plan, 0U, 0U, 0U}; + } + + const auto data_set_name = object_name( + state_ref.data_set_domain(), + state_ref.data_set_item()); + MmsStaticInformationReportInput report; + report.report_id = state_ref.report_id(); + report.optional_fields = state_ref.optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.buffered = false; + report.buffer_overflow = false; + report.entry_id = {}; + report.conf_revision = state_ref.conf_revision; + report.reason_for_inclusion = static_cast(plan.reason); + + return map_report_result( + MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + *objects_, + *data_sets_, + data_set_name, + report, + destination, + workspace)); +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::commit( + const MmsStaticUrcbEmissionPlan& plan, + const std::uint64_t now_ms) noexcept { + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (plan.index >= definitions_.size()) { + return MmsStaticUrcbStatus::index_out_of_range; + } + auto& state_ref = states_[plan.index]; + if (!plan_matches_state(plan, state_ref)) { + return MmsStaticUrcbStatus::stale_plan; + } + + state_ref.sequence_number = plan.sequence_number; + if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { + state_ref.general_interrogation_pending = false; + } else if (plan.reason == MmsStaticUrcbReportReason::integrity) { + const auto period = effective_integrity_period(state_ref); + state_ref.next_integrity_due_ms = period == 0U + ? 0U + : saturating_add(now_ms, period); + state_ref.integrity_armed = period != 0U && + (state_ref.trigger_options & kTriggerIntegrity) != 0U && state_ref.enabled; + } else { + return MmsStaticUrcbStatus::stale_plan; + } + bump_revision(state_ref); + return MmsStaticUrcbStatus::ok; +} + +} // namespace ar::iec61850::mms diff --git a/tests/test_mms_server_urcb_report.cpp b/tests/test_mms_server_urcb_report.cpp new file mode 100644 index 000000000..e8941198a --- /dev/null +++ b/tests/test_mms_server_urcb_report.cpp @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_report_connection.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +constexpr std::array kAssociationRequest{ + 0x0DU, 0xB6U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0xA0U, + 0x31U, 0x81U, 0x9DU, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x81U, 0x95U, 0x81U, + 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, 0x52U, 0x01U, 0x00U, 0x01U, + 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, + 0x61U, 0x62U, 0x30U, 0x60U, 0x02U, 0x01U, 0x01U, 0xA0U, 0x5BU, 0x60U, 0x59U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, 0xA2U, 0x07U, 0x06U, 0x05U, + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U, 0xA3U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xA6U, 0x06U, + 0x06U, 0x04U, 0x29U, 0x01U, 0x87U, 0x67U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, + 0xA8U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, + 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, + 0x00U, 0x79U, 0xEFU, 0x18U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +template +[[nodiscard]] wire::EncodeResult build_data_tpkt( + const std::span session_payload, + std::array& output, + std::array& scratch) noexcept { + const auto cotp = osi::CotpSpanCodec::encode_data_into(session_payload, scratch); + if (!cotp.success()) { + return cotp; + } + return osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), output); +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + const std::span request, + const std::span response, + const std::span workspace, + const std::span scratch) noexcept { + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) { + return false; + } + const auto cr_tpkt = osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cr.bytes_written), request); + if (!cr_tpkt.success()) { + return false; + } + auto result = connection.process_tcp_window( + std::span{request.data(), cr_tpkt.bytes_written}, + response, + workspace); + if (!result.response_ready() || + connection.state() != mms::MmsStaticConnectionState::awaiting_association) { + return false; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into(kAssociationRequest, scratch); + if (!cotp.success()) { + return false; + } + const auto association = osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), request); + if (!association.success()) { + return false; + } + result = connection.process_tcp_window( + std::span{request.data(), association.bytes_written}, + response, + workspace); + return result.response_ready() && + connection.state() == mms::MmsStaticConnectionState::established && + connection.mms_presentation_context_id() == 3U; +} + +[[nodiscard]] bool decode_report_frame( + const std::span frame, + mms::MmsInformationReportView& report) noexcept { + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) && + cotp.kind == osi::CotpWireKind::data && cotp.end_of_transmission && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) && + pdv.context_id == 3U && + mms::MmsInformationReportSpanCodec::try_decode_information_report( + pdv.single_asn1_type, report); +} + +[[nodiscard]] bool decode_unsigned_item( + const mms::MmsReadAccessResultView& item, + std::uint32_t& value) noexcept { + value = 0U; + if (!item.success) { + return false; + } + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +} // namespace + +int main() { + bool relay_state = true; + bool alarm_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "A1", kBooleanType, read_boolean, &alarm_state, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "A1"}}; + const std::array data_set_entries{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_sets{data_set_entries}; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_sets}; + mms::MmsStaticConnectionRuntime connection{dispatcher}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 7U, + {0x7CU, 0x80U}, + 0U, + 0x08U, + 1'000U}}; + std::array states{}; + mms::MmsStaticUrcbRuntime reports{ + definitions, states, object_table, data_sets}; + if (!reports.initialize()) { + return 1; + } + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + + auto poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + if (poll.status != mms::MmsStaticReportConnectionStatus::not_established) { + return 2; + } + if (!establish(connection, request, response, workspace, scratch)) { + return 3; + } + if (mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace).status != + mms::MmsStaticReportConnectionStatus::no_report_due) { + return 4; + } + + if (reports.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok || + reports.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok) { + return 5; + } + const auto* state = reports.state(0U); + if (state == nullptr || state->sequence_number != 0U || + !state->general_interrogation_pending) { + return 6; + } + + std::array tiny_response{}; + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, tiny_response, workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::response_buffer_too_small || + poll.required_response_bytes <= tiny_response.size() || + poll.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + poll.sequence_number != 1U || state == nullptr || state->sequence_number != 0U || + !state->general_interrogation_pending) { + return 7; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + state = reports.state(0U); + if (!poll.response_ready() || poll.bytes_written == 0U || + poll.control_block_index != 0U || poll.sequence_number != 1U || + poll.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + state == nullptr || state->sequence_number != 1U || + state->general_interrogation_pending) { + return 8; + } + + mms::MmsInformationReportView report; + if (!decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || + report.item_count != 13U) { + return 9; + } + mms::MmsReadAccessResultView item; + std::uint32_t sequence = 0U; + if (!report.try_item(2U, item) || !decode_unsigned_item(item, sequence) || sequence != 1U) { + return 10; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + if (poll.status != mms::MmsStaticReportConnectionStatus::no_report_due) { + return 11; + } + + // Integrity is due at t=1100. A workspace failure must preserve SqNum and due state. + std::array tiny_workspace{}; + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 1'100U, kReportTime, response, tiny_workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::workspace_too_small || + poll.required_workspace_bytes <= tiny_workspace.size() || + poll.reason != mms::MmsStaticUrcbReportReason::integrity || + poll.sequence_number != 2U || state == nullptr || state->sequence_number != 1U || + state->next_integrity_due_ms != 1'100U) { + return 12; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 1'100U, kReportTime, response, workspace); + state = reports.state(0U); + if (!poll.response_ready() || poll.sequence_number != 2U || + poll.reason != mms::MmsStaticUrcbReportReason::integrity || + state == nullptr || state->sequence_number != 2U || + state->next_integrity_due_ms != 2'100U || + !decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || + !report.try_item(2U, item) || !decode_unsigned_item(item, sequence) || sequence != 2U) { + return 13; + } + + connection.reset(); + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 2'100U, kReportTime, response, workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::not_established || + state == nullptr || state->sequence_number != 2U || + state->next_integrity_due_ms != 2'100U) { + return 14; + } + + return 0; +} diff --git a/tests/test_mms_static_direct_control.cpp b/tests/test_mms_static_direct_control.cpp new file mode 100644 index 000000000..b59f8b07d --- /dev/null +++ b/tests/test_mms_static_direct_control.cpp @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_span_codec.hpp" +#include "ariec61850/mms/static_direct_control.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::mms; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +[[nodiscard]] std::vector make_oper( + const bool value, + const std::uint8_t ctl_num, + const bool test, + const std::uint8_t check_bits = 0U, + const std::uint8_t origin_category = 2U) { + constexpr std::array origin_id{'H', 'M', 'I'}; + constexpr std::array no_check{0U}; + const std::array check_byte{check_bits}; + + auto oper = MmsDataValue::structure({ + MmsDataValue::boolean(value), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(origin_category), + MmsDataValue::octet_string(origin_id), + }), + MmsDataValue::unsigned_integer(ctl_num), + MmsDataValue::utc_time(Iec61850UtcTime{}), + MmsDataValue::boolean(test), + MmsDataValue::bit_string( + 6U, + check_bits == 0U + ? std::span{no_check} + : std::span{check_byte}), + }); + + const auto required = MmsDataSpanCodec::encoded_size(oper); + CHECK(required.has_value()); + std::vector bytes(*required); + const auto encoded = MmsDataSpanCodec::encode_into(oper, bytes); + CHECK(encoded.success()); + CHECK(encoded.bytes_written == bytes.size()); + return bytes; +} + +void valid_oper_updates_live_state() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state}; + const auto bytes = make_oper(true, 7U, false); + + MmsStaticDirectBooleanOperate decoded; + CHECK(try_decode_static_direct_boolean_operate(bytes, decoded)); + CHECK(decoded.control_value); + CHECK(decoded.origin_category == 2U); + CHECK(decoded.control_number == 7U); + CHECK(!decoded.test); + CHECK(!decoded.synchro_check); + CHECK(!decoded.interlock_check); + + const auto result = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(result.success); + CHECK(state.value == 1U); + CHECK(state.last_control_number == 7U); + CHECK(!state.last_test); + CHECK(state.accepted_operations == 1U); + CHECK(state.rejected_operations == 0U); +} + +void test_oper_is_non_mutating() { + MmsStaticDirectBooleanControlState state{}; + state.value = 1U; + MmsStaticDirectBooleanControlBinding binding{&state}; + const auto bytes = make_oper(false, 8U, true); + + const auto result = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(result.success); + CHECK(state.value == 1U); + CHECK(state.last_control_number == 8U); + CHECK(state.last_test); + CHECK(state.accepted_operations == 1U); +} + +void unsupported_check_bits_fail_closed() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state}; + const auto bytes = make_oper(true, 9U, false, 0x80U); + + const auto denied = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(!denied.success); + CHECK(denied.failure_code == 11U); + CHECK(state.value == 0U); + CHECK(state.rejected_operations == 1U); + + binding.policy.allow_synchro_check = true; + const auto accepted = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(accepted.success); + CHECK(state.value == 1U); +} + +void invalid_shape_and_values_are_rejected() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state}; + + auto malformed = make_oper(true, 10U, false); + malformed.pop_back(); + auto result = mms_static_direct_boolean_write_oper(&binding, malformed); + CHECK(!result.success); + CHECK(result.failure_code == 7U); + + result = mms_static_direct_boolean_write_oper(&binding, make_oper(true, 0U, false)); + CHECK(!result.success); + CHECK(result.failure_code == 11U); + + result = mms_static_direct_boolean_write_oper(&binding, make_oper(true, 11U, false, 0U, 9U)); + CHECK(!result.success); + CHECK(result.failure_code == 11U); + CHECK(state.value == 0U); +} + +[[nodiscard]] bool reject_backend(void*, bool) noexcept { + return false; +} + +void backend_failure_does_not_publish_state() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state, reject_backend, nullptr}; + const auto result = mms_static_direct_boolean_write_oper( + &binding, make_oper(true, 12U, false)); + CHECK(!result.success); + CHECK(result.failure_code == 10U); + CHECK(state.value == 0U); + CHECK(state.accepted_operations == 0U); + CHECK(state.rejected_operations == 1U); +} + +void read_callbacks_match_mms_types() { + MmsStaticDirectBooleanControlState state{}; + state.value = 1U; + std::array bytes{}; + + auto read = mms_static_direct_boolean_read_state(&state, bytes); + CHECK(read.success()); + CHECK(read.bytes_written == 3U); + CHECK(bytes[0] == 0x83U && bytes[1] == 0x01U && bytes[2] == 0xFFU); + + bytes.fill(0U); + read = mms_static_direct_normal_read_ctl_model(nullptr, bytes); + CHECK(read.success()); + CHECK(read.bytes_written == 3U); + CHECK(bytes[0] == 0x86U && bytes[1] == 0x01U && bytes[2] == 0x01U); + + read = mms_static_control_read_unavailable(nullptr, bytes); + CHECK(!read.success()); +} + +} // namespace + +int main() { + try { + valid_oper_updates_live_state(); + test_oper_is_non_mutating(); + unsupported_check_bits_fail_closed(); + invalid_shape_and_values_are_rejected(); + backend_failure_does_not_publish_state(); + read_callbacks_match_mms_types(); + std::cout << "MMS static direct-control tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/mms_direct_control_server.cpp b/tools/mms_direct_control_server.cpp new file mode 100644 index 000000000..9bfc139af --- /dev/null +++ b/tools/mms_direct_control_server.cpp @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_direct_control.hpp" +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/services.hpp" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#else +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +#ifdef _WIN32 +using SocketHandle = SOCKET; +using SocketLength = int; +constexpr SocketHandle k_invalid_socket = INVALID_SOCKET; +#else +using SocketHandle = int; +using SocketLength = socklen_t; +constexpr SocketHandle k_invalid_socket = -1; +#endif + +struct Options final { + std::string bind_address{"0.0.0.0"}; + std::uint16_t port{8102U}; + std::uint8_t digital_input_mask{}; + std::uint8_t digital_output_mask{}; + bool serve_once{}; +}; + +class SocketSystem final { +public: + SocketSystem() noexcept { +#ifdef _WIN32 + WSADATA data{}; + ready_ = WSAStartup(MAKEWORD(2, 2), &data) == 0; +#else + ready_ = true; +#endif + } + ~SocketSystem() { +#ifdef _WIN32 + if (ready_) WSACleanup(); +#endif + } + SocketSystem(const SocketSystem&) = delete; + SocketSystem& operator=(const SocketSystem&) = delete; + [[nodiscard]] bool ready() const noexcept { return ready_; } +private: + bool ready_{}; +}; + +class OwnedSocket final { +public: + OwnedSocket() noexcept = default; + explicit OwnedSocket(const SocketHandle handle) noexcept : handle_{handle} {} + ~OwnedSocket() { reset(); } + OwnedSocket(const OwnedSocket&) = delete; + OwnedSocket& operator=(const OwnedSocket&) = delete; + OwnedSocket(OwnedSocket&& other) noexcept : handle_{other.release()} {} + OwnedSocket& operator=(OwnedSocket&& other) noexcept { + if (this != &other) reset(other.release()); + return *this; + } + [[nodiscard]] SocketHandle get() const noexcept { return handle_; } + [[nodiscard]] bool valid() const noexcept { return handle_ != k_invalid_socket; } + [[nodiscard]] SocketHandle release() noexcept { + const auto value = handle_; + handle_ = k_invalid_socket; + return value; + } + void reset(const SocketHandle replacement = k_invalid_socket) noexcept { + if (valid()) { +#ifdef _WIN32 + closesocket(handle_); +#else + close(handle_); +#endif + } + handle_ = replacement; + } +private: + SocketHandle handle_{k_invalid_socket}; +}; + +[[nodiscard]] int last_socket_error() noexcept { +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif +} + +[[nodiscard]] bool is_would_block(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEWOULDBLOCK; +#else + return error == EAGAIN || error == EWOULDBLOCK; +#endif +} + +[[nodiscard]] bool is_timeout(const int error) noexcept { +#ifdef _WIN32 + return error == WSAETIMEDOUT; +#else + return error == ETIMEDOUT; +#endif +} + +[[nodiscard]] bool is_interrupted(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEINTR; +#else + return error == EINTR; +#endif +} + +[[nodiscard]] embedded::IoResult socket_receive( + void* context, + const std::span destination) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + destination.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = destination.size(); +#endif + const auto received = recv( + socket, reinterpret_cast(destination.data()), requested, 0); + if (received > 0) return {embedded::IoStatus::ok, static_cast(received)}; + if (received == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult socket_send( + void* context, + const std::span bytes) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + bytes.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = bytes.size(); +#endif +#ifdef MSG_NOSIGNAL + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; +#endif + const auto sent = send( + socket, reinterpret_cast(bytes.data()), requested, flags); + if (sent > 0) return {embedded::IoStatus::ok, static_cast(sent)}; + if (sent == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +template +[[nodiscard]] bool parse_integer(const std::string_view text, Integer& output) noexcept { + auto digits = text; + int base = 10; + if (digits.size() > 2U && digits[0] == '0' && + (digits[1] == 'x' || digits[1] == 'X')) { + digits.remove_prefix(2U); + base = 16; + } + unsigned int parsed{}; + const auto result = std::from_chars( + digits.data(), digits.data() + digits.size(), parsed, base); + if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size() || + parsed > static_cast(std::numeric_limits::max())) { + return false; + } + output = static_cast(parsed); + return true; +} + +void print_help() { + std::cout + << "ARStack61850 bounded Direct-Normal MMS control server\n\n" + << "Usage:\n" + << " ariec61850_mms_direct_control_server [options]\n\n" + << "Options:\n" + << " --bind ADDRESS Bind address (default 0.0.0.0)\n" + << " --port PORT TCP port (default 8102; use 102 for IEC 61850)\n" + << " --di-mask VALUE Initial GGIO1 Ind1..Ind8 bit mask\n" + << " --do-mask VALUE Initial GGIO1 SPCSO1..8 status bit mask\n" + << " --once Serve one TCP connection, then exit\n" + << " --help Show this help\n\n" + << "Control objects: ESP32S3IOLD0/GGIO1.SPCSO1 .. SPCSO8\n" + << "ctlModel: Direct-with-normal-security (1).\n" + << "Check bits are fail-closed; Test=true is acknowledged without changing output.\n"; +} + +[[nodiscard]] bool parse_options( + const int argc, + char** argv, + Options& options, + bool& help) { + help = false; + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + help = true; + return true; + } + if (argument == "--once") { + options.serve_once = true; + continue; + } + if (index + 1 >= argc) { + std::cerr << "Missing value after " << argument << '\n'; + return false; + } + const std::string_view value{argv[++index]}; + if (argument == "--bind") { + options.bind_address.assign(value); + } else if (argument == "--port") { + if (!parse_integer(value, options.port) || options.port == 0U) return false; + } else if (argument == "--di-mask") { + if (!parse_integer(value, options.digital_input_mask)) return false; + } else if (argument == "--do-mask") { + if (!parse_integer(value, options.digital_output_mask)) return false; + } else { + std::cerr << "Unknown option: " << argument << '\n'; + return false; + } + } + return true; +} + +[[nodiscard]] OwnedSocket create_listener(const Options& options) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + hints.ai_flags = AI_PASSIVE; + const auto service = std::to_string(options.port); + addrinfo* addresses{}; + if (getaddrinfo( + options.bind_address.empty() ? nullptr : options.bind_address.c_str(), + service.c_str(), &hints, &addresses) != 0) { + return {}; + } + + OwnedSocket listener; + for (auto* address = addresses; address != nullptr; address = address->ai_next) { + OwnedSocket candidate{socket( + address->ai_family, address->ai_socktype, address->ai_protocol)}; + if (!candidate.valid()) continue; + constexpr int enabled = 1; + static_cast(setsockopt( + candidate.get(), SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&enabled), + static_cast(sizeof(enabled)))); + const auto address_length = static_cast(address->ai_addrlen); + if (bind(candidate.get(), address->ai_addr, address_length) == 0 && + listen(candidate.get(), 8) == 0) { + listener = std::move(candidate); + break; + } + } + freeaddrinfo(addresses); + return listener; +} + +void set_client_timeouts(const SocketHandle socket) noexcept { +#ifdef _WIN32 + constexpr DWORD timeout_ms = 1000U; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); +#else + constexpr timeval timeout{1, 0}; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout))); +#endif +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, required}; + if (destination.size() < required) return {wire::EncodeStatus::buffer_too_small, 0U, required}; + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) != 0U ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +struct EncodedValue final { std::span bytes; }; + +[[nodiscard]] wire::EncodeResult read_encoded( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + const auto bytes = static_cast(context)->bytes; + if (destination.size() < bytes.size()) return {wire::EncodeStatus::buffer_too_small, 0U, bytes.size()}; + std::copy(bytes.begin(), bytes.end(), destination.begin()); + return {wire::EncodeStatus::ok, bytes.size(), bytes.size()}; +} + +[[nodiscard]] mms::MmsTypeSpecification scalar( + const mms::MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt) { + mms::MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification structure( + std::string name, + std::vector children) { + mms::MmsTypeSpecification result; + result.kind = mms::MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification direct_boolean_oper_spec() { + return structure("Oper", { + scalar(mms::MmsTypeKind::boolean, "ctlVal"), + structure("origin", { + scalar(mms::MmsTypeKind::unsigned_integer, "orCat"), + scalar(mms::MmsTypeKind::octet_string, "orIdent", 64U), + }), + scalar(mms::MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(mms::MmsTypeKind::utc_time, "T"), + scalar(mms::MmsTypeKind::boolean, "Test"), + scalar(mms::MmsTypeKind::bit_string, "Check", 2U), + }); +} + +[[nodiscard]] std::vector build_single_status_ln_type( + const std::string& data_object) { + return mms::MmsServiceCodec::encode_type_specification(structure("", { + structure("ST", {structure(data_object, {scalar(mms::MmsTypeKind::boolean, "stVal")})}) + })); +} + +[[nodiscard]] std::vector build_ggio_type() { + std::vector status; + std::vector config; + std::vector controls; + status.reserve(16U); + config.reserve(8U); + controls.reserve(8U); + for (std::size_t index = 0U; index < 8U; ++index) { + const auto number = std::to_string(index + 1U); + status.push_back(structure("Ind" + number, {scalar(mms::MmsTypeKind::boolean, "stVal")})); + status.push_back(structure("SPCSO" + number, {scalar(mms::MmsTypeKind::boolean, "stVal")})); + config.push_back(structure("SPCSO" + number, { + scalar(mms::MmsTypeKind::unsigned_integer, "ctlModel") + })); + controls.push_back(structure("SPCSO" + number, {direct_boolean_oper_spec()})); + } + return mms::MmsServiceCodec::encode_type_specification(structure("", { + structure("ST", std::move(status)), + structure("CF", std::move(config)), + structure("CO", std::move(controls)), + })); +} + +[[nodiscard]] bool serve_client( + const SocketHandle client, + const mms::MmsStaticApplicationDispatcher& dispatcher) { + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + auto socket_copy = client; + embedded::TcpByteStream stream{&socket_copy, socket_send, socket_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{runtime, stream, {receive, response, workspace}}; + for (;;) { + const auto result = session.poll_once(); + switch (result.status) { + case mms::MmsStaticServerSessionStatus::progressed: + case mms::MmsStaticServerSessionStatus::would_block: + case mms::MmsStaticServerSessionStatus::timed_out: + continue; + case mms::MmsStaticServerSessionStatus::response_pending: + if (result.application_service != mms::MmsWireConfirmedService::unknown && + result.application_status != mms::MmsStaticDispatchStatus::response_ready && + result.application_status != mms::MmsStaticDispatchStatus::object_not_found) { + std::cerr + << "MMS_DIRECT_CONTROL_APPLICATION_DIAGNOSTIC status=" + << static_cast(result.application_status) + << " service=" << static_cast(result.application_service) + << " invokeID=" << result.invoke_id << '\n'; + } + continue; + case mms::MmsStaticServerSessionStatus::application_rejected: + std::cerr << "MMS request rejected; connection remains open.\n"; + continue; + case mms::MmsStaticServerSessionStatus::peer_closed: + return true; + default: + std::cerr + << "MMS_DIRECT_CONTROL_SESSION_TERMINAL sessionStatus=" + << static_cast(result.status) + << " connectionStatus=" + << static_cast(result.connection_status) + << " bytesReceived=" << result.bytes_received + << " bytesSent=" << result.bytes_sent + << " bufferedInput=" << result.buffered_input_bytes + << " pendingOutput=" << result.pending_output_bytes << '\n'; + return false; + } + } +} + +[[nodiscard]] std::uint8_t output_mask( + const std::array& states) noexcept { + std::uint8_t mask{}; + for (std::size_t index = 0U; index < states.size(); ++index) { + if (states[index].value != 0U) mask = static_cast(mask | (1U << index)); + } + return mask; +} + +} // namespace + +int main(int argc, char** argv) { + Options options; + bool help{}; + if (!parse_options(argc, argv, options, help)) return 2; + if (help) { + print_help(); + return 0; + } + + SocketSystem sockets; + if (!sockets.ready()) return 3; + + constexpr std::string_view domain{"ESP32S3IOLD0"}; + constexpr std::array boolean_type{0x83U, 0x00U}; + constexpr std::array unsigned_type{0x86U, 0x00U}; + const auto oper_type = mms::MmsServiceCodec::encode_type_specification(direct_boolean_oper_spec()); + const auto lln0_type = build_single_status_ln_type("Mod"); + const auto lphd1_type = build_single_status_ln_type("PhyHealth"); + const auto ggio1_type = build_ggio_type(); + + constexpr std::array healthy_ln_data{ + 0xA2U, 0x07U, 0xA2U, 0x05U, 0xA2U, 0x03U, 0x83U, 0x01U, 0xFFU}; + const std::array root_values{ + EncodedValue{healthy_ln_data}, EncodedValue{healthy_ln_data}}; + + std::array read_values{}; + read_values[0] = 1U; + read_values[1] = 1U; + for (std::size_t index = 0U; index < 8U; ++index) { + read_values[index + 2U] = static_cast((options.digital_input_mask >> index) & 0x01U); + } + + std::array control_states{}; + std::array control_bindings{}; + std::array input_items{}; + std::array output_status_items{}; + std::array ctl_model_items{}; + std::array oper_items{}; + + for (std::size_t index = 0U; index < 8U; ++index) { + const auto number = std::to_string(index + 1U); + input_items[index] = "GGIO1$ST$Ind" + number + "$stVal"; + output_status_items[index] = "GGIO1$ST$SPCSO" + number + "$stVal"; + ctl_model_items[index] = "GGIO1$CF$SPCSO" + number + "$ctlModel"; + oper_items[index] = "GGIO1$CO$SPCSO" + number + "$Oper"; + control_states[index].value = static_cast( + (options.digital_output_mask >> index) & 0x01U); + control_bindings[index].state = &control_states[index]; + } + + std::array objects{}; + objects[0] = {domain, "LLN0", lln0_type, read_encoded, &root_values[0], false, nullptr, nullptr, nullptr}; + objects[1] = {domain, "LPHD1", lphd1_type, read_encoded, &root_values[1], false, nullptr, nullptr, nullptr}; + objects[2] = {domain, "GGIO1", ggio1_type, mms::mms_static_control_read_unavailable, nullptr, false, nullptr, nullptr, nullptr}; + objects[3] = {domain, "LLN0$ST$Mod$stVal", boolean_type, read_boolean, &read_values[0]}; + objects[4] = {domain, "LPHD1$ST$PhyHealth$stVal", boolean_type, read_boolean, &read_values[1]}; + for (std::size_t index = 0U; index < 8U; ++index) { + objects[5U + index] = {domain, input_items[index], boolean_type, read_boolean, &read_values[index + 2U]}; + const auto base = 13U + index * 3U; + objects[base] = {domain, output_status_items[index], boolean_type, + mms::mms_static_direct_boolean_read_state, &control_states[index]}; + objects[base + 1U] = {domain, ctl_model_items[index], unsigned_type, + mms::mms_static_direct_normal_read_ctl_model, nullptr}; + objects[base + 2U] = {domain, oper_items[index], oper_type, + mms::mms_static_control_read_unavailable, nullptr, false, + mms::mms_static_direct_boolean_write_oper, &control_bindings[index]}; + } + + const mms::MmsStaticObjectTable object_table{objects}; + if (!object_table.valid()) { + std::cerr << "Static MMS control model is invalid.\n"; + return 4; + } + + std::array output_members{}; + for (std::size_t index = 0U; index < output_members.size(); ++index) { + output_members[index] = {domain, output_status_items[index]}; + } + const std::array data_sets{{ + {domain, "LLN0$Outputs", output_members, false} + }}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid_against(object_table)) { + std::cerr << "Static output DataSet is invalid.\n"; + return 4; + } + + mms::MmsStaticDispatchPolicy policy; + policy.maximum_write_variables = 1U; + policy.advertise_flattened_child_aliases = true; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table, policy}; + + auto listener = create_listener(options); + if (!listener.valid()) { + std::cerr << "Unable to bind/listen on " << options.bind_address << ':' << options.port << ".\n"; + return 5; + } + + std::cout << "MMS_DIRECT_CONTROL_SERVER_READY bind=" << options.bind_address + << " port=" << options.port + << " domain=" << domain + << " controls=8 ctlModel=1 dataset=ESP32S3IOLD0/LLN0.Outputs" + << " doMask=" << static_cast(output_mask(control_states)) << '\n'; + + do { + sockaddr_storage peer{}; + SocketLength peer_size = static_cast(sizeof(peer)); + OwnedSocket client{accept( + listener.get(), reinterpret_cast(&peer), &peer_size)}; + if (!client.valid()) { + const auto error = last_socket_error(); + if (is_interrupted(error)) continue; + std::cerr << "TCP accept failed with platform error " << error << ".\n"; + return 6; + } + set_client_timeouts(client.get()); + std::cout << "MMS_DIRECT_CONTROL_CLIENT_ACCEPTED\n"; + const auto clean_close = serve_client(client.get(), dispatcher); + std::size_t accepted{}; + std::size_t rejected{}; + for (const auto& state : control_states) { + accepted += state.accepted_operations; + rejected += state.rejected_operations; + } + std::cout << "MMS_DIRECT_CONTROL_STATE doMask=" + << static_cast(output_mask(control_states)) + << " acceptedOps=" << accepted + << " rejectedOps=" << rejected << '\n'; + std::cout << (clean_close ? "MMS_DIRECT_CONTROL_CLIENT_CLOSED" + : "MMS_DIRECT_CONTROL_CLIENT_ABORTED") << '\n'; + } while (!options.serve_once); + + return 0; +} diff --git a/tools/mms_direct_control_server/CMakeLists.txt b/tools/mms_direct_control_server/CMakeLists.txt new file mode 100644 index 000000000..a44feb106 --- /dev/null +++ b/tools/mms_direct_control_server/CMakeLists.txt @@ -0,0 +1,137 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850MmsR12ServerProfile LANGUAGES CXX) + +get_filename_component(ARSTACK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) + +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_WARNINGS_AS_ERRORS ON CACHE BOOL "" FORCE) + +# Keep the repository's regular targets available, but do not link the public +# MMS server core into this A/B profile. The profile below compiles the current +# codecs with the isolated IED-simulator parity runtime instead. +add_subdirectory("${ARSTACK_ROOT}" arstack-core) + +function(server_strict_warnings target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endfunction() + +add_library(ariec61850_mms_r12_server_core STATIC + "${ARSTACK_ROOT}/src/asn1/ber_span_reader.cpp" + "${ARSTACK_ROOT}/src/osi/tpkt_span.cpp" + "${ARSTACK_ROOT}/src/osi/cotp_span.cpp" + "${ARSTACK_ROOT}/src/osi/session_span.cpp" + "${ARSTACK_ROOT}/src/osi/presentation_span.cpp" + "${ARSTACK_ROOT}/src/acse/association_span.cpp" + "${ARSTACK_ROOT}/src/acse/association_compat_span.cpp" + "${ARSTACK_ROOT}/src/mms/pdu_span.cpp" + "${ARSTACK_ROOT}/src/mms/pdu_compat_span.cpp" + "${ARSTACK_ROOT}/src/mms/services_span.cpp" + "${ARSTACK_ROOT}/src/mms/write_span.cpp" + "${ARSTACK_ROOT}/src/mms/data_set_span.cpp" + "${ARSTACK_ROOT}/src/mms/information_report_span.cpp" + "${ARSTACK_ROOT}/src/mms/static_object_table.cpp" + "${ARSTACK_ROOT}/src/mms/static_write.cpp" + "${ARSTACK_ROOT}/src/mms/static_data_set_table.cpp" + "${ARSTACK_ROOT}/src/mms/static_dispatcher.cpp" + "${ARSTACK_ROOT}/src/mms/connection_runtime_iedsim.cpp" + "${ARSTACK_ROOT}/src/mms/static_server_session.cpp" + "${ARSTACK_ROOT}/src/mms/static_direct_control.cpp" + "${ARSTACK_ROOT}/src/mms/static_information_report.cpp" + "${ARSTACK_ROOT}/src/mms/static_report_connection.cpp" + "${ARSTACK_ROOT}/src/mms/static_urcb_objects.cpp" + "${ARSTACK_ROOT}/src/mms/static_urcb_runtime.cpp") +target_include_directories(ariec61850_mms_r12_server_core PUBLIC + "${ARSTACK_ROOT}/include") +target_compile_features(ariec61850_mms_r12_server_core PUBLIC cxx_std_20) +server_strict_warnings(ariec61850_mms_r12_server_core) + +add_executable(ariec61850_mms_direct_control_server + "${ARSTACK_ROOT}/tools/mms_direct_control_server.cpp") +target_link_libraries(ariec61850_mms_direct_control_server PRIVATE + ariec61850_mms_r12_server_core + ARIEC61850::core) +if(WIN32) + target_link_libraries(ariec61850_mms_direct_control_server PRIVATE ws2_32) +endif() +target_compile_features(ariec61850_mms_direct_control_server PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_direct_control_server) + +add_executable(ariec61850_mms_urcb_server + "${ARSTACK_ROOT}/tools/mms_urcb_server.cpp") +target_link_libraries(ariec61850_mms_urcb_server PRIVATE + ariec61850_mms_r12_server_core + ARIEC61850::core) +if(WIN32) + target_link_libraries(ariec61850_mms_urcb_server PRIVATE ws2_32) +endif() +target_compile_features(ariec61850_mms_urcb_server PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_urcb_server) + +# Build the repository's operational discovery client inside this isolated +# profile. The loopback CTest below is the integration contract between the +# runnable servers and the client path used by current engineering workflows. +add_executable(ariec61850_mms_live_discover_client + "${ARSTACK_ROOT}/tools/live_discover.cpp") +target_link_libraries(ariec61850_mms_live_discover_client PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_mms_live_discover_client PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_live_discover_client) + +add_executable(ariec61850_mms_control_inventory_client + "${ARSTACK_ROOT}/tools/control_inventory_probe.cpp") +target_link_libraries(ariec61850_mms_control_inventory_client PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_mms_control_inventory_client PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_control_inventory_client) + +add_executable(ariec61850_mms_control_interop_client + "${ARSTACK_ROOT}/tools/control_interop_probe.cpp") +target_link_libraries(ariec61850_mms_control_interop_client PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_mms_control_interop_client PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_control_interop_client) + +include(CTest) +enable_testing() + +add_executable(ariec61850_mms_direct_control_tests + "${ARSTACK_ROOT}/tests/test_mms_static_direct_control.cpp") +target_link_libraries(ariec61850_mms_direct_control_tests PRIVATE + ariec61850_mms_r12_server_core + ARIEC61850::core) +target_compile_features(ariec61850_mms_direct_control_tests PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_direct_control_tests) + +add_executable(ariec61850_mms_server_urcb_tests + "${ARSTACK_ROOT}/tests/test_mms_server_urcb_report.cpp") +target_link_libraries(ariec61850_mms_server_urcb_tests PRIVATE + ariec61850_mms_r12_server_core + ARIEC61850::core) +target_compile_features(ariec61850_mms_server_urcb_tests PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_server_urcb_tests) + +add_test(NAME mms_direct_control_unit COMMAND ariec61850_mms_direct_control_tests) +add_test(NAME mms_direct_control_help COMMAND ariec61850_mms_direct_control_server --help) +add_test(NAME mms_server_urcb_report COMMAND ariec61850_mms_server_urcb_tests) +add_test(NAME mms_urcb_server_help COMMAND ariec61850_mms_urcb_server --help) + +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_test( + NAME mms_server_live_discovery_loopback + COMMAND + ${Python3_EXECUTABLE} + "${ARSTACK_ROOT}/scripts/test-mms-server-live-discovery.py" + --client $ + --control-inventory $ + --control-client $ + --direct-server $ + --urcb-server $) diff --git a/tools/mms_urcb_server.cpp b/tools/mms_urcb_server.cpp new file mode 100644 index 000000000..54889957b --- /dev/null +++ b/tools/mms_urcb_server.cpp @@ -0,0 +1,555 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/static_report_connection.hpp" +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/static_urcb_objects.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" +#include "ariec61850/mms/services.hpp" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#else +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +#ifdef _WIN32 +using SocketHandle = SOCKET; +using SocketLength = int; +constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +#else +using SocketHandle = int; +using SocketLength = socklen_t; +constexpr SocketHandle kInvalidSocket = -1; +#endif + +struct Options final { + std::string bind_address{"0.0.0.0"}; + std::uint16_t port{8102U}; + std::uint8_t output_mask{}; + bool serve_once{}; +}; + +class SocketSystem final { +public: + SocketSystem() noexcept { +#ifdef _WIN32 + WSADATA data{}; + ready_ = WSAStartup(MAKEWORD(2, 2), &data) == 0; +#else + ready_ = true; +#endif + } + ~SocketSystem() { +#ifdef _WIN32 + if (ready_) WSACleanup(); +#endif + } + SocketSystem(const SocketSystem&) = delete; + SocketSystem& operator=(const SocketSystem&) = delete; + [[nodiscard]] bool ready() const noexcept { return ready_; } +private: + bool ready_{}; +}; + +class OwnedSocket final { +public: + OwnedSocket() noexcept = default; + explicit OwnedSocket(SocketHandle handle) noexcept : handle_{handle} {} + ~OwnedSocket() { reset(); } + OwnedSocket(const OwnedSocket&) = delete; + OwnedSocket& operator=(const OwnedSocket&) = delete; + OwnedSocket(OwnedSocket&& other) noexcept : handle_{other.release()} {} + OwnedSocket& operator=(OwnedSocket&& other) noexcept { + if (this != &other) reset(other.release()); + return *this; + } + [[nodiscard]] SocketHandle get() const noexcept { return handle_; } + [[nodiscard]] bool valid() const noexcept { return handle_ != kInvalidSocket; } + [[nodiscard]] SocketHandle release() noexcept { + const auto value = handle_; + handle_ = kInvalidSocket; + return value; + } + void reset(SocketHandle replacement = kInvalidSocket) noexcept { + if (valid()) { +#ifdef _WIN32 + closesocket(handle_); +#else + close(handle_); +#endif + } + handle_ = replacement; + } +private: + SocketHandle handle_{kInvalidSocket}; +}; + +[[nodiscard]] int last_socket_error() noexcept { +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif +} + +[[nodiscard]] bool is_would_block(int error) noexcept { +#ifdef _WIN32 + return error == WSAEWOULDBLOCK; +#else + return error == EAGAIN || error == EWOULDBLOCK; +#endif +} + +[[nodiscard]] bool is_timeout(int error) noexcept { +#ifdef _WIN32 + return error == WSAETIMEDOUT; +#else + return error == ETIMEDOUT; +#endif +} + +[[nodiscard]] bool is_interrupted(int error) noexcept { +#ifdef _WIN32 + return error == WSAEINTR; +#else + return error == EINTR; +#endif +} + +[[nodiscard]] embedded::IoResult socket_receive( + void* context, + std::span destination) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + destination.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = destination.size(); +#endif + const auto received = recv( + socket, reinterpret_cast(destination.data()), requested, 0); + if (received > 0) return {embedded::IoStatus::ok, static_cast(received)}; + if (received == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult socket_send( + void* context, + std::span bytes) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + bytes.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = bytes.size(); +#endif +#ifdef MSG_NOSIGNAL + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; +#endif + const auto sent = send(socket, reinterpret_cast(bytes.data()), requested, flags); + if (sent > 0) return {embedded::IoStatus::ok, static_cast(sent)}; + if (sent == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] bool send_all( + SocketHandle socket, + std::span bytes) noexcept { + std::size_t offset{}; + while (offset < bytes.size()) { + auto socket_copy = socket; + const auto result = socket_send(&socket_copy, bytes.subspan(offset)); + if (result.status != embedded::IoStatus::ok || result.transferred == 0U) return false; + offset += result.transferred; + } + return true; +} + +template +[[nodiscard]] bool parse_integer(std::string_view text, Integer& output) noexcept { + auto digits = text; + int base = 10; + if (digits.size() > 2U && digits[0] == '0' && (digits[1] == 'x' || digits[1] == 'X')) { + digits.remove_prefix(2U); + base = 16; + } + unsigned int parsed{}; + const auto result = std::from_chars(digits.data(), digits.data() + digits.size(), parsed, base); + if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size() || + parsed > static_cast(std::numeric_limits::max())) { + return false; + } + output = static_cast(parsed); + return true; +} + +void print_help() { + std::cout + << "ARStack61850 bounded static URCB reporting server\n\n" + << "Usage:\n" + << " ariec61850_mms_urcb_server [options]\n\n" + << "Options:\n" + << " --bind ADDRESS Bind address (default 0.0.0.0)\n" + << " --port PORT TCP port (default 8102; use 102 for IEC 61850)\n" + << " --mask VALUE Initial SPCSO1..8 Boolean status mask\n" + << " --once Serve one TCP connection, then exit\n" + << " --help Show this help\n\n" + << "DataSet: ESP32S3IOLD0/LLN0.Outputs\n" + << "URCB: ESP32S3IOLD0/LLN0$RP$Outputs01\n" + << "First live-report gate: reserve/enable the URCB and write GI=true.\n"; +} + +[[nodiscard]] bool parse_options(int argc, char** argv, Options& options, bool& help) { + help = false; + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + help = true; + return true; + } + if (argument == "--once") { + options.serve_once = true; + continue; + } + if (index + 1 >= argc) return false; + const std::string_view value{argv[++index]}; + if (argument == "--bind") { + options.bind_address.assign(value); + } else if (argument == "--port") { + if (!parse_integer(value, options.port) || options.port == 0U) return false; + } else if (argument == "--mask") { + if (!parse_integer(value, options.output_mask)) return false; + } else { + return false; + } + } + return true; +} + +[[nodiscard]] OwnedSocket create_listener(const Options& options) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + hints.ai_flags = AI_PASSIVE; + const auto service = std::to_string(options.port); + addrinfo* addresses{}; + if (getaddrinfo(options.bind_address.c_str(), service.c_str(), &hints, &addresses) != 0) return {}; + OwnedSocket listener; + for (auto* address = addresses; address != nullptr; address = address->ai_next) { + OwnedSocket candidate{socket(address->ai_family, address->ai_socktype, address->ai_protocol)}; + if (!candidate.valid()) continue; + constexpr int enabled = 1; + static_cast(setsockopt(candidate.get(), SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&enabled), static_cast(sizeof(enabled)))); + const auto address_length = static_cast(address->ai_addrlen); + if (bind(candidate.get(), address->ai_addr, address_length) == 0 && + listen(candidate.get(), 8) == 0) { + listener = std::move(candidate); + break; + } + } + freeaddrinfo(addresses); + return listener; +} + +void set_client_timeouts(SocketHandle socket) noexcept { +#ifdef _WIN32 + constexpr DWORD timeout_ms = 1000U; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); +#else + constexpr timeval timeout{1, 0}; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout))); +#endif +} + +[[nodiscard]] std::uint64_t monotonic_ms() noexcept { + using namespace std::chrono; + return static_cast(duration_cast( + steady_clock::now().time_since_epoch()).count()); +} + +[[nodiscard]] std::uint64_t report_now_ms(const void*) noexcept { + return monotonic_ms(); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, required}; + if (destination.size() < required) return {wire::EncodeStatus::buffer_too_small, 0U, required}; + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) != 0U ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +struct EncodedValue final { std::span bytes; }; + +[[nodiscard]] wire::EncodeResult read_encoded( + const void* context, + std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + const auto bytes = static_cast(context)->bytes; + if (destination.size() < bytes.size()) return {wire::EncodeStatus::buffer_too_small, 0U, bytes.size()}; + std::copy(bytes.begin(), bytes.end(), destination.begin()); + return {wire::EncodeStatus::ok, bytes.size(), bytes.size()}; +} + +[[nodiscard]] mms::MmsTypeSpecification scalar( + const mms::MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt) { + mms::MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification structure( + std::string name, + std::vector children) { + mms::MmsTypeSpecification result; + result.kind = mms::MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +[[nodiscard]] std::vector build_ggio_type() { + std::vector statuses; + statuses.reserve(8U); + for (std::size_t index = 0U; index < 8U; ++index) { + statuses.push_back(structure("SPCSO" + std::to_string(index + 1U), { + scalar(mms::MmsTypeKind::boolean, "stVal") + })); + } + return mms::MmsServiceCodec::encode_type_specification(structure("", { + structure("ST", std::move(statuses)) + })); +} + +[[nodiscard]] std::vector build_lln0_type() { + return mms::MmsServiceCodec::encode_type_specification(structure("", { + structure("RP", { + structure("Outputs01", { + scalar(mms::MmsTypeKind::visible_string, "RptID", 255U), + scalar(mms::MmsTypeKind::boolean, "RptEna"), + scalar(mms::MmsTypeKind::boolean, "Resv"), + scalar(mms::MmsTypeKind::visible_string, "DatSet", 255U), + scalar(mms::MmsTypeKind::unsigned_integer, "ConfRev", 32U), + scalar(mms::MmsTypeKind::bit_string, "OptFlds", 10U), + scalar(mms::MmsTypeKind::unsigned_integer, "BufTm", 32U), + scalar(mms::MmsTypeKind::bit_string, "TrgOps", 6U), + scalar(mms::MmsTypeKind::unsigned_integer, "IntgPd", 32U), + scalar(mms::MmsTypeKind::boolean, "GI"), + scalar(mms::MmsTypeKind::unsigned_integer, "SqNum", 32U), + }), + }), + })); +} + +[[nodiscard]] wire::EncodeResult read_unavailable( + const void*, + const std::span) noexcept { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +[[nodiscard]] bool serve_client( + SocketHandle client, + const mms::MmsStaticApplicationDispatcher& dispatcher, + mms::MmsStaticUrcbRuntime& urcbs) { + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + auto socket_copy = client; + embedded::TcpByteStream stream{&socket_copy, socket_send, socket_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + std::array report_frame{}; + std::array report_workspace{}; + mms::MmsStaticServerSession session{runtime, stream, {receive, response, workspace}}; + + for (;;) { + const auto session_result = session.poll_once(); + switch (session_result.status) { + case mms::MmsStaticServerSessionStatus::peer_closed: + return true; + case mms::MmsStaticServerSessionStatus::transport_error: + case mms::MmsStaticServerSessionStatus::protocol_error: + case mms::MmsStaticServerSessionStatus::receive_buffer_full: + case mms::MmsStaticServerSessionStatus::invalid_configuration: + return false; + default: + break; + } + + if (session.pending_output_bytes() != 0U) continue; + + const auto report = mms::MmsStaticReportConnection::poll( + runtime, urcbs, monotonic_ms(), {}, report_frame, report_workspace); + if (report.status == mms::MmsStaticReportConnectionStatus::response_ready) { + if (!send_all(client, std::span{ + report_frame.data(), report.bytes_written})) { + return false; + } + std::cout << "MMS_URCB_REPORT_SENT index=" << report.control_block_index + << " sqNum=" << static_cast(report.sequence_number) + << " reason=" << static_cast(report.reason) + << " bytes=" << report.bytes_written << '\n'; + } else if (report.status == mms::MmsStaticReportConnectionStatus::report_encode_failed || + report.status == mms::MmsStaticReportConnectionStatus::response_buffer_too_small || + report.status == mms::MmsStaticReportConnectionStatus::workspace_too_small) { + std::cerr << "MMS_URCB_REPORT_ERROR status=" << static_cast(report.status) + << " urcbStatus=" << static_cast(report.urcb_status) << '\n'; + } + } +} + +} // namespace + +int main(int argc, char** argv) { + Options options; + bool help{}; + if (!parse_options(argc, argv, options, help)) return 2; + if (help) { + print_help(); + return 0; + } + + SocketSystem sockets; + if (!sockets.ready()) return 3; + + constexpr std::string_view domain{"ESP32S3IOLD0"}; + constexpr std::array boolean_type{0x83U, 0x00U}; + const auto lln0_type = build_lln0_type(); + const auto ggio_type = build_ggio_type(); + const EncodedValue ggio_root{ggio_type}; + + std::array values{}; + std::array status_items{}; + std::array base_objects{}; + base_objects[0] = {domain, "LLN0", lln0_type, read_unavailable, nullptr}; + base_objects[1] = {domain, "GGIO1", ggio_type, read_encoded, &ggio_root}; + for (std::size_t index = 0U; index < 8U; ++index) { + values[index] = static_cast((options.output_mask >> index) & 0x01U); + status_items[index] = "GGIO1$ST$SPCSO" + std::to_string(index + 1U) + "$stVal"; + base_objects[index + 2U] = { + domain, status_items[index], boolean_type, read_boolean, &values[index] + }; + } + const mms::MmsStaticObjectTable base_table{base_objects}; + if (!base_table.valid()) return 4; + + std::array members{}; + for (std::size_t index = 0U; index < members.size(); ++index) { + members[index] = {domain, status_items[index]}; + } + const std::array data_sets{{ + {domain, "LLN0$Outputs", members, false} + }}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid_against(base_table)) return 4; + + constexpr std::array opt_fields{0x5CU, 0x80U}; + const std::array definitions{{ + {domain, "LLN0$RP$Outputs01", "ARSTACK-Outputs01", + domain, "LLN0$Outputs", 1U, opt_fields, 0U, 0U, 0U} + }}; + std::array states{}; + mms::MmsStaticUrcbRuntime urcbs{definitions, states, base_table, data_set_table}; + if (!urcbs.initialize()) return 4; + + std::array object_storage{}; + std::array context_storage{}; + std::array name_storage{}; + mms::MmsStaticUrcbObjectBank object_bank{ + urcbs, base_objects, object_storage, context_storage, name_storage, + report_now_ms, nullptr}; + if (!object_bank.initialize()) return 4; + + mms::MmsStaticDispatchPolicy policy; + policy.maximum_write_variables = 1U; + policy.advertise_flattened_child_aliases = true; + const mms::MmsStaticApplicationDispatcher dispatcher{ + object_bank.table(), data_set_table, policy}; + + auto listener = create_listener(options); + if (!listener.valid()) { + std::cerr << "Unable to bind/listen on " << options.bind_address << ':' << options.port << ".\n"; + return 5; + } + + std::cout << "MMS_URCB_SERVER_READY bind=" << options.bind_address + << " port=" << options.port + << " dataset=ESP32S3IOLD0/LLN0.Outputs" + << " urcb=ESP32S3IOLD0/LLN0$RP$Outputs01" + << " members=8 mask=" << static_cast(options.output_mask) << '\n'; + + do { + static_cast(urcbs.set_enabled(0U, false, monotonic_ms())); + static_cast(urcbs.set_reserved(0U, false)); + sockaddr_storage peer{}; + SocketLength peer_size = static_cast(sizeof(peer)); + OwnedSocket client{accept(listener.get(), reinterpret_cast(&peer), &peer_size)}; + if (!client.valid()) { + const auto error = last_socket_error(); + if (is_interrupted(error)) continue; + return 6; + } + set_client_timeouts(client.get()); + std::cout << "MMS_URCB_CLIENT_ACCEPTED\n"; + const auto clean_close = serve_client(client.get(), dispatcher, urcbs); + const auto state = urcbs.state(0U); + std::cout << "MMS_URCB_STATE enabled=" << (state != nullptr && state->enabled ? "true" : "false") + << " reserved=" << (state != nullptr && state->reserved ? "true" : "false") + << " sqNum=" << (state != nullptr ? static_cast(state->sequence_number) : 0U) + << '\n'; + static_cast(urcbs.set_enabled(0U, false, monotonic_ms())); + static_cast(urcbs.set_reserved(0U, false)); + std::cout << (clean_close ? "MMS_URCB_CLIENT_CLOSED" : "MMS_URCB_CLIENT_ABORTED") << '\n'; + } while (!options.serve_once); + + return 0; +} From 97227a811982ddccdfad31b63dfdafcd38148f49 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 22:23:59 +0700 Subject: [PATCH 123/129] Add smart live injection and resizable Qt dock shell --- apps/arstack_studio/CMakeLists.txt | 23 + apps/arstack_studio/README.md | 80 ++- apps/arstack_studio/assets/LUCIDE_LICENSE.txt | 40 ++ .../arstack_studio/assets/lucide/activity.svg | 1 + .../assets/lucide/chevron-down.svg | 1 + .../assets/lucide/chevron-up.svg | 1 + .../assets/lucide/circle-check.svg | 1 + .../assets/lucide/circle-off.svg | 1 + apps/arstack_studio/assets/lucide/clock-3.svg | 1 + .../assets/lucide/external-link.svg | 1 + .../assets/lucide/folder-open.svg | 1 + .../assets/lucide/panels-top-left.svg | 1 + apps/arstack_studio/assets/lucide/play.svg | 1 + .../assets/lucide/radio-tower.svg | 1 + apps/arstack_studio/assets/lucide/scale.svg | 1 + .../assets/lucide/scan-search.svg | 1 + .../assets/lucide/settings-2.svg | 1 + apps/arstack_studio/assets/lucide/square.svg | 1 + apps/arstack_studio/assets/lucide/upload.svg | 1 + apps/arstack_studio/assets/lucide/x.svg | 1 + apps/arstack_studio/qml/CalmButton.qml | 32 +- apps/arstack_studio/qml/ConfigurationHub.qml | 117 ++++ apps/arstack_studio/qml/DarkToolButton.qml | 44 ++ apps/arstack_studio/qml/DeviceExpertPanel.qml | 85 +++ apps/arstack_studio/qml/DockFrame.qml | 19 +- apps/arstack_studio/qml/Main.qml | 626 ++++++++++++------ apps/arstack_studio/qml/NumericField.qml | 15 +- apps/arstack_studio/qml/PtpExpertPanel.qml | 118 ++++ apps/arstack_studio/qml/SignalMatrix.qml | 16 +- apps/arstack_studio/qml/SignalPreview.qml | 83 ++- apps/arstack_studio/qml/TelemetryDock.qml | 41 +- .../qml/WaveformExpertPanel.qml | 74 +++ apps/arstack_studio/qml/WorkflowBar.qml | 429 ++++++------ apps/arstack_studio/run-windows.ps1 | 12 +- apps/arstack_studio/src/DeviceController.cpp | 293 +++++++- apps/arstack_studio/src/DeviceController.hpp | 69 +- .../esp32p4_smv_injector/main/ethernet_port.c | 15 + .../esp32p4_smv_injector/main/ethernet_port.h | 4 + .../main/live_control.cpp | 188 +++++- .../sampled_values/live_signal_state.hpp | 44 +- tests/test_live_signal.cpp | 58 +- 41 files changed, 2034 insertions(+), 508 deletions(-) create mode 100644 apps/arstack_studio/assets/LUCIDE_LICENSE.txt create mode 100644 apps/arstack_studio/assets/lucide/activity.svg create mode 100644 apps/arstack_studio/assets/lucide/chevron-down.svg create mode 100644 apps/arstack_studio/assets/lucide/chevron-up.svg create mode 100644 apps/arstack_studio/assets/lucide/circle-check.svg create mode 100644 apps/arstack_studio/assets/lucide/circle-off.svg create mode 100644 apps/arstack_studio/assets/lucide/clock-3.svg create mode 100644 apps/arstack_studio/assets/lucide/external-link.svg create mode 100644 apps/arstack_studio/assets/lucide/folder-open.svg create mode 100644 apps/arstack_studio/assets/lucide/panels-top-left.svg create mode 100644 apps/arstack_studio/assets/lucide/play.svg create mode 100644 apps/arstack_studio/assets/lucide/radio-tower.svg create mode 100644 apps/arstack_studio/assets/lucide/scale.svg create mode 100644 apps/arstack_studio/assets/lucide/scan-search.svg create mode 100644 apps/arstack_studio/assets/lucide/settings-2.svg create mode 100644 apps/arstack_studio/assets/lucide/square.svg create mode 100644 apps/arstack_studio/assets/lucide/upload.svg create mode 100644 apps/arstack_studio/assets/lucide/x.svg create mode 100644 apps/arstack_studio/qml/ConfigurationHub.qml create mode 100644 apps/arstack_studio/qml/DarkToolButton.qml create mode 100644 apps/arstack_studio/qml/DeviceExpertPanel.qml create mode 100644 apps/arstack_studio/qml/PtpExpertPanel.qml create mode 100644 apps/arstack_studio/qml/WaveformExpertPanel.qml diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 97120e11e..96119a3e5 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -33,12 +33,17 @@ qt_add_qml_module(arstack_studio qml/StudioTheme.qml qml/SurfacePanel.qml qml/DockFrame.qml + qml/DarkToolButton.qml qml/TelemetryDock.qml qml/WorkflowBar.qml qml/CalmButton.qml qml/NumericField.qml qml/StateBadge.qml qml/ProfileInspector.qml + qml/ConfigurationHub.qml + qml/PtpExpertPanel.qml + qml/DeviceExpertPanel.qml + qml/WaveformExpertPanel.qml qml/SignalMatrix.qml qml/QualityEditor.qml qml/SignalPreview.qml @@ -46,6 +51,24 @@ qt_add_qml_module(arstack_studio RESOURCES assets/InterVariable.ttf assets/INTER_LICENSE.txt + assets/LUCIDE_LICENSE.txt + assets/lucide/x.svg + assets/lucide/external-link.svg + assets/lucide/chevron-up.svg + assets/lucide/chevron-down.svg + assets/lucide/radio-tower.svg + assets/lucide/scale.svg + assets/lucide/circle-off.svg + assets/lucide/activity.svg + assets/lucide/circle-check.svg + assets/lucide/settings-2.svg + assets/lucide/upload.svg + assets/lucide/square.svg + assets/lucide/play.svg + assets/lucide/panels-top-left.svg + assets/lucide/folder-open.svg + assets/lucide/scan-search.svg + assets/lucide/clock-3.svg ) target_link_libraries(arstack_studio PRIVATE diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md index 7e8a98fcd..c682ae54d 100644 --- a/apps/arstack_studio/README.md +++ b/apps/arstack_studio/README.md @@ -32,11 +32,20 @@ The Qt application is a presentation and control plane. The PC is not the 4000/4 - 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; +- smart USB discovery with firmware-handshake verification and sequential + read-only fallback probing when Windows exposes only generic COM metadata; +- explicit `IDENTIFY` device identity backed by the ESP32-P4 factory MAC, so + the normal workflow never asks the operator to choose a COM port; +- live magnitude, phase, editable AC frequency, DC, enable and quality controls; +- mandatory live apply after verified connection; no mode switch can leave the + operator editing a stale local copy while output is running; +- an ARSVIN-compatible CT-saturation test shape with bounded DC offset, + harmonic, order, and clip controls in Expert mode; - START / STOP / ZERO / SHOW and runtime telemetry; - keyboard-first 4I + 4V matrix navigation; - 3-phase linkage; - persistent generated phasor and waveform preview; +- PTP Lab TX status, bounded runtime configuration, start/stop, and counters; - serial diagnostics surface. The current deployment gate intentionally accepts only the embedded layout already supported by the ESP32-P4 runtime. Broader SCL structures may parse correctly while remaining `unsupported-layout`; the GUI does not guess them into the firmware profile. @@ -45,6 +54,9 @@ The current deployment gate intentionally accepts only the embedded layout alrea The phasor and waveform are **generated setpoint previews**. They visualize the configured engineering state. They are not independent Ethernet on-wire evidence. +CT Saturation is a deterministic test-waveform approximation, not a calibrated +electromagnetic CT model. Its preview and parameters are labeled accordingly. + A later Process Bus monitor can add an observed trace and generated-vs-observed comparison without changing this distinction. `smpSynch` remains an embedded timing/synchronization truth. The GUI must not claim synchronized output until the runtime has measured synchronization evidence. @@ -79,32 +91,50 @@ Use `-NoLaunch` for a build-only verification. The launcher discovers the installed Visual Studio C++ toolchain with `vswhere`, reuses its bundled Ninja, and does not modify the user's global `PATH`. -## P2 fast-workflow command deck - -The fixed command deck carries ARSVIN's short operator loop into the native Qt -surface without copying its visual styling: +## P2 fast-workflow ribbon -1. Source shows whether an engineering file or development profile is active. -2. Device shows the serial connection state. -3. Output shows the authoritative runtime gate (`OFFLINE`, `ARMED`, `RUNNING`, - `PROFILE CHANGED`, or `PROFILE BLOCKED`). +The compact Home / View / Engineering ribbon carries ARSVIN's short operator +loop into the native Qt surface without copying its visual styling. Home keeps +Balanced, Zero, Check, Configure, Deploy, Start, and Stop one action away. View +owns dock visibility and detachment; Engineering owns SCL, smart discovery, +PTP refresh, and diagnostics. Device and output state stay in the header/footer +instead of being repeated as large workflow cards. -Open SCL, Balanced, Zero, Check, Deploy, Start, and Stop remain visible above -the three-column workspace. Keyboard operators can use `Ctrl+O`, `Ctrl+B`, -`Ctrl+0`, `Ctrl+K`, `Ctrl+D`, `F5`, and `F6`. Check reports readiness through -the existing non-modal status surface; it does not bypass the deployment gate. +Keyboard operators can use `Ctrl+O`, `Ctrl+B`, `Ctrl+0`, `Ctrl+K`, `Ctrl+D`, +`F5`, and `F6`. Check reports readiness through the existing non-modal status +surface; it does not bypass the deployment gate. ## P2 dock shell The main window now behaves as a compact engineering shell rather than a fixed -form. Signal Preview is a right-side dock that can be hidden or detached into a -real secondary Qt window. Status History and Output Monitor share a collapsible -bottom dock so runtime evidence stays visible without displacing the manual -injection grid. The Views menu restores closed panels and detaches Preview. - -This first dock phase intentionally keeps the safety-critical editor fixed in -the center. Arbitrary panel dragging and layout persistence remain follow-up -work; detaching a view must never move or duplicate the output controls. +form. Engineering source, profile, scaling, quick setup, and runtime details +live in a dedicated Smart / Expert Configuration window instead of consuming +permanent space beside the injector. Phasor View and Waveform View are separate +right-side docks; each can be hidden or detached into its own real Qt window. +Status History and Output Monitor share a bottom dock that starts collapsed. +The View ribbon restores closed panels, detaches either plot, and expands the +bottom monitor. + +The shell uses nested draggable splitters: editor vs preview docks, Phasor vs +Waveform, and workspace vs bottom monitor. Every pane keeps a protective +minimum size, while its split can be resized directly like an engineering MDI +shell. The complete monitor header is clickable for smooth expand/collapse. + +Smart mode is the default and guides device recognition, PTP inspection, and SV +profile selection. Expert mode exposes the detailed SV profile compiler plus +bounded PTP domain, transportSpecific, VLAN, interval, and peer-delay controls. +Deploy and Start remain disabled until the serial response proves the connected +port is the ARStack ESP32-P4 injector. + +PTP remains explicitly labeled as a laboratory timing companion. The firmware +serial surface now supports `PTP SHOW`, `PTP START`, `PTP STOP`, and bounded +`PTP CONFIG`; none of these claims external grandmaster lock or changes the +truthful `smpSynch` boundary. + +The safety-critical editor remains the stable center pane. Split sizes are +operator-adjustable, while arbitrary drag-reordering and layout persistence +remain follow-up work; detaching a view must never move or duplicate the output +controls. For redistribution, package the required dynamically linked Qt runtime libraries with the normal Qt deployment tooling and review the applicable Qt/LGPL distribution obligations for the chosen module set. @@ -123,12 +153,14 @@ A green desktop CI proves that the application builds and QML instantiates in th 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; +1. native serial `IDENTIFY`, SHOW, and PROFILE SHOW using updated firmware; 2. development 4000 fps START/STOP and live SET/FREQ/ENABLE/QUALITY updates; 3. SCL Class-A profile deployment followed by PROFILE committed/armed confirmation; 4. 4800 fps profile deployment and observed ~4800 fps with the expected smpCnt cycle; 5. live magnitude/phase changes without unintended counter restart; -6. generated-vs-on-wire identity comparison using a trusted capture path. +6. DC and CT-saturation SHAPE commands with observed on-wire sample values; +7. PTP Lab TX configuration/counters and explicit non-lock truth labeling; +8. generated-vs-on-wire identity comparison using a trusted capture path. ## UI direction @@ -136,6 +168,8 @@ The desktop visual target is a calm premium engineering instrument: - Inter Variable is bundled under the SIL Open Font License and used for all interface, numeric, status, and plot-label text; no monospace family is used; +- compact Lucide SVG icons are bundled with upstream license notices for the + ribbon and dock controls; - Current and Voltage are the primary working matrices; - SCL/profile information stays available without dominating the work surface; - phasor and waveform remain persistent; diff --git a/apps/arstack_studio/assets/LUCIDE_LICENSE.txt b/apps/arstack_studio/assets/LUCIDE_LICENSE.txt new file mode 100644 index 000000000..adcf16b39 --- /dev/null +++ b/apps/arstack_studio/assets/LUCIDE_LICENSE.txt @@ -0,0 +1,40 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +Some Lucide icons are derived from the Feather project and remain covered by +the MIT License. + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/arstack_studio/assets/lucide/activity.svg b/apps/arstack_studio/assets/lucide/activity.svg new file mode 100644 index 000000000..328370794 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/activity.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/chevron-down.svg b/apps/arstack_studio/assets/lucide/chevron-down.svg new file mode 100644 index 000000000..a97e994b3 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/chevron-up.svg b/apps/arstack_studio/assets/lucide/chevron-up.svg new file mode 100644 index 000000000..cc1ec44ff --- /dev/null +++ b/apps/arstack_studio/assets/lucide/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/circle-check.svg b/apps/arstack_studio/assets/lucide/circle-check.svg new file mode 100644 index 000000000..ae7167845 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/circle-check.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/circle-off.svg b/apps/arstack_studio/assets/lucide/circle-off.svg new file mode 100644 index 000000000..a254a8b04 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/circle-off.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/clock-3.svg b/apps/arstack_studio/assets/lucide/clock-3.svg new file mode 100644 index 000000000..202654f1d --- /dev/null +++ b/apps/arstack_studio/assets/lucide/clock-3.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/external-link.svg b/apps/arstack_studio/assets/lucide/external-link.svg new file mode 100644 index 000000000..a111301d0 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/external-link.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/folder-open.svg b/apps/arstack_studio/assets/lucide/folder-open.svg new file mode 100644 index 000000000..7e28117c0 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/folder-open.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/panels-top-left.svg b/apps/arstack_studio/assets/lucide/panels-top-left.svg new file mode 100644 index 000000000..f355b7b9e --- /dev/null +++ b/apps/arstack_studio/assets/lucide/panels-top-left.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/play.svg b/apps/arstack_studio/assets/lucide/play.svg new file mode 100644 index 000000000..1dcf0afaf --- /dev/null +++ b/apps/arstack_studio/assets/lucide/play.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/radio-tower.svg b/apps/arstack_studio/assets/lucide/radio-tower.svg new file mode 100644 index 000000000..b24729ce9 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/radio-tower.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/scale.svg b/apps/arstack_studio/assets/lucide/scale.svg new file mode 100644 index 000000000..0d5f346a3 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/scale.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/scan-search.svg b/apps/arstack_studio/assets/lucide/scan-search.svg new file mode 100644 index 000000000..eda5979e0 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/scan-search.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/settings-2.svg b/apps/arstack_studio/assets/lucide/settings-2.svg new file mode 100644 index 000000000..4680c14d1 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/settings-2.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/square.svg b/apps/arstack_studio/assets/lucide/square.svg new file mode 100644 index 000000000..c3de14945 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/square.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/upload.svg b/apps/arstack_studio/assets/lucide/upload.svg new file mode 100644 index 000000000..2457a0832 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/upload.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/lucide/x.svg b/apps/arstack_studio/assets/lucide/x.svg new file mode 100644 index 000000000..8917e5382 --- /dev/null +++ b/apps/arstack_studio/assets/lucide/x.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index 73f88b389..72d43dd19 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -7,6 +7,9 @@ Button { property var theme property string uiFont: "Inter" property string tone: "neutral" + property url iconSource + property real iconSize: 16 + property string toolTipText: "" implicitHeight: theme ? theme.controlHeight : 36 implicitWidth: Math.max(70, contentItem.implicitWidth + 24) @@ -31,14 +34,31 @@ Button { 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 + contentItem: Row { + spacing: control.iconSource.toString().length > 0 ? 7 : 0 + anchors.centerIn: parent + Image { + visible: control.iconSource.toString().length > 0 + width: control.iconSize + height: control.iconSize + anchors.verticalCenter: parent.verticalCenter + source: control.iconSource + sourceSize.width: control.iconSize * 2 + sourceSize.height: control.iconSize * 2 + opacity: control.enabled ? 0.9 : 0.32 + } + Text { + text: control.text + color: control.activeText + font: control.font + anchors.verticalCenter: parent.verticalCenter + } } + ToolTip.visible: hovered && toolTipText.length > 0 + ToolTip.text: toolTipText + ToolTip.delay: 500 + background: Rectangle { radius: control.theme ? control.theme.controlRadius : 7 color: control.activeFill diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml new file mode 100644 index 000000000..6c57629f1 --- /dev/null +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: hub + property var theme + property var controller + property var device + property var profiles + property string uiFont: "Inter" + property string monoFont: "Inter" + + function openEngineeringFile() { profileInspector.openEngineeringFile() } + + component ExpertTab: TabButton { + implicitHeight: 36 + font.family: hub.uiFont + font.pixelSize: 9 + font.weight: checked ? Font.DemiBold : Font.Medium + contentItem: Label { + text: parent.text + color: parent.checked ? hub.theme.text : hub.theme.muted + font: parent.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.checked ? hub.theme.raised : hub.theme.surface2 + border.width: 1 + border.color: parent.checked ? hub.theme.line : hub.theme.lineSoft + Rectangle { + visible: parent.parent.checked + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 28 + anchors.rightMargin: 28 + height: 2 + color: hub.theme.accent + } + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + ColumnLayout { + spacing: 0 + Label { text: "EXPERT CONFIGURATION"; color: hub.theme.muted; font.family: hub.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } + Label { text: "Protocol & timing setup"; color: hub.theme.text; font.family: hub.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + Label { + text: hub.device.deviceVerified + ? "ESP32-P4 · ID " + hub.device.deviceId.slice(-6) + : "Offline editing" + color: hub.device.deviceVerified ? hub.theme.green : hub.theme.muted + font.family: hub.uiFont + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + + TabBar { + id: expertTabs + Layout.fillWidth: true + implicitHeight: 36 + background: Rectangle { color: hub.theme.surface2; radius: 6 } + ExpertTab { text: "SV Profile" } + ExpertTab { text: "Waveform" } + ExpertTab { text: "PTP Lab" } + ExpertTab { text: "Device Protocol" } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: expertTabs.currentIndex + + ProfileInspector { + id: profileInspector + theme: hub.theme + controller: hub.controller + device: hub.device + profiles: hub.profiles + uiFont: hub.uiFont + monoFont: hub.monoFont + compact: false + } + WaveformExpertPanel { + theme: hub.theme + controller: hub.controller + uiFont: hub.uiFont + monoFont: hub.monoFont + } + PtpExpertPanel { + theme: hub.theme + device: hub.device + uiFont: hub.uiFont + monoFont: hub.monoFont + } + DeviceExpertPanel { + theme: hub.theme + device: hub.device + uiFont: hub.uiFont + monoFont: hub.monoFont + } + } + } +} diff --git a/apps/arstack_studio/qml/DarkToolButton.qml b/apps/arstack_studio/qml/DarkToolButton.qml new file mode 100644 index 000000000..f21242095 --- /dev/null +++ b/apps/arstack_studio/qml/DarkToolButton.qml @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +ToolButton { + id: control + property var theme + property string uiFont: "Inter" + property url iconSource + property real iconSize: 18 + + implicitWidth: 32 + implicitHeight: 30 + font.family: uiFont + font.pixelSize: theme.bodySize + + contentItem: Item { + Image { + visible: control.iconSource.toString().length > 0 + anchors.centerIn: parent + width: control.iconSize + height: control.iconSize + source: control.iconSource + sourceSize.width: control.iconSize * 2 + sourceSize.height: control.iconSize * 2 + opacity: control.enabled ? (control.hovered ? 1 : 0.82) : 0.32 + } + Label { + visible: control.iconSource.toString().length === 0 + anchors.fill: parent + text: control.text + color: control.enabled ? (control.hovered ? "#ffffff" : theme.textSoft) : theme.muted2 + font: control.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + background: Rectangle { + radius: 6 + color: control.down ? theme.accentSoft : (control.hovered ? theme.raisedHover : "transparent") + border.width: control.activeFocus ? 1 : 0 + border.color: theme.accent + } +} diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml new file mode 100644 index 000000000..b09b8446c --- /dev/null +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var device + property string uiFont: "Inter" + property string monoFont: "Inter" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Label { text: "Device protocol"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } + Label { + Layout.fillWidth: true + text: "The normal workflow discovers and verifies the injector automatically. Manual serial selection lives here only for commissioning and recovery." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + lineHeight: 1.35 + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 104 + radius: 8 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.device.deviceVerified ? "#2c674e" : panel.theme.lineSoft + GridLayout { + anchors.fill: parent + anchors.margins: 12 + columns: 2 + columnSpacing: 18 + rowSpacing: 7 + Label { text: "Product"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.deviceVerified ? panel.device.deviceProduct : "Not identified"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { text: "Hardware / ID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.deviceVerified ? "ESP32-P4 / " + panel.device.deviceId : "-"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: "GUI protocol"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.deviceVerified ? "v" + panel.device.protocolVersion : "-"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Label { text: "MANUAL RECOVERY"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } + + RowLayout { + Layout.fillWidth: true + ComboBox { + id: recoveryPort + Layout.preferredWidth: 170 + model: panel.device.ports + enabled: !panel.device.connected + font.family: panel.uiFont + font.pixelSize: 9 + onPressedChanged: if (pressed) panel.device.refreshPorts() + } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; onClicked: panel.device.refreshPorts() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: panel.device.connected ? "danger" : "accent" + text: panel.device.connected ? "Disconnect" : "Verify selected port" + onClicked: panel.device.connected ? panel.device.disconnectPort() : panel.device.connectPort(recoveryPort.currentText) + } + Item { Layout.fillWidth: true } + } + + Label { + Layout.fillWidth: true + text: panel.device.discoveryStatus + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + Item { Layout.fillHeight: true } + } +} diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml index f8c981307..236e791af 100644 --- a/apps/arstack_studio/qml/DockFrame.qml +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -48,6 +48,7 @@ Rectangle { font.pixelSize: theme.labelSize font.weight: Font.DemiBold elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter } Label { visible: frame.statusText.length > 0 @@ -55,21 +56,23 @@ Rectangle { color: theme.muted font.family: frame.monoFont font.pixelSize: theme.captionSize - 1 + verticalAlignment: Text.AlignVCenter } - ToolButton { + DarkToolButton { visible: frame.detachable - text: "↗" - font.family: frame.uiFont - font.pixelSize: theme.bodySize + iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + theme: frame.theme + uiFont: frame.uiFont onClicked: frame.detachRequested() ToolTip.visible: hovered ToolTip.text: "Detach into a separate window" } - ToolButton { + DarkToolButton { visible: frame.closable - text: "×" - font.family: frame.uiFont - font.pixelSize: theme.bodySize + iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") + iconSize: 19 + theme: frame.theme + uiFont: frame.uiFont onClicked: frame.closeRequested() ToolTip.visible: hovered ToolTip.text: "Close this view" diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 86c45e721..31d6d77d2 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -23,6 +23,12 @@ ApplicationWindow { property real currentScale: 1000.0 property real voltageScale: 100.0 property real signalFrequency: 50.0 + property real previousAcFrequency: 50.0 + property bool ctSaturationEnabled: false + property real ctDcOffsetPercent: 30.0 + property real ctHarmonicPercent: 28.0 + property int ctHarmonicOrder: 2 + property real ctClipPercent: 60.0 property string activeSignal: "Ia" property int activeGroup: 0 property int activeRow: 0 @@ -32,10 +38,12 @@ ApplicationWindow { property real activeQuality: 0 property string transientMessage: "" property bool transientError: false - property bool previewDockVisible: true - property bool previewDetached: false + property bool phasorDockVisible: true + property bool phasorDetached: false + property bool waveformDockVisible: true + property bool waveformDetached: false property bool telemetryDockVisible: true - property bool telemetryExpanded: true + property bool telemetryExpanded: false font.family: root.uiFont @@ -49,11 +57,12 @@ ApplicationWindow { sclProfiles.selectedProfile.compatibilityClass === "A" && sclProfiles.selectedProfile.deviceSupport === "ready" readonly property bool canDeploy: - device.connected && !device.running && selectedProfileDeployable && !device.profileDeploying + device.deviceVerified && !device.running && selectedProfileDeployable && !device.profileDeploying readonly property bool canStart: - device.connected && !device.running && + device.deviceVerified && !device.running && (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) readonly property string instrumentState: { + if (device.discovering || (device.connected && !device.deviceVerified)) return "VERIFYING" if (!device.connected) return "OFFLINE" if (device.profileDeploying) return "DEPLOYING" if (device.running) return "RUNNING" @@ -66,12 +75,13 @@ ApplicationWindow { readonly property color instrumentStateColor: { if (instrumentState === "RUNNING") return studioTheme.green if (instrumentState === "ARMED") return studioTheme.accent - if (instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return studioTheme.amber + if (instrumentState === "VERIFYING" || instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return studioTheme.amber if (instrumentState === "PROFILE BLOCKED") return studioTheme.red return device.connected ? studioTheme.textSoft : studioTheme.muted } readonly property string stateReason: { - if (!device.connected) return "Connect the injector; setpoints remain editable offline." + if (device.discovering || (device.connected && !device.deviceVerified)) return device.discoveryStatus + if (!device.connected) return device.discoveryStatus if (device.profileDeploying) return "Committing immutable SCL profile to the device." if (device.running) return "Live apply active · valid edits update SMV immediately." if (profileDirty) return "Selected profile changed · deploy before Start." @@ -95,12 +105,19 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } - function onConnectedChanged() { - if (device.connected) + function onDeviceVerifiedChanged() { + if (device.deviceVerified) Qt.callLater(root.applyAllSignals) } } + Timer { + interval: 650 + running: true + repeat: false + onTriggered: device.autoDetectAndConnect() + } + Timer { id: messageTimer interval: 2600 @@ -144,17 +161,34 @@ ApplicationWindow { messageTimer.restart() } - function detachPreview() { - previewDockVisible = false - previewDetached = true - detachedPreviewWindow.raise() - detachedPreviewWindow.requestActivate() + function detachPhasor() { + phasorDockVisible = false + phasorDetached = true + detachedPhasorWindow.raise() + detachedPhasorWindow.requestActivate() + } + + function detachWaveform() { + waveformDockVisible = false + waveformDetached = true + detachedWaveformWindow.raise() + detachedWaveformWindow.requestActivate() } function openEngineeringFile() { profilePanel.openEngineeringFile() } + function openConfiguration() { + configurationWindow.show() + configurationWindow.raise() + configurationWindow.requestActivate() + } + + function openDiagnostics() { + diagnosticsDialog.open() + } + function deploySelectedProfile() { if (canDeploy && device.deployProfile(sclProfiles.selectedProfile)) profileDirty = true @@ -169,10 +203,10 @@ ApplicationWindow { showMessage("Selected stream is valid SCL but outside the current ESP32-P4 deployment boundary.", true) return } - if (!device.connected) { + if (!device.deviceVerified) { showMessage(sclProfiles.hasProfiles ? - "Profile is valid. Connect the injector before deployment." : - "Development setpoints are ready. Connect the injector before Start.", false) + "Profile is valid. Waiting for injector recognition before deployment." : + "Development setpoints are ready. Waiting for injector recognition.", false) return } if (sclProfiles.hasProfiles && (profileDirty || !device.profileArmed)) { @@ -223,7 +257,9 @@ ApplicationWindow { } function validMagnitude(group, value) { - return isFinite(value) && value >= 0 && value <= maximumMagnitude(group) + return isFinite(value) && + (signalFrequency === 0 ? Math.abs(value) : value) <= maximumMagnitude(group) && + (signalFrequency === 0 || value >= 0) } function validPhase(value) { @@ -231,7 +267,7 @@ ApplicationWindow { } function validFrequency(value) { - return isFinite(value) && value > 0 && value <= 1000.0 + return isFinite(value) && value >= 0 && value <= 1000.0 } function normalizedAngle(value) { @@ -244,8 +280,12 @@ ApplicationWindow { function refreshPreview() { if (previewPanel) previewPanel.requestPaint() - if (detachedPreviewPanel) - detachedPreviewPanel.requestPaint() + if (waveformPanel) + waveformPanel.requestPaint() + if (detachedPhasorPanel) + detachedPhasorPanel.requestPaint() + if (detachedWaveformPanel) + detachedWaveformPanel.requestPaint() } function selectSignal(group, row) { @@ -286,7 +326,7 @@ ApplicationWindow { } function sendSignal(group, row) { - if (!device.connected) + if (!device.deviceVerified) return true var signal = groupModel(group).get(row) return device.setSignal( @@ -333,7 +373,7 @@ ApplicationWindow { } selectSignal(group, row) - if (device.connected) { + if (device.deviceVerified) { if (phaseLink && row < 3) sendLinkedGroup(group) else @@ -343,25 +383,59 @@ ApplicationWindow { } function applyAllSignals() { - if (!device.connected) + if (!device.deviceVerified) return + device.setFrequency(signalFrequency) for (var group = 0; group < 2; ++group) applyGroupSignals(group) - device.setFrequency(signalFrequency) + device.setCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) } function setFrequencyValue(value) { if (!validFrequency(value)) return false signalFrequency = value + if (value > 0) + previousAcFrequency = value frequencyField.text = value.toFixed(3) frequencyField.invalidInput = false refreshPreview() - if (device.connected) + if (device.deviceVerified) device.setFrequency(value) return true } + function setWaveformMode(mode) { + if (mode === "DC") { + if (ctSaturationEnabled) + setCtSaturation(false) + if (signalFrequency > 0) + previousAcFrequency = signalFrequency + setFrequencyValue(0) + showMessage("DC mode selected. Magnitude is an instantaneous signed value; phase is not used.", false) + } else { + setFrequencyValue(previousAcFrequency > 0 ? previousAcFrequency : 50) + showMessage("AC mode selected. Frequency and phase controls are active.", false) + } + } + + function setCtSaturation(enabled) { + if (enabled && signalFrequency === 0) { + showMessage("CT saturation shaping requires AC mode.", true) + return false + } + ctSaturationEnabled = enabled + refreshPreview() + if (device.deviceVerified) + device.setCtSaturation(enabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + showMessage(enabled + ? "CT saturation stress enabled · DC offset + 2nd harmonic + clipping approximation." + : "CT saturation stress disabled.", false) + return true + } + function balanced() { var angles = [0, -120, 120, 0] for (var i = 0; i < 4; ++i) { @@ -374,7 +448,7 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.connected) + if (device.deviceVerified) applyAllSignals() } @@ -385,7 +459,7 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.connected) + if (device.deviceVerified) device.zero() } @@ -393,7 +467,7 @@ ApplicationWindow { var unsignedValue = Number(value) >>> 0 groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue - if (device.connected) + if (device.deviceVerified) device.setQuality(activeSignal, unsignedValue) } @@ -442,7 +516,7 @@ ApplicationWindow { } header: Rectangle { - height: 54 + height: 50 color: studioTheme.chrome border.width: 1 border.color: studioTheme.lineSoft @@ -454,87 +528,86 @@ ApplicationWindow { spacing: 9 Rectangle { - width: 27 - height: 27 + width: 30 + height: 30 radius: 6 color: "#101b27" border.width: 1 border.color: "#315071" - Label { anchors.centerIn: parent; text: "≋"; color: studioTheme.accent; font.pixelSize: 16 } + Image { + anchors.centerIn: parent + width: 17 + height: 17 + source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + sourceSize.width: 34 + sourceSize.height: 34 + } } ColumnLayout { spacing: 0 - Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.Bold; font.letterSpacing: 1.0 } - Label { text: "SMV Injector"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Layout.alignment: Qt.AlignVCenter + Label { text: "ARSTACK61850"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.Bold; font.letterSpacing: 1.0; verticalAlignment: Text.AlignVCenter } + Label { text: "SMV Injector"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } } Item { Layout.fillWidth: true } Rectangle { - visible: device.connected - implicitWidth: liveApplyText.implicitWidth + 18 - implicitHeight: 22 - radius: 5 - color: studioTheme.greenSoft + Layout.preferredWidth: root.compactLayout ? 160 : 190 + implicitHeight: 32 + radius: 7 + color: studioTheme.surface2 border.width: 1 - border.color: "#2b674d" - Label { - id: liveApplyText - anchors.centerIn: parent - text: "LIVE APPLY" - color: studioTheme.green - font.family: root.monoFont - font.pixelSize: 7 - font.weight: Font.Bold - font.letterSpacing: 0.65 - } - } - - Label { - visible: !root.compactLayout - text: sclProfiles.hasProfiles ? (sclProfiles.selectedProfile.svId || "Resolved SV") : "Development profile" - color: studioTheme.textSoft - font.family: root.monoFont - font.pixelSize: 9 - } - - Rectangle { width: 1; height: 19; color: studioTheme.line } - - ComboBox { - id: portCombo - implicitWidth: root.compactLayout ? 96 : 112 - model: device.ports - enabled: !device.connected - font.family: root.monoFont - font.pixelSize: 9 - onPressedChanged: if (pressed) device.refreshPorts() - } + border.color: device.deviceVerified ? "#2c674e" : (device.discovering ? "#705827" : studioTheme.lineSoft) - Rectangle { width: 7; height: 7; radius: 4; color: device.connected ? studioTheme.green : studioTheme.muted2 } - - Label { - visible: !root.compactLayout - text: device.connected ? device.portName : "Device offline" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 9 - } - - CalmButton { - theme: studioTheme - uiFont: root.uiFont - text: device.connected ? "Disconnect" : "Connect" - onClicked: { - if (device.connected) device.disconnectPort() - else device.connectPort(portCombo.currentText) + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + Rectangle { + width: 8; height: 8; radius: 4 + color: device.deviceVerified ? studioTheme.green : (device.discovering ? studioTheme.amber : studioTheme.muted2) + } + Label { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: device.deviceVerified ? "Injector ready" : (device.discovering ? "Recognizing..." : "Injector offline") + color: device.deviceVerified ? studioTheme.green : studioTheme.textSoft + font.family: root.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + Label { + text: device.deviceVerified ? "•••" : "↻" + color: studioTheme.accent + font.family: root.uiFont + font.pixelSize: 13 + font.weight: Font.Bold + verticalAlignment: Text.AlignVCenter + } } + MouseArea { + id: identityMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !device.discovering + onClicked: device.deviceVerified ? root.openConfiguration() : device.autoDetectAndConnect() + } + ToolTip.visible: identityMouse.containsMouse + ToolTip.text: device.deviceVerified + ? "ARStack ESP32-P4 · ID " + device.deviceId.slice(-6) + " · Protocol v" + device.protocolVersion + : device.discoveryStatus + ToolTip.delay: 450 } } } footer: Rectangle { - height: 62 + height: 48 color: studioTheme.chrome border.width: 1 border.color: studioTheme.lineSoft @@ -547,23 +620,13 @@ ApplicationWindow { StateBadge { theme: studioTheme; state: root.instrumentState; stateColor: root.instrumentStateColor; monoFont: root.monoFont } - ColumnLayout { - Layout.maximumWidth: root.compactLayout ? 280 : 420 - spacing: 1 - Label { - Layout.fillWidth: true - text: root.stateReason - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 9 - elide: Text.ElideRight - } - Label { - text: "FPS " + device.fps + " · MISSED " + device.missed + " · TX FAIL " + device.txFailures - color: studioTheme.muted - font.family: root.monoFont - font.pixelSize: 7 - } + Label { + Layout.maximumWidth: root.compactLayout ? 320 : 520 + text: root.stateReason + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: studioTheme.captionSize + elide: Text.ElideRight } Item { Layout.fillWidth: true } @@ -586,19 +649,57 @@ ApplicationWindow { } Window { - id: detachedPreviewWindow - width: 520 - height: 720 + id: configurationWindow + width: 760 + height: 780 + minimumWidth: 640 + minimumHeight: 660 + visible: false + title: "ARStack Studio · Configuration" + color: studioTheme.bg + + onClosing: function(close) { + close.accepted = false + configurationWindow.hide() + } + + DockFrame { + anchors.fill: parent + anchors.margins: 10 + theme: studioTheme + titleText: "Smart & Expert Configuration" + statusText: device.deviceVerified ? "ESP32-P4 VERIFIED" : "OFFLINE EDIT" + uiFont: root.uiFont + monoFont: root.monoFont + closable: false + + ConfigurationHub { + id: profilePanel + anchors.fill: parent + theme: studioTheme + controller: root + device: device + profiles: sclProfiles + uiFont: root.uiFont + monoFont: root.monoFont + } + } + } + + Window { + id: detachedPhasorWindow + width: 560 + height: 580 minimumWidth: 400 - minimumHeight: 520 - visible: root.previewDetached - title: "ARStack Studio · Signal Preview" + minimumHeight: 400 + visible: root.phasorDetached + title: "ARStack Studio · Phasor View" color: studioTheme.bg onClosing: function(close) { close.accepted = false - root.previewDetached = false - root.previewDockVisible = true + root.phasorDetached = false + root.phasorDockVisible = true } Rectangle { @@ -607,7 +708,7 @@ ApplicationWindow { color: studioTheme.bg SignalPreview { - id: detachedPreviewPanel + id: detachedPhasorPanel anchors.fill: parent theme: studioTheme currentModel: currentModel @@ -615,11 +716,62 @@ ApplicationWindow { uiFont: root.uiFont monoFont: root.monoFont compact: false + viewMode: "phasor" activeSignal: root.activeSignal activeUnit: root.activeUnit activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } + } + } + + Window { + id: detachedWaveformWindow + width: 720 + height: 480 + minimumWidth: 480 + minimumHeight: 340 + visible: root.waveformDetached + title: "ARStack Studio · Waveform View" + color: studioTheme.bg + + onClosing: function(close) { + close.accepted = false + root.waveformDetached = false + root.waveformDockVisible = true + } + + Rectangle { + anchors.fill: parent + anchors.margins: 10 + color: studioTheme.bg + + SignalPreview { + id: detachedWaveformPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: false + viewMode: "waveform" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent } } } @@ -631,8 +783,8 @@ ApplicationWindow { WorkflowBar { Layout.fillWidth: true - Layout.preferredHeight: 82 - Layout.minimumHeight: 82 + Layout.preferredHeight: 94 + Layout.minimumHeight: 94 theme: studioTheme controller: root device: device @@ -642,29 +794,48 @@ ApplicationWindow { compact: root.compactLayout } - RowLayout { + SplitView { + id: shellSplit Layout.fillWidth: true Layout.fillHeight: true - spacing: root.compactLayout ? 9 : 11 + orientation: Qt.Vertical + handle: Rectangle { + implicitHeight: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: parent.width + height: 1 + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitVCursor } + } - ProfileInspector { - id: profilePanel - Layout.preferredWidth: root.compactLayout ? 200 : 238 - Layout.minimumWidth: root.compactLayout ? 192 : 214 - Layout.fillHeight: true - theme: studioTheme - controller: root - device: device - profiles: sclProfiles - uiFont: root.uiFont - monoFont: root.monoFont - compact: root.compactLayout - } + SplitView { + id: workspaceSplit + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 390 + orientation: Qt.Horizontal + handle: Rectangle { + implicitWidth: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: 1 + height: parent.height + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitHCursor } + } SurfacePanel { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumWidth: root.compactLayout ? 500 : 550 + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumWidth: root.compactLayout ? 500 : 550 + SplitView.preferredWidth: root.compactLayout ? 700 : 860 theme: studioTheme ColumnLayout { @@ -680,23 +851,6 @@ ApplicationWindow { Label { text: "Manual Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - Rectangle { - implicitWidth: workspaceApplyText.implicitWidth + 17 - implicitHeight: 22 - radius: 5 - color: device.connected ? studioTheme.greenSoft : "#141b23" - border.width: 1 - border.color: device.connected ? "#2c674e" : studioTheme.lineSoft - Label { - id: workspaceApplyText - anchors.centerIn: parent - text: device.connected ? "LIVE APPLY" : "LOCAL EDIT" - color: device.connected ? studioTheme.green : studioTheme.muted - font.family: root.monoFont - font.pixelSize: 7 - font.weight: Font.Bold - } - } } Rectangle { @@ -713,9 +867,14 @@ ApplicationWindow { anchors.rightMargin: 11 spacing: 8 + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") } + + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + ColumnLayout { spacing: 0 - Label { text: "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Label { text: root.signalFrequency === 0 ? "DC MODE" : "EDITABLE FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } RowLayout { spacing: 5 NumericField { @@ -723,16 +882,19 @@ ApplicationWindow { theme: studioTheme monoFont: root.monoFont compact: root.compactLayout - implicitWidth: 84 - text: "50.000" + implicitWidth: 84 + text: "50.000" + suffixText: "Hz" + enabled: root.signalFrequency > 0 validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } onTextEdited: { var value = root.parseOperatorNumber(text) if (root.validFrequency(value)) { invalidInput = false root.signalFrequency = value + if (value > 0) root.previousAcFrequency = value root.refreshPreview() - if (device.connected) device.setFrequency(value) + if (device.deviceVerified) device.setFrequency(value) } else { invalidInput = true } @@ -742,22 +904,22 @@ ApplicationWindow { 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) + root.showMessage("AC frequency must be greater than 0 and not exceed 1000 Hz.", true) } else { text = value.toFixed(3) } } } - Label { text: "Hz"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: 8 } } } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } CheckBox { + enabled: root.signalFrequency > 0 checked: root.phaseLink text: "3-phase link" onToggled: root.phaseLink = checked @@ -766,14 +928,6 @@ ApplicationWindow { } Item { Layout.fillWidth: true } - - Label { - visible: !root.compactLayout - text: device.connected ? "Valid edit → SMV immediately" : "Connect to stream edits live" - color: device.connected ? studioTheme.green : studioTheme.muted - font.family: root.uiFont - font.pixelSize: 8 - } } } @@ -795,7 +949,7 @@ ApplicationWindow { groupIndex: 0 titleText: "Current" symbolText: "I" - unitText: "A RMS" + unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" compact: root.compactLayout } @@ -812,7 +966,7 @@ ApplicationWindow { groupIndex: 1 titleText: "Voltage" symbolText: "U" - unitText: "V RMS" + unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" compact: root.compactLayout } } @@ -825,7 +979,9 @@ ApplicationWindow { Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } Label { - text: root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" + text: root.signalFrequency === 0 + ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" + : root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" color: studioTheme.muted font.family: root.uiFont font.pixelSize: 9 @@ -852,43 +1008,114 @@ ApplicationWindow { } } - DockFrame { - visible: root.previewDockVisible && !root.previewDetached - Layout.preferredWidth: root.compactLayout ? 290 : 360 - Layout.minimumWidth: root.compactLayout ? 280 : 320 - Layout.fillHeight: true - theme: studioTheme - titleText: "Signal preview" - statusText: "GENERATED" - uiFont: root.uiFont - monoFont: root.monoFont - detachable: true - onDetachRequested: root.detachPreview() - onCloseRequested: root.previewDockVisible = false + SplitView { + visible: (root.phasorDockVisible && !root.phasorDetached) || + (root.waveformDockVisible && !root.waveformDetached) + SplitView.preferredWidth: root.compactLayout ? 300 : 380 + SplitView.minimumWidth: root.compactLayout ? 270 : 300 + SplitView.maximumWidth: 680 + SplitView.fillHeight: true + orientation: Qt.Vertical + handle: Rectangle { + implicitHeight: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: parent.width + height: 1 + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitVCursor } + } - SignalPreview { - id: previewPanel - anchors.fill: parent + DockFrame { + visible: root.phasorDockVisible && !root.phasorDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 theme: studioTheme - currentModel: currentModel - voltageModel: voltageModel + titleText: "Phasor View" + statusText: "GENERATED" uiFont: root.uiFont monoFont: root.monoFont - compact: root.compactLayout - showHeader: false - activeSignal: root.activeSignal - activeUnit: root.activeUnit - activeMagnitude: root.activeMagnitude - activePhase: root.activePhase - signalFrequency: root.signalFrequency + detachable: true + onDetachRequested: root.detachPhasor() + onCloseRequested: root.phasorDockVisible = false + + SignalPreview { + id: previewPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "phasor" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } + } + + DockFrame { + visible: root.waveformDockVisible && !root.waveformDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 + theme: studioTheme + titleText: "Waveform View" + statusText: root.signalFrequency.toFixed(3) + " Hz" + uiFont: root.uiFont + monoFont: root.monoFont + detachable: true + onDetachRequested: root.detachWaveform() + onCloseRequested: root.waveformDockVisible = false + + SignalPreview { + id: waveformPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "waveform" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } } } } TelemetryDock { visible: root.telemetryDockVisible - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight + SplitView.fillWidth: true + SplitView.minimumHeight: root.telemetryExpanded ? 88 : 32 + SplitView.maximumHeight: root.telemetryExpanded ? 320 : 32 + SplitView.preferredHeight: root.telemetryExpanded ? 124 : 32 + Behavior on SplitView.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } theme: studioTheme device: device currentModel: currentModel @@ -900,6 +1127,7 @@ ApplicationWindow { onExpandedChanged: root.telemetryExpanded = expanded onCloseRequested: root.telemetryDockVisible = false } + } } StatusToast { diff --git a/apps/arstack_studio/qml/NumericField.qml b/apps/arstack_studio/qml/NumericField.qml index 6e48d2949..1a7bd44ec 100644 --- a/apps/arstack_studio/qml/NumericField.qml +++ b/apps/arstack_studio/qml/NumericField.qml @@ -8,9 +8,11 @@ TextField { property string monoFont: "Inter" property bool invalidInput: false property bool compact: false + property string suffixText: "" selectByMouse: true horizontalAlignment: Text.AlignRight + verticalAlignment: TextInput.AlignVCenter color: invalidInput ? theme.red : theme.text selectionColor: theme.accent selectedTextColor: theme.bg @@ -18,9 +20,20 @@ TextField { font.pixelSize: compact ? 10 : 12 font.weight: Font.DemiBold leftPadding: 8 - rightPadding: 8 + rightPadding: suffixText.length > 0 ? 28 : 8 implicitHeight: compact ? 31 : 34 + Label { + visible: field.suffixText.length > 0 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: field.suffixText + color: field.theme.muted + font.family: field.monoFont + font.pixelSize: field.compact ? 8 : 9 + } + background: Rectangle { radius: 6 color: field.activeFocus ? "#0c141d" : "#111820" diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml new file mode 100644 index 000000000..9c6da8b07 --- /dev/null +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var device + property string uiFont: "Inter" + property string monoFont: "Inter" + + function applyProfile() { + device.configurePtp({ + "domain": Number(domainField.text), + "transportSpecific": Number(transportField.text), + "vlanEnabled": vlanCheck.checked, + "vlanId": Number(vlanField.text), + "vlanPriority": Number(pcpField.text), + "announceIntervalMs": Number(announceField.text), + "syncIntervalMs": Number(syncField.text), + "respondToPeerDelay": pdelayCheck.checked + }) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "PTP LAB TIMING"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + Label { text: "Expert profile"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + StateBadge { + theme: panel.theme + monoFont: panel.uiFont + state: panel.device.ptpStatus.toUpperCase() + stateColor: panel.device.ptpRunning ? panel.theme.amber : (panel.device.ptpAvailable ? panel.theme.green : panel.theme.muted) + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 58 + radius: 7 + color: panel.theme.amberSoft + border.width: 1 + border.color: "#6a5529" + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + Label { text: "Laboratory timing companion"; color: panel.theme.amber; font.family: panel.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { Layout.fillWidth: true; text: "This transmitter is not a GPS-backed grandmaster and does not prove that SV is synchronized."; wrapMode: Text.WordWrap; color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9 } + } + } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: 10 + rowSpacing: 9 + + Label { text: "Domain"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: domainField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpDomain === "-" ? "0" : panel.device.ptpDomain; validator: IntValidator { bottom: 0; top: 255 } } + Label { text: "transportSpecific"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: transportField; theme: panel.theme; monoFont: panel.uiFont; text: "0"; validator: IntValidator { bottom: 0; top: 15 } } + + Label { text: "Announce"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } + Label { text: "Sync"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "250"; validator: IntValidator { bottom: 20; top: 5000 } } + + CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } + Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } + } + + CheckBox { + id: pdelayCheck + text: "Respond to peer-delay requests using ESP32-P4 hardware timestamps" + checked: true + font.family: panel.uiFont + font.pixelSize: 9 + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + + GridLayout { + Layout.fillWidth: true + columns: 3 + rowSpacing: 8 + columnSpacing: 14 + Label { text: "Announce TX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "Sync TX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "TX failures"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.device.ptpAnnounceSent; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Label { text: panel.device.ptpSyncSent; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + Label { text: panel.device.ptpTxFailures; color: Number(panel.device.ptpTxFailures) > 0 ? panel.theme.red : panel.theme.text; font.family: panel.uiFont; font.pixelSize: 13; font.weight: Font.DemiBold } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; enabled: panel.device.deviceVerified; onClicked: panel.device.sendPtpShow() } + Item { Layout.fillWidth: true } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Apply profile"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.applyProfile() } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "danger"; text: "Stop PTP"; enabled: panel.device.ptpRunning; onClicked: panel.device.stopPtp() } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "accent"; text: "Start PTP"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.device.startPtp() } + } + } +} diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index f0cb4e817..092233d36 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -62,6 +62,7 @@ Rectangle { font.family: matrix.uiFont font.pixelSize: 11 font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter } Item { Layout.fillWidth: true } Label { @@ -69,6 +70,7 @@ Rectangle { color: matrix.theme.muted font.family: matrix.uiFont font.pixelSize: 9 + verticalAlignment: Text.AlignVCenter } } @@ -78,10 +80,10 @@ Rectangle { Layout.leftMargin: 9 Layout.rightMargin: 9 spacing: 7 - Label { text: "ON"; Layout.preferredWidth: 28; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } - Label { text: "CH"; Layout.preferredWidth: 34; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } - Label { text: "MAGNITUDE"; Layout.fillWidth: true; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } - Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold } + Label { text: "ON"; Layout.preferredWidth: 28; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "CH"; Layout.preferredWidth: 34; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "MAGNITUDE"; Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } } Repeater { @@ -125,6 +127,7 @@ Rectangle { CheckBox { Layout.preferredWidth: 28 + Layout.alignment: Qt.AlignVCenter checked: signalRow.channelEnabled onToggled: { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) @@ -137,6 +140,7 @@ Rectangle { RowLayout { Layout.preferredWidth: 34 + Layout.alignment: Qt.AlignVCenter spacing: 5 Rectangle { width: 6; height: 6; radius: 3; color: signalRow.phaseColor } Label { @@ -145,6 +149,7 @@ Rectangle { font.family: matrix.monoFont font.pixelSize: 9 font.weight: Font.Bold + verticalAlignment: Text.AlignVCenter } } @@ -154,6 +159,7 @@ Rectangle { theme: matrix.theme monoFont: matrix.monoFont compact: matrix.compact + suffixText: matrix.groupIndex === 0 ? "A" : "V" text: signalRow.mag.toFixed(3) invalidInput: false onActiveFocusChanged: { @@ -195,10 +201,12 @@ Rectangle { NumericField { id: phaseField + enabled: matrix.controller.signalFrequency > 0 Layout.preferredWidth: matrix.compact ? 82 : 98 theme: matrix.theme monoFont: matrix.monoFont compact: matrix.compact + suffixText: "°" text: signalRow.angle.toFixed(2) invalidInput: false onActiveFocusChanged: { diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml index dfdca9d2f..f92fccf57 100644 --- a/apps/arstack_studio/qml/SignalPreview.qml +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -15,7 +15,13 @@ SurfacePanel { property real activeMagnitude: 1 property real activePhase: 0 property real signalFrequency: 50 + property bool ctSaturationEnabled: false + property real ctDcOffsetPercent: 30 + property real ctHarmonicPercent: 28 + property int ctHarmonicOrder: 2 + property real ctClipPercent: 60 property bool showHeader: true + property string viewMode: "both" // both, phasor, waveform function requestPaint() { phasor.requestPaint() @@ -40,12 +46,15 @@ SurfacePanel { } RowLayout { + visible: preview.viewMode !== "waveform" Layout.fillWidth: true Label { text: "Phasor"; color: preview.theme.textSoft; font.family: preview.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } Item { Layout.fillWidth: true } Label { Layout.maximumWidth: parent.width * 0.72 - text: preview.activeSignal + " · " + preview.activeMagnitude.toFixed(3) + " " + preview.activeUnit + " ∠ " + preview.activePhase.toFixed(2) + "°" + text: preview.signalFrequency === 0 + ? preview.activeSignal + " · " + preview.activeMagnitude.toFixed(3) + " " + preview.activeUnit + " DC" + : preview.activeSignal + " · " + preview.activeMagnitude.toFixed(3) + " " + preview.activeUnit + " ∠ " + preview.activePhase.toFixed(2) + "°" color: preview.theme.muted font.family: preview.monoFont font.pixelSize: 8 @@ -55,6 +64,7 @@ SurfacePanel { Canvas { id: phasor + visible: preview.viewMode !== "waveform" Layout.fillWidth: true Layout.fillHeight: true Layout.preferredHeight: 1 @@ -84,13 +94,33 @@ SurfacePanel { ctx.stroke() } - function drawGroup(model, scale, dashed) { + ctx.fillStyle = "#8794a3" + ctx.font = "500 10px '" + preview.uiFont + "'" + ctx.textBaseline = "middle" + ctx.textAlign = "left" + ctx.fillText("0°", cx + r + 7, cy) + ctx.textAlign = "center" + ctx.fillText("90°", cx, cy - r - 9) + ctx.textAlign = "right" + ctx.fillText("180°", cx - r - 7, cy) + ctx.textAlign = "center" + ctx.fillText("270°", cx, cy + r + 10) + + if (preview.signalFrequency === 0) { + ctx.fillStyle = "#778493" + ctx.font = "500 11px '" + preview.uiFont + "'" + ctx.textAlign = "center" + ctx.fillText("DC · phasor not applicable", cx, cy + 4) + return + } + + function drawGroup(model, scale, isCurrent) { var maxMagnitude = 0.0001 for (var i = 0; i < 4; ++i) - maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + maxMagnitude = Math.max(maxMagnitude, Math.abs(model.get(i).magnitude)) for (var j = 0; j < 4; ++j) { var signal = model.get(j) - if (!signal.enabled || signal.magnitude <= 0) + 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)) @@ -98,17 +128,15 @@ SurfacePanel { var ey = cy - Math.sin(radians) * length var active = signal.signalId === preview.activeSignal ctx.save() - ctx.globalAlpha = active ? 1 : 0.68 + ctx.globalAlpha = isCurrent ? (active ? 1 : 0.88) : (active ? 0.82 : 0.52) ctx.strokeStyle = signal.traceColor ctx.fillStyle = signal.traceColor - ctx.lineWidth = active ? 2.8 : 1.7 + ctx.lineWidth = isCurrent ? (active ? 3.0 : 2.2) : (active ? 2.2 : 1.45) 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) @@ -130,14 +158,16 @@ SurfacePanel { } RowLayout { + visible: preview.viewMode !== "phasor" Layout.fillWidth: true Label { text: "Waveform"; color: preview.theme.textSoft; font.family: preview.uiFont; font.pixelSize: 10; font.weight: Font.DemiBold } Item { Layout.fillWidth: true } - Label { text: preview.signalFrequency.toFixed(3) + " Hz · 40 ms"; color: preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8 } + Label { text: preview.signalFrequency === 0 ? "DC · constant" : preview.signalFrequency.toFixed(3) + " Hz · 40 ms" + (preview.ctSaturationEnabled ? " · CT SAT" : ""); color: preview.ctSaturationEnabled ? preview.theme.amber : preview.theme.muted; font.family: preview.monoFont; font.pixelSize: 8 } } Canvas { id: waveform + visible: preview.viewMode !== "phasor" Layout.fillWidth: true Layout.fillHeight: true Layout.preferredHeight: 0.84 @@ -149,7 +179,7 @@ SurfacePanel { var left = 24 var right = 8 var top = 10 - var bottom = 12 + var bottom = 24 var plotWidth = width - left - right var plotHeight = height - top - bottom var gap = 14 @@ -166,6 +196,14 @@ SurfacePanel { ctx.lineTo(xg, top + plotHeight) ctx.stroke() } + ctx.fillStyle = "#687585" + ctx.font = "500 8px '" + preview.uiFont + "'" + ctx.textAlign = "center" + ctx.textBaseline = "top" + for (var tick = 0; tick <= 4; ++tick) { + var tickX = left + plotWidth * tick / 4 + ctx.fillText((tick * 10) + " ms", tickX, top + plotHeight + 6) + } ctx.strokeStyle = "#33404d" for (var b = 0; b < 2; ++b) { var baseline = b === 0 ? voltageY : currentY @@ -175,28 +213,37 @@ SurfacePanel { ctx.stroke() } - function drawWaveGroup(model, center, dashed) { + function drawWaveGroup(model, center, isCurrent) { var maxMagnitude = 0.0001 for (var i = 0; i < 4; ++i) - maxMagnitude = Math.max(maxMagnitude, model.get(i).magnitude) + maxMagnitude = Math.max(maxMagnitude, Math.abs(model.get(i).magnitude)) for (var j = 0; j < 4; ++j) { var signal = model.get(j) - if (!signal.enabled || signal.magnitude <= 0) + 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 amplitude = laneHeight * 0.39 * Math.min(1, Math.abs(signal.magnitude) / maxMagnitude) var phaseRadians = signal.phase * Math.PI / 180 ctx.save() ctx.strokeStyle = signal.traceColor - ctx.globalAlpha = active ? 1 : 0.62 - ctx.lineWidth = active ? 2.3 : 1.5 + ctx.globalAlpha = isCurrent ? (active ? 1 : 0.88) : (active ? 0.82 : 0.52) + ctx.lineWidth = active ? 2.6 : 2.0 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 sample = preview.signalFrequency === 0 + ? (signal.magnitude < 0 ? -1.0 : 1.0) + : Math.sin(2 * Math.PI * preview.signalFrequency * time + phaseRadians) + if (isCurrent && preview.ctSaturationEnabled && preview.signalFrequency > 0) { + var electricalAngle = 2 * Math.PI * preview.signalFrequency * time + phaseRadians + sample += (preview.ctHarmonicPercent / 100.0) * + Math.sin(electricalAngle * preview.ctHarmonicOrder) + sample += preview.ctDcOffsetPercent / 100.0 + var clip = Math.abs(preview.ctClipPercent / 100.0) + sample = Math.max(-clip, Math.min(clip, sample)) + } var x = left + plotWidth * ratio var y = center - sample * amplitude if (point === 0) ctx.moveTo(x, y) diff --git a/apps/arstack_studio/qml/TelemetryDock.qml b/apps/arstack_studio/qml/TelemetryDock.qml index 123cf0bc5..a48b2ce15 100644 --- a/apps/arstack_studio/qml/TelemetryDock.qml +++ b/apps/arstack_studio/qml/TelemetryDock.qml @@ -17,22 +17,33 @@ Rectangle { signal closeRequested() - implicitHeight: expanded ? 108 : 32 + implicitHeight: 32 + Layout.preferredHeight: expanded ? 124 : 32 color: theme.surface radius: theme.panelRadius border.width: 1 border.color: theme.line clip: true - Behavior on implicitHeight { NumberAnimation { duration: 130; easing.type: Easing.OutCubic } } + Behavior on Layout.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } ColumnLayout { anchors.fill: parent spacing: 0 Rectangle { + id: headerBar Layout.fillWidth: true Layout.preferredHeight: 32 - color: theme.raised + color: headerMouse.containsMouse ? theme.raisedHover : theme.raised + Behavior on color { ColorAnimation { duration: 90 } } + + MouseArea { + id: headerMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: telemetry.expanded = !telemetry.expanded + } RowLayout { anchors.fill: parent @@ -47,6 +58,7 @@ Rectangle { font.family: telemetry.uiFont font.pixelSize: theme.labelSize font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter } Label { text: telemetry.device.running ? "LIVE" : "STANDBY" @@ -54,6 +66,7 @@ Rectangle { font.family: telemetry.monoFont font.pixelSize: theme.captionSize - 1 font.weight: Font.Bold + verticalAlignment: Text.AlignVCenter } Item { Layout.fillWidth: true } Label { @@ -61,17 +74,23 @@ Rectangle { color: theme.muted font.family: telemetry.monoFont font.pixelSize: theme.captionSize - 1 + verticalAlignment: Text.AlignVCenter } - ToolButton { - text: telemetry.expanded ? "⌄" : "⌃" - font.pixelSize: theme.bodySize + DarkToolButton { + theme: telemetry.theme + uiFont: telemetry.uiFont + iconSource: telemetry.expanded + ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") + : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") onClicked: telemetry.expanded = !telemetry.expanded ToolTip.visible: hovered ToolTip.text: telemetry.expanded ? "Collapse monitor" : "Expand monitor" } - ToolButton { - text: "×" - font.pixelSize: theme.bodySize + DarkToolButton { + theme: telemetry.theme + uiFont: telemetry.uiFont + iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") + iconSize: 19 onClicked: telemetry.closeRequested() ToolTip.visible: hovered ToolTip.text: "Close monitor" @@ -80,7 +99,9 @@ Rectangle { } RowLayout { - visible: telemetry.expanded + visible: opacity > 0 + opacity: telemetry.expanded ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: telemetry.expanded ? 180 : 110; easing.type: Easing.OutCubic } } Layout.fillWidth: true Layout.fillHeight: true Layout.margins: 9 diff --git a/apps/arstack_studio/qml/WaveformExpertPanel.qml b/apps/arstack_studio/qml/WaveformExpertPanel.qml new file mode 100644 index 000000000..fdc888d96 --- /dev/null +++ b/apps/arstack_studio/qml/WaveformExpertPanel.qml @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var controller + property string uiFont: "Inter" + property string monoFont: "Inter" + + function applyShape() { + controller.ctDcOffsetPercent = Number(dcField.text) + controller.ctHarmonicPercent = Number(harmonicField.text) + controller.ctHarmonicOrder = Number(orderField.text) + controller.ctClipPercent = Number(clipField.text) + controller.setCtSaturation(enabledCheck.checked) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Label { text: "Waveform stress"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } + Label { Layout.fillWidth: true; text: "CT saturation uses the ARSVIN-compatible publisher approximation: DC offset, harmonic content, and current clipping. It is a protection-test stress waveform, not a calibrated electromagnetic CT model."; wrapMode: Text.WordWrap; color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 10; lineHeight: 1.35 } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 62 + radius: 8 + color: panel.controller.ctSaturationEnabled ? panel.theme.amberSoft : panel.theme.surface2 + border.width: 1 + border.color: panel.controller.ctSaturationEnabled ? "#705827" : panel.theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.margins: 12 + CheckBox { id: enabledCheck; checked: panel.controller.ctSaturationEnabled; text: "Enable CT saturation stress"; font.family: panel.uiFont; font.pixelSize: 10 } + Item { Layout.fillWidth: true } + Label { text: panel.controller.ctSaturationEnabled ? "ACTIVE" : "CLEAN SINE"; color: panel.controller.ctSaturationEnabled ? panel.theme.amber : panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.Bold } + } + } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: 12 + rowSpacing: 10 + Label { text: "Current DC offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: dcField; theme: panel.theme; monoFont: panel.uiFont; text: panel.controller.ctDcOffsetPercent.toFixed(1); validator: DoubleValidator { bottom: -300; top: 300; decimals: 1 } } + Label { text: "% of peak"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Item { Layout.fillWidth: true } + + Label { text: "Harmonic"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: harmonicField; theme: panel.theme; monoFont: panel.uiFont; text: panel.controller.ctHarmonicPercent.toFixed(1); validator: DoubleValidator { bottom: 0; top: 300; decimals: 1 } } + Label { text: "%"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + NumericField { id: orderField; theme: panel.theme; monoFont: panel.uiFont; text: String(panel.controller.ctHarmonicOrder); validator: IntValidator { bottom: 2; top: 63 } } + + Label { text: "Current clip"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: clipField; theme: panel.theme; monoFont: panel.uiFont; text: panel.controller.ctClipPercent.toFixed(1); validator: DoubleValidator { bottom: 1; top: 1000; decimals: 1 } } + Label { text: "% of peak"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Item { Layout.fillWidth: true } + } + + RowLayout { + Layout.fillWidth: true + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "ARSVIN CT preset"; onClicked: { dcField.text = "30.0"; harmonicField.text = "28.0"; orderField.text = "2"; clipField.text = "60.0"; enabledCheck.checked = true; panel.applyShape() } } + Item { Layout.fillWidth: true } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "accent"; text: "Apply waveform"; enabled: panel.controller.signalFrequency > 0; onClicked: panel.applyShape() } + } + + Item { Layout.fillHeight: true } + } +} diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index abfeca8d9..f481bc370 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -4,7 +4,7 @@ import QtQuick.Controls import QtQuick.Layouts SurfacePanel { - id: workflow + id: ribbon property var controller property var device @@ -12,242 +12,237 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false + property int activeTab: 0 - function withAlpha(value, alpha) { - return Qt.rgba(value.r, value.g, value.b, alpha) - } - - Menu { - id: viewMenu - MenuItem { - text: "Signal preview" - checkable: true - checked: workflow.controller.previewDockVisible || workflow.controller.previewDetached - onTriggered: { - workflow.controller.previewDetached = false - workflow.controller.previewDockVisible = checked - } - } - MenuItem { - text: "Status & output monitor" - checkable: true - checked: workflow.controller.telemetryDockVisible - onTriggered: workflow.controller.telemetryDockVisible = checked - } - MenuSeparator { } - MenuItem { - text: "Detach signal preview" - enabled: workflow.controller.previewDockVisible && !workflow.controller.previewDetached - onTriggered: workflow.controller.detachPreview() - } - } - - implicitHeight: 82 + implicitHeight: 94 color: theme.raised border.color: theme.line - component StepMarker: Rectangle { - required property string numberText - required property string titleText - required property string statusText - required property color statusColor - - implicitWidth: workflow.compact ? 94 : 112 - implicitHeight: 48 - radius: workflow.theme.controlRadius - color: "#101820" - border.width: 1 - border.color: statusColor === workflow.theme.muted ? workflow.theme.lineSoft : workflow.withAlpha(statusColor, 0.55) - - RowLayout { - anchors.fill: parent - anchors.margins: 8 - spacing: 7 - + component RibbonTab: TabButton { + implicitWidth: 104 + implicitHeight: 32 + font.family: ribbon.uiFont + font.pixelSize: 10 + font.weight: checked ? Font.DemiBold : Font.Medium + contentItem: Label { + text: parent.text + color: parent.checked ? ribbon.theme.text : ribbon.theme.muted + font: parent.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.checked ? ribbon.theme.surface2 : "transparent" + radius: 6 Rectangle { - width: 23 - height: 23 - radius: 6 - color: workflow.withAlpha(statusColor, 0.16) - border.width: 1 - border.color: workflow.withAlpha(statusColor, 0.48) - Label { - anchors.centerIn: parent - text: numberText - color: statusColor - font.family: workflow.monoFont - font.pixelSize: workflow.theme.captionSize - font.weight: Font.Bold - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - Label { - Layout.fillWidth: true - text: titleText - color: workflow.theme.textSoft - font.family: workflow.uiFont - font.pixelSize: workflow.theme.labelSize - font.weight: Font.DemiBold - elide: Text.ElideRight - } - Label { - Layout.fillWidth: true - text: statusText - color: statusColor - font.family: workflow.monoFont - font.pixelSize: workflow.theme.captionSize - 1 - elide: Text.ElideRight - } + visible: parent.parent.checked + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 14 + anchors.rightMargin: 14 + height: 2 + radius: 1 + color: ribbon.theme.accent } } } - RowLayout { + component RibbonAction: CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + implicitHeight: 42 + iconSize: 16 + } + + component RibbonDivider: Rectangle { + width: 1 + height: 32 + color: ribbon.theme.lineSoft + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { anchors.fill: parent - anchors.leftMargin: workflow.compact ? 10 : 13 - anchors.rightMargin: workflow.compact ? 10 : 13 - spacing: workflow.compact ? 7 : 9 - - ColumnLayout { - visible: !workflow.compact - Layout.preferredWidth: 92 - spacing: 1 - Label { - text: "FAST WORKFLOW" - color: workflow.theme.muted - font.family: workflow.uiFont - font.pixelSize: workflow.theme.captionSize - 1 - font.weight: Font.DemiBold - font.letterSpacing: 0.9 - } - Label { - text: "Manual" - color: workflow.theme.text - font.family: workflow.uiFont - font.pixelSize: workflow.theme.subtitleSize - font.weight: Font.DemiBold - } - Label { - text: "live setpoints" - color: workflow.theme.muted - font.family: workflow.uiFont - font.pixelSize: workflow.theme.captionSize - } - } + spacing: 0 - StepMarker { - numberText: "1" - titleText: "Source" - statusText: workflow.profiles.hasProfiles ? "READY" : "DEV PROFILE" - statusColor: workflow.profiles.hasProfiles ? workflow.theme.green : workflow.theme.muted + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 33 + Layout.leftMargin: 9 + Layout.rightMargin: 9 + spacing: 2 + + RibbonTab { text: "Home"; checked: ribbon.activeTab === 0; onClicked: ribbon.activeTab = 0 } + RibbonTab { text: "View"; checked: ribbon.activeTab === 1; onClicked: ribbon.activeTab = 1 } + RibbonTab { text: "Engineering"; checked: ribbon.activeTab === 2; onClicked: ribbon.activeTab = 2 } + Item { Layout.fillWidth: true } } - StepMarker { - numberText: "2" - titleText: "Device" - statusText: workflow.device.connected ? workflow.device.portName : "OFFLINE" - statusColor: workflow.device.connected ? workflow.theme.green : workflow.theme.muted - } + Rectangle { Layout.fillWidth: true; height: 1; color: ribbon.theme.lineSoft } - StepMarker { - numberText: "3" - titleText: "Output" - statusText: workflow.controller.instrumentState - statusColor: workflow.controller.instrumentStateColor - } + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: ribbon.activeTab - Rectangle { width: 1; height: 40; color: workflow.theme.lineSoft } + RowLayout { + Layout.leftMargin: 11 + Layout.rightMargin: 11 + spacing: 7 - RowLayout { - spacing: 6 - - CalmButton { - theme: workflow.theme - uiFont: workflow.uiFont - text: "Open SCL" - enabled: !workflow.device.running - onClicked: workflow.controller.openEngineeringFile() - ToolTip.visible: hovered - ToolTip.text: "Open SCL, CID, SCD or IID · Ctrl+O" - } - CalmButton { - visible: !workflow.compact - theme: workflow.theme - uiFont: workflow.uiFont - text: "Balanced" - onClicked: workflow.controller.balanced() - ToolTip.visible: hovered - ToolTip.text: "Apply balanced 3-phase defaults · Ctrl+B" - } - CalmButton { - theme: workflow.theme - uiFont: workflow.uiFont - text: "Zero" - onClicked: workflow.controller.zeroAll() - ToolTip.visible: hovered - ToolTip.text: "Set all magnitudes to zero · Ctrl+0" - } - CalmButton { - visible: !workflow.compact - theme: workflow.theme - uiFont: workflow.uiFont - text: "Check" - onClicked: workflow.controller.runReadinessCheck() - ToolTip.visible: hovered - ToolTip.text: "Check profile and device readiness · Ctrl+K" - } - CalmButton { - id: viewsButton - theme: workflow.theme - uiFont: workflow.uiFont - text: "Views" - onClicked: viewMenu.popup(viewsButton, 0, viewsButton.height) - ToolTip.visible: hovered - ToolTip.text: "Show, hide, or detach engineering views" + RibbonAction { + text: "Balanced" + iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") + toolTipText: "Apply a balanced three-phase setpoint" + onClicked: ribbon.controller.balanced() + } + RibbonAction { + text: "Zero" + iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") + toolTipText: "Set all magnitudes to zero" + onClicked: ribbon.controller.zeroAll() + } + RibbonAction { + visible: !ribbon.compact + text: "CT Saturation" + iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") + tone: ribbon.controller.ctSaturationEnabled ? "accent" : "neutral" + enabled: ribbon.controller.signalFrequency > 0 + toolTipText: ribbon.controller.ctSaturationEnabled ? "Disable CT saturation stress" : "Enable CT saturation stress" + onClicked: ribbon.controller.setCtSaturation(!ribbon.controller.ctSaturationEnabled) + } + RibbonDivider {} + RibbonAction { + visible: !ribbon.compact + text: "Check" + iconSource: Qt.resolvedUrl("../assets/lucide/circle-check.svg") + toolTipText: "Run readiness checks" + onClicked: ribbon.controller.runReadinessCheck() + } + RibbonAction { + text: "Configure" + iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + toolTipText: "Open Smart and Expert configuration" + onClicked: ribbon.controller.openConfiguration() + } + Item { Layout.fillWidth: true } + RibbonDivider {} + RibbonAction { + visible: !ribbon.compact + tone: "accent" + text: ribbon.device.profileDeploying ? "Deploying..." : "Deploy" + iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") + enabled: ribbon.controller.canDeploy + toolTipText: "Deploy the validated SV profile" + onClicked: ribbon.controller.deploySelectedProfile() + } + RibbonAction { + tone: "danger" + text: "Stop" + iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") + implicitWidth: 78 + enabled: ribbon.device.deviceVerified && ribbon.device.running + onClicked: ribbon.device.stop() + } + RibbonAction { + tone: "success" + text: "Start" + iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") + implicitWidth: 88 + enabled: ribbon.controller.canStart + onClicked: ribbon.device.start() + } } - } - Item { Layout.fillWidth: true } - - CalmButton { - visible: !workflow.compact - theme: workflow.theme - uiFont: workflow.uiFont - tone: "accent" - text: workflow.device.profileDeploying ? "Deploying…" : "Deploy" - enabled: workflow.controller.canDeploy - onClicked: workflow.controller.deploySelectedProfile() - ToolTip.visible: hovered - ToolTip.text: "Commit the selected immutable profile · Ctrl+D" - } + RowLayout { + Layout.leftMargin: 11 + Layout.rightMargin: 11 + spacing: 7 + + RibbonAction { + text: "Phasor" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + tone: ribbon.controller.phasorDockVisible || ribbon.controller.phasorDetached ? "accent" : "neutral" + onClicked: { + ribbon.controller.phasorDetached = false + ribbon.controller.phasorDockVisible = !ribbon.controller.phasorDockVisible + } + } + RibbonAction { + text: "Waveform" + iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") + tone: ribbon.controller.waveformDockVisible || ribbon.controller.waveformDetached ? "accent" : "neutral" + onClicked: { + ribbon.controller.waveformDetached = false + ribbon.controller.waveformDockVisible = !ribbon.controller.waveformDockVisible + } + } + RibbonAction { + text: "Monitor" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + tone: ribbon.controller.telemetryDockVisible ? "accent" : "neutral" + onClicked: ribbon.controller.telemetryDockVisible = !ribbon.controller.telemetryDockVisible + } + RibbonDivider {} + RibbonAction { + visible: !ribbon.compact + text: "Detach phasor" + iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + enabled: ribbon.controller.phasorDockVisible && !ribbon.controller.phasorDetached + onClicked: ribbon.controller.detachPhasor() + } + RibbonAction { + visible: !ribbon.compact + text: "Detach waveform" + iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + enabled: ribbon.controller.waveformDockVisible && !ribbon.controller.waveformDetached + onClicked: ribbon.controller.detachWaveform() + } + Item { Layout.fillWidth: true } + RibbonAction { + text: ribbon.controller.telemetryExpanded ? "Collapse" : "Expand" + iconSource: ribbon.controller.telemetryExpanded + ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") + : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") + onClicked: ribbon.controller.telemetryExpanded = !ribbon.controller.telemetryExpanded + } + } - CalmButton { - theme: workflow.theme - uiFont: workflow.uiFont - tone: "danger" - text: "Stop" - implicitWidth: 72 - implicitHeight: 42 - enabled: workflow.device.connected && workflow.device.running - onClicked: workflow.device.stop() - ToolTip.visible: hovered - ToolTip.text: "Stop output · F6" - } + RowLayout { + Layout.leftMargin: 11 + Layout.rightMargin: 11 + spacing: 7 - CalmButton { - theme: workflow.theme - uiFont: workflow.uiFont - tone: "success" - text: "Start" - implicitWidth: 82 - implicitHeight: 42 - enabled: workflow.controller.canStart - onClicked: workflow.device.start() - ToolTip.visible: hovered - ToolTip.text: "Start armed output · F5" + RibbonAction { + text: "Configuration" + iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + onClicked: ribbon.controller.openConfiguration() + } + RibbonAction { + text: "Open SCL" + iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") + onClicked: ribbon.controller.openEngineeringFile() + } + RibbonAction { + text: "Detect device" + iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") + enabled: !ribbon.device.connected + onClicked: ribbon.device.autoDetectAndConnect() + } + RibbonAction { + text: "Refresh PTP" + iconSource: Qt.resolvedUrl("../assets/lucide/clock-3.svg") + enabled: ribbon.device.deviceVerified + onClicked: ribbon.device.sendPtpShow() + } + Item { Layout.fillWidth: true } + RibbonAction { + text: "Diagnostics" + iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") + onClicked: ribbon.controller.openDiagnostics() + } + } } } } diff --git a/apps/arstack_studio/run-windows.ps1 b/apps/arstack_studio/run-windows.ps1 index faf8ceb7c..801d4fed3 100644 --- a/apps/arstack_studio/run-windows.ps1 +++ b/apps/arstack_studio/run-windows.ps1 @@ -27,12 +27,22 @@ if (-not (Test-Path -LiteralPath $developerCommand)) { } $environmentLines = & $env:ComSpec /s /c "`"$developerCommand`" -no_logo -arch=x64 -host_arch=x64 && set" +$developerPath = $null foreach ($line in $environmentLines) { $separator = $line.IndexOf('=') if ($separator -gt 0) { - [Environment]::SetEnvironmentVariable($line.Substring(0, $separator), $line.Substring($separator + 1), 'Process') + $name = $line.Substring(0, $separator) + $value = $line.Substring($separator + 1) + if ($name -ceq 'PATH') { + $developerPath = $value + } elseif ($name -ine 'Path') { + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } } } +if ($developerPath) { + $env:Path = $developerPath +} $ninja = Join-Path $visualStudio 'Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe' if (-not (Test-Path -LiteralPath $ninja)) { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 3027498c3..4e7aa23a4 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -28,6 +28,33 @@ const QRegularExpression kProfileCommittedExpression{ const QRegularExpression kProfileArmedExpression{ QStringLiteral("PROFILE armed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kIdentityExpression{ + QStringLiteral("ARSTACK identity product=([A-Z0-9_-]+) target=ESP32-P4 protocol=(\\d+) device_id=([A-Fa-f0-9]{12})"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpStatusExpression{ + QStringLiteral("PTP status=(RUNNING|STOPPED) Announce=(\\d+) Sync=(\\d+) FollowUp=(\\d+) PdelayFrames=(\\d+) TXfail=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpConfigExpression{ + QStringLiteral("PTP config domain=(\\d+) transportSpecific=0x([0-9A-Fa-f]+) VLAN=(\\S+) Announce=(\\d+) ms Sync=(\\d+) ms Pdelay=(ON|OFF)"), + QRegularExpression::CaseInsensitiveOption}; + +struct PortCandidate { + QString name; + int confidence{}; +}; + +[[nodiscard]] int portConfidence(const QSerialPortInfo& info) { + int score = 0; + if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100; + const QString identity = QStringLiteral("%1 %2 %3") + .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower(); + if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; + else if (identity.contains(QStringLiteral("esp32"))) score += 65; + if (identity.contains(QStringLiteral("espressif"))) score += 60; + if (identity.contains(QStringLiteral("usb jtag")) || + identity.contains(QStringLiteral("usb serial"))) score += 15; + return score; +} [[nodiscard]] bool finitePositive(const double value) noexcept { return std::isfinite(value) && value > 0.0; @@ -57,10 +84,36 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { } }); + verificationTimer_.setSingleShot(true); + verificationTimer_.setInterval(2600); + connect(&verificationTimer_, &QTimer::timeout, this, [this] { + if (!serial_.isOpen() || deviceVerified_) return; + const bool wasAutomatic = automaticConnection_; + const bool continueProbing = wasAutomatic && genericProbeActive_ && !probeQueue_.isEmpty(); + disconnectPort(); + if (continueProbing) { + QTimer::singleShot(0, this, [this] { static_cast(tryNextProbe()); }); + return; + } + genericProbeActive_ = false; + setDiscoveryState( + wasAutomatic + ? QStringLiteral("A connected serial device did not identify as an ARStack injector.") + : QStringLiteral("The selected serial port did not answer as an ARStack injector."), + false); + }); + refreshPorts(); } QStringList DeviceController::ports() const { return ports_; } +QString DeviceController::recommendedPort() const { return recommendedPort_; } +QString DeviceController::discoveryStatus() const { return discoveryStatus_; } +bool DeviceController::discovering() const noexcept { return discovering_; } +bool DeviceController::deviceVerified() const noexcept { return deviceVerified_; } +QString DeviceController::deviceProduct() const { return deviceProduct_; } +QString DeviceController::deviceId() const { return deviceId_; } +QString DeviceController::protocolVersion() const { return protocolVersion_; } bool DeviceController::connected() const noexcept { return serial_.isOpen(); } bool DeviceController::running() const noexcept { return running_; } QString DeviceController::portName() const { return serial_.portName(); } @@ -73,28 +126,102 @@ QString DeviceController::signalGeneration() const { return signalGeneration_; } QString DeviceController::profileGeneration() const { return profileGeneration_; } bool DeviceController::profileArmed() const noexcept { return profileArmed_; } bool DeviceController::profileDeploying() const noexcept { return profileDeploying_; } +bool DeviceController::ptpAvailable() const noexcept { return ptpAvailable_; } +bool DeviceController::ptpRunning() const noexcept { return ptpRunning_; } +QString DeviceController::ptpStatus() const { return ptpStatus_; } +QString DeviceController::ptpDomain() const { return ptpDomain_; } +QString DeviceController::ptpTransportSpecific() const { return ptpTransportSpecific_; } +QString DeviceController::ptpVlan() const { return ptpVlan_; } +QString DeviceController::ptpAnnounceSent() const { return ptpAnnounceSent_; } +QString DeviceController::ptpSyncSent() const { return ptpSyncSent_; } +QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::refreshPorts() { - QStringList discovered; + QList candidates; for (const auto& info : QSerialPortInfo::availablePorts()) { - discovered.push_back(info.portName()); + candidates.push_back({info.portName(), portConfidence(info)}); } - discovered.removeDuplicates(); - std::sort(discovered.begin(), discovered.end(), [](const QString& left, const QString& right) { - return left.localeAwareCompare(right) < 0; + std::sort(candidates.begin(), candidates.end(), [](const PortCandidate& left, const PortCandidate& right) { + if (left.confidence != right.confidence) return left.confidence > right.confidence; + return left.name.localeAwareCompare(right.name) < 0; }); - if (discovered == ports_) return; + + QStringList discovered; + QStringList highConfidence; + for (const auto& candidate : candidates) { + discovered.push_back(candidate.name); + if (candidate.confidence >= 60) highConfidence.push_back(candidate.name); + } + discovered.removeDuplicates(); + const QString recommended = highConfidence.size() == 1 ? highConfidence.front() : QString{}; + const bool portsDidChange = discovered != ports_; + const bool recommendationChanged = recommended != recommendedPort_; ports_ = std::move(discovered); - emit portsChanged(); + recommendedPort_ = recommended; + if (portsDidChange) emit portsChanged(); + if (recommendationChanged) emit discoveryChanged(); + + if (!serial_.isOpen() && !discovering_) { + if (!recommendedPort_.isEmpty()) { + setDiscoveryState(QStringLiteral("Compatible ESP32-P4 USB device found."), false); + } else if (highConfidence.size() > 1) { + setDiscoveryState(QStringLiteral("Multiple Espressif devices found; choose the intended injector."), false); + } else { + setDiscoveryState(QStringLiteral("No verified injector found; setpoints remain editable offline."), false); + } + } } bool DeviceController::connectPort(const QString& portName) { + return connectPortInternal(portName, false); +} + +bool DeviceController::autoDetectAndConnect() { + if (deviceVerified_) return true; + refreshPorts(); + probeQueue_.clear(); + genericProbeActive_ = false; + if (!recommendedPort_.isEmpty()) { + setDiscoveryState( + QStringLiteral("Compatible device found. Verifying ARStack identity..."), true); + return connectPortInternal(recommendedPort_, true); + } + if (ports_.isEmpty()) return false; + + // Windows often exposes ESP USB CDC as the generic "USB Serial Device". + // Probe one port at a time with read-only SHOW commands and trust only the + // firmware-specific response grammar parsed by processLine(). + probeQueue_ = ports_; + genericProbeActive_ = true; + return tryNextProbe(); +} + +bool DeviceController::tryNextProbe() { + while (!probeQueue_.isEmpty()) { + const QString port = probeQueue_.takeFirst(); + setDiscoveryState(QStringLiteral("Checking connected devices for an ARStack injector..."), true); + if (connectPortInternal(port, true)) return true; + } + genericProbeActive_ = false; + setDiscoveryState(QStringLiteral("No ARStack injector answered; manual port selection remains available."), false); + return false; +} + +bool DeviceController::connectPortInternal(const QString& portName, const bool automatic) { if (portName.trimmed().isEmpty()) { setError(QStringLiteral("Select a serial port first.")); return false; } if (serial_.isOpen()) disconnectPort(); + if (!automatic) { + probeQueue_.clear(); + genericProbeActive_ = false; + } + automaticConnection_ = automatic; + deviceVerified_ = false; + setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); + serial_.setPortName(portName.trimmed()); serial_.setBaudRate(kBaudRate); serial_.setDataBits(QSerialPort::Data8); @@ -103,7 +230,11 @@ bool DeviceController::connectPort(const QString& portName) { serial_.setFlowControl(QSerialPort::NoFlowControl); if (!serial_.open(QIODevice::ReadWrite)) { - setError(QStringLiteral("Cannot open %1: %2").arg(portName, serial_.errorString())); + automaticConnection_ = false; + if (!automatic) { + setDiscoveryState(QStringLiteral("The selected serial port could not be opened."), false); + setError(QStringLiteral("Cannot open %1: %2").arg(portName, serial_.errorString())); + } return false; } @@ -118,6 +249,9 @@ bool DeviceController::connectPort(const QString& portName) { emit runningChanged(); emit lastErrorChanged(); emit profileStateChanged(); + verificationTimer_.setInterval(genericProbeActive_ ? 1100 : 2600); + verificationTimer_.start(); + static_cast(sendCommand(QStringLiteral("IDENTIFY"))); static_cast(sendCommand(QStringLiteral("SHOW"))); static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); return true; @@ -125,6 +259,7 @@ bool DeviceController::connectPort(const QString& portName) { void DeviceController::disconnectPort() { const bool wasConnected = serial_.isOpen(); + verificationTimer_.stop(); if (serial_.isOpen()) serial_.close(); pendingRx_.clear(); if (running_) { @@ -137,6 +272,19 @@ void DeviceController::disconnectPort() { emit profileStateChanged(); } resetTelemetry(); + const bool wasVerified = deviceVerified_; + deviceVerified_ = false; + deviceProduct_.clear(); + deviceId_.clear(); + protocolVersion_.clear(); + discovering_ = false; + automaticConnection_ = false; + ptpAvailable_ = false; + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("Waiting for device"); + emit discoveryChanged(); + emit ptpStateChanged(); + if (wasVerified) emit deviceVerifiedChanged(); if (wasConnected) { appendLog(QStringLiteral("•"), QStringLiteral("Device disconnected")); emit connectedChanged(); @@ -149,13 +297,15 @@ 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.")); + if (!std::isfinite(hz) || hz < 0.0 || hz > 1000.0) { + setError(QStringLiteral("Frequency must be within 0..1000 Hz (0 = DC).")); return false; } const auto millihertz = std::llround(hz * 1000.0); - if (millihertz <= 0 || millihertz > std::numeric_limits::max()) return false; - return sendCommand(QStringLiteral("FREQ %1").arg(millihertz)); + if (millihertz < 0 || millihertz > std::numeric_limits::max()) return false; + if (!sendCommand(QStringLiteral("FREQ %1").arg(millihertz))) return false; + signalFrequencyHz_ = hz; + return true; } bool DeviceController::setSignal( @@ -169,7 +319,8 @@ bool DeviceController::setSignal( 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)) { + if (!validIds.contains(id) || !std::isfinite(magnitude) || + (signalFrequencyHz_ > 0.0 && magnitude < 0.0) || !std::isfinite(phaseDegrees)) { setError(QStringLiteral("Invalid signal setpoint.")); return false; } @@ -207,7 +358,7 @@ bool DeviceController::setQuality(const QString& signalId, const quint32 quality } bool DeviceController::deployProfile(const QVariantMap& profile) { - if (!connected()) { + if (!deviceVerified_) { setError(QStringLiteral("Connect the ESP32-P4 before deployment.")); return false; } @@ -274,6 +425,61 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { return true; } +bool DeviceController::setCtSaturation( + const bool enabled, + const double dcOffsetPercent, + const double harmonicPercent, + const int harmonicOrder, + const double clipPercent) { + if (enabled && signalFrequencyHz_ == 0.0) { + setError(QStringLiteral("CT saturation shaping requires AC mode.")); + return false; + } + if (!std::isfinite(dcOffsetPercent) || dcOffsetPercent < -300.0 || dcOffsetPercent > 300.0 || + !std::isfinite(harmonicPercent) || harmonicPercent < 0.0 || harmonicPercent > 300.0 || + harmonicOrder < 2 || harmonicOrder > 63 || !std::isfinite(clipPercent) || + clipPercent < 1.0 || clipPercent > 1000.0) { + setError(QStringLiteral("CT saturation parameters are outside the bounded lab range.")); + return false; + } + return sendCommand(QStringLiteral("SHAPE CT %1 %2 %3 %4 %5") + .arg(enabled ? 1 : 0) + .arg(std::llround(dcOffsetPercent * 10.0)) + .arg(std::llround(harmonicPercent * 10.0)) + .arg(harmonicOrder) + .arg(std::llround(clipPercent * 10.0))); +} + +bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } +bool DeviceController::startPtp() { return sendCommand(QStringLiteral("PTP START")); } +bool DeviceController::stopPtp() { return sendCommand(QStringLiteral("PTP STOP")); } + +bool DeviceController::configurePtp(const QVariantMap& profile) { + if (!deviceVerified_ || ptpRunning_) { + setError(ptpRunning_ + ? QStringLiteral("Stop PTP Lab TX before applying expert timing settings.") + : QStringLiteral("Verify the ESP32-P4 before configuring PTP.")); + return false; + } + const auto domain = profile.value(QStringLiteral("domain")).toUInt(); + const auto transport = profile.value(QStringLiteral("transportSpecific")).toUInt(); + const bool vlanEnabled = profile.value(QStringLiteral("vlanEnabled")).toBool(); + const auto vlanId = profile.value(QStringLiteral("vlanId")).toUInt(); + const auto pcp = profile.value(QStringLiteral("vlanPriority")).toUInt(); + const auto announceMs = profile.value(QStringLiteral("announceIntervalMs")).toUInt(); + const auto syncMs = profile.value(QStringLiteral("syncIntervalMs")).toUInt(); + const bool pdelay = profile.value(QStringLiteral("respondToPeerDelay")).toBool(); + if (domain > 255U || transport > 15U || (vlanEnabled && (vlanId == 0U || vlanId > 4094U)) || + pcp > 7U || announceMs < 100U || announceMs > 10000U || + syncMs < 20U || syncMs > 5000U) { + setError(QStringLiteral("PTP expert profile is outside the firmware safety bounds.")); + return false; + } + return sendCommand(QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") + .arg(domain).arg(transport).arg(vlanEnabled ? 1 : 0).arg(vlanId).arg(pcp) + .arg(announceMs).arg(syncMs).arg(pdelay ? 1 : 0)); +} + void DeviceController::clearLog() { if (logText_.isEmpty()) return; logText_.clear(); @@ -300,6 +506,26 @@ void DeviceController::setRunning(const bool value) { emit runningChanged(); } +void DeviceController::setDiscoveryState(const QString& status, const bool active) { + if (discoveryStatus_ == status && discovering_ == active) return; + discoveryStatus_ = status; + discovering_ = active; + emit discoveryChanged(); +} + +void DeviceController::markDeviceVerified() { + if (deviceVerified_) return; + verificationTimer_.stop(); + deviceVerified_ = true; + automaticConnection_ = false; + genericProbeActive_ = false; + probeQueue_.clear(); + setDiscoveryState(QStringLiteral("ARStack ESP32-P4 identity verified."), false); + emit deviceVerifiedChanged(); + emit deviceMessage(QStringLiteral("ESP32-P4 recognized. Device is ready.")); + static_cast(sendPtpShow()); +} + void DeviceController::setError(const QString& message) { if (message.isEmpty() || lastError_ == message) return; lastError_ = message; @@ -364,6 +590,45 @@ void DeviceController::processLine(const QString& rawLine) { emit profileStateChanged(); } + match = kIdentityExpression.match(line); + if (match.hasMatch()) { + deviceProduct_ = match.captured(1); + protocolVersion_ = match.captured(2); + deviceId_ = match.captured(3).toUpper(); + markDeviceVerified(); + } + + match = kPtpStatusExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpRunning_ = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + ptpAnnounceSent_ = match.captured(2); + ptpSyncSent_ = match.captured(3); + ptpTxFailures_ = match.captured(6); + ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : QStringLiteral("Lab TX stopped"); + emit ptpStateChanged(); + } + + match = kPtpConfigExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpDomain_ = match.captured(1); + ptpTransportSpecific_ = QStringLiteral("0x%1").arg(match.captured(2).toUpper()); + ptpVlan_ = match.captured(3); + emit ptpStateChanged(); + } + + if (line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive)) { + ptpAvailable_ = false; + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("Not enabled in firmware"); + emit ptpStateChanged(); + } + if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) { + emit deviceMessage(QStringLiteral("PTP expert profile accepted.")); + static_cast(sendPtpShow()); + } + if (line.contains(QStringLiteral("PROFILE commit rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PROFILE rejected"), Qt::CaseInsensitive)) { profileDeploying_ = false; diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 93783bbe6..fe4942199 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -4,11 +4,19 @@ #include #include #include +#include #include class DeviceController : public QObject { Q_OBJECT Q_PROPERTY(QStringList ports READ ports NOTIFY portsChanged) + Q_PROPERTY(QString recommendedPort READ recommendedPort NOTIFY discoveryChanged) + Q_PROPERTY(QString discoveryStatus READ discoveryStatus NOTIFY discoveryChanged) + Q_PROPERTY(bool discovering READ discovering NOTIFY discoveryChanged) + Q_PROPERTY(bool deviceVerified READ deviceVerified NOTIFY deviceVerifiedChanged) + Q_PROPERTY(QString deviceProduct READ deviceProduct NOTIFY deviceVerifiedChanged) + Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceVerifiedChanged) + Q_PROPERTY(QString protocolVersion READ protocolVersion NOTIFY deviceVerifiedChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) @@ -21,11 +29,27 @@ class DeviceController : public QObject { Q_PROPERTY(QString profileGeneration READ profileGeneration NOTIFY profileStateChanged) Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) + Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpStatus READ ptpStatus NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpDomain READ ptpDomain NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpTransportSpecific READ ptpTransportSpecific NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpVlan READ ptpVlan NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpAnnounceSent READ ptpAnnounceSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpSyncSent READ ptpSyncSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpTxFailures READ ptpTxFailures NOTIFY ptpStateChanged) public: explicit DeviceController(QObject* parent = nullptr); [[nodiscard]] QStringList ports() const; + [[nodiscard]] QString recommendedPort() const; + [[nodiscard]] QString discoveryStatus() const; + [[nodiscard]] bool discovering() const noexcept; + [[nodiscard]] bool deviceVerified() const noexcept; + [[nodiscard]] QString deviceProduct() const; + [[nodiscard]] QString deviceId() const; + [[nodiscard]] QString protocolVersion() const; [[nodiscard]] bool connected() const noexcept; [[nodiscard]] bool running() const noexcept; [[nodiscard]] QString portName() const; @@ -38,8 +62,18 @@ class DeviceController : public QObject { [[nodiscard]] QString profileGeneration() const; [[nodiscard]] bool profileArmed() const noexcept; [[nodiscard]] bool profileDeploying() const noexcept; + [[nodiscard]] bool ptpAvailable() const noexcept; + [[nodiscard]] bool ptpRunning() const noexcept; + [[nodiscard]] QString ptpStatus() const; + [[nodiscard]] QString ptpDomain() const; + [[nodiscard]] QString ptpTransportSpecific() const; + [[nodiscard]] QString ptpVlan() const; + [[nodiscard]] QString ptpAnnounceSent() const; + [[nodiscard]] QString ptpSyncSent() const; + [[nodiscard]] QString ptpTxFailures() const; Q_INVOKABLE void refreshPorts(); + Q_INVOKABLE bool autoDetectAndConnect(); Q_INVOKABLE bool connectPort(const QString& portName); Q_INVOKABLE void disconnectPort(); Q_INVOKABLE bool sendShow(); @@ -56,21 +90,33 @@ class DeviceController : public QObject { double voltageCountsPerVolt); Q_INVOKABLE bool setEnabled(const QString& signalId, bool enabled); Q_INVOKABLE bool setQuality(const QString& signalId, quint32 quality); + Q_INVOKABLE bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); Q_INVOKABLE bool deployProfile(const QVariantMap& profile); + Q_INVOKABLE bool sendPtpShow(); + Q_INVOKABLE bool startPtp(); + Q_INVOKABLE bool stopPtp(); + Q_INVOKABLE bool configurePtp(const QVariantMap& profile); Q_INVOKABLE void clearLog(); signals: void portsChanged(); + void discoveryChanged(); + void deviceVerifiedChanged(); void connectedChanged(); void runningChanged(); void lastErrorChanged(); void logTextChanged(); void telemetryChanged(); void profileStateChanged(); + void ptpStateChanged(); void deviceMessage(const QString& message); private: + bool connectPortInternal(const QString& portName, bool automatic); + bool tryNextProbe(); bool sendCommand(const QString& command); + void markDeviceVerified(); + void setDiscoveryState(const QString& status, bool active); void setRunning(bool value); void setError(const QString& message); void appendLog(const QString& direction, const QString& line); @@ -81,7 +127,14 @@ class DeviceController : public QObject { static QString compactMac(const QString& text); QSerialPort serial_; + QTimer verificationTimer_; QStringList ports_; + QString recommendedPort_; + QStringList probeQueue_; + QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; + QString deviceProduct_; + QString deviceId_; + QString protocolVersion_; QByteArray pendingRx_; QString lastError_; QString logText_; @@ -91,6 +144,20 @@ class DeviceController : public QObject { QString signalGeneration_{QStringLiteral("—")}; QString profileGeneration_{QStringLiteral("—")}; bool running_{false}; + bool discovering_{false}; + bool deviceVerified_{false}; + bool automaticConnection_{false}; + bool genericProbeActive_{false}; bool profileArmed_{false}; bool profileDeploying_{false}; -}; \ No newline at end of file + bool ptpAvailable_{false}; + bool ptpRunning_{false}; + double signalFrequencyHz_{50.0}; + QString ptpStatus_{QStringLiteral("Waiting for device")}; + QString ptpDomain_{QStringLiteral("-")}; + QString ptpTransportSpecific_{QStringLiteral("-")}; + QString ptpVlan_{QStringLiteral("-")}; + QString ptpAnnounceSent_{QStringLiteral("-")}; + QString ptpSyncSent_{QStringLiteral("-")}; + QString ptpTxFailures_{QStringLiteral("-")}; +}; diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 936d64843..5841dc392 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -12,6 +12,7 @@ #endif static const char *TAG = "ar_smv_eth"; +static esp_eth_handle_t s_eth_handle = NULL; // Bench-validated ESP32-P4 Ethernet-board RMII wiring. // REF_CLK is supplied by the onboard PHY as a 50 MHz input to ESP32-P4. @@ -73,6 +74,7 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) phy->del(phy); return NULL; } + s_eth_handle = handle; #if CONFIG_AR_PTP_LAB_TX // The PTP task starts independently and tolerates link-not-ready TX failures @@ -86,3 +88,16 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) AR_PHY_ADDRESS, AR_MDC_GPIO, AR_MDIO_GPIO, AR_RMII_REF_CLK_GPIO); return handle; } + +bool ar_esp32p4_ptp_start(void) +{ +#if CONFIG_AR_PTP_LAB_TX + if (s_eth_handle == NULL) { + return false; + } + ar_ptp_lab_start(s_eth_handle); + return true; +#else + return false; +#endif +} diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.h b/embedded/esp32p4_smv_injector/main/ethernet_port.h index a5e4726eb..2c52ffe40 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.h +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.h @@ -2,6 +2,7 @@ #pragma once #include "esp_eth.h" +#include #ifdef __cplusplus extern "C" { @@ -18,6 +19,9 @@ extern "C" { */ esp_eth_handle_t ar_esp32p4_eth_init(void); +/** Restart the optional PTP lab runtime on the initialized Ethernet device. */ +bool ar_esp32p4_ptp_start(void); + #ifdef __cplusplus } #endif diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 683397519..406a07269 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -1,11 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "live_control.hpp" +#include "ethernet_port.h" #include "profile_control.hpp" #include "esp_log.h" +#include "esp_mac.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sdkconfig.h" + +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#endif #include #include @@ -95,14 +102,142 @@ bool parse_u32(const char* text, std::uint32_t& value) noexcept { void print_help() noexcept { ESP_LOGI(kTag, "Live control commands (bench serial transport):"); - ESP_LOGI(kTag, " START | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " IDENTIFY | START | STOP | SHOW | ZERO | HELP"); ESP_LOGI(kTag, " FREQ "); ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); ESP_LOGI(kTag, " QUALITY "); + ESP_LOGI(kTag, " SHAPE CT <0|1> "); + ESP_LOGI(kTag, " PTP SHOW | PTP START | PTP STOP"); + ESP_LOGI(kTag, " PTP CONFIG "); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } +void print_identity() noexcept { + std::array device_id{}; + if (esp_read_mac(device_id.data(), ESP_MAC_EFUSE_FACTORY) != ESP_OK) { + ESP_LOGE(kTag, "ARSTACK identity unavailable: factory device ID read failed"); + return; + } + ESP_LOGI(kTag, + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X", + static_cast(device_id[0]), static_cast(device_id[1]), + static_cast(device_id[2]), static_cast(device_id[3]), + static_cast(device_id[4]), static_cast(device_id[5])); +} + +void print_ptp_state() noexcept { +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_lab_status_t status{}; + ar_ptp_lab_config_t config{}; + if (!ar_ptp_lab_get_status(&status) || !ar_ptp_lab_get_config(&config)) { + ESP_LOGE(kTag, "PTP status unavailable"); + return; + } + ESP_LOGI(kTag, + "PTP status=%s Announce=%llu Sync=%llu FollowUp=%llu PdelayFrames=%llu TXfail=%llu", + status.is_running ? "RUNNING" : "STOPPED", + static_cast(status.announce_sent), + static_cast(status.sync_sent), + static_cast(status.follow_up_sent), + static_cast(status.peer_delay_frames_sent), + static_cast(status.tx_failure_count)); + if (config.vlan_enabled) { + ESP_LOGI(kTag, + "PTP config domain=%u transportSpecific=0x%X VLAN=%u/%u Announce=%lu ms Sync=%lu ms Pdelay=%s", + static_cast(config.domain_number), + static_cast(config.transport_specific), + static_cast(config.vlan_id), + static_cast(config.vlan_priority), + static_cast(config.announce_interval_ms), + static_cast(config.sync_interval_ms), + config.respond_to_peer_delay ? "ON" : "OFF"); + } else { + ESP_LOGI(kTag, + "PTP config domain=%u transportSpecific=0x%X VLAN=OFF Announce=%lu ms Sync=%lu ms Pdelay=%s", + static_cast(config.domain_number), + static_cast(config.transport_specific), + static_cast(config.announce_interval_ms), + static_cast(config.sync_interval_ms), + config.respond_to_peer_delay ? "ON" : "OFF"); + } +#else + ESP_LOGW(kTag, "PTP unavailable: firmware built without CONFIG_AR_PTP_LAB_TX"); +#endif +} + +void handle_ptp_command(char* save) noexcept { + char* subcommand = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (subcommand == nullptr) { + ESP_LOGE(kTag, "PTP subcommand required"); + return; + } + uppercase_ascii(subcommand); + + if (std::strcmp(subcommand, "SHOW") == 0) { + if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: PTP SHOW"); + else print_ptp_state(); + return; + } +#if CONFIG_AR_PTP_LAB_TX + if (std::strcmp(subcommand, "START") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: PTP START"); + return; + } + if (!ar_esp32p4_ptp_start()) ESP_LOGE(kTag, "PTP start rejected: Ethernet is unavailable"); + else ESP_LOGI(kTag, "PTP start accepted"); + print_ptp_state(); + return; + } + if (std::strcmp(subcommand, "STOP") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: PTP STOP"); + return; + } + ar_ptp_lab_stop(); + ESP_LOGI(kTag, "PTP stop requested"); + return; + } + if (std::strcmp(subcommand, "CONFIG") == 0) { + std::array values{}; + for (auto& value : values) { + if (!parse_u32(strtok_r(nullptr, kTokenDelimiters.data(), &save), value)) { + ESP_LOGE(kTag, "Usage: PTP CONFIG "); + return; + } + } + if (has_extra_token(&save) || values[0] > 255U || values[1] > 15U || values[2] > 1U || + values[3] > 4094U || values[4] > 7U || values[5] < 100U || values[5] > 10000U || + values[6] < 20U || values[6] > 5000U || values[7] > 1U) { + ESP_LOGE(kTag, "PTP configuration rejected: value outside safe bounds"); + return; + } + ar_ptp_lab_config_t config{}; + if (!ar_ptp_lab_get_config(&config)) { + ESP_LOGE(kTag, "PTP configuration unavailable"); + return; + } + config.domain_number = static_cast(values[0]); + config.transport_specific = static_cast(values[1]); + config.vlan_enabled = values[2] != 0U; + config.vlan_id = static_cast(values[3]); + config.vlan_priority = static_cast(values[4]); + config.announce_interval_ms = values[5]; + config.sync_interval_ms = values[6]; + config.respond_to_peer_delay = values[7] != 0U; + if (!ar_ptp_lab_configure(&config)) { + ESP_LOGE(kTag, "PTP configuration rejected: stop PTP first or correct the profile"); + return; + } + ESP_LOGI(kTag, "PTP configuration accepted"); + print_ptp_state(); + return; + } +#endif + ESP_LOGE(kTag, "Unknown or unavailable PTP subcommand '%s'", subcommand); +} + void print_state() noexcept { const auto state = g_signal_bank.snapshot(); ESP_LOGI(kTag, @@ -140,6 +275,12 @@ void handle_line(char* line) noexcept { if (command == nullptr) return; uppercase_ascii(command); + if (std::strcmp(command, "IDENTIFY") == 0) { + if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: IDENTIFY"); + else print_identity(); + return; + } + if (std::strcmp(command, "START") == 0) { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: START"); @@ -181,6 +322,10 @@ void handle_line(char* line) noexcept { handle_profile_command(save); return; } + if (std::strcmp(command, "PTP") == 0) { + handle_ptp_command(save); + return; + } if (std::strcmp(command, "ZERO") == 0) { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: ZERO"); @@ -204,6 +349,47 @@ void handle_line(char* line) noexcept { return; } + if (std::strcmp(command, "SHAPE") == 0) { + char* shape_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (shape_text == nullptr) { + ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); + return; + } + uppercase_ascii(shape_text); + char* enabled_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* dc_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* harmonic_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* order_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* clip_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + std::uint32_t enabled{}; + std::int32_t dc_offset{}; + std::uint32_t harmonic{}; + std::uint32_t order{}; + std::uint32_t clip{}; + if (std::strcmp(shape_text, "CT") != 0 || !parse_u32(enabled_text, enabled) || + !parse_i32(dc_text, dc_offset) || !parse_u32(harmonic_text, harmonic) || + !parse_u32(order_text, order) || !parse_u32(clip_text, clip) || has_extra_token(&save) || + enabled > 1U || dc_offset < -3000 || dc_offset > 3000 || harmonic > 3000U || + order < 2U || order > 63U || clip < 10U || clip > 10000U) { + ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); + return; + } + auto state = g_signal_bank.snapshot(); + state.current_shape.enabled = enabled != 0U; + state.current_shape.dc_offset_permille = dc_offset; + state.current_shape.harmonic_permille = harmonic; + state.current_shape.harmonic_order = static_cast(order); + state.current_shape.clip_permille = clip; + static_cast(publish_state(state)); + ESP_LOGI(kTag, "CT saturation shape %s: DC=%ld.%u%% H%u=%lu.%u%% clip=%lu.%u%%", + enabled ? "enabled" : "disabled", + static_cast(dc_offset / 10), static_cast(std::abs(dc_offset % 10)), + static_cast(order), + static_cast(harmonic / 10U), static_cast(harmonic % 10U), + static_cast(clip / 10U), static_cast(clip % 10U)); + return; + } + if (std::strcmp(command, "SET") == 0 || std::strcmp(command, "ENABLE") == 0 || std::strcmp(command, "QUALITY") == 0) { diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp index 37ca82617..cfad3c3a8 100644 --- a/include/ariec61850/sampled_values/live_signal_state.hpp +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -2,6 +2,7 @@ #pragma once +#include #include #include #include @@ -27,11 +28,22 @@ struct SvLiveChannelState final { friend bool operator==(const SvLiveChannelState&, const SvLiveChannelState&) = default; }; +struct SvCurrentWaveformShape final { + bool enabled{false}; + std::int32_t dc_offset_permille{0}; + std::uint32_t harmonic_permille{0}; + std::uint8_t harmonic_order{2}; + std::uint32_t clip_permille{1000}; + + friend bool operator==(const SvCurrentWaveformShape&, const SvCurrentWaveformShape&) = default; +}; + struct SvLiveSignalState final { // Fundamental signal frequency in millihertz. This is deliberately // independent of the SV publisher event rate. std::uint32_t frequency_millihz{50000U}; std::array channels{}; + SvCurrentWaveformShape current_shape{}; std::uint64_t generation{}; friend bool operator==(const SvLiveSignalState&, const SvLiveSignalState&) = default; @@ -96,7 +108,9 @@ class SvLiveSignalBank final { } [[nodiscard]] static bool validate(const SvLiveSignalState& state) noexcept { - if (state.frequency_millihz < 1000U || state.frequency_millihz > 1000000U) { + // 0 mHz is the explicit DC mode. AC remains bounded to 1..1000 Hz. + if (state.frequency_millihz != 0U && + (state.frequency_millihz < 1000U || state.frequency_millihz > 1000000U)) { return false; } for (const auto& channel : state.channels) { @@ -108,6 +122,15 @@ class SvLiveSignalBank final { return false; } } + if (state.current_shape.dc_offset_permille < -3000 || + state.current_shape.dc_offset_permille > 3000 || + state.current_shape.harmonic_permille > 3000U || + state.current_shape.harmonic_order < 2U || + state.current_shape.harmonic_order > 63U || + state.current_shape.clip_permille < 10U || + state.current_shape.clip_permille > 10000U) { + return false; + } return true; } @@ -175,10 +198,27 @@ class SvFixedPointSineEngine final { continue; } + if (state.frequency_millihz == 0U) { + // In DC mode the engineering magnitude is an instantaneous + // signed value. Phase is intentionally ignored. + row[index] = channel.rms_counts; + continue; + } + const auto offset = phase_from_millidegrees(channel.phase_millidegrees); const std::uint32_t phase = base_phase + offset; const auto lut_index = static_cast(phase >> (32U - 12U)); - const std::int64_t sine_q30 = sine_lut_[lut_index & (kLiveSineLutSize - 1U)]; + std::int64_t sine_q30 = sine_lut_[lut_index & (kLiveSineLutSize - 1U)]; + if (index < 4U && state.current_shape.enabled) { + const auto harmonic_phase = phase * state.current_shape.harmonic_order; + const auto harmonic_index = static_cast(harmonic_phase >> (32U - 12U)); + const std::int64_t harmonic_q30 = sine_lut_[harmonic_index & (kLiveSineLutSize - 1U)]; + constexpr std::int64_t q30 = std::int64_t{1} << 30U; + sine_q30 += (harmonic_q30 * state.current_shape.harmonic_permille) / 1000LL; + sine_q30 += (q30 * state.current_shape.dc_offset_permille) / 1000LL; + const auto clip_q30 = (q30 * state.current_shape.clip_permille) / 1000LL; + sine_q30 = std::clamp(sine_q30, -clip_q30, clip_q30); + } const std::int64_t peak_counts = rms_to_peak_counts(channel.rms_counts); const std::int64_t value = (sine_q30 * peak_counts) >> 30U; row[index] = clamp_i32(value); diff --git a/tests/test_live_signal.cpp b/tests/test_live_signal.cpp index 0f353d30e..5b9becb30 100644 --- a/tests/test_live_signal.cpp +++ b/tests/test_live_signal.cpp @@ -41,10 +41,60 @@ void bank_commits_complete_generations() { CHECK(after.channels[4].rms_counts == 12000); CHECK(after.channels[4].phase_millidegrees == 15000); - auto invalid = after; - invalid.frequency_millihz = 0U; + auto dc = after; + dc.frequency_millihz = 0U; + CHECK(bank.publish(dc)); + CHECK(bank.snapshot().frequency_millihz == 0U); + + auto invalid = dc; + invalid.frequency_millihz = 500U; CHECK(!bank.publish(invalid)); - CHECK(bank.snapshot() == after); + CHECK(bank.snapshot().frequency_millihz == 0U); +} + +void dc_engine_outputs_constant_engineering_values() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.frequency_millihz = 0U; + state.channels[0].rms_counts = 1234; + state.channels[0].phase_millidegrees = -90000; + state.channels[1].rms_counts = -4321; + + const auto phase_before = engine.phase_accumulator(); + for (int sample = 0; sample < 12; ++sample) { + const auto row = engine.next(state, 4000U); + CHECK(row[0] == 1234); + CHECK(row[1] == -4321); + } + CHECK(engine.phase_accumulator() == phase_before); +} + +void ct_saturation_shape_is_bounded_and_asymmetric() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.current_shape.enabled = true; + state.current_shape.dc_offset_permille = 300; + state.current_shape.harmonic_permille = 280U; + state.current_shape.harmonic_order = 2U; + state.current_shape.clip_permille = 600U; + + std::int32_t maximum = std::numeric_limits::min(); + std::int32_t minimum = std::numeric_limits::max(); + std::int64_t sum{}; + for (int sample = 0; sample < 80; ++sample) { + const auto row = engine.next(state, 4000U); + maximum = std::max(maximum, row[0]); + minimum = std::min(minimum, row[0]); + sum += row[0]; + } + CHECK(maximum <= 850); + CHECK(minimum >= -850); + CHECK(maximum > 700); + CHECK(sum > 0); } void fixed_point_engine_preserves_frequency_and_phase() { @@ -121,6 +171,8 @@ int main() { try { bank_commits_complete_generations(); fixed_point_engine_preserves_frequency_and_phase(); + dc_engine_outputs_constant_engineering_values(); + ct_saturation_shape_is_bounded_and_asymmetric(); independent_channels_change_without_stream_restart(); std::cout << "[PASS] live Sampled Values signal state\n"; return 0; From 05b64e6ab33274a3ad568178c38c077eacd1b728 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 22:26:22 +0700 Subject: [PATCH 124/129] Honor negotiated MMS server limits (#67) Addresses the remaining actionable PR #51 review findings with embedded and live-client regression coverage. --- .../mms_dispatcher_hard_profile_smoke.cpp | 19 ++ ...s_negotiated_limits_hard_profile_smoke.cpp | 162 +++++++++++++++++- embedded/mms_pdu_hard_profile_smoke.cpp | 25 ++- include/ariec61850/acse/association_span.hpp | 5 + include/ariec61850/mms/pdu_span.hpp | 7 + src/acse/association_span.cpp | 159 +++++++++++++++-- src/mms/connection_runtime.cpp | 67 ++++++-- src/mms/connection_runtime_iedsim.cpp | 60 +++++-- src/mms/pdu_span.cpp | 77 +++++++++ src/mms/static_dispatcher.cpp | 6 + 10 files changed, 542 insertions(+), 45 deletions(-) diff --git a/embedded/mms_dispatcher_hard_profile_smoke.cpp b/embedded/mms_dispatcher_hard_profile_smoke.cpp index 234002136..04b902339 100644 --- a/embedded/mms_dispatcher_hard_profile_smoke.cpp +++ b/embedded/mms_dispatcher_hard_profile_smoke.cpp @@ -68,6 +68,17 @@ constexpr std::array kReadRequest{ 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, 0x1AU, 0x02U, 0x4DU, 0x31U}; +constexpr std::array kReadWithSpecificationRequest{ + 0xA0U, 0x2AU, 0x02U, 0x01U, 0x0DU, + 0xA4U, 0x25U, 0x80U, 0x01U, 0xFFU, + 0xA1U, 0x20U, 0xA0U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; + constexpr std::array kReadResponse{ 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, 0xA4U, 0x08U, 0xA1U, 0x06U, @@ -239,6 +250,14 @@ int main() { return 6; } + dispatched = dispatcher.dispatch( + kReadWithSpecificationRequest, response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::unsupported_request || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.invoke_id != 13U) { + return 30; + } + std::array tiny_workspace{}; dispatched = dispatcher.dispatch(kReadRequest, response, tiny_workspace); if (dispatched.status != mms::MmsStaticDispatchStatus::workspace_too_small || diff --git a/embedded/mms_negotiated_limits_hard_profile_smoke.cpp b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp index e6ab2f5bf..c3ba8b7ed 100644 --- a/embedded/mms_negotiated_limits_hard_profile_smoke.cpp +++ b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp @@ -68,6 +68,32 @@ constexpr std::array kReadRequest{ return {wire::EncodeStatus::ok, required, required}; } +[[nodiscard]] mms::MmsStaticWriteResult write_boolean( + void* context, + const std::span encoded_data) noexcept { + if (context == nullptr || encoded_data.size() != 3U || + encoded_data[0] != 0x83U || encoded_data[1] != 0x01U) { + return {false, 10U}; + } + *static_cast(context) = encoded_data[2] != 0U; + return {true, 0U}; +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 3U}; + } + if (destination.size() < 3U) { + return {wire::EncodeStatus::buffer_too_small, 0U, 3U}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, 3U, 3U}; +} + [[nodiscard]] bool set_maximum_mms_pdu( std::array& association, const std::uint32_t value) noexcept { @@ -94,6 +120,82 @@ constexpr std::array kReadRequest{ return false; } +[[nodiscard]] bool set_initiate_negotiation( + std::array& association, + const std::uint8_t outstanding_calling, + const std::uint8_t outstanding_called, + const std::uint8_t nesting_level) noexcept { + bool calling_set = false; + bool called_set = false; + bool nesting_set = false; + for (std::size_t index = 0U; index + 3U <= association.size(); ++index) { + if (association[index + 1U] != 0x01U) { + continue; + } + if (!calling_set && association[index] == 0x81U) { + association[index + 2U] = outstanding_calling; + calling_set = true; + } else if (!called_set && association[index] == 0x82U) { + association[index + 2U] = outstanding_called; + called_set = true; + } else if (!nesting_set && association[index] == 0x83U) { + association[index + 2U] = nesting_level; + nesting_set = true; + } + } + return calling_set && called_set && nesting_set; +} + +[[nodiscard]] bool decode_initiate_response( + const std::span response, + mms::MmsInitiateView& initiate) noexcept { + for (std::size_t index = 0U; index + 2U <= response.size(); ++index) { + if (response[index] != 0xA9U) { + continue; + } + const auto content = static_cast(response[index + 1U]); + if ((response[index + 1U] & 0x80U) != 0U || + content > response.size() - index - 2U) { + continue; + } + if (mms::MmsPduSpanCodec::try_decode_initiate_response_view( + response.subspan(index, content + 2U), initiate)) { + return true; + } + } + return false; +} + +[[nodiscard]] wire::EncodeResult build_ten_variable_write( + std::array& output) noexcept { + constexpr std::array definition{ + 0x30U,0x0DU,0xA0U,0x0BU,0xA1U,0x09U, + 0x1AU,0x03U,0x4CU,0x44U,0x30U, + 0x1AU,0x02U,0x57U,0x31U}; + std::array service{}; + std::size_t offset = 0U; + service[offset++] = 0xA0U; + service[offset++] = 0x81U; + service[offset++] = 0x96U; + for (std::size_t index = 0U; index < 10U; ++index) { + for (const auto byte : definition) { + service[offset++] = byte; + } + } + service[offset++] = 0xA0U; + service[offset++] = 0x1EU; + for (std::size_t index = 0U; index < 10U; ++index) { + service[offset++] = 0x83U; + service[offset++] = 0x01U; + service[offset++] = 0xFFU; + } + if (offset != service.size()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + return mms::MmsPduSpanCodec::encode_confirmed_request_into( + 20U, 5, true, service, output); +} + template [[nodiscard]] wire::EncodeResult build_data_tpkt( const std::span payload, @@ -164,9 +266,17 @@ template std::span request, std::span response, std::span workspace, - std::span scratch) noexcept { + std::span scratch, + const std::uint8_t outstanding_calling = 10U, + const std::uint8_t outstanding_called = 10U, + const std::uint8_t nesting_level = 5U) noexcept { auto association = kAssociationRequest; - if (!set_maximum_mms_pdu(association, maximum_mms_pdu)) { + if (!set_maximum_mms_pdu(association, maximum_mms_pdu) || + !set_initiate_negotiation( + association, + outstanding_calling, + outstanding_called, + nesting_level)) { return {}; } const auto cotp = osi::CotpSpanCodec::encode_data_into(association, scratch); @@ -189,18 +299,26 @@ int main() { for (std::size_t index = 0U; index < large_value.size(); ++index) { large_value[index] = static_cast(index); } - const std::array objects{ + bool write_state = false; + constexpr std::array boolean_type{0x83U,0x00U}; + const std::array objects{ mms::MmsStaticObjectEntry{ "LD0", "R1", kOctet80Type, read_large_octets, &large_value, false}, mms::MmsStaticObjectEntry{ - "LD0", "M1", kOctet80Type, read_large_octets, &large_value, false}}; + "LD0", "M1", kOctet80Type, read_large_octets, &large_value, false}, + mms::MmsStaticObjectEntry{ + "LD0", "W1", boolean_type, read_boolean, &write_state, false, + write_boolean, &write_state}}; const mms::MmsStaticObjectTable object_table{objects}; const std::array members{ mms::MmsStaticDataSetMember{"LD0", "R1"}}; const std::array data_sets{ mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; const mms::MmsStaticDataSetTable data_set_table{data_sets}; - const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; + mms::MmsStaticDispatchPolicy dispatch_policy; + dispatch_policy.maximum_write_variables = 10U; + const mms::MmsStaticApplicationDispatcher dispatcher{ + object_table, data_set_table, dispatch_policy}; if (!object_table.valid() || !data_set_table.valid_against(object_table)) { return 1; } @@ -236,14 +354,44 @@ int main() { if (!connect_cotp(runtime, 0x09U, request, response, workspace, scratch)) { return 4; } - const auto association = associate(runtime, 64U, request, response, workspace, scratch); + const auto association = associate( + runtime, 64U, request, response, workspace, scratch, 2U, 3U, 1U); + mms::MmsInitiateView negotiated; if (!association.response_ready() || runtime.state() != mms::MmsStaticConnectionState::established || runtime.negotiated_tpdu_size_bytes() != 512U || - runtime.negotiated_mms_pdu_size() != 64U) { + runtime.negotiated_mms_pdu_size() != 64U || + !decode_initiate_response( + std::span{response}.first(association.bytes_written), + negotiated) || + negotiated.maximum_mms_pdu_size != 64U || + negotiated.maximum_outstanding_calling != 2U || + negotiated.maximum_outstanding_called != 3U || + negotiated.data_structure_nesting_level != 1U) { return 5; } + std::array write_pdu{}; + const auto write_encoded = build_ten_variable_write(write_pdu); + const auto write_tpkt = write_encoded.success() + ? build_mms_tpkt( + std::span{write_pdu}.first(write_encoded.bytes_written), + request, + presentation, + scratch) + : wire::EncodeResult{}; + if (!write_tpkt.success()) { + return 13; + } + const auto write_result = runtime.process_tcp_window( + std::span{request}.first(write_tpkt.bytes_written), + response, + workspace); + if (write_result.status != mms::MmsStaticConnectionStatus::peer_limit_exceeded || + write_result.bytes_written != 0U || write_state) { + return 14; + } + // The two large Read results exceed the peer's 64-byte MMS PDU offer. No // oversized confirmed response may be placed on the wire. const auto read_tpkt = build_mms_tpkt( diff --git a/embedded/mms_pdu_hard_profile_smoke.cpp b/embedded/mms_pdu_hard_profile_smoke.cpp index 270e2c08b..89ebf9079 100644 --- a/embedded/mms_pdu_hard_profile_smoke.cpp +++ b/embedded/mms_pdu_hard_profile_smoke.cpp @@ -106,6 +106,29 @@ int main() { return 3; } + const auto dynamic_default = mms::MmsPduSpanCodec::encode_initiate_response_into( + 65'000U, 10U, 10U, 5U, buffer); + if (!dynamic_default.success() || + dynamic_default.bytes_written != kInitiateResponse.size() || + !matches( + std::span{buffer}.first(dynamic_default.bytes_written), + kInitiateResponse)) { + return 30; + } + + const auto negotiated = mms::MmsPduSpanCodec::encode_initiate_response_into( + 1'024U, 2U, 3U, 1U, buffer); + if (!negotiated.success() || + !mms::MmsPduSpanCodec::try_decode_initiate_response_view( + std::span{buffer}.first(negotiated.bytes_written), + initiate) || + initiate.maximum_mms_pdu_size != 1'024U || + initiate.maximum_outstanding_calling != 2U || + initiate.maximum_outstanding_called != 3U || + initiate.data_structure_nesting_level != 1U) { + return 31; + } + constexpr std::array confirmed_request_golden{ 0xA0U, 0x05U, 0x02U, 0x01U, 0x07U, 0xA4U, 0x00U}; const auto confirmed_request_result = @@ -229,4 +252,4 @@ int main() { } return 0; -} \ No newline at end of file +} diff --git a/include/ariec61850/acse/association_span.hpp b/include/ariec61850/acse/association_span.hpp index d8446b363..7e7337297 100644 --- a/include/ariec61850/acse/association_span.hpp +++ b/include/ariec61850/acse/association_span.hpp @@ -79,6 +79,11 @@ class AcseSpanCodec final { [[nodiscard]] static wire::EncodeResult build_accept_response_into( const AssociationRequestView& request, std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult build_accept_response_into( + const AssociationRequestView& request, + std::span mms_initiate_response, + std::span destination) noexcept; }; } // namespace ar::iec61850::acse diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp index 145d1cf25..94bf40ddd 100644 --- a/include/ariec61850/mms/pdu_span.hpp +++ b/include/ariec61850/mms/pdu_span.hpp @@ -76,6 +76,13 @@ class MmsPduSpanCodec final { [[nodiscard]] static wire::EncodeResult encode_default_initiate_response_into( std::span destination) noexcept; + [[nodiscard]] static wire::EncodeResult encode_initiate_response_into( + std::uint32_t maximum_mms_pdu_size, + std::uint32_t maximum_outstanding_calling, + std::uint32_t maximum_outstanding_called, + std::uint32_t data_structure_nesting_level, + std::span destination) noexcept; + [[nodiscard]] static bool try_decode_confirmed_request_view( std::span bytes, MmsConfirmedPduView& request) noexcept; diff --git a/src/acse/association_span.cpp b/src/acse/association_span.cpp index ee976a8f4..4caa894db 100644 --- a/src/acse/association_span.cpp +++ b/src/acse/association_span.cpp @@ -3,6 +3,7 @@ #include "ariec61850/acse/association_span.hpp" #include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" #include #include @@ -69,6 +70,115 @@ constexpr std::array kDefaultAcceptAare{ 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; +[[nodiscard]] std::optional accept_aare_size( + const std::size_t mms_initiate_response_bytes) noexcept { + const auto application_context_oid = asn1::BerSpanWriter::tlv_size( + 6, kMmsApplicationContext.size()); + const auto application_context = application_context_oid + ? asn1::BerSpanWriter::tlv_size(1, *application_context_oid) + : std::nullopt; + const auto result_integer = asn1::BerSpanWriter::tlv_size(2, 1U); + const auto result = result_integer + ? asn1::BerSpanWriter::tlv_size(2, *result_integer) + : std::nullopt; + const auto diagnostic_integer = asn1::BerSpanWriter::tlv_size(2, 1U); + const auto diagnostic_choice = diagnostic_integer + ? asn1::BerSpanWriter::tlv_size(1, *diagnostic_integer) + : std::nullopt; + const auto diagnostic = diagnostic_choice + ? asn1::BerSpanWriter::tlv_size(3, *diagnostic_choice) + : std::nullopt; + const auto direct_reference = asn1::BerSpanWriter::tlv_size(6, 2U); + const auto indirect_reference = asn1::BerSpanWriter::tlv_size(2, 1U); + const auto single_asn1_type = asn1::BerSpanWriter::tlv_size( + 0, mms_initiate_response_bytes); + if (!application_context || !result || !diagnostic || + !direct_reference || !indirect_reference || !single_asn1_type) { + return std::nullopt; + } + const auto external_content = + *direct_reference + *indirect_reference + *single_asn1_type; + const auto external = asn1::BerSpanWriter::tlv_size(8, external_content); + if (!external) { + return std::nullopt; + } + const auto user_information = asn1::BerSpanWriter::tlv_size(30, *external); + if (!user_information) { + return std::nullopt; + } + const auto aare_content = + *application_context + *result + *diagnostic + *user_information; + return asn1::BerSpanWriter::tlv_size(1, aare_content); +} + +[[nodiscard]] wire::EncodeResult encode_accept_aare_into( + const std::span mms_initiate_response, + const std::span destination) noexcept { + const auto required = accept_aare_size(mms_initiate_response.size()); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + const auto application_context_oid = asn1::BerSpanWriter::tlv_size( + 6, kMmsApplicationContext.size()).value(); + const auto result_integer = asn1::BerSpanWriter::tlv_size(2, 1U).value(); + const auto diagnostic_integer = asn1::BerSpanWriter::tlv_size(2, 1U).value(); + const auto diagnostic_choice = asn1::BerSpanWriter::tlv_size( + 1, diagnostic_integer).value(); + const auto direct_reference = asn1::BerSpanWriter::tlv_size(6, 2U).value(); + const auto indirect_reference = asn1::BerSpanWriter::tlv_size(2, 1U).value(); + const auto single_asn1_type = asn1::BerSpanWriter::tlv_size( + 0, mms_initiate_response.size()).value(); + const auto external_content = + direct_reference + indirect_reference + single_asn1_type; + const auto external = asn1::BerSpanWriter::tlv_size(8, external_content).value(); + const auto user_information = asn1::BerSpanWriter::tlv_size(30, external).value(); + const auto application_context = asn1::BerSpanWriter::tlv_size( + 1, application_context_oid).value(); + const auto result = asn1::BerSpanWriter::tlv_size(2, result_integer).value(); + const auto diagnostic = asn1::BerSpanWriter::tlv_size( + 3, diagnostic_choice).value(); + const auto aare_content = application_context + result + diagnostic + user_information; + + constexpr std::array direct_reference_oid{0x51U, 0x01U}; + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header(asn1::BerClass::application, true, 1, aare_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, application_context_oid) || + !writer.write_tlv( + asn1::BerClass::universal, false, 6, kMmsApplicationContext) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, result_integer) || + !writer.write_tlv_header(asn1::BerClass::universal, false, 2, 1U) || + !writer.write_byte(0U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 3, diagnostic_choice) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, diagnostic_integer) || + !writer.write_tlv_header(asn1::BerClass::universal, false, 2, 1U) || + !writer.write_byte(0U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 30, external) || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 8, external_content) || + !writer.write_tlv( + asn1::BerClass::universal, false, 6, direct_reference_oid) || + !writer.write_tlv_header(asn1::BerClass::universal, false, 2, 1U) || + !writer.write_byte(3U) || + !writer.write_tlv( + asn1::BerClass::context_specific, + true, + 0, + mms_initiate_response) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + [[nodiscard]] bool valid_oid( const std::span value, const bool allow_empty = false) noexcept { @@ -375,24 +485,41 @@ std::optional AcseSpanCodec::accept_response_size( wire::EncodeResult AcseSpanCodec::build_accept_response_into( const AssociationRequestView& request, const std::span destination) noexcept { - const auto required = accept_response_size(request); - if (!required) { + return build_accept_response_into(request, kDefaultMmsInitiateResponse, destination); +} + +wire::EncodeResult AcseSpanCodec::build_accept_response_into( + const AssociationRequestView& request, + const std::span mms_initiate_response, + const std::span destination) noexcept { + const auto aare_size = accept_aare_size(mms_initiate_response.size()); + if (!aare_size || !request_is_valid_for_response(request)) { return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; } - if (destination.size() < *required) { - return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + const auto cpa_size = osi::PresentationSpanCodec::cpa_accepting_size( + request.presentation, *aare_size); + if (!cpa_size || *cpa_size > std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto user_parameter_bytes = 2U + *cpa_size; + if (user_parameter_bytes > std::numeric_limits::max() || + request.session.parameter_bytes.size() > + std::numeric_limits::max() - user_parameter_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = + request.session.parameter_bytes.size() + user_parameter_bytes + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; } - std::array aare{}; - const auto aare_result = encode_default_accept_aare_into(aare); - const auto cpa_size = osi::PresentationSpanCodec::cpa_accepting_size( - request.presentation, aare_result.bytes_written); - if (!aare_result.success() || !cpa_size || - *cpa_size > std::numeric_limits::max()) { - return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + std::array aare{}; + const auto aare_result = encode_accept_aare_into(mms_initiate_response, aare); + if (!aare_result.success() || aare_result.bytes_written != *aare_size) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; } - const auto body_bytes = *required - 2U; + const auto body_bytes = required - 2U; destination[0] = osi::SessionSpanCodec::accept_code; destination[1] = static_cast(body_bytes); std::copy( @@ -409,14 +536,14 @@ wire::EncodeResult AcseSpanCodec::build_accept_response_into( std::span{aare}.first(aare_result.bytes_written), destination.subspan(offset, *cpa_size)); if (!cpa_result.success() || cpa_result.bytes_written != *cpa_size) { - return {cpa_result.status, 0U, *required}; + return {cpa_result.status, 0U, required}; } offset += cpa_result.bytes_written; - if (offset != *required) { - return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + if (offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; } - return {wire::EncodeStatus::ok, *required, *required}; + return {wire::EncodeStatus::ok, required, required}; } } // namespace ar::iec61850::acse diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index 5f8b9cc7b..cd402f460 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -22,6 +22,9 @@ namespace { // Must remain aligned with MmsPduSpanCodec::encode_default_initiate_response_into(). constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; +constexpr std::uint32_t kServerMaximumOutstandingCalling = 10U; +constexpr std::uint32_t kServerMaximumOutstandingCalled = 10U; +constexpr std::uint32_t kServerMaximumNestingLevel = 5U; [[nodiscard]] MmsStaticConnectionResult make_result( const MmsStaticConnectionStatus status, @@ -171,8 +174,10 @@ constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; const MmsConfirmedPduView& confirmed, const MmsStaticApplicationDispatcher& dispatcher, const std::uint32_t presentation_context_id, - std::size_t& required) noexcept { - required = 0U; + std::size_t& mms_required, + std::size_t& frame_required) noexcept { + mms_required = 0U; + frame_required = 0U; if (confirmed.service() != MmsWireConfirmedService::write) { return false; } @@ -198,9 +203,10 @@ constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; probe.required_bytes == 0U) { return false; } + mms_required = probe.required_bytes; const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( presentation_context_id, probe.required_bytes); - return fully_encoded && add_overhead(*fully_encoded, 11U, required); + return fully_encoded && add_overhead(*fully_encoded, 11U, frame_required); } } // namespace @@ -371,8 +377,32 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( const auto selected_mms_pdu_size = std::min( initiate.maximum_mms_pdu_size, kServerMaximumMmsPduSize); + const auto selected_outstanding_calling = std::min( + initiate.maximum_outstanding_calling, + kServerMaximumOutstandingCalling); + const auto selected_outstanding_called = std::min( + initiate.maximum_outstanding_called, + kServerMaximumOutstandingCalled); + const auto selected_nesting_level = std::min( + initiate.data_structure_nesting_level, + kServerMaximumNestingLevel); + std::array initiate_response{}; + const auto initiate_encoded = MmsPduSpanCodec::encode_initiate_response_into( + selected_mms_pdu_size, + selected_outstanding_calling, + selected_outstanding_called, + selected_nesting_level, + initiate_response); + if (!initiate_encoded.success()) { + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + const auto accept = acse::AcseSpanCodec::build_accept_response_into( - association, workspace); + association, + std::span{initiate_response}.first( + initiate_encoded.bytes_written), + workspace); if (!accept.success()) { if (accept.status == wire::EncodeStatus::buffer_too_small) { return make_workspace_capacity(state_, accept.required_bytes); @@ -426,14 +456,29 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( const bool is_confirmed_request = MmsPduSpanCodec::try_decode_confirmed_request_view( pdv.single_asn1_type, confirmed); if (is_confirmed_request) { - std::size_t write_required{}; + std::size_t write_mms_required{}; + std::size_t write_frame_required{}; if (write_outer_capacity( - confirmed, dispatcher_, mms_presentation_context_id_, write_required)) { - if (response.size() < write_required) { - return make_response_capacity(state_, write_required); + confirmed, + dispatcher_, + mms_presentation_context_id_, + write_mms_required, + write_frame_required)) { + if (negotiated_mms_pdu_size_ == 0U || + write_mms_required > negotiated_mms_pdu_size_ || + write_frame_required < osi::TpktSpanCodec::header_length || + write_frame_required - osi::TpktSpanCodec::header_length > + negotiated_tpdu_size_bytes_) { + return make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state_, + peek.frame_bytes); + } + if (response.size() < write_frame_required) { + return make_response_capacity(state_, write_frame_required); } - if (workspace.size() < write_required) { - return make_workspace_capacity(state_, write_required); + if (workspace.size() < write_frame_required) { + return make_workspace_capacity(state_, write_frame_required); } } } @@ -548,4 +593,4 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return wrapped; } -} // namespace ar::iec61850::mms \ No newline at end of file +} // namespace ar::iec61850::mms diff --git a/src/mms/connection_runtime_iedsim.cpp b/src/mms/connection_runtime_iedsim.cpp index 44b4bc836..6da4e90a8 100644 --- a/src/mms/connection_runtime_iedsim.cpp +++ b/src/mms/connection_runtime_iedsim.cpp @@ -22,6 +22,9 @@ namespace { // Must remain aligned with MmsPduSpanCodec::encode_default_initiate_response_into(). constexpr std::uint32_t kServerMaximumMmsPduSize = 65'000U; +constexpr std::uint32_t kServerMaximumOutstandingCalling = 10U; +constexpr std::uint32_t kServerMaximumOutstandingCalled = 10U; +constexpr std::uint32_t kServerMaximumNestingLevel = 5U; constexpr std::array kConservativeStructureType{ 0xA2U, 0x02U, 0xA1U, 0x00U}; @@ -287,8 +290,10 @@ constexpr std::array kEmptyFileDirectoryFields{ const MmsStaticApplicationDispatcher& dispatcher, const std::uint32_t presentation_context_id, const std::size_t negotiated_tpdu_size_bytes, - std::size_t& required) noexcept { - required = 0U; + std::size_t& mms_required, + std::size_t& frame_required) noexcept { + mms_required = 0U; + frame_required = 0U; if (confirmed.service() != MmsWireConfirmedService::write) { return false; } @@ -314,6 +319,7 @@ constexpr std::array kEmptyFileDirectoryFields{ probe.required_bytes == 0U) { return false; } + mms_required = probe.required_bytes; const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( presentation_context_id, probe.required_bytes); if (!fully_encoded) { @@ -326,7 +332,7 @@ constexpr std::array kEmptyFileDirectoryFields{ negotiated_tpdu_size_bytes, maximum_user_data, segment_count, - required); + frame_required); } [[nodiscard]] bool compatibility_error_status( @@ -615,8 +621,33 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( const auto selected_mms_pdu_size = std::min( initiate.maximum_mms_pdu_size, kServerMaximumMmsPduSize); + const auto selected_outstanding_calling = std::min( + initiate.maximum_outstanding_calling, + kServerMaximumOutstandingCalling); + const auto selected_outstanding_called = std::min( + initiate.maximum_outstanding_called, + kServerMaximumOutstandingCalled); + const auto selected_nesting_level = std::min( + initiate.data_structure_nesting_level, + kServerMaximumNestingLevel); + std::array initiate_response{}; + const auto initiate_encoded = MmsPduSpanCodec::encode_initiate_response_into( + selected_mms_pdu_size, + selected_outstanding_calling, + selected_outstanding_called, + selected_nesting_level, + initiate_response); + if (!initiate_encoded.success()) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + const auto accept = acse::AcseSpanCodec::build_accept_response_into( - association, workspace); + association, + std::span{initiate_response}.first( + initiate_encoded.bytes_written), + workspace); if (!accept.success()) { if (accept.status == wire::EncodeStatus::buffer_too_small) { return make_workspace_capacity(state_, accept.required_bytes); @@ -739,18 +770,27 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( confirmed.invoke_id)); } - std::size_t write_required{}; + std::size_t write_mms_required{}; + std::size_t write_frame_required{}; if (write_outer_capacity( confirmed, dispatcher_, mms_presentation_context_id_, negotiated_tpdu_size_bytes_, - write_required)) { - if (response.size() < write_required) { - return make_response_capacity(state_, write_required); + write_mms_required, + write_frame_required)) { + if (negotiated_mms_pdu_size_ == 0U || + write_mms_required > negotiated_mms_pdu_size_) { + return finish(make_result( + MmsStaticConnectionStatus::peer_limit_exceeded, + state_, + peek.frame_bytes)); + } + if (response.size() < write_frame_required) { + return make_response_capacity(state_, write_frame_required); } - if (workspace.size() < write_required) { - return make_workspace_capacity(state_, write_required); + if (workspace.size() < write_frame_required) { + return make_workspace_capacity(state_, write_frame_required); } } } diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp index d03b83bae..e58584ba8 100644 --- a/src/mms/pdu_span.cpp +++ b/src/mms/pdu_span.cpp @@ -32,6 +32,12 @@ constexpr std::array kDefaultInitiateResponse{ 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; +constexpr std::array kInitiateResponseDetail{ + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0x4EU, 0x08U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U}; + [[nodiscard]] bool read_u32( const asn1::BerTlvView& tlv, std::uint32_t& value) noexcept { @@ -420,6 +426,77 @@ wire::EncodeResult MmsPduSpanCodec::encode_default_initiate_response_into( kDefaultInitiateResponse.size()}; } +wire::EncodeResult MmsPduSpanCodec::encode_initiate_response_into( + const std::uint32_t maximum_mms_pdu_size, + const std::uint32_t maximum_outstanding_calling, + const std::uint32_t maximum_outstanding_called, + const std::uint32_t data_structure_nesting_level, + const std::span destination) noexcept { + if (maximum_mms_pdu_size < 64U || + maximum_mms_pdu_size > maximum_pdu_bytes || + maximum_outstanding_calling == 0U || + maximum_outstanding_called == 0U || + data_structure_nesting_level == 0U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const std::array values{ + maximum_mms_pdu_size, + maximum_outstanding_calling, + maximum_outstanding_called, + data_structure_nesting_level}; + std::array value_sizes{}; + std::size_t content_size = 0U; + for (std::size_t index = 0U; index < values.size(); ++index) { + value_sizes[index] = positive_integer_size(values[index]); + const auto field = asn1::BerSpanWriter::tlv_size( + static_cast(index), value_sizes[index]); + if (!field || *field > std::numeric_limits::max() - content_size) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + content_size += *field; + } + const auto detail = asn1::BerSpanWriter::tlv_size(4, kInitiateResponseDetail.size()); + if (!detail || *detail > std::numeric_limits::max() - content_size) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + content_size += *detail; + const auto required = asn1::BerSpanWriter::tlv_size(9, content_size); + if (!required || destination.size() < *required) { + return { + required ? wire::EncodeStatus::buffer_too_small + : wire::EncodeStatus::value_out_of_range, + 0U, + required.value_or(0U)}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 9, content_size)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + for (std::size_t index = 0U; index < values.size(); ++index) { + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, + false, + static_cast(index), + value_sizes[index]) || + !write_positive_integer(writer, values[index])) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, + true, + 4, + kInitiateResponseDetail.size()) || + !writer.write_bytes(kInitiateResponseDetail) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + bool MmsPduSpanCodec::try_decode_confirmed_request_view( const std::span bytes, MmsConfirmedPduView& request) noexcept { diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp index 919fd964e..63d5a3c1f 100644 --- a/src/mms/static_dispatcher.cpp +++ b/src/mms/static_dispatcher.cpp @@ -297,6 +297,12 @@ namespace { if (!MmsServiceSpanCodec::try_decode_read_request(confirmed, request)) { return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); } + if (request.specification_with_result) { + // This bounded profile does not encode variableAccessSpecification in + // Read responses. Reject the optional request form explicitly before + // invoking any object callbacks instead of returning an incomplete PDU. + return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + } std::array results{}; std::size_t workspace_offset = 0U; From ec7daefc8e8e3e032e9ecaba9f381b15c7dfb406 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 22:27:45 +0700 Subject: [PATCH 125/129] Fix CT waveform int64 portability --- include/ariec61850/sampled_values/live_signal_state.hpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp index cfad3c3a8..7291def0c 100644 --- a/include/ariec61850/sampled_values/live_signal_state.hpp +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -214,9 +214,10 @@ class SvFixedPointSineEngine final { const auto harmonic_index = static_cast(harmonic_phase >> (32U - 12U)); const std::int64_t harmonic_q30 = sine_lut_[harmonic_index & (kLiveSineLutSize - 1U)]; constexpr std::int64_t q30 = std::int64_t{1} << 30U; - sine_q30 += (harmonic_q30 * state.current_shape.harmonic_permille) / 1000LL; - sine_q30 += (q30 * state.current_shape.dc_offset_permille) / 1000LL; - const auto clip_q30 = (q30 * state.current_shape.clip_permille) / 1000LL; + sine_q30 += (harmonic_q30 * state.current_shape.harmonic_permille) / std::int64_t{1000}; + sine_q30 += (q30 * state.current_shape.dc_offset_permille) / std::int64_t{1000}; + const std::int64_t clip_q30 = + (q30 * state.current_shape.clip_permille) / std::int64_t{1000}; sine_q30 = std::clamp(sine_q30, -clip_q30, clip_q30); } const std::int64_t peak_counts = rms_to_peak_counts(channel.rms_counts); From 3adc52cf5bd60c0730e4acf9c84fc92ed1316427 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 22:37:37 +0700 Subject: [PATCH 126/129] Return ConfirmedError for missing MMS objects --- src/mms/connection_runtime.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index cd402f460..c131f4af2 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -158,8 +158,8 @@ constexpr std::uint32_t kServerMaximumNestingLevel = 5U; return MmsConfirmedRequestRejectReason::unrecognized_service; case MmsStaticDispatchStatus::malformed_request: case MmsStaticDispatchStatus::unsupported_request: - case MmsStaticDispatchStatus::object_not_found: return MmsConfirmedRequestRejectReason::invalid_argument; + case MmsStaticDispatchStatus::object_not_found: case MmsStaticDispatchStatus::response_ready: case MmsStaticDispatchStatus::invalid_object_table: case MmsStaticDispatchStatus::workspace_too_small: @@ -531,10 +531,14 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return result; } - const auto rejected = MmsPduSpanCodec::encode_confirmed_request_reject_into( - confirmed.invoke_id, - reject_reason_for(application.status), - response); + const auto rejected = application.status == MmsStaticDispatchStatus::object_not_found + ? MmsPduSpanCodec::encode_confirmed_error_into( + confirmed.invoke_id, + response) + : MmsPduSpanCodec::encode_confirmed_request_reject_into( + confirmed.invoke_id, + reject_reason_for(application.status), + response); if (!rejected.success()) { if (rejected.status == wire::EncodeStatus::buffer_too_small) { const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( @@ -593,4 +597,4 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return wrapped; } -} // namespace ar::iec61850::mms +} // namespace ar::iec61850::mms \ No newline at end of file From acaca9496ef8ea47eb26ab83725c53932f6bd1db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 22:41:49 +0700 Subject: [PATCH 127/129] Regress strict missing-object ConfirmedError --- ..._connection_runtime_hard_profile_smoke.cpp | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/embedded/mms_connection_runtime_hard_profile_smoke.cpp b/embedded/mms_connection_runtime_hard_profile_smoke.cpp index 976e6cd5f..b2f05de1d 100644 --- a/embedded/mms_connection_runtime_hard_profile_smoke.cpp +++ b/embedded/mms_connection_runtime_hard_profile_smoke.cpp @@ -85,6 +85,16 @@ constexpr std::array kWriteResponse{ constexpr std::array kIdentifyReject{ 0xA4U, 0x06U, 0x80U, 0x01U, 0x15U, 0x81U, 0x01U, 0x01U}; +constexpr std::array kMissingAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x16U, + 0xA6U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x5AU, 0x5AU}; + +constexpr std::array kMissingObjectConfirmedError{ + 0xA2U, 0x0AU, 0x80U, 0x01U, 0x16U, + 0xA2U, 0x05U, 0xA0U, 0x03U, 0x87U, 0x01U, 0x02U}; + template [[nodiscard]] bool matches( const std::span actual, @@ -361,6 +371,28 @@ int main() { return 11; } + const auto missing_attributes_tpkt = build_mms_tpkt( + kMissingAttributesRequest, request, presentation, scratch); + if (!missing_attributes_tpkt.success()) { + return 18; + } + result = runtime.process_tcp_window( + std::span{request}.first(missing_attributes_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || + result.application_status != mms::MmsStaticDispatchStatus::object_not_found || + result.application_service != + mms::MmsWireConfirmedService::get_variable_access_attributes || + result.invoke_id != 22U || + result.consumed_bytes != missing_attributes_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::established || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kMissingObjectConfirmedError)) { + return 19; + } + const auto write_tpkt = build_mms_tpkt( kWriteRequest, request, presentation, scratch); if (!write_tpkt.success() || !relay_state) { From 0ffce108a2e2750f5ffdb861e88c458da0036b62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 22:47:30 +0700 Subject: [PATCH 128/129] Keep strict ConfirmedError inside bounded runtime --- src/mms/connection_runtime.cpp | 88 ++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index c131f4af2..8d3caf4d0 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -3,6 +3,7 @@ #include "ariec61850/mms/connection_runtime.hpp" #include "ariec61850/acse/association_span.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" #include "ariec61850/mms/pdu_span.hpp" #include "ariec61850/mms/services_span.hpp" #include "ariec61850/osi/cotp_span.hpp" @@ -170,6 +171,89 @@ constexpr std::uint32_t kServerMaximumNestingLevel = 5U; return MmsConfirmedRequestRejectReason::other; } +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + std::size_t bytes = 1U; + auto remaining = value; + while (remaining > 0xFFU) { + ++bytes; + remaining >>= 8U; + } + const auto leading = static_cast( + value >> static_cast((bytes - 1U) * 8U)); + return bytes + ((leading & 0x80U) != 0U ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value, + const std::size_t bytes) noexcept { + const auto encoded_bytes = bytes - + ((bytes > 1U && + (value >> static_cast((bytes - 2U) * 8U)) <= 0xFFU) + ? 1U + : 0U); + if (encoded_bytes < bytes && !writer.write_byte(0U)) { + return false; + } + for (std::size_t index = encoded_bytes; index > 0U; --index) { + const auto shift = static_cast((index - 1U) * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] wire::EncodeResult encode_object_not_found_confirmed_error( + const std::uint32_t invoke_id, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + // ConfirmedErrorPDU ::= [2] { invokeID [0], serviceError [2] } + // serviceError.errorClass uses access[7] object-non-existent(2). + const auto invoke_bytes = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(0, invoke_bytes); + const auto access_tlv = asn1::BerSpanWriter::tlv_size(7, 1U); + if (!invoke_tlv || !access_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto error_class_tlv = asn1::BerSpanWriter::tlv_size(0, *access_tlv); + if (!error_class_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto service_error_tlv = asn1::BerSpanWriter::tlv_size(2, *error_class_tlv); + if (!service_error_tlv || + *invoke_tlv > std::numeric_limits::max() - *service_error_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content = *invoke_tlv + *service_error_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(2, content); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header(asn1::BerClass::context_specific, true, 2, content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, invoke_bytes) || + !write_positive_integer(writer, invoke_id, invoke_bytes) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, *error_class_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *access_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 7, 1U) || + !writer.write_byte(2U) || writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + [[nodiscard]] bool write_outer_capacity( const MmsConfirmedPduView& confirmed, const MmsStaticApplicationDispatcher& dispatcher, @@ -532,9 +616,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( } const auto rejected = application.status == MmsStaticDispatchStatus::object_not_found - ? MmsPduSpanCodec::encode_confirmed_error_into( - confirmed.invoke_id, - response) + ? encode_object_not_found_confirmed_error(confirmed.invoke_id, response) : MmsPduSpanCodec::encode_confirmed_request_reject_into( confirmed.invoke_id, reject_reason_for(application.status), From 472b3fcca9e2072b54f6a9b99e42534e3d423ed7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 22:54:07 +0700 Subject: [PATCH 129/129] Harden Qt live injection safety gates --- apps/arstack_studio/qml/SignalMatrix.qml | 2 +- .../esp32p4_profile_support.hpp | 38 +++++++++++++++++++ .../sampled_values/live_signal_state.hpp | 14 ++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 092233d36..1b0806538 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -133,7 +133,7 @@ Rectangle { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) matrix.controller.refreshPreview() - if (matrix.device.connected) + if (matrix.device.deviceVerified) matrix.device.setEnabled(signalRow.sid, checked) } } diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp index 75be57a27..6123f21ad 100644 --- a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -3,6 +3,7 @@ #include "ariec61850/sampled_values/publisher_profile.hpp" +#include #include #include #include @@ -50,6 +51,32 @@ namespace detail { profile.asdu_options.synch_source_id) { return false; } + + // The current ESP32-P4 runtime emits a fixed wire order: + // IA, IB, IC, IN, UA, UB, UC, UN, with one Quality word after each value. + // Do not accept a merely shape-compatible DataSet: that would silently map + // unrelated INT32 members onto the fixed injector channels. + constexpr std::array expected_values{ + "tctr1.amp.instmag.i", + "tctr2.amp.instmag.i", + "tctr3.amp.instmag.i", + "tctr4.amp.instmag.i", + "tvtr1.vol.instmag.i", + "tvtr2.vol.instmag.i", + "tvtr3.vol.instmag.i", + "tvtr4.vol.instmag.i", + }; + constexpr std::array expected_qualities{ + "tctr1.amp.q", + "tctr2.amp.q", + "tctr3.amp.q", + "tctr4.amp.q", + "tvtr1.vol.q", + "tvtr2.vol.q", + "tvtr3.vol.q", + "tvtr4.vol.q", + }; + for (std::size_t i = 0U; i < profile.channels.size(); ++i) { const auto& channel = profile.channels[i]; if (channel.wire_width_bytes != 4U) return false; @@ -61,6 +88,17 @@ namespace detail { return false; } } + + for (std::size_t signal = 0U; signal < expected_values.size(); ++signal) { + const auto value_reference = + sv_support_lower_copy(profile.channels[signal * 2U].signal_reference); + const auto quality_reference = + sv_support_lower_copy(profile.channels[signal * 2U + 1U].signal_reference); + if (value_reference.find(expected_values[signal]) == std::string::npos || + quality_reference.find(expected_qualities[signal]) == std::string::npos) { + return false; + } + } return true; } } // namespace detail diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp index 7291def0c..83dce1d7f 100644 --- a/include/ariec61850/sampled_values/live_signal_state.hpp +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -221,7 +221,7 @@ class SvFixedPointSineEngine final { sine_q30 = std::clamp(sine_q30, -clip_q30, clip_q30); } const std::int64_t peak_counts = rms_to_peak_counts(channel.rms_counts); - const std::int64_t value = (sine_q30 * peak_counts) >> 30U; + const std::int64_t value = scale_q30(peak_counts, sine_q30); row[index] = clamp_i32(value); } @@ -275,6 +275,18 @@ class SvFixedPointSineEngine final { return (static_cast(rms) * sqrt2_q30) >> 30U; } + [[nodiscard]] static std::int64_t scale_q30( + const std::int64_t value, + const std::int64_t factor_q30) noexcept { + // Splitting the Q30 factor avoids overflowing int64_t when extreme + // clip/DC/harmonic settings meet near-INT32 signal magnitudes. The + // validated shape range keeps both products below int64_t limits. + constexpr std::int64_t q30 = std::int64_t{1} << 30U; + const std::int64_t whole = factor_q30 / q30; + const std::int64_t remainder = factor_q30 % q30; + return value * whole + (value * remainder) / q30; + } + [[nodiscard]] static std::int32_t clamp_i32(const std::int64_t value) noexcept { if (value > std::numeric_limits::max()) { return std::numeric_limits::max();