From 373b3d596e62b5c91e1d4389481d6c3f1598aa27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:12:45 +0700 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 10/14] 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 11/14] 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 12/14] 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 e5c63032d944e310737a4056358933056314d9d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 09:27:58 +0700 Subject: [PATCH 13/14] 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 14/14] 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); } }