From 0b100679df7e788c7ec0edb8156d8424fd369f0d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:52:49 +0700 Subject: [PATCH 001/188] Add allocation-bounded SV publisher runtime --- .../ariec61850/sampled_values/publisher.hpp | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 include/ariec61850/sampled_values/publisher.hpp diff --git a/include/ariec61850/sampled_values/publisher.hpp b/include/ariec61850/sampled_values/publisher.hpp new file mode 100644 index 000000000..91a6e8afa --- /dev/null +++ b/include/ariec61850/sampled_values/publisher.hpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/sample_counter.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// Deterministic, allocation-bounded publisher runtime for host and MCU use. +// +// The caller owns the SampledValuesFrame configuration and Ethernet buffer. +// Configuration-time std::string/std::vector storage inside SampledValuesFrame is +// intentionally retained for compatibility, but poll()/publish_now() do not grow +// or replace those containers. The steady-state path mutates only smpCnt, encodes +// into caller-owned storage, and hands the resulting bytes to RawEthernetPort. +// +// This class deliberately does not sleep or create a thread. A host event loop, +// FreeRTOS task, esp_timer callback, or bare-metal scheduler owns wake-up policy. +struct SampledValuesPublisherConfig final { + std::uint32_t sample_rate_hz{4'000U}; + std::optional sample_count_wrap{std::uint16_t{4'000U}}; + std::uint16_t initial_sample_count{}; + bool send_immediately{true}; +}; + +enum class SampledValuesPublishStatus : std::uint8_t { + sent, + not_due, + invalid_configuration, + encode_failed, + transmit_failed, +}; + +struct SampledValuesPublisherStatistics final { + std::uint64_t polls{}; + std::uint64_t frames_sent{}; + std::uint64_t encode_failures{}; + std::uint64_t transmit_failures{}; + std::uint64_t late_polls{}; + std::uint64_t maximum_lateness_us{}; +}; + +struct SampledValuesPublishResult final { + SampledValuesPublishStatus status{SampledValuesPublishStatus::not_due}; + wire::EncodeStatus encode_status{wire::EncodeStatus::ok}; + embedded::IoStatus io_status{embedded::IoStatus::ok}; + std::size_t frame_bytes{}; + std::uint16_t sample_count{}; + std::uint64_t next_due_us{}; + std::uint64_t lateness_us{}; + + [[nodiscard]] constexpr bool sent() const noexcept { + return status == SampledValuesPublishStatus::sent; + } +}; + +class SampledValuesPublisher final { +public: + static constexpr std::uint32_t maximum_supported_sample_rate_hz = 1'000'000U; + + SampledValuesPublisher( + SampledValuesFrame& frame, + const std::span frame_buffer, + const embedded::RawEthernetPort ethernet_port, + const SampledValuesPublisherConfig config = {}) noexcept + : frame_(&frame), + frame_buffer_(frame_buffer), + ethernet_port_(ethernet_port), + config_(config), + sample_count_(config.initial_sample_count) { + valid_ = + config_.sample_rate_hz > 0U && + config_.sample_rate_hz <= maximum_supported_sample_rate_hz && + !frame_buffer_.empty() && + ethernet_port_.transmit != nullptr && + !frame_->pdu.asdus.empty(); + apply_sample_count(); + } + + [[nodiscard]] bool valid() const noexcept { return valid_; } + [[nodiscard]] bool started() const noexcept { return started_; } + [[nodiscard]] std::uint16_t next_sample_count() const noexcept { return sample_count_; } + [[nodiscard]] std::uint64_t next_due_us() const noexcept { return next_due_us_; } + [[nodiscard]] const SampledValuesPublisherStatistics& statistics() const noexcept { + return statistics_; + } + + // Reset pacing and optionally the counter. The first later poll() starts a new + // publisher session. This is the intended way to apply a deliberate rate or + // stream-session change rather than feeding scheduler lateness back into pacing. + void reset(const std::uint16_t sample_count = 0U) noexcept { + started_ = false; + next_due_us_ = 0U; + interval_remainder_ = 0U; + sample_count_ = sample_count; + apply_sample_count(); + } + + // Poll the deterministic pacer at a monotonic timestamp. At most one frame is + // attempted per call. After scheduler lateness, the next deadline is anchored + // to the actual attempt time, preventing catch-up bursts. + [[nodiscard]] SampledValuesPublishResult poll(const std::uint64_t now_us) noexcept { + ++statistics_.polls; + + if (!valid_) { + return make_result( + SampledValuesPublishStatus::invalid_configuration, + wire::EncodeStatus::value_out_of_range, + embedded::IoStatus::invalid_argument, + 0U, + sample_count_, + 0U); + } + + if (!started_) { + started_ = true; + next_due_us_ = config_.send_immediately + ? now_us + : saturating_add(now_us, next_interval_us()); + } + + if (now_us < next_due_us_) { + return make_result( + SampledValuesPublishStatus::not_due, + wire::EncodeStatus::ok, + embedded::IoStatus::ok, + 0U, + sample_count_, + 0U); + } + + const auto lateness = now_us - next_due_us_; + if (lateness > 0U) { + ++statistics_.late_polls; + statistics_.maximum_lateness_us = + std::max(statistics_.maximum_lateness_us, lateness); + } + + return transmit_current(now_us, lateness); + } + + // Bypass due-time checking but keep the same no-catch-up scheduling rule. + // Useful when an external high-resolution timer already provides the 4 kHz tick. + [[nodiscard]] SampledValuesPublishResult publish_now( + const std::uint64_t now_us) noexcept { + ++statistics_.polls; + + if (!valid_) { + return make_result( + SampledValuesPublishStatus::invalid_configuration, + wire::EncodeStatus::value_out_of_range, + embedded::IoStatus::invalid_argument, + 0U, + sample_count_, + 0U); + } + + if (!started_) { + started_ = true; + } + return transmit_current(now_us, 0U); + } + +private: + [[nodiscard]] static std::uint64_t saturating_add( + const std::uint64_t left, + const std::uint64_t right) noexcept { + if (right > std::numeric_limits::max() - left) { + return std::numeric_limits::max(); + } + return left + right; + } + + [[nodiscard]] std::uint64_t next_interval_us() noexcept { + const auto rate = static_cast(config_.sample_rate_hz); + auto interval = 1'000'000ULL / rate; + interval_remainder_ += 1'000'000ULL % rate; + if (interval_remainder_ >= rate) { + ++interval; + interval_remainder_ -= rate; + } + return interval; + } + + void schedule_after(const std::uint64_t now_us) noexcept { + next_due_us_ = saturating_add(now_us, next_interval_us()); + } + + void apply_sample_count() noexcept { + if (frame_ == nullptr) { + return; + } + for (auto& asdu : frame_->pdu.asdus) { + asdu.sample_count = sample_count_; + } + } + + [[nodiscard]] SampledValuesPublishResult make_result( + const SampledValuesPublishStatus status, + const wire::EncodeStatus encode_status, + const embedded::IoStatus io_status, + const std::size_t frame_bytes, + const std::uint16_t sample_count, + const std::uint64_t lateness_us) const noexcept { + return { + status, + encode_status, + io_status, + frame_bytes, + sample_count, + next_due_us_, + lateness_us}; + } + + [[nodiscard]] SampledValuesPublishResult transmit_current( + const std::uint64_t now_us, + const std::uint64_t lateness_us) noexcept { + const auto transmitted_sample_count = sample_count_; + apply_sample_count(); + + const auto encoded = SampledValuesFrameCodec::encode_into( + *frame_, frame_buffer_); + if (!encoded.success()) { + ++statistics_.encode_failures; + schedule_after(now_us); + return make_result( + SampledValuesPublishStatus::encode_failed, + encoded.status, + embedded::IoStatus::ok, + 0U, + transmitted_sample_count, + lateness_us); + } + + const auto bytes = frame_buffer_.first(encoded.bytes_written); + const auto transmitted = ethernet_port_.send(bytes); + if (!transmitted.success() || transmitted.transferred != encoded.bytes_written) { + ++statistics_.transmit_failures; + schedule_after(now_us); + const auto status = transmitted.success() + ? embedded::IoStatus::io_error + : transmitted.status; + return make_result( + SampledValuesPublishStatus::transmit_failed, + wire::EncodeStatus::ok, + status, + encoded.bytes_written, + transmitted_sample_count, + lateness_us); + } + + ++statistics_.frames_sent; + sample_count_ = SampleCounterPolicy::increment( + sample_count_, config_.sample_count_wrap); + apply_sample_count(); + schedule_after(now_us); + return make_result( + SampledValuesPublishStatus::sent, + wire::EncodeStatus::ok, + embedded::IoStatus::ok, + encoded.bytes_written, + transmitted_sample_count, + lateness_us); + } + + SampledValuesFrame* frame_{}; + std::span frame_buffer_{}; + embedded::RawEthernetPort ethernet_port_{}; + SampledValuesPublisherConfig config_{}; + SampledValuesPublisherStatistics statistics_{}; + std::uint64_t next_due_us_{}; + std::uint64_t interval_remainder_{}; + std::uint16_t sample_count_{}; + bool valid_{}; + bool started_{}; +}; + +} // namespace ar::iec61850::sampled_values From 9536b97f0ff02e74a934678e72a402f401bd8406 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:53:18 +0700 Subject: [PATCH 002/188] Add embedded SV publisher pacing smoke test --- embedded/sv_publisher_smoke.cpp | 169 ++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 embedded/sv_publisher_smoke.cpp diff --git a/embedded/sv_publisher_smoke.cpp b/embedded/sv_publisher_smoke.cpp new file mode 100644 index 000000000..cfb087737 --- /dev/null +++ b/embedded/sv_publisher_smoke.cpp @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +struct FakeEthernet final { + std::array last_frame{}; + std::size_t last_size{}; + std::uint64_t sends{}; + bool fail_next{}; +}; + +embedded::IoResult capture_frame( + void* context, + const std::span frame) noexcept { + auto* fake = static_cast(context); + if (fake == nullptr || frame.size() > fake->last_frame.size()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (fake->fail_next) { + fake->fail_next = false; + return {embedded::IoStatus::timeout, 0U}; + } + + std::copy(frame.begin(), frame.end(), fake->last_frame.begin()); + fake->last_size = frame.size(); + ++fake->sends; + return {embedded::IoStatus::ok, frame.size()}; +} + +sampled_values::SampledValuesFrame make_frame() { + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + const std::array source{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ESP32P4/LLN0$MSVCB01"; + asdu.data_set_reference = "ESP32P4/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{4'000U}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload = { + 0x00U, 0x00U, 0x00U, 0x64U, + 0x00U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0xC8U, + 0x00U, 0x00U, 0x00U, 0x00U}; + + return { + ethernet::MacAddress{destination}, + ethernet::MacAddress{source}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +} // namespace + +int main() { + using namespace ar::iec61850; + + FakeEthernet fake; + embedded::RawEthernetPort port{&fake, &capture_frame}; + auto frame = make_frame(); + std::array frame_buffer{}; + + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + 4'000U, + std::uint16_t{4'000U}, + 3'999U, + true}); + + if (!publisher.valid()) { + return 1; + } + + const auto first = publisher.poll(1'000U); + if (!first.sent() || first.sample_count != 3'999U || + publisher.next_sample_count() != 0U || first.next_due_us != 1'250U || + fake.sends != 1U) { + return 2; + } + + sampled_values::SampledValuesFrame decoded; + if (!sampled_values::SampledValuesFrameCodec::try_decode( + std::span{fake.last_frame}.first(fake.last_size), + decoded) || + decoded.pdu.asdus.size() != 1U || + decoded.pdu.asdus.front().sample_count != 3'999U) { + return 3; + } + + const auto early = publisher.poll(1'249U); + if (early.status != sampled_values::SampledValuesPublishStatus::not_due || + fake.sends != 1U) { + return 4; + } + + const auto wrapped = publisher.poll(1'250U); + if (!wrapped.sent() || wrapped.sample_count != 0U || + publisher.next_sample_count() != 1U || fake.sends != 2U) { + return 5; + } + + // Arrive very late. Exactly one frame may be sent, then the next deadline is + // anchored 250 us after the actual send. This prevents scheduler catch-up bursts. + const auto late = publisher.poll(2'000U); + if (!late.sent() || late.lateness_us != 500U || + late.next_due_us != 2'250U || fake.sends != 3U) { + return 6; + } + const auto no_catch_up = publisher.poll(2'001U); + if (no_catch_up.status != sampled_values::SampledValuesPublishStatus::not_due || + fake.sends != 3U) { + return 7; + } + + const auto before_failure = publisher.next_sample_count(); + fake.fail_next = true; + const auto failed = publisher.publish_now(2'100U); + if (failed.status != sampled_values::SampledValuesPublishStatus::transmit_failed || + failed.io_status != embedded::IoStatus::timeout || + publisher.next_sample_count() != before_failure || + fake.sends != 3U) { + return 8; + } + + auto small_frame = make_frame(); + std::array small_buffer{}; + sampled_values::SampledValuesPublisher too_small( + small_frame, + small_buffer, + port, + sampled_values::SampledValuesPublisherConfig{}); + const auto encoding_failure = too_small.publish_now(3'000U); + if (encoding_failure.status != sampled_values::SampledValuesPublishStatus::encode_failed || + encoding_failure.encode_status != wire::EncodeStatus::buffer_too_small) { + return 9; + } + + const auto& statistics = publisher.statistics(); + if (statistics.frames_sent != 3U || statistics.transmit_failures != 1U || + statistics.late_polls != 1U || statistics.maximum_lateness_us != 500U) { + return 10; + } + + return 0; +} From db472879655b371ff2d4cd3bdac09dc09f1c90df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:53:41 +0700 Subject: [PATCH 003/188] Build embedded SV publisher smoke test --- embedded/CMakeLists.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index ee0dddb9e..6ced524c8 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -92,8 +92,21 @@ target_compile_definitions(ariec61850_embedded_link_smoke PRIVATE ARIEC61850_HOST_SERVICES=0) ariec61850_apply_embedded_flags(ariec61850_embedded_link_smoke) +# Exercise the allocation-bounded SV publisher and its no-catch-up pacing policy +# against a fake RawEthernetPort using the same embedded core library. +add_executable(ariec61850_embedded_sv_publisher_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_publisher_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_publisher_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_publisher_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_publisher_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_publisher_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_publisher_smoke) + enable_testing() add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_smoke) +add_test(NAME ariec61850_embedded_sv_publisher_smoke COMMAND ariec61850_embedded_sv_publisher_smoke) message(STATUS "arstack61850 embedded profile: host services excluded") message(STATUS "arstack61850 embedded profile: RTTI disabled=${ARIEC61850_EMBEDDED_NO_RTTI}") From 7445c7f9494ca0f08d9fc00ea4d618bd020371ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:53:49 +0700 Subject: [PATCH 004/188] Add ESP-IDF raw Ethernet adapter --- .../ports/esp_idf/ethernet_port.hpp | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ports/esp_idf/include/ariec61850/ports/esp_idf/ethernet_port.hpp diff --git a/ports/esp_idf/include/ariec61850/ports/esp_idf/ethernet_port.hpp b/ports/esp_idf/include/ariec61850/ports/esp_idf/ethernet_port.hpp new file mode 100644 index 000000000..11eca2cf0 --- /dev/null +++ b/ports/esp_idf/include/ariec61850/ports/esp_idf/ethernet_port.hpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" + +#include "esp_eth.h" + +namespace ar::iec61850::ports::esp_idf { + +// Small adapter context owned by the ESP-IDF application. The protocol core +// never sees esp_eth_handle_t; only this platform adapter does. +struct RawEthernetContext final { + esp_eth_handle_t handle{}; +}; + +[[nodiscard]] embedded::IoResult raw_ethernet_transmit( + void* context, + std::span frame) noexcept; + +[[nodiscard]] embedded::RawEthernetPort make_raw_ethernet_port( + RawEthernetContext& context) noexcept; + +[[nodiscard]] std::uint64_t monotonic_microseconds(void* context) noexcept; + +[[nodiscard]] embedded::Clock make_monotonic_clock() noexcept; + +} // namespace ar::iec61850::ports::esp_idf From 8964734d40d498a4211205ccf09b8a5288bbd978 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:53:59 +0700 Subject: [PATCH 005/188] Implement ESP-IDF Ethernet transmit adapter --- ports/esp_idf/ethernet_port.cpp | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 ports/esp_idf/ethernet_port.cpp diff --git a/ports/esp_idf/ethernet_port.cpp b/ports/esp_idf/ethernet_port.cpp new file mode 100644 index 000000000..e066be1da --- /dev/null +++ b/ports/esp_idf/ethernet_port.cpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" + +#include "esp_err.h" +#include "esp_timer.h" + +#include + +namespace ar::iec61850::ports::esp_idf { + +embedded::IoResult raw_ethernet_transmit( + void* context, + const std::span frame) noexcept { + auto* adapter = static_cast(context); + if (adapter == nullptr || adapter->handle == nullptr || frame.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + // ESP-IDF's API currently accepts a mutable void* even though transmit does + // not require arstack to modify the frame. Keep the const_cast contained in + // this platform boundary instead of leaking it into the protocol core. + const auto result = esp_eth_transmit( + adapter->handle, + const_cast(frame.data()), + frame.size()); + + if (result == ESP_OK) { + return {embedded::IoStatus::ok, frame.size()}; + } + if (result == ESP_ERR_TIMEOUT) { + return {embedded::IoStatus::timeout, 0U}; + } + if (result == ESP_ERR_INVALID_ARG) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (result == ESP_ERR_INVALID_STATE) { + return {embedded::IoStatus::closed, 0U}; + } + return {embedded::IoStatus::io_error, 0U}; +} + +embedded::RawEthernetPort make_raw_ethernet_port( + RawEthernetContext& context) noexcept { + return {&context, &raw_ethernet_transmit}; +} + +std::uint64_t monotonic_microseconds(void*) noexcept { + const auto value = esp_timer_get_time(); + return value <= 0 ? 0U : static_cast(value); +} + +embedded::Clock make_monotonic_clock() noexcept { + return {nullptr, &monotonic_microseconds, nullptr}; +} + +} // namespace ar::iec61850::ports::esp_idf From 2436fa2037441219e6fc84532dbe5b5fd6fd658d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:54:13 +0700 Subject: [PATCH 006/188] Add ESP-IDF embedded-core component build --- ports/esp_idf/CMakeLists.txt | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 ports/esp_idf/CMakeLists.txt diff --git a/ports/esp_idf/CMakeLists.txt b/ports/esp_idf/CMakeLists.txt new file mode 100644 index 000000000..e5d579445 --- /dev/null +++ b/ports/esp_idf/CMakeLists.txt @@ -0,0 +1,54 @@ +# arstack61850 ESP-IDF component entry point. +# +# Add this directory to EXTRA_COMPONENT_DIRS from an ESP-IDF application. The +# component intentionally compiles only the MCU portability boundary plus the +# ESP-IDF Ethernet adapter; host discovery, filesystem, PCAP, COMTRADE and CLI +# sources are not pulled into firmware. + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +set(ARIEC61850_ESP_IDF_SOURCES + ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp + + ${ARIEC61850_ROOT}/src/goose/retransmission_schedule.cpp + ${ARIEC61850_ROOT}/src/goose/pdu_codec.cpp + ${ARIEC61850_ROOT}/src/goose/frame_codec.cpp + + ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/quality.cpp + ${ARIEC61850_ROOT}/src/sampled_values/payload_inspector.cpp + ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp + + ${ARIEC61850_ROOT}/src/osi/tpkt.cpp + ${ARIEC61850_ROOT}/src/osi/cotp.cpp + ${ARIEC61850_ROOT}/src/osi/session.cpp + ${ARIEC61850_ROOT}/src/osi/presentation.cpp + ${ARIEC61850_ROOT}/src/acse/association.cpp + + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/mms/data_codec.cpp + ${ARIEC61850_ROOT}/src/mms/pdu.cpp + ${ARIEC61850_ROOT}/src/mms/services.cpp + + ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp) + +idf_component_register( + SRCS ${ARIEC61850_ESP_IDF_SOURCES} + INCLUDE_DIRS + ${ARIEC61850_ROOT}/include + ${CMAKE_CURRENT_LIST_DIR}/include + REQUIRES esp_eth esp_timer) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) +target_compile_definitions(${COMPONENT_LIB} PUBLIC + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) + +# RTTI is not required by the embedded protocol boundary. Exceptions remain +# enabled temporarily because legacy BER/MMS configuration/error paths still +# contain throws; the 4 kHz SV publish path itself is noexcept. +target_compile_options(${COMPONENT_LIB} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -fno-rtti) From 448d0e3ba7fa771e020f8f3ef5d543774af16637 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:56:17 +0700 Subject: [PATCH 007/188] Add allocation-free SV payload writer --- .../sampled_values/payload_writer.hpp | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 include/ariec61850/sampled_values/payload_writer.hpp diff --git a/include/ariec61850/sampled_values/payload_writer.hpp b/include/ariec61850/sampled_values/payload_writer.hpp new file mode 100644 index 000000000..418149b56 --- /dev/null +++ b/include/ariec61850/sampled_values/payload_writer.hpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// Helpers for the common SV seqOfData shape where an INT32 sample is followed +// by its 32-bit IEC 61850 quality word. The helper only writes caller-owned +// bytes; stream/DataSet semantics and engineering scaling remain application +// configuration responsibilities. +class SampledValuesPayloadWriter final { +public: + static constexpr std::size_t int32_quality_pair_bytes = 8U; + + [[nodiscard]] static constexpr std::size_t required_bytes_for_pairs( + const std::size_t pair_count) noexcept { + return pair_count > (static_cast(-1) / int32_quality_pair_bytes) + ? 0U + : pair_count * int32_quality_pair_bytes; + } + + [[nodiscard]] static bool write_int32_quality_pair( + const std::span payload, + const std::size_t pair_index, + const std::int32_t value, + const std::uint32_t quality) noexcept { + if (pair_index > (static_cast(-1) / int32_quality_pair_bytes)) { + return false; + } + const auto offset = pair_index * int32_quality_pair_bytes; + if (offset > payload.size() || + payload.size() - offset < int32_quality_pair_bytes) { + return false; + } + + write_u32_be(payload.subspan(offset, 4U), static_cast(value)); + write_u32_be(payload.subspan(offset + 4U, 4U), quality); + return true; + } + +private: + static 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); + } +}; + +} // namespace ar::iec61850::sampled_values From 27db02b3da1f083add91101b913538fee085ce5c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:56:50 +0700 Subject: [PATCH 008/188] Document ESP32-P4 SV first trial --- docs/ESP32_P4_SV_FIRST_TRIAL.md | 220 ++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/ESP32_P4_SV_FIRST_TRIAL.md diff --git a/docs/ESP32_P4_SV_FIRST_TRIAL.md b/docs/ESP32_P4_SV_FIRST_TRIAL.md new file mode 100644 index 000000000..cc5b28f3f --- /dev/null +++ b/docs/ESP32_P4_SV_FIRST_TRIAL.md @@ -0,0 +1,220 @@ +# ESP32-P4 Sampled Values first trial + +This is the first active process-bus transmit proof for arstack61850. It is a +controlled laboratory step, not a protection or metering accuracy claim. + +## Target + +Start with an ESP32-P4 board that exposes the internal Ethernet EMAC through an +RMII PHY and for which the application already owns a started `esp_eth_handle_t`. +The arstack protocol code does not configure board-specific PHY pins. That stays +in the ESP-IDF application so the same stack can be reused with different P4 +boards and later with other MCU families. + +The first trial intentionally uses an **untagged** Ethernet frame. ESP-IDF v6.0.2 +still documents VLAN-tagged Ethernet frames as unsupported by the basic Ethernet +path. VLAN transmission is therefore a separate hardware/driver acceptance gate, +not something the protocol core should hide or emulate. + +Useful Espressif references: + +- ESP32-P4 Ethernet: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/network/esp_eth.html +- ESP32-P4 getting started: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/get-started/index.html + +## What this branch provides + +- `SampledValuesPduCodec::encode_into()` and + `SampledValuesFrameCodec::encode_into()` reuse caller-owned buffers. +- `SampledValuesPublisher` provides deterministic sample-count progression and a + no-catch-up pacing policy. +- `SampledValuesPayloadWriter` updates common INT32 + quality pairs in a + pre-sized `seqOfData` buffer without allocating. +- `embedded::RawEthernetPort` is the platform-neutral raw Ethernet contract. +- `ports/esp_idf` maps that contract to `esp_eth_transmit()` and exposes + `esp_timer_get_time()` as a monotonic microsecond clock. + +The steady-state publisher path is `noexcept` and does not resize the frame, +ASDU, or payload containers. Allocate and size configuration objects before the +publisher loop starts. + +## Reference first profile + +Use this conservative initial profile: + +| Item | First-trial value | +| --- | --- | +| System frequency | 50 Hz | +| Samples/cycle | 80 | +| Sample instants | 4,000/s | +| ASDUs/frame | 1 | +| Ethernet | untagged first | +| EtherType | `0x88BA` | +| Destination MAC | `01:0C:CD:04:00:01` | +| APPID | `0x4001` | +| `confRev` | `1` for the whole run | +| `smpSynch` | `0` until a real synchronized time source is proven | +| `refrTm` | omitted for throughput proof | +| `smpCnt` wrap | `4000` for this first profile | + +The source MAC must be the actual Ethernet MAC assigned to the ESP32-P4 +interface. Do not copy the synthetic host-test source MAC into a physical unit. + +## ESP-IDF component integration + +Add the repository's `ports/esp_idf` directory as an extra component in the +application-level `CMakeLists.txt`: + +```cmake +set(EXTRA_COMPONENT_DIRS + "${CMAKE_CURRENT_LIST_DIR}/../../arstack61850/ports/esp_idf") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack_sv_trial) +``` + +The component compiles only the embedded protocol boundary plus the ESP-IDF +adapter. Host TCP discovery, PCAP, COMTRADE, SCL parsing, filesystem and CLI +sources are intentionally excluded from firmware. + +## Minimal publisher setup + +The board application is responsible for installing and starting Ethernet. Once +it has a valid `esp_eth_handle_t eth_handle`, build the publisher once and reuse +it: + +```cpp +#include "ariec61850/embedded/profile.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include + +using namespace ar::iec61850; + +ports::esp_idf::RawEthernetContext ethernet_context{eth_handle}; +auto raw_port = ports::esp_idf::make_raw_ethernet_port(ethernet_context); +auto clock = ports::esp_idf::make_monotonic_clock(); + +sampled_values::SampledValueAsdu asdu; +asdu.sv_id = "ESP32P4/LLN0$MSVCB01"; +asdu.data_set_reference = "ESP32P4/LLN0$PhsMeas1"; +asdu.configuration_revision = 1; +asdu.sample_synchronization = 0; +asdu.sample_rate = std::uint16_t{4000}; +asdu.sample_mode = std::uint16_t{1}; +asdu.sample_payload.resize(64); // eight INT32 + quality pairs; allocate once + +std::array dst{0x01, 0x0C, 0xCD, 0x04, 0x00, 0x01}; +std::array src{}; // fill from the actual Ethernet MAC + +sampled_values::SampledValuesFrame frame{ + ethernet::MacAddress{dst}, + ethernet::MacAddress{src}, + std::nullopt, + 0x4001, + 0, + 0, + sampled_values::SampledValuesPdu{{asdu}}}; + +std::array + ethernet_buffer{}; + +sampled_values::SampledValuesPublisher publisher{ + frame, + ethernet_buffer, + raw_port, + sampled_values::SampledValuesPublisherConfig{ + 4000, + std::uint16_t{4000}, + 0, + true}}; +``` + +For every sample instant, update the already-sized payload in place. The exact +channel order, scaling and quality semantics must match the DataSet/profile that +will eventually describe this stream; the following is only a synthetic wire +proof: + +```cpp +auto payload = std::span{frame.pdu.asdus.front().sample_payload}; + +sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, 0, synthetic_ia, 0); +sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, 1, synthetic_ib, 0); +// ...pre-sized remaining pairs... + +const auto result = publisher.poll(clock.monotonic_us()); +``` + +`poll()` never sends more than one frame per call. If the FreeRTOS task wakes up +late, the publisher records lateness and anchors the next deadline after the +actual attempt instead of emitting a burst of back-to-back catch-up frames. +This mirrors the pacing lesson learned from the C# transport implementation. + +If a high-resolution timer already owns the 4 kHz cadence, call +`publish_now(clock.monotonic_us())` once per tick instead. Do not call both +pacing modes for the same stream session. + +## Recommended FreeRTOS ownership + +For the first hardware proof: + +1. Use one dedicated high-priority publisher task pinned according to the board + application's CPU/task policy. +2. Prepare strings, vectors, frame configuration and the Ethernet buffer before + entering the steady-state loop. +3. Update only payload bytes and runtime counter state in the hot path. +4. Do not log every frame. Aggregate counters and print them at a slow interval. +5. Avoid dynamic allocation, filesystem work, JSON, Wi-Fi work or UI activity in + the publisher task. +6. If `esp_eth_transmit()` returns timeout/error, retain the same `smpCnt`; the + next successful transmission will not silently skip a publisher-generated + counter value. + +ESP-IDF documents that short/frequent Ethernet traffic can require DMA descriptor +and buffer tuning. If TX cannot sustain the target, inspect Ethernet DMA buffer +size/count configuration before changing IEC 61850 wire semantics. + +## Wireshark acceptance + +Capture on an isolated lab switch/TAP and filter: + +```text +eth.type == 0x88ba +``` + +First acceptance requires: + +- destination multicast MAC is exact; +- source MAC matches the ESP32-P4 interface; +- EtherType is `0x88BA`; +- APPID is `0x4001`; +- one ASDU decodes; +- `svID`, DataSet reference and `confRev` remain stable; +- `smpCnt` is continuous and wraps at the configured point; +- no scheduler-late catch-up bursts are generated by the publisher logic; +- no steady-state growth in application heap attributable to the arstack + publisher path; +- TX errors/timeouts are counted and visible. + +Run a short functional capture first, then a 10-minute soak. If stable, extend to +one hour. Record at minimum: + +- `frames_sent`; +- `transmit_failures`; +- `late_polls`; +- `maximum_lateness_us`; +- current free heap and minimum-ever free heap; +- observed packets/s in Wireshark; +- any counter gap/duplicate/out-of-order finding from an independent subscriber. + +## What this proof does not claim + +A successful first trial proves the C++ wire core, MCU portability boundary, +raw-Ethernet adapter and basic pacing path can cooperate on ESP32-P4. It does not +yet prove IEC 61869-9 measurement scaling, PTP synchronization, VLAN priority, +protection-grade timing, multi-vendor interoperability, or certification. From 31934352dafd9f6cd7005e9e1a8c58c32a573ff4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:57:15 +0700 Subject: [PATCH 009/188] Add ESP-IDF P4 compile smoke app --- ports/esp_idf/smoke_app/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 ports/esp_idf/smoke_app/CMakeLists.txt diff --git a/ports/esp_idf/smoke_app/CMakeLists.txt b/ports/esp_idf/smoke_app/CMakeLists.txt new file mode 100644 index 000000000..f151d6382 --- /dev/null +++ b/ports/esp_idf/smoke_app/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/..") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack61850_esp32p4_compile_smoke) From b2a73c438991b694b42371db8103af564c4ff072 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:57:22 +0700 Subject: [PATCH 010/188] Wire ESP-IDF compile smoke main component --- ports/esp_idf/smoke_app/main/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 ports/esp_idf/smoke_app/main/CMakeLists.txt diff --git a/ports/esp_idf/smoke_app/main/CMakeLists.txt b/ports/esp_idf/smoke_app/main/CMakeLists.txt new file mode 100644 index 000000000..6fea1cef5 --- /dev/null +++ b/ports/esp_idf/smoke_app/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES esp_idf) From 9e57d3561a6d39531a46930b3ad3ee1b19994c61 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:57:30 +0700 Subject: [PATCH 011/188] Exercise ESP-IDF adapter headers in P4 smoke app --- ports/esp_idf/smoke_app/main/main.cpp | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 ports/esp_idf/smoke_app/main/main.cpp diff --git a/ports/esp_idf/smoke_app/main/main.cpp b/ports/esp_idf/smoke_app/main/main.cpp new file mode 100644 index 000000000..50f8dade0 --- /dev/null +++ b/ports/esp_idf/smoke_app/main/main.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/profile.hpp" +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include + +extern "C" void app_main(void) { + using namespace ar::iec61850; + + static_assert(embedded::Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); + static_assert(sampled_values::SampledValuesPublisher::maximum_supported_sample_rate_hz >= 4'000U); + static_assert(sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes == 8U); + + ports::esp_idf::RawEthernetContext context{}; + const auto port = ports::esp_idf::make_raw_ethernet_port(context); + const auto clock = ports::esp_idf::make_monotonic_clock(); + + std::array payload{}; + const auto wrote = sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + std::span{payload}, 0U, 1234, 0U); + + // Build-only smoke: no Ethernet driver is installed on CI hardware. Keep the + // objects live so headers, adapter symbols and C++20 span usage are compiled + // and linked for the real ESP32-P4 toolchain. + (void)port; + (void)clock; + (void)wrote; +} From 6d936c9b7c7eace773683313c4893e896f9412c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:57:38 +0700 Subject: [PATCH 012/188] Add ESP32-P4 ESP-IDF compile gate --- .github/workflows/esp-idf-ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/esp-idf-ci.yml diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml new file mode 100644 index 000000000..ae37c229f --- /dev/null +++ b/.github/workflows/esp-idf-ci.yml @@ -0,0 +1,26 @@ +name: ESP-IDF Compile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + esp32p4-compile: + name: ESP32-P4 / ESP-IDF v6.0.2 + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build ESP32-P4 component smoke app + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v6.0.2 + target: esp32p4 + path: ports/esp_idf/smoke_app From 4177f3cca709e4c789b3053a7c9e87ccd2448435 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:58:43 +0700 Subject: [PATCH 013/188] Record physical MMS pagination acceptance --- docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md diff --git a/docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md b/docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md new file mode 100644 index 000000000..75e0d1096 --- /dev/null +++ b/docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md @@ -0,0 +1,87 @@ +# Physical MMS pagination evidence — OCR7SR12 + +This document records the physical `GetNameList` continuation evidence observed +on the OCR7SR12 laboratory target after the Phase 4C read-only interoperability +work. It closes the previously pending requirement to demonstrate pagination on +a response that actually requires multiple pages. + +## Environment + +- target: `OCR7SR12` +- endpoint used in the lab: `192.168.1.10:102` +- association profile selected by arstack61850: `BalancedApTitle` +- operation class: read-only MMS discovery +- executable: `ariec61850_pagination_probe` +- command limits: `--timeout-ms 30000 --max-pages 256 --max-names 65536 --max-domains 4096 --require-pagination` + +The Release build completed before the physical probe and the complete local CTest +suite reported 21/21 tests passing. + +## Accepted probe summary + +The physical run reported: + +```text +Read-only GetNameList pagination probe: +endpoint=192.168.1.10:102, +associationProfile=BalancedApTitle, +associationAttempts=1, +queries=9, +paginatedQueries=4, +continuationRequests=88, +accepted=true. +``` + +Observed domain enumeration: + +| Scope | Pages | Names | Continuations | Final state | +| --- | ---: | ---: | ---: | --- | +| VMD Domain | 1 | 4 | 0 | `moreFollows=false` | +| `OCR7SR12CTRL` NamedVariable | 27 | 2686 | 26 | final page 86 names | +| `OCR7SR12DR` NamedVariable | 3 | 276 | 2 | final page 76 names | +| `OCR7SR12MEAS` NamedVariable | 14 | 1347 | 13 | final page 47 names | +| `OCR7SR12PROT` NamedVariable | 48 | 4758 | 47 | final page 58 names | + +`NamedVariableList` was also queried in each domain. The PROT domain exposed one +list named `LLN0$DataSet`; CTRL, DR and MEAS returned zero lists in this bounded +probe. + +## Continuation behavior + +For every multi-page variable query, the continuation request used the final +name returned by the previous page as `continueAfter`. The relay then returned +the next bounded page and retained `moreFollows=true` until the final page. + +The largest observed sequence was `OCR7SR12PROT`: + +- 48 pages; +- 4,758 names; +- 47 continuation requests; +- normally 100 names/page; +- final page 58 names; +- final response `moreFollows=false`. + +This is materially stronger than a synthetic continuation test because the +client completed a long real-device sequence without repeating a page, losing +the continuation marker, exceeding the configured bounds, or terminating before +the relay cleared `moreFollows`. + +## Acceptance conclusion + +The Phase 4C.1 physical pagination continuation gate is **accepted for this +OCR7SR12 observation window**. + +The evidence proves that arstack61850 can execute bounded real-device MMS +`GetNameList` continuation across multiple logical-device domains, including a +48-page sequence, on one fresh association. It does not by itself prove +multi-vendor pagination interoperability or every server-specific continuation +edge case. + +## Remaining physical process-bus gates + +The next active laboratory work is separate from this read-only MMS evidence: + +- physical IEC 61850 SV transmit/capture interoperability; +- sustained ESP32-P4 SV timing/heap/TX-error evidence; +- later VLAN/priority validation subject to the selected Ethernet driver path; +- physical GOOSE transmit/subscriber interoperability. From cc737e8b4899262195652a1155fba469a6874ed0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:04:28 +0700 Subject: [PATCH 014/188] Narrow ESP-IDF component to SV transmit slice --- ports/esp_idf/CMakeLists.txt | 40 +++++++++++++++--------------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/ports/esp_idf/CMakeLists.txt b/ports/esp_idf/CMakeLists.txt index e5d579445..24196e0b4 100644 --- a/ports/esp_idf/CMakeLists.txt +++ b/ports/esp_idf/CMakeLists.txt @@ -1,9 +1,11 @@ # arstack61850 ESP-IDF component entry point. # -# Add this directory to EXTRA_COMPONENT_DIRS from an ESP-IDF application. The -# component intentionally compiles only the MCU portability boundary plus the -# ESP-IDF Ethernet adapter; host discovery, filesystem, PCAP, COMTRADE and CLI -# sources are not pulled into firmware. +# Add this directory to EXTRA_COMPONENT_DIRS from an ESP-IDF application. +# This first MCU component is deliberately narrower than the desktop/host core: +# it contains only the dependencies required to build and transmit Sampled +# Values Ethernet frames plus the ESP-IDF raw-Ethernet adapter. Host discovery, +# MMS-over-TCP runtime, reporting, GOOSE, SCL, PCAP, COMTRADE, filesystem and CLI +# sources remain outside the first hardware proof. set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") @@ -11,28 +13,12 @@ set(ARIEC61850_ESP_IDF_SOURCES ${ARIEC61850_ROOT}/src/asn1/ber.cpp ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp - ${ARIEC61850_ROOT}/src/goose/retransmission_schedule.cpp - ${ARIEC61850_ROOT}/src/goose/pdu_codec.cpp - ${ARIEC61850_ROOT}/src/goose/frame_codec.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp - ${ARIEC61850_ROOT}/src/sampled_values/quality.cpp - ${ARIEC61850_ROOT}/src/sampled_values/payload_inspector.cpp ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp - ${ARIEC61850_ROOT}/src/osi/tpkt.cpp - ${ARIEC61850_ROOT}/src/osi/cotp.cpp - ${ARIEC61850_ROOT}/src/osi/session.cpp - ${ARIEC61850_ROOT}/src/osi/presentation.cpp - ${ARIEC61850_ROOT}/src/acse/association.cpp - - ${ARIEC61850_ROOT}/src/mms/utc_time.cpp - ${ARIEC61850_ROOT}/src/mms/data_value.cpp - ${ARIEC61850_ROOT}/src/mms/data_codec.cpp - ${ARIEC61850_ROOT}/src/mms/pdu.cpp - ${ARIEC61850_ROOT}/src/mms/services.cpp - ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp) idf_component_register( @@ -47,8 +33,14 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC ARIEC61850_EMBEDDED_PROFILE=1 ARIEC61850_HOST_SERVICES=0) -# RTTI is not required by the embedded protocol boundary. Exceptions remain -# enabled temporarily because legacy BER/MMS configuration/error paths still -# contain throws; the 4 kHz SV publish path itself is noexcept. +# RTTI is not required by this process-bus transmit slice. +# +# IMPORTANT: the shared BER/Ethernet/SV source files still retain exception-based +# convenience/validation APIs used by the desktop stack. ESP-IDF disables C++ +# exceptions by default, so an application using this transitional component must +# enable CONFIG_COMPILER_CXX_EXCEPTIONS. The steady-state publisher API itself is +# noexcept and uses status-returning encode_into()/RawEthernetPort operations. +# Removing the remaining compile-time exception dependency is a separate embedded +# hardening gate; do not hide it by weakening the cross-compile CI. target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -fno-rtti) From 82377bffc8cc8cea104d1e21eb6dbce635ffb47e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:04:38 +0700 Subject: [PATCH 015/188] Enable transitional C++ exceptions for ESP-IDF smoke build --- ports/esp_idf/smoke_app/sdkconfig.defaults | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 ports/esp_idf/smoke_app/sdkconfig.defaults diff --git a/ports/esp_idf/smoke_app/sdkconfig.defaults b/ports/esp_idf/smoke_app/sdkconfig.defaults new file mode 100644 index 000000000..d4b863632 --- /dev/null +++ b/ports/esp_idf/smoke_app/sdkconfig.defaults @@ -0,0 +1,6 @@ +# Transitional portability setting for the first ESP32-P4 SV transmit proof. +# The shared desktop codecs still contain exception-based convenience and +# validation APIs, while the publisher hot path uses noexcept/status-returning +# APIs. Removing this compile-time dependency is tracked as a later embedded +# hardening gate. +CONFIG_COMPILER_CXX_EXCEPTIONS=y From 28ebf38d4932b8d8f9d7fa843d4410f386a59082 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:05:19 +0700 Subject: [PATCH 016/188] Record pagination acceptance and ESP32-P4 SV port status --- MIGRATION_CHECKLIST.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index d2bc4d573..48b17916d 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -17,8 +17,12 @@ behavioral oracle until laboratory interoperability is complete. - [x] GOOSE wire codec and offline runtime supervision. - [x] Sampled Values PDU/frame codec, quality, payload, counter, and stream diagnostics. - [x] Synthetic PCAP equivalence, sanitizer, mutation, and libFuzzer hardening. +- [x] Allocation-bounded SV publisher runtime with caller-owned Ethernet buffer, successful-TX counter progression, and no-catch-up pacing. +- [x] Platform-neutral raw-Ethernet/clock HAL plus ESP-IDF `esp_eth_transmit()` adapter. +- [~] ESP32-P4 ESP-IDF cross-compile acceptance for the first SV transmit slice. - [ ] Real IED or vendor-simulator GOOSE/SV capture accepted. -- [ ] Real-time SV publisher timing-health validation. +- [~] Real-time SV publisher timing-health validation: deterministic host/CI pacing implemented; physical ESP32-P4 evidence pending. +- [ ] Exception-free embedded codec build; the first ESP-IDF trial temporarily enables C++ exceptions for shared legacy validation/convenience APIs while the publisher hot path remains `noexcept`. ## Phase 2 — engineering file formats @@ -144,7 +148,7 @@ behavioral oracle until laboratory interoperability is complete. - [x] C# and C++ same-IED OCR7SR12 structural/type/runtime comparison accepted with zero blocking findings. - [x] Primary-vendor OCR7SR12 ten-cycle acceptance: 10/10 stable discovery cycles, full 286/286 RCB and control-block gates, 3/3 StableProceed contention cycles, and 13/13 fresh associations. - [x] Controlled OCR7SR12 timeout/recovery evidence accepted: healthy baseline, post-association response withholding, client request timeout observed, fresh direct recovery, and identical `934b555dff76a46f` structural fingerprint before/after recovery. -- [ ] Pagination continuation evidence accepted on a target that requires more than one page. +- [x] Physical GetNameList pagination accepted on OCR7SR12: 9 queries, 4 paginated queries, 88 continuation requests; largest sequence 48 pages / 4,758 names / 47 continuations with final `moreFollows=false`. ### Later Phase 4 work From 02e346cc1d76cf8c9664f663535b5ce7de8173e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:05:59 +0700 Subject: [PATCH 017/188] Document transitional ESP-IDF exception requirement --- docs/ESP32_P4_SV_FIRST_TRIAL.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/ESP32_P4_SV_FIRST_TRIAL.md b/docs/ESP32_P4_SV_FIRST_TRIAL.md index cc5b28f3f..54a6e0298 100644 --- a/docs/ESP32_P4_SV_FIRST_TRIAL.md +++ b/docs/ESP32_P4_SV_FIRST_TRIAL.md @@ -20,6 +20,7 @@ Useful Espressif references: - ESP32-P4 Ethernet: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/network/esp_eth.html - ESP32-P4 getting started: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/get-started/index.html +- ESP-IDF C++ support: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-guides/cplusplus.html ## What this branch provides @@ -32,11 +33,33 @@ Useful Espressif references: - `embedded::RawEthernetPort` is the platform-neutral raw Ethernet contract. - `ports/esp_idf` maps that contract to `esp_eth_transmit()` and exposes `esp_timer_get_time()` as a monotonic microsecond clock. +- the ESP-IDF component intentionally cross-compiles only the first SV transmit + slice rather than pulling the complete desktop MMS/discovery stack into the + first firmware image. The steady-state publisher path is `noexcept` and does not resize the frame, ASDU, or payload containers. Allocate and size configuration objects before the publisher loop starts. +## Transitional C++ exception requirement + +ESP-IDF disables C++ exceptions by default. The shared desktop BER, Ethernet, +and SV codec source files still contain exception-based convenience/validation +APIs even though the first publisher hot path uses status-returning +`encode_into()` and raw-Ethernet operations. + +For this first cross-compile/hardware proof, enable: + +```text +CONFIG_COMPILER_CXX_EXCEPTIONS=y +``` + +The repository's `ports/esp_idf/smoke_app/sdkconfig.defaults` already does this +for CI. This is a **transitional portability dependency**, not a claim that +exceptions belong in the 4 kHz real-time path. Removing the remaining +compile-time dependency on C++ exceptions is tracked as a separate embedded +hardening gate. Do not throw exceptions from the publisher task. + ## Reference first profile Use this conservative initial profile: @@ -71,9 +94,10 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(arstack_sv_trial) ``` -The component compiles only the embedded protocol boundary plus the ESP-IDF -adapter. Host TCP discovery, PCAP, COMTRADE, SCL parsing, filesystem and CLI -sources are intentionally excluded from firmware. +The component compiles only the dependencies needed for the first SV transmit +slice plus the ESP-IDF adapter. Host TCP discovery, reporting, GOOSE, PCAP, +COMTRADE, SCL parsing, filesystem and CLI sources are intentionally excluded +from this first firmware target. ## Minimal publisher setup @@ -174,6 +198,8 @@ For the first hardware proof: 6. If `esp_eth_transmit()` returns timeout/error, retain the same `smpCnt`; the next successful transmission will not silently skip a publisher-generated counter value. +7. Do not use C++ exception control flow in the publisher task even while the + transitional project-level exception setting is enabled. ESP-IDF documents that short/frequent Ethernet traffic can require DMA descriptor and buffer tuning. If TX cannot sustain the target, inspect Ethernet DMA buffer From 9316c5ffdc62e9294a549d626b6eb60f7bd900bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:08:30 +0700 Subject: [PATCH 018/188] Retarget embedded roadmap to ESP32-P4 first SV proof --- ESP32_WAVESHARE_ROADMAP.md | 336 ++++++++++++++++++------------------- 1 file changed, 165 insertions(+), 171 deletions(-) diff --git a/ESP32_WAVESHARE_ROADMAP.md b/ESP32_WAVESHARE_ROADMAP.md index 34ff38045..fd21f1e21 100644 --- a/ESP32_WAVESHARE_ROADMAP.md +++ b/ESP32_WAVESHARE_ROADMAP.md @@ -1,4 +1,4 @@ -# arstack61850 — Public Alpha + Waveshare ESP32-S3 Roadmap +# arstack61850 — Embedded / ESP32 Roadmap ## Product direction @@ -11,9 +11,26 @@ industrial implementation with two supported deployment classes: 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**. +The **first process-bus transmit reference is now ESP32-P4** using the official +ESP-IDF Ethernet abstraction and a board-specific EMAC/PHY configuration owned +by the application. The previously selected **Waveshare ESP32-S3-POE-ETH-8DI-8DO** +remains a useful secondary I/O reference with W5500 Ethernet, but protocol code +must not depend on either board. -## Reference hardware facts +## Reference hardware roles + +### ESP32-P4 — first SV timing / raw-Ethernet proof + +Use ESP32-P4 for the first active Sampled Values trial because it provides a +stronger MCU target for deterministic process-bus experimentation while still +using the same `esp_eth` adapter boundary. + +The protocol stack must receive an already-started `esp_eth_handle_t`; board PHY +selection, RMII pins, clocking and link initialization remain application code. +The first proof is deliberately **untagged SV**. VLAN/PCP acceptance is a later +hardware/driver gate rather than a hidden protocol-core assumption. + +### Waveshare ESP32-S3-POE-ETH-8DI-8DO — secondary I/O IED reference Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. @@ -25,10 +42,11 @@ Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. - 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. + +When this board is used, the official ESP-IDF W5500 driver should own the chip +MACRAW path. arstack should not independently program W5500 hardware sockets. +Raw SV/GOOSE and lwIP TCP/MMS must share one Ethernet interface through platform +adapters. Raw Layer-2 SV/GOOSE is an Ethernet feature. Wi-Fi is not a substitute for the reference process-bus path. @@ -37,28 +55,30 @@ reference process-bus path. ### Embedded core -`ARIEC61850::embedded_core` may contain wire codecs and bounded runtime state, -but must not depend on: +`ARIEC61850::embedded_core` and platform slices may contain wire codecs and +bounded runtime state, but must not make the wire protocol depend on: - WinSock or BSD/POSIX sockets; - filesystem, iostream or CLI code; - PCAP/evidence tooling; - COMTRADE; - full SCL parsing; -- live-discovery host workflows. +- live-discovery host workflows; +- ESP-IDF, FreeRTOS, W5500, RMII or a specific PHY type. Platform ownership is injected through small callback HAL contracts. ESP-IDF, -lwIP, FreeRTOS and W5500 details stay outside the protocol engine. +lwIP, FreeRTOS and board details stay outside the protocol engine. ### Memory discipline -Steady-state publisher/subscriber/runtime paths should move toward: +Steady-state publisher/subscriber/runtime paths move toward: - caller-owned buffers; - bounded capacities; - no per-frame allocation; - no RTTI; -- eventually no C++ exceptions in the MCU core. +- no exception control flow in real-time paths; +- eventually a fully `-fno-exceptions` MCU build. 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 @@ -66,7 +86,7 @@ steady-state encode/transmit path. ### Compatibility -Existing host APIs remain source-compatible where practical. Allocation-free +Existing host APIs remain source-compatible where practical. Allocation-bounded APIs are added underneath host convenience wrappers, so Windows/Linux users do not lose ergonomics while the same codec becomes MCU-capable. @@ -76,19 +96,22 @@ not lose ergonomics while the same codec becomes MCU-capable. ## Track A — Public Alpha -### A0. Live read-only MMS foundation — CURRENT +### A0. Live read-only MMS foundation — ACCEPTED ON CURRENT PRIMARY IED -Acceptance: +Acceptance achieved on the current OCR7SR12 laboratory target includes: -- 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. +- TPKT/COTP/Session/Presentation/ACSE association; +- GetNameList domains, variables and DataSets; +- GetVariableAccessAttributes; +- Read; +- DataSet and RCB inventories; +- dynamic/unbound RCB state without false failure; +- read-only RCB planner/availability evidence; +- bounded multi-page `GetNameList` continuation, including a physical 48-page + / 4,758-name sequence; +- GCC, Clang and MSVC regression coverage. + +Multi-vendor evidence remains separate from this one-target acceptance. ### A1. Public discovery/model alpha @@ -106,17 +129,8 @@ Deliverables: - 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; @@ -139,9 +153,9 @@ Not release-blocking: ## Track E — ESP32 reference product -### E0. Embedded architecture baseline — IN PROGRESS +### E0. Embedded architecture baseline — IMPLEMENTED -Deliverables: +Delivered: - standalone `embedded/CMakeLists.txt`; - `ARIEC61850::embedded_core`; @@ -149,19 +163,16 @@ Deliverables: - source-boundary audit; - callback HAL for raw Ethernet, connected TCP and clocks; - configurable ESP32 small-capacity profile; -- Waveshare board facts/profile. - -Exit gate: +- ESP-IDF raw-Ethernet/clock adapter boundary. -- 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. +Remaining hardening: -### E1. Allocation-bounded SV encoder — IN PROGRESS +- remove the shared codec's compile-time C++ exception dependency from the + MCU-specific build rather than merely avoiding exceptions in the hot path. -Goal: the 4 kHz publisher loop does not allocate a new frame buffer per sample. +### E1. Allocation-bounded SV encoder — IMPLEMENTED -API direction: +The first steady-state API direction is now implemented: ```cpp std::optional SampledValuesFrameCodec::encoded_size(...) noexcept; @@ -170,78 +181,92 @@ wire::EncodeResult SampledValuesFrameCodec::encode_into( std::span destination) noexcept; ``` -Host `encode()` remains as a convenience wrapper. - -Acceptance: +The caller can reuse the same Ethernet buffer continuously. Host `encode()` +remains a convenience wrapper. -- 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. Portable SV publisher runtime — IMPLEMENTED / PHYSICAL EVIDENCE PENDING -### E2. ESP-IDF W5500 Ethernet adapter +`SampledValuesPublisher` now owns only protocol runtime state. It does not create +threads or sleep. The application provides the monotonic timestamp and decides +whether a FreeRTOS task or high-resolution timer drives the stream. -Goal: map `embedded::RawEthernetPort` to the official ESP-IDF W5500 Ethernet -MACRAW path without leaking ESP-IDF/W5500 APIs into protocol code. +Implemented behavior: -Preferred architecture: +- 4,000 samples/s first profile; +- caller-owned Ethernet frame buffer; +- sample counter advances after successful full-frame transmit only; +- configured sample-counter wrap; +- at most one transmit attempt per `poll()`; +- late scheduler wake-up does not generate catch-up bursts; +- TX/encode/lateness statistics; +- allocation-free payload writer for common INT32 + quality pairs. -```text -arstack raw SV/GOOSE ----> esp_eth raw Ethernet transmit ----+ - | -MMS TCP <---- lwIP / BSD socket API <---- esp_netif --------+--> ESP-IDF W5500 MACRAW -``` +Host smoke acceptance covers due/not-due behavior, 3999→0 wrap, late wake-up, +TX timeout and encode buffer failure. -The ESP-IDF W5500 driver owns the chip-level MACRAW operation. arstack should -not independently program W5500 hardware sockets when using that driver. +### E3. ESP32-P4 SV Publisher hardware proof — CURRENT -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. +Reference first profile: -Acceptance on board: +- 50 Hz system; +- 80 samples/cycle = 4,000 sample instants/s; +- one ASDU/frame initially; +- EtherType `0x88BA`; +- APPID `0x4001`; +- multicast destination `01:0C:CD:04:00:01`; +- source MAC from the physical interface; +- `confRev=1` for the run; +- `smpSynch=0` until synchronized time is independently proven; +- no `refrTm` for the first throughput proof; +- untagged Ethernet first. + +Cross-compile gate: + +- real ESP32-P4 target; +- ESP-IDF v6.0.2; +- firmware component contains the first SV TX dependency slice only; +- current transition enables `CONFIG_COMPILER_CXX_EXCEPTIONS=y` because shared + desktop validation/convenience APIs still contain `throw/catch`; publisher hot + path must not use exception control flow. + +Physical acceptance: + +- raw SV frame visible with `eth.type == 0x88ba`; +- exact destination/source MAC and APPID; +- Wireshark decodes one ASDU correctly; +- sustained 4,000 frames/s target or an explicitly documented alternative; +- no publisher-generated sample-counter discontinuity; +- no catch-up burst after scheduler lateness; +- no steady-state frame-buffer allocation; +- short capture, 10-minute soak, then 1-hour soak; +- CPU usage, heap delta/minimum free heap, TX errors and lateness statistics + recorded. -- 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. +See `docs/ESP32_P4_SV_FIRST_TRIAL.md` for the execution checklist. -### E3. ESP32-S3 SV Publisher hardware proof +### E4. Exception-free MCU SV TX slice — NEXT HARDENING GATE -Start with synthetic waveform; do not block on ADC hardware. +Goal: build the first SV publisher firmware with C++ exceptions disabled again, +without changing desktop convenience APIs. -Reference first profile: +Preferred direction: -- 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. +- separate status-returning/no-throw transmit codec implementation units from + throw-based desktop wrappers where necessary; +- add non-throwing MAC/config construction paths suitable for static firmware + configuration; +- keep decode/diagnostic convenience APIs host-side when they are not required + by the first publisher image; +- CI with ESP32-P4 and `CONFIG_COMPILER_CXX_EXCEPTIONS=n`. 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. +- ESP-IDF P4 cross-compile green with exceptions disabled; +- no `throw`/`catch` reachable or required by the MCU SV TX component; +- publisher golden bytes remain unchanged; +- host GCC/Clang/MSVC API/regression suite remains green. -### E4. Board I/O HAL +### E5. Board I/O HAL — Waveshare ESP32-S3 secondary reference Goal: make the Waveshare hardware useful as a real substation I/O prototype. @@ -258,21 +283,13 @@ Outputs: - 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 +### E6. Minimal MMS server foundation -This is required for the board to become an actual IEC 61850 I/O IED. +This is required for an embedded 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. -Implement a static, bounded server model first. Do not port desktop dynamic -model discovery into the MCU server. - -Minimum server services: +Minimum services: 1. association acceptor; 2. MMS Initiate negotiation; @@ -283,7 +300,7 @@ Minimum server services: Initial static model proposal: ```text -ESP32S3IO +ARSTACKIO └── LD0 ├── LLN0 ├── LPHD1 @@ -293,57 +310,34 @@ ESP32S3IO Acceptance: -- IEDScout can associate; -- IEDScout can browse the static model; -- eight DI values can be read; +- an independent IEC 61850 engineering client can associate; +- the client can browse the static model; +- configured I/O 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. +### E7. Simple IEC 61850 I/O IED -Sequence: +Add controlled output operation only after read-only server browse/read is stable. +Then add a static DataSet, URCB first, data-change reports and BRCB later. -- simple Write where standards/model permit; -- IEC 61850 control model required for the exposed DOs; -- interlock/check hooks; -- operation timeout; -- command audit/result status. +### E8. GOOSE I/O extension -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: +After raw MAC transport is proven by SV: - DI state as GOOSE publisher; - GOOSE subscriber mapped to selected DO only through application safety policy; - retransmission schedule and TAL supervision; -- VLAN priority validation. +- VLAN priority validation on a hardware/driver path that supports it correctly. -### E8. Industrialization / STM32 migration +### E9. Industrialization / STM32-NXP migration -ESP32 is the reference accessibility target, not the final certification claim. -The same embedded core should later move to STM32H7/NXP-class hardware. +ESP32 is the accessibility/reference target, not a certification claim. The same +embedded core should later move to STM32H7/NXP-class hardware by replacing +platform adapters and capacities, not protocol semantics. Industrialization work: -- `-fno-exceptions` embedded core; +- fully exception-free MCU core; - replace remaining hot-path dynamic containers with bounded storage/views; - explicit memory budget report; - deterministic scheduler/timing hooks; @@ -356,30 +350,30 @@ Industrialization work: # 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. +Until Public Alpha is tagged, host parity and embedded architecture progress in +parallel. Embedded changes that prevent architectural debt — allocation-bounded +wire encoding, HAL boundaries, real cross-compile gates and real-time pacing +rules — are performed before adding new protocol surface. -Exception: embedded changes that prevent architectural debt (allocation-free -wire encoding, HAL boundaries, compile gates) are performed immediately before -new protocol features are ported. +The immediate embedded order is: -After Public Alpha: - -- finish E2/E3 hardware SV proof; -- then shift primary development toward E5/E6 MMS server + I/O IED. +1. ESP32-P4 SV cross-compile; +2. physical untagged SV capture and timing evidence; +3. exception-free P4 SV TX hardening; +4. VLAN/priority path validation; +5. GOOSE raw Ethernet; +6. bounded 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 +of these without forks of IEC 61850 wire semantics: + +1. Windows/Linux engineering client can browse real IEDs. +2. ESP32-P4 can publish standards-shaped SV frames through the ESP-IDF Ethernet + abstraction with measured timing/heap evidence. +3. The MCU SV TX path can build with exceptions disabled. +4. A small ESP32 I/O reference can expose isolated inputs/outputs through a + bounded IEC 61850 server model. +5. A later STM32/NXP port changes platform adapters and capacities, not protocol semantics. From 68d6d921d905df02e48adcb77e403674aa8088c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:10:04 +0700 Subject: [PATCH 019/188] Keep strict warnings while isolating ESP-IDF header extensions --- ports/esp_idf/CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ports/esp_idf/CMakeLists.txt b/ports/esp_idf/CMakeLists.txt index 24196e0b4..14aa1db27 100644 --- a/ports/esp_idf/CMakeLists.txt +++ b/ports/esp_idf/CMakeLists.txt @@ -44,3 +44,12 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC # hardening gate; do not hide it by weakening the cross-compile CI. target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -fno-rtti) + +# Keep -Wpedantic on the portable protocol sources. The one ESP-IDF adapter must +# include vendor C headers which intentionally use GNU extensions such as +# #include_next and anonymous structs. Suppress only pedantic diagnostics for this +# translation unit; ESP-IDF's global -Werror and all other arstack warnings remain +# active. This prevents vendor-header syntax from weakening the core warning gate. +set_source_files_properties( + ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp + PROPERTIES COMPILE_OPTIONS "-Wno-pedantic") From 834928cc595651db8a80ad5d9ce954e7fb119a76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:12:13 +0700 Subject: [PATCH 020/188] Add no-throw static MAC construction path --- include/ariec61850/ethernet/ethernet.hpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/include/ariec61850/ethernet/ethernet.hpp b/include/ariec61850/ethernet/ethernet.hpp index 1d23b5ed2..143611215 100644 --- a/include/ariec61850/ethernet/ethernet.hpp +++ b/include/ariec61850/ethernet/ethernet.hpp @@ -19,6 +19,13 @@ inline constexpr std::uint16_t ptp_ethertype = 0x88F7; class MacAddress final { public: MacAddress() = default; + + // Exact-size construction is the preferred embedded path: the size is + // encoded in the type, so there is nothing to validate or throw at runtime. + explicit constexpr MacAddress(const std::array& bytes) noexcept + : bytes_(bytes) {} + + // Retained for host/source compatibility when the caller only has a span. explicit MacAddress(std::span bytes); [[nodiscard]] static MacAddress parse(const std::string& text); From 885f9dca8eff122cb00f7160e76f71f8f34a2991 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:46:41 +0700 Subject: [PATCH 021/188] Add flashable ESP32-P4 SV trial firmware --- ports/esp_idf/sv_trial_app/CMakeLists.txt | 8 + ports/esp_idf/sv_trial_app/README.md | 65 ++++ .../esp_idf/sv_trial_app/main/CMakeLists.txt | 4 + ports/esp_idf/sv_trial_app/main/main.cpp | 305 ++++++++++++++++++ ports/esp_idf/sv_trial_app/sdkconfig.defaults | 5 + 5 files changed, 387 insertions(+) create mode 100644 ports/esp_idf/sv_trial_app/CMakeLists.txt create mode 100644 ports/esp_idf/sv_trial_app/README.md create mode 100644 ports/esp_idf/sv_trial_app/main/CMakeLists.txt create mode 100644 ports/esp_idf/sv_trial_app/main/main.cpp create mode 100644 ports/esp_idf/sv_trial_app/sdkconfig.defaults diff --git a/ports/esp_idf/sv_trial_app/CMakeLists.txt b/ports/esp_idf/sv_trial_app/CMakeLists.txt new file mode 100644 index 000000000..f859ac46a --- /dev/null +++ b/ports/esp_idf/sv_trial_app/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) + +# Flashable first-trial firmware for the Waveshare ESP32-P4-ETH board. +# The protocol core remains a separate ESP-IDF component one directory above. +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/..") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack61850_esp32p4_sv_trial) diff --git a/ports/esp_idf/sv_trial_app/README.md b/ports/esp_idf/sv_trial_app/README.md new file mode 100644 index 000000000..1de046c28 --- /dev/null +++ b/ports/esp_idf/sv_trial_app/README.md @@ -0,0 +1,65 @@ +# ESP32-P4 Sampled Values first-trial firmware + +This is the first flashable arstack61850 firmware target. It is intentionally narrower than the full desktop stack and proves the active Sampled Values transmit vertical slice on ESP32-P4. + +## Hardware profile + +- board target: Waveshare ESP32-P4-ETH +- ESP-IDF target: `esp32p4` +- Ethernet: internal ESP32-P4 EMAC + RMII +- PHY: IP101-compatible IEEE 802.3 path +- PHY address: 1 +- PHY reset GPIO: 51 +- Sampled Values: 50 Hz / 80 samples per cycle / 4,000 sample instants per second +- APPID: `0x4001` +- destination MAC: `01:0C:CD:04:00:01` +- untagged Ethernet first proof +- one ASDU per Ethernet frame +- 8 synthetic `INT32 + quality` pairs (64-byte `seqOfData`) + +The synthetic sample values are deterministic and are only for transport/wire validation. They are not IEC 61869-9 instrument-transformer scaling. + +## Build + +From an ESP-IDF v6.0.2 shell: + +```bash +idf.py set-target esp32p4 +idf.py build +``` + +GitHub Actions builds the same project with ESP-IDF v6.0.2. A successful workflow uploads the complete flashable build set as the `esp32p4-sv-trial-firmware` artifact, including the application image, bootloader, partition table, ELF, map, `flash_args`, `flasher_args.json`, and the generated sdkconfig. + +## Flash + +With the repository and ESP-IDF environment available: + +```bash +idf.py -p flash monitor +``` + +A downloaded CI artifact can also be flashed with Espressif's flashing tools using the included `flash_args`/`flasher_args.json`. Keep the extracted directory structure intact so the relative binary paths remain valid. + +## Expected serial output + +After boot the firmware: + +1. initializes the ESP32-P4 EMAC and PHY; +2. waits for an Ethernet link; +3. starts a 250 us periodic ESP timer; +4. wakes a dedicated high-priority publisher task; +5. publishes at most one SV frame for each processed timer notification; +6. reports counters approximately once per second. + +The QA line reports frames sent, encode/TX failures, late polls, maximum lateness, timer notifications, coalesced timer notifications, current free heap, and minimum-ever free heap. + +## What CI proves without hardware + +A green repository can prove: + +- portable protocol core builds with GCC and Clang; +- host-sim publisher behavior passes CTest; +- ESP32-P4 RISC-V cross-compilation and final link succeed under ESP-IDF v6.0.2; +- a complete flashable firmware image set is produced. + +CI **cannot** prove PHY electrical bring-up, real cable transmission, DMA behavior under sustained 4 kHz traffic, scheduler jitter on silicon, or Wireshark-observed packet continuity. Those remain explicit hardware gates. diff --git a/ports/esp_idf/sv_trial_app/main/CMakeLists.txt b/ports/esp_idf/sv_trial_app/main/CMakeLists.txt new file mode 100644 index 000000000..ccbb6319a --- /dev/null +++ b/ports/esp_idf/sv_trial_app/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES esp_idf esp_eth esp_event esp_timer) diff --git a/ports/esp_idf/sv_trial_app/main/main.cpp b/ports/esp_idf/sv_trial_app/main/main.cpp new file mode 100644 index 000000000..a3fbf0a14 --- /dev/null +++ b/ports/esp_idf/sv_trial_app/main/main.cpp @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include "esp_eth.h" +#include "esp_event.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/task.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kSampleRateHz = 4'000U; +constexpr std::uint64_t kPublishIntervalUs = 250U; +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::size_t kChannelCount = 8U; +constexpr std::size_t kPayloadBytes = + kChannelCount * sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; + +// Waveshare ESP32-P4-ETH: IP101 PHY on RMII. ESP32-P4's +// ETH_ESP32_EMAC_DEFAULT_CONFIG() supplies the matching default RMII data-plane +// GPIOs; this board-specific layer supplies the PHY management values. +constexpr std::int32_t kPhyAddress = 1; +constexpr int kPhyResetGpio = 51; + +constexpr EventBits_t kEthernetLinkUpBit = BIT0; +constexpr UBaseType_t kPublisherTaskPriority = 18U; +constexpr std::uint32_t kPublisherTaskStackBytes = 8'192U; + +const char* kTag = "arstack_sv_trial"; +EventGroupHandle_t g_link_events{}; +TaskHandle_t g_publisher_task{}; +esp_eth_handle_t g_eth_handle{}; + +void ethernet_event_handler( + void* argument, + esp_event_base_t event_base, + const std::int32_t event_id, + void* event_data) { + (void)argument; + (void)event_base; + + auto handle = *static_cast(event_data); + switch (event_id) { + case ETHERNET_EVENT_CONNECTED: { + std::array mac{}; + if (esp_eth_ioctl(handle, ETH_CMD_G_MAC_ADDR, mac.data()) == ESP_OK) { + ESP_LOGI( + kTag, + "Ethernet link up, MAC %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } else { + ESP_LOGI(kTag, "Ethernet link up"); + } + xEventGroupSetBits(g_link_events, kEthernetLinkUpBit); + break; + } + case ETHERNET_EVENT_DISCONNECTED: + xEventGroupClearBits(g_link_events, kEthernetLinkUpBit); + ESP_LOGW(kTag, "Ethernet link down; SV transmission paused"); + break; + case ETHERNET_EVENT_START: + ESP_LOGI(kTag, "Ethernet driver started"); + break; + case ETHERNET_EVENT_STOP: + xEventGroupClearBits(g_link_events, kEthernetLinkUpBit); + ESP_LOGI(kTag, "Ethernet driver stopped"); + break; + default: + break; + } +} + +esp_err_t initialize_ethernet(esp_eth_handle_t* out_handle) { + if (out_handle == nullptr) { + return ESP_ERR_INVALID_ARG; + } + + eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); + eth_esp32_emac_config_t emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); + eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); + phy_config.phy_addr = kPhyAddress; + phy_config.reset_gpio_num = kPhyResetGpio; + + esp_eth_mac_t* mac = esp_eth_mac_new_esp32(&emac_config, &mac_config); + if (mac == nullptr) { + return ESP_FAIL; + } + + // Generic IEEE 802.3 PHY support is sufficient for the first IP101 + // transmit proof and avoids depending on vendor-only PHY features. + esp_eth_phy_t* phy = esp_eth_phy_new_generic(&phy_config); + if (phy == nullptr) { + mac->del(mac); + return ESP_FAIL; + } + + esp_eth_config_t driver_config = ETH_DEFAULT_CONFIG(mac, phy); + const auto installed = esp_eth_driver_install(&driver_config, out_handle); + if (installed != ESP_OK) { + mac->del(mac); + phy->del(phy); + return installed; + } + + return ESP_OK; +} + +sampled_values::SampledValuesFrame make_sv_frame( + const std::array& source_mac) { + const std::array destination_mac{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_P4_TRIAL"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{kSampleRateHz}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(kPayloadBytes, 0U); + + return { + ethernet::MacAddress{destination_mac}, + ethernet::MacAddress{source_mac}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +bool update_synthetic_payload( + sampled_values::SampledValueAsdu& asdu, + const std::uint16_t sample_count) noexcept { + if (asdu.sample_payload.size() != kPayloadBytes) { + return false; + } + + const auto payload = std::span{ + asdu.sample_payload.data(), asdu.sample_payload.size()}; + const auto count = static_cast(sample_count); + + for (std::size_t channel = 0U; channel < kChannelCount; ++channel) { + // Deterministic synthetic values make captures easy to validate without + // floating-point work or ADC dependencies in this first transport proof. + const auto value = + count * 1'000 + static_cast(channel) * 100'000; + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, channel, value, 0U)) { + return false; + } + } + return true; +} + +void sv_timer_callback(void* argument) { + (void)argument; + if (g_publisher_task != nullptr) { + xTaskNotifyGive(g_publisher_task); + } +} + +void publisher_task(void* argument) { + (void)argument; + g_publisher_task = xTaskGetCurrentTaskHandle(); + + ESP_LOGI(kTag, "Waiting for Ethernet link before starting SV publisher"); + (void)xEventGroupWaitBits( + g_link_events, + kEthernetLinkUpBit, + pdFALSE, + pdTRUE, + portMAX_DELAY); + + std::array source_mac{}; + ESP_ERROR_CHECK(esp_eth_ioctl(g_eth_handle, ETH_CMD_G_MAC_ADDR, source_mac.data())); + + auto frame = make_sv_frame(source_mac); + std::array frame_buffer{}; + ports::esp_idf::RawEthernetContext ethernet_context{g_eth_handle}; + const auto raw_port = ports::esp_idf::make_raw_ethernet_port(ethernet_context); + const auto clock = ports::esp_idf::make_monotonic_clock(); + + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + raw_port, + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + + if (!publisher.valid()) { + ESP_LOGE(kTag, "SV publisher configuration is invalid"); + vTaskDelete(nullptr); + return; + } + + esp_timer_handle_t timer{}; + esp_timer_create_args_t timer_args{}; + timer_args.callback = &sv_timer_callback; + timer_args.dispatch_method = ESP_TIMER_TASK; + timer_args.name = "arstack_sv_4k"; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer)); + ESP_ERROR_CHECK(esp_timer_start_periodic(timer, kPublishIntervalUs)); + + ESP_LOGI( + kTag, + "SV publisher active: 4000/s, APPID=0x4001, dst=01:0c:cd:04:00:01, payload=%u bytes", + static_cast(kPayloadBytes)); + + std::uint64_t timer_notifications{}; + std::uint64_t coalesced_notifications{}; + std::uint64_t report_notifications{}; + + while (true) { + const auto notifications = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + timer_notifications += notifications; + report_notifications += notifications; + if (notifications > 1U) { + coalesced_notifications += static_cast(notifications - 1U); + } + + if ((xEventGroupGetBits(g_link_events) & kEthernetLinkUpBit) == 0U) { + continue; + } + + auto& asdu = frame.pdu.asdus.front(); + if (!update_synthetic_payload(asdu, publisher.next_sample_count())) { + ESP_LOGE(kTag, "Synthetic payload update failed"); + continue; + } + + const auto result = publisher.poll(clock.monotonic_us()); + (void)result; + + if (report_notifications >= kSampleRateHz) { + report_notifications -= kSampleRateHz; + const auto& stats = publisher.statistics(); + ESP_LOGI( + kTag, + "QA sent=%" PRIu64 " txFail=%" PRIu64 " encFail=%" PRIu64 + " late=%" PRIu64 " maxLateUs=%" PRIu64 + " timerTicks=%" PRIu64 " coalesced=%" PRIu64 + " heap=%u minHeap=%u", + stats.frames_sent, + stats.transmit_failures, + stats.encode_failures, + stats.late_polls, + stats.maximum_lateness_us, + timer_notifications, + coalesced_notifications, + static_cast(esp_get_free_heap_size()), + static_cast(esp_get_minimum_free_heap_size())); + } + } +} + +} // namespace + +extern "C" void app_main(void) { + g_link_events = xEventGroupCreate(); + if (g_link_events == nullptr) { + ESP_LOGE(kTag, "Failed to create Ethernet link event group"); + return; + } + + ESP_ERROR_CHECK(esp_event_loop_create_default()); + ESP_ERROR_CHECK(initialize_ethernet(&g_eth_handle)); + ESP_ERROR_CHECK(esp_event_handler_register( + ETH_EVENT, + ESP_EVENT_ANY_ID, + ðernet_event_handler, + nullptr)); + ESP_ERROR_CHECK(esp_eth_start(g_eth_handle)); + + const auto created = xTaskCreate( + &publisher_task, + "arstack_sv_pub", + kPublisherTaskStackBytes, + nullptr, + kPublisherTaskPriority, + nullptr); + if (created != pdPASS) { + ESP_LOGE(kTag, "Failed to create SV publisher task"); + } +} diff --git a/ports/esp_idf/sv_trial_app/sdkconfig.defaults b/ports/esp_idf/sv_trial_app/sdkconfig.defaults new file mode 100644 index 000000000..1cd3bcce0 --- /dev/null +++ b/ports/esp_idf/sv_trial_app/sdkconfig.defaults @@ -0,0 +1,5 @@ +# Transitional setting: shared codecs still retain exception-based host APIs. +CONFIG_COMPILER_CXX_EXCEPTIONS=y + +# First physical proof uses the ESP32-P4 internal EMAC/RMII path. +CONFIG_ETH_USE_ESP32_EMAC=y From 7825998c6d6a44311feba3afe86663eb3862525a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:47:07 +0700 Subject: [PATCH 022/188] Build and publish flashable ESP32-P4 firmware artifact --- .github/workflows/esp-idf-ci.yml | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index ae37c229f..04a8e9453 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -24,3 +24,41 @@ jobs: esp_idf_version: v6.0.2 target: esp32p4 path: ports/esp_idf/smoke_app + + - name: Build flashable ESP32-P4 SV trial firmware + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v6.0.2 + target: esp32p4 + path: ports/esp_idf/sv_trial_app + + - name: Verify flashable firmware outputs + shell: bash + run: | + set -euo pipefail + build=ports/esp_idf/sv_trial_app/build + test -f "$build/arstack61850_esp32p4_sv_trial.bin" + test -f "$build/arstack61850_esp32p4_sv_trial.elf" + test -f "$build/arstack61850_esp32p4_sv_trial.map" + test -f "$build/bootloader/bootloader.bin" + test -f "$build/partition_table/partition-table.bin" + test -f "$build/flash_args" + test -f "$build/flasher_args.json" + echo "Flashable ESP32-P4 firmware set is complete." + + - name: Upload ESP32-P4 flashable firmware + uses: actions/upload-artifact@v4 + with: + name: esp32p4-sv-trial-firmware-${{ github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.bin + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.elf + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.map + ports/esp_idf/sv_trial_app/build/bootloader/bootloader.bin + ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin + ports/esp_idf/sv_trial_app/build/flash_args + ports/esp_idf/sv_trial_app/build/flasher_args.json + ports/esp_idf/sv_trial_app/build/sdkconfig + ports/esp_idf/sv_trial_app/README.md From abadcb5a3bf6ba5380356f454df9271ca4a499f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:47:33 +0700 Subject: [PATCH 023/188] Publish embedded host-simulation QA results --- .github/workflows/embedded-ci.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index f3682334a..bc643a94f 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -97,12 +97,24 @@ jobs: - name: Build embedded profile run: cmake --build build-embedded --parallel - - name: Test embedded full-link smoke - run: ctest --test-dir build-embedded --output-on-failure + - name: Test embedded host simulation + run: >- + ctest --test-dir build-embedded + --output-on-failure + --output-junit embedded-ctest.xml - name: Report host-sim footprint shell: bash run: | if command -v size >/dev/null 2>&1; then size build-embedded/ariec61850_embedded_link_smoke || true + size build-embedded/ariec61850_embedded_sv_publisher_smoke || true fi + + - name: Upload host-simulation QA report + uses: actions/upload-artifact@v4 + with: + name: embedded-host-sim-${{ matrix.compiler }}-${{ github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-embedded/embedded-ctest.xml From c6227917ff85fcfc0a71ff11af70a9e26dfbb62d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:47:59 +0700 Subject: [PATCH 024/188] Extend SV publisher host simulation soak coverage --- embedded/sv_publisher_smoke.cpp | 60 ++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/embedded/sv_publisher_smoke.cpp b/embedded/sv_publisher_smoke.cpp index cfb087737..204961917 100644 --- a/embedded/sv_publisher_smoke.cpp +++ b/embedded/sv_publisher_smoke.cpp @@ -107,7 +107,9 @@ int main() { std::span{fake.last_frame}.first(fake.last_size), decoded) || decoded.pdu.asdus.size() != 1U || - decoded.pdu.asdus.front().sample_count != 3'999U) { + decoded.pdu.asdus.front().sample_count != 3'999U || + decoded.app_id != 0x4001U || + decoded.vlan.has_value()) { return 3; } @@ -165,5 +167,61 @@ int main() { return 10; } + // Simulate two full seconds at the first-trial 4 kHz profile. This is not a + // wall-clock performance benchmark; it is deterministic protocol/pacing QA. + // Every nominal 250 us tick must produce exactly one frame, smpCnt must wrap + // at 4000, the resulting wire frame must decode, and the stream must report + // no scheduler lateness when the simulated clock is exact. + FakeEthernet stream_fake; + embedded::RawEthernetPort stream_port{&stream_fake, &capture_frame}; + auto stream_frame = make_frame(); + std::array stream_buffer{}; + sampled_values::SampledValuesPublisher stream_publisher( + stream_frame, + stream_buffer, + stream_port, + sampled_values::SampledValuesPublisherConfig{ + 4'000U, + std::uint16_t{4'000U}, + 0U, + true}); + + constexpr std::uint64_t stream_start_us = 1'000'000U; + constexpr std::uint64_t simulated_frames = 8'000U; + for (std::uint64_t index = 0U; index < simulated_frames; ++index) { + const auto now_us = stream_start_us + index * 250U; + const auto expected_count = static_cast(index % 4'000U); + const auto result = stream_publisher.poll(now_us); + if (!result.sent() || result.sample_count != expected_count || + stream_fake.sends != index + 1U) { + return 11; + } + + sampled_values::SampledValuesFrame stream_decoded; + if (!sampled_values::SampledValuesFrameCodec::try_decode( + std::span{stream_fake.last_frame}.first(stream_fake.last_size), + stream_decoded) || + stream_decoded.destination != stream_frame.destination || + stream_decoded.source != stream_frame.source || + stream_decoded.app_id != 0x4001U || + stream_decoded.vlan.has_value() || + stream_decoded.pdu.asdus.size() != 1U || + stream_decoded.pdu.asdus.front().sample_count != expected_count || + stream_decoded.pdu.asdus.front().sample_payload != + stream_frame.pdu.asdus.front().sample_payload) { + return 12; + } + } + + const auto& stream_statistics = stream_publisher.statistics(); + if (stream_statistics.frames_sent != simulated_frames || + stream_statistics.encode_failures != 0U || + stream_statistics.transmit_failures != 0U || + stream_statistics.late_polls != 0U || + stream_statistics.maximum_lateness_us != 0U || + stream_publisher.next_sample_count() != 0U) { + return 13; + } + return 0; } From 7c70efff5bf0ec338476d015992881ed7b7fa859 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 19:48:57 +0700 Subject: [PATCH 025/188] Document embedded simulation and hardware QA gates --- docs/EMBEDDED_QA_GATES.md | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/EMBEDDED_QA_GATES.md diff --git a/docs/EMBEDDED_QA_GATES.md b/docs/EMBEDDED_QA_GATES.md new file mode 100644 index 000000000..9088b044a --- /dev/null +++ b/docs/EMBEDDED_QA_GATES.md @@ -0,0 +1,79 @@ +# Embedded QA gates + +arstack61850 deliberately separates software evidence from physical-hardware evidence. A green CI run must never be described as proof of protection-grade timing or successful Ethernet transmission on silicon. + +## Gate A — portable embedded host simulation + +Runs on Linux with both GCC and Clang, using the MCU-oriented source boundary and host-service exclusions. + +Required evidence: + +- embedded source-boundary audit passes; +- strict warnings-as-errors build passes; +- full embedded link smoke passes; +- SV publisher edge-case smoke passes; +- deterministic 8,000-frame simulation passes at a virtual 4,000 samples/s; +- every simulated frame can be decoded again with expected MAC identity, APPID, untagged profile, payload, and `smpCnt` sequence/wrap; +- zero simulated lateness is reported when the supplied monotonic clock is exact; +- JUnit output is retained as a GitHub Actions artifact. + +This gate proves deterministic software behavior. It does not measure wall-clock performance. + +## Gate B — real ESP32-P4 cross-compile + +Uses the official ESP-IDF v6.0.2 toolchain with target `esp32p4`. + +Required evidence: + +- the narrow arstack61850 ESP-IDF component compiles and links; +- the compile-only integration smoke app builds; +- the flashable first-trial SV firmware builds and links; +- no host-only TCP/filesystem/capture services leak into the embedded component boundary. + +This gate proves that the code is accepted by the real ESP32-P4 RISC-V compiler/linker and ESP-IDF component system. + +## Gate C — flashable artifact completeness + +The ESP-IDF workflow must fail if any required deployment output is missing. + +The retained `esp32p4-sv-trial-firmware-*` artifact contains: + +- application `.bin`; +- application `.elf`; +- application `.map`; +- bootloader image; +- partition-table image; +- `flash_args`; +- `flasher_args.json`; +- generated `sdkconfig`; +- firmware-specific flashing/behavior README. + +Passing Gate C means a developer can download a CI-produced firmware set instead of rebuilding it locally. + +## Gate D — physical ESP32-P4 Ethernet acceptance + +This gate requires the actual board and an isolated laboratory Ethernet segment. + +Required evidence for the first Sampled Values proof: + +1. ESP32-P4 boots the Gate-C firmware and the PHY reports link up. +2. Wireshark observes EtherType `0x88BA` frames from the board. +3. Destination MAC and APPID are exactly the configured first-trial values. +4. Observed packet rate is near 4,000 frames/s under the 50 Hz / 80 samples-per-cycle profile. +5. `smpCnt` behavior matches the accepted publisher semantics and wraps at 4,000. +6. No unexpected catch-up burst appears after scheduler delay. +7. TX/encode failures, timer coalescing, lateness, and heap-watermark telemetry are recorded. +8. Short run, 10-minute soak, and one-hour soak are progressively accepted. + +Only Gate D can validate real PHY wiring, EMAC/DMA behavior, scheduler jitter on silicon, cable transmission, switch behavior, and packet continuity observed by another device. + +## Status vocabulary + +Use these terms in issues, PRs, release notes, and test evidence: + +- `SIMULATED/PASSED` — Gate A passed. +- `ESP32P4/CROSS-COMPILED` — Gate B passed. +- `FLASHABLE/READY` — Gate C passed. +- `HARDWARE/PASSED` — Gate D passed on a recorded physical run. + +Never promote `FLASHABLE/READY` to `HARDWARE/PASSED` without physical evidence. From 1c33f4dc5d0948154fbe735807b042ca3678aa85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:07:51 +0700 Subject: [PATCH 026/188] Add independent SV PCAP oracle and flashable artifact checks --- .github/workflows/embedded-ci.yml | 20 +- .github/workflows/esp-idf-ci.yml | 25 ++- docs/EMBEDDED_QA_GATES.md | 23 ++- embedded/CMakeLists.txt | 17 ++ embedded/sv_stream_pcap.cpp | 206 ++++++++++++++++++++ tools/verify_sv_pcap.py | 302 ++++++++++++++++++++++++++++++ 6 files changed, 578 insertions(+), 15 deletions(-) create mode 100644 embedded/sv_stream_pcap.cpp create mode 100755 tools/verify_sv_pcap.py diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index bc643a94f..191a01fb7 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -103,18 +103,32 @@ jobs: --output-on-failure --output-junit embedded-ctest.xml + - name: Independently verify exact SV PCAP wire image + shell: bash + run: | + set -euo pipefail + python3 tools/verify_sv_pcap.py \ + build-embedded/sv_trial_2s.pcap \ + --expected-frames 8000 \ + --report build-embedded/sv-pcap-qa.json + sha256sum build-embedded/sv_trial_2s.pcap + - name: Report host-sim footprint shell: bash run: | if command -v size >/dev/null 2>&1; then size build-embedded/ariec61850_embedded_link_smoke || true size build-embedded/ariec61850_embedded_sv_publisher_smoke || true + size build-embedded/ariec61850_embedded_sv_stream_pcap || true fi - - name: Upload host-simulation QA report + - name: Upload host-simulation QA evidence uses: actions/upload-artifact@v4 with: - name: embedded-host-sim-${{ matrix.compiler }}-${{ github.sha }} + name: embedded-host-sim-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} if-no-files-found: error retention-days: 14 - path: build-embedded/embedded-ctest.xml + path: | + build-embedded/embedded-ctest.xml + build-embedded/sv_trial_2s.pcap + build-embedded/sv-pcap-qa.json diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index 04a8e9453..43a1c201e 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -36,7 +36,8 @@ jobs: shell: bash run: | set -euo pipefail - build=ports/esp_idf/sv_trial_app/build + app=ports/esp_idf/sv_trial_app + build="$app/build" test -f "$build/arstack61850_esp32p4_sv_trial.bin" test -f "$build/arstack61850_esp32p4_sv_trial.elf" test -f "$build/arstack61850_esp32p4_sv_trial.map" @@ -44,12 +45,26 @@ jobs: test -f "$build/partition_table/partition-table.bin" test -f "$build/flash_args" test -f "$build/flasher_args.json" - echo "Flashable ESP32-P4 firmware set is complete." + test -f "$app/sdkconfig" + grep -q '^CONFIG_IDF_TARGET_ESP32P4=y$' "$app/sdkconfig" + python3 -m json.tool "$build/flasher_args.json" >/dev/null + ( + cd "$app" + sha256sum \ + build/arstack61850_esp32p4_sv_trial.bin \ + build/arstack61850_esp32p4_sv_trial.elf \ + build/bootloader/bootloader.bin \ + build/partition_table/partition-table.bin \ + build/flash_args \ + build/flasher_args.json \ + sdkconfig > build/SHA256SUMS + ) + echo "FLASHABLE/READY: complete ESP32-P4 firmware set verified." - name: Upload ESP32-P4 flashable firmware uses: actions/upload-artifact@v4 with: - name: esp32p4-sv-trial-firmware-${{ github.sha }} + name: esp32p4-sv-trial-firmware-${{ github.event.pull_request.head.sha || github.sha }} if-no-files-found: error retention-days: 14 path: | @@ -60,5 +75,7 @@ jobs: ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin ports/esp_idf/sv_trial_app/build/flash_args ports/esp_idf/sv_trial_app/build/flasher_args.json - ports/esp_idf/sv_trial_app/build/sdkconfig + ports/esp_idf/sv_trial_app/build/SHA256SUMS + ports/esp_idf/sv_trial_app/sdkconfig + ports/esp_idf/sv_trial_app/sdkconfig.defaults ports/esp_idf/sv_trial_app/README.md diff --git a/docs/EMBEDDED_QA_GATES.md b/docs/EMBEDDED_QA_GATES.md index 9088b044a..470b31160 100644 --- a/docs/EMBEDDED_QA_GATES.md +++ b/docs/EMBEDDED_QA_GATES.md @@ -13,11 +13,15 @@ Required evidence: - full embedded link smoke passes; - SV publisher edge-case smoke passes; - deterministic 8,000-frame simulation passes at a virtual 4,000 samples/s; -- every simulated frame can be decoded again with expected MAC identity, APPID, untagged profile, payload, and `smpCnt` sequence/wrap; +- C++ publisher/encoder emits a two-second classic-PCAP capture; +- an independent pure-Python reference implementation rebuilds the expected Ethernet + process-bus + BER SV stream and requires the entire PCAP to match byte-for-byte; +- the independent parser also validates destination/source MAC, EtherType `0x88BA`, APPID `0x4001`, declared length, reserved fields, `noASDU`, `svID`, DataSet reference, `confRev`, `smpCnt`, `smpSynch`, `smpRate`, `smpMod`, 64-byte synthetic payload, and exact 250 us capture cadence; - zero simulated lateness is reported when the supplied monotonic clock is exact; -- JUnit output is retained as a GitHub Actions artifact. +- JUnit, PCAP, and machine-readable JSON QA evidence are retained as GitHub Actions artifacts for both GCC and Clang. -This gate proves deterministic software behavior. It does not measure wall-clock performance. +The external verifier intentionally does not import or call the arstack61850 decoder. This prevents a matching encoder/decoder defect from satisfying Gate A merely because both implementations accept the same incorrect wire image. + +This gate proves deterministic software behavior and exact first-trial wire construction under a virtual clock. It does not measure wall-clock performance. ## Gate B — real ESP32-P4 cross-compile @@ -28,13 +32,14 @@ Required evidence: - the narrow arstack61850 ESP-IDF component compiles and links; - the compile-only integration smoke app builds; - the flashable first-trial SV firmware builds and links; +- generated `sdkconfig` identifies `CONFIG_IDF_TARGET_ESP32P4=y`; - no host-only TCP/filesystem/capture services leak into the embedded component boundary. This gate proves that the code is accepted by the real ESP32-P4 RISC-V compiler/linker and ESP-IDF component system. ## Gate C — flashable artifact completeness -The ESP-IDF workflow must fail if any required deployment output is missing. +The ESP-IDF workflow must fail if any required deployment output is missing or malformed. The retained `esp32p4-sv-trial-firmware-*` artifact contains: @@ -44,11 +49,13 @@ The retained `esp32p4-sv-trial-firmware-*` artifact contains: - bootloader image; - partition-table image; - `flash_args`; -- `flasher_args.json`; -- generated `sdkconfig`; +- valid `flasher_args.json`; +- generated project-level `sdkconfig`; +- `sdkconfig.defaults`; +- `SHA256SUMS` covering the deployment-critical images/configuration; - firmware-specific flashing/behavior README. -Passing Gate C means a developer can download a CI-produced firmware set instead of rebuilding it locally. +Passing Gate C means a developer can download a CI-produced firmware set instead of rebuilding it locally, and can verify the retained files before flashing. ## Gate D — physical ESP32-P4 Ethernet acceptance @@ -71,7 +78,7 @@ Only Gate D can validate real PHY wiring, EMAC/DMA behavior, scheduler jitter on Use these terms in issues, PRs, release notes, and test evidence: -- `SIMULATED/PASSED` — Gate A passed. +- `SIMULATED/PASSED` — Gate A passed, including the independent exact-byte PCAP oracle. - `ESP32P4/CROSS-COMPILED` — Gate B passed. - `FLASHABLE/READY` — Gate C passed. - `HARDWARE/PASSED` — Gate D passed on a recorded physical run. diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index 6ced524c8..1340e1abe 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -104,9 +104,26 @@ target_compile_definitions(ariec61850_embedded_sv_publisher_smoke PRIVATE ARIEC61850_HOST_SERVICES=0) ariec61850_apply_embedded_flags(ariec61850_embedded_sv_publisher_smoke) +# Produce a deterministic two-second classic-PCAP capture using only the +# publisher/encoder path. A separate Python implementation verifies the complete +# wire image byte-for-byte in CI, so encoder bugs cannot be hidden by the C++ +# decoder accepting the same mistake. +add_executable(ariec61850_embedded_sv_stream_pcap + ${CMAKE_CURRENT_LIST_DIR}/sv_stream_pcap.cpp) +target_link_libraries(ariec61850_embedded_sv_stream_pcap PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_stream_pcap PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_stream_pcap PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_stream_pcap PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_stream_pcap) + enable_testing() add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_smoke) add_test(NAME ariec61850_embedded_sv_publisher_smoke COMMAND ariec61850_embedded_sv_publisher_smoke) +add_test( + NAME ariec61850_embedded_sv_stream_pcap + COMMAND ariec61850_embedded_sv_stream_pcap "${CMAKE_BINARY_DIR}/sv_trial_2s.pcap") message(STATUS "arstack61850 embedded profile: host services excluded") message(STATUS "arstack61850 embedded profile: RTTI disabled=${ARIEC61850_EMBEDDED_NO_RTTI}") diff --git a/embedded/sv_stream_pcap.cpp b/embedded/sv_stream_pcap.cpp new file mode 100644 index 000000000..5d6535ab4 --- /dev/null +++ b/embedded/sv_stream_pcap.cpp @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kSampleRateHz = 4'000U; +constexpr std::uint64_t kPublishIntervalUs = 250U; +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::uint64_t kCaptureFrames = 8'000U; +constexpr std::uint64_t kCaptureStartUs = 1'000'000U; +constexpr std::size_t kChannelCount = 8U; +constexpr std::size_t kPayloadBytes = + kChannelCount * sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; + +bool write_bytes( + std::ofstream& output, + const std::span bytes) noexcept { + output.write( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + return static_cast(output); +} + +bool write_u16_le(std::ofstream& output, const std::uint16_t value) noexcept { + const std::array bytes{ + static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU)}; + return write_bytes(output, bytes); +} + +bool write_u32_le(std::ofstream& output, const std::uint32_t value) noexcept { + const std::array bytes{ + static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU), + static_cast((value >> 16U) & 0xFFU), + static_cast((value >> 24U) & 0xFFU)}; + return write_bytes(output, bytes); +} + +bool write_pcap_header(std::ofstream& output) noexcept { + // Classic PCAP, little-endian, microsecond timestamps, Ethernet link type. + return write_u32_le(output, 0xA1B2C3D4U) && + write_u16_le(output, 2U) && + write_u16_le(output, 4U) && + write_u32_le(output, 0U) && + write_u32_le(output, 0U) && + write_u32_le(output, 65'535U) && + write_u32_le(output, 1U); +} + +struct PcapEthernet final { + std::ofstream* output{}; + std::uint64_t frame_index{}; + bool failed{}; +}; + +embedded::IoResult capture_frame( + void* context, + const std::span frame) noexcept { + auto* capture = static_cast(context); + if (capture == nullptr || capture->output == nullptr || frame.empty() || + frame.size() > std::numeric_limits::max()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + const auto timestamp_us = + kCaptureStartUs + capture->frame_index * kPublishIntervalUs; + const auto seconds = static_cast(timestamp_us / 1'000'000U); + const auto microseconds = static_cast(timestamp_us % 1'000'000U); + const auto frame_size = static_cast(frame.size()); + + auto& output = *capture->output; + const auto written = + write_u32_le(output, seconds) && + write_u32_le(output, microseconds) && + write_u32_le(output, frame_size) && + write_u32_le(output, frame_size) && + write_bytes(output, frame); + if (!written) { + capture->failed = true; + return {embedded::IoStatus::io_error, 0U}; + } + + ++capture->frame_index; + return {embedded::IoStatus::ok, frame.size()}; +} + +sampled_values::SampledValuesFrame make_frame() { + const std::array destination_mac{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + const std::array source_mac{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_P4_TRIAL"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{kSampleRateHz}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(kPayloadBytes, 0U); + + return { + ethernet::MacAddress{destination_mac}, + ethernet::MacAddress{source_mac}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +bool update_synthetic_payload( + sampled_values::SampledValueAsdu& asdu, + const std::uint16_t sample_count) noexcept { + if (asdu.sample_payload.size() != kPayloadBytes) { + return false; + } + + const auto payload = std::span{ + asdu.sample_payload.data(), asdu.sample_payload.size()}; + const auto count = static_cast(sample_count); + for (std::size_t channel = 0U; channel < kChannelCount; ++channel) { + const auto value = + count * 1'000 + static_cast(channel) * 100'000; + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, channel, value, 0U)) { + return false; + } + } + return true; +} + +} // namespace + +int main(const int argc, char** argv) { + const char* output_path = argc > 1 ? argv[1] : "sv_trial_2s.pcap"; + std::ofstream output(output_path, std::ios::binary | std::ios::trunc); + if (!output || !write_pcap_header(output)) { + return 1; + } + + PcapEthernet capture{&output}; + embedded::RawEthernetPort port{&capture, &capture_frame}; + auto frame = make_frame(); + std::array frame_buffer{}; + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + + if (!publisher.valid()) { + return 2; + } + + for (std::uint64_t index = 0U; index < kCaptureFrames; ++index) { + const auto expected_count = static_cast(index % kSampleCountWrap); + if (publisher.next_sample_count() != expected_count || + !update_synthetic_payload(frame.pdu.asdus.front(), expected_count)) { + return 3; + } + + const auto now_us = kCaptureStartUs + index * kPublishIntervalUs; + const auto result = publisher.poll(now_us); + if (!result.sent() || result.sample_count != expected_count) { + return 4; + } + } + + output.flush(); + if (!output || capture.failed || capture.frame_index != kCaptureFrames) { + return 5; + } + + const auto& statistics = publisher.statistics(); + if (statistics.frames_sent != kCaptureFrames || + statistics.encode_failures != 0U || + statistics.transmit_failures != 0U || + statistics.late_polls != 0U || + statistics.maximum_lateness_us != 0U || + publisher.next_sample_count() != 0U) { + return 6; + } + + return 0; +} diff --git a/tools/verify_sv_pcap.py b/tools/verify_sv_pcap.py new file mode 100755 index 000000000..71fc4ae9b --- /dev/null +++ b/tools/verify_sv_pcap.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import sys +from pathlib import Path + +DESTINATION_MAC = bytes.fromhex("010ccd040001") +SOURCE_MAC = bytes.fromhex("020000000002") +ETHERTYPE_SV = 0x88BA +APPID = 0x4001 +SV_ID = b"ARSTACK61850_P4_TRIAL" +DATASET = b"ARSTACK61850/LLN0$PhsMeas1" +SAMPLE_RATE_HZ = 4_000 +SAMPLE_INTERVAL_US = 250 +SAMPLE_COUNT_WRAP = 4_000 +CHANNEL_COUNT = 8 +CAPTURE_START_US = 1_000_000 + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def ber_length(length: int) -> bytes: + require(length >= 0, "negative BER length") + if length < 0x80: + return bytes((length,)) + width = max(1, (length.bit_length() + 7) // 8) + require(width <= 4, "BER length exceeds verifier profile") + return bytes((0x80 | width,)) + length.to_bytes(width, "big") + + +def tlv(tag: int, value: bytes) -> bytes: + return bytes((tag,)) + ber_length(len(value)) + value + + +def unsigned_value(value: int) -> bytes: + require(value >= 0, "negative unsigned value") + width = max(1, (value.bit_length() + 7) // 8) + return value.to_bytes(width, "big") + + +def synthetic_payload(sample_count: int) -> bytes: + payload = bytearray() + for channel in range(CHANNEL_COUNT): + value = sample_count * 1_000 + channel * 100_000 + payload.extend(struct.pack(">iI", value, 0)) + return bytes(payload) + + +def reference_frame(sample_count: int) -> bytes: + asdu_content = b"".join( + ( + tlv(0x80, SV_ID), + tlv(0x81, DATASET), + tlv(0x82, unsigned_value(sample_count)), + tlv(0x83, unsigned_value(1)), + tlv(0x85, unsigned_value(0)), + tlv(0x86, unsigned_value(SAMPLE_RATE_HZ)), + tlv(0x87, synthetic_payload(sample_count)), + tlv(0x88, unsigned_value(1)), + ) + ) + asdu = tlv(0x30, asdu_content) + apdu = tlv(0x60, tlv(0x80, unsigned_value(1)) + tlv(0xA2, asdu)) + declared_length = 8 + len(apdu) + process_bus = struct.pack(">HHHH", APPID, declared_length, 0, 0) + apdu + return ( + DESTINATION_MAC + + SOURCE_MAC + + struct.pack(">H", ETHERTYPE_SV) + + process_bus + ) + + +def reference_pcap(frame_count: int) -> bytes: + output = bytearray( + struct.pack( + " tuple[int, bytes, int]: + require(offset + 2 <= len(data), "truncated BER TLV header") + tag = data[offset] + first_length = data[offset + 1] + cursor = offset + 2 + if first_length & 0x80: + width = first_length & 0x7F + require(width > 0, "indefinite BER length is not accepted") + require(width <= 4, "BER length width exceeds verifier profile") + require(cursor + width <= len(data), "truncated BER long-form length") + length = int.from_bytes(data[cursor : cursor + width], "big") + cursor += width + else: + length = first_length + end = cursor + length + require(end <= len(data), "BER value extends past packet boundary") + return tag, data[cursor:end], end + + +def parse_asdu(asdu: bytes, expected_count: int) -> None: + fields: dict[int, bytes] = {} + offset = 0 + while offset < len(asdu): + tag, value, offset = read_tlv(asdu, offset) + require(tag not in fields, f"duplicate ASDU field tag 0x{tag:02x}") + fields[tag] = value + + required = {0x80, 0x81, 0x82, 0x83, 0x85, 0x86, 0x87, 0x88} + require(required.issubset(fields), "missing required ASDU field") + require(0x84 not in fields, "refrTm must be absent in first-trial profile") + require(fields[0x80] == SV_ID, "unexpected svID") + require(fields[0x81] == DATASET, "unexpected DataSet reference") + require(int.from_bytes(fields[0x82], "big") == expected_count, "smpCnt mismatch") + require(int.from_bytes(fields[0x83], "big") == 1, "confRev mismatch") + require(int.from_bytes(fields[0x85], "big") == 0, "smpSynch mismatch") + require(int.from_bytes(fields[0x86], "big") == SAMPLE_RATE_HZ, "smpRate mismatch") + require(int.from_bytes(fields[0x88], "big") == 1, "smpMod mismatch") + require(fields[0x87] == synthetic_payload(expected_count), "seqData payload mismatch") + + +def parse_frame(frame: bytes, expected_count: int) -> None: + require(len(frame) >= 22, "Ethernet frame too short") + require(frame[:6] == DESTINATION_MAC, "destination MAC mismatch") + require(frame[6:12] == SOURCE_MAC, "source MAC mismatch") + ether_type = int.from_bytes(frame[12:14], "big") + require(ether_type != 0x8100, "VLAN tag present in untagged first-trial profile") + require(ether_type == ETHERTYPE_SV, "EtherType is not IEC 61850 Sampled Values") + + appid, declared_length, reserved1, reserved2 = struct.unpack(">HHHH", frame[14:22]) + require(appid == APPID, "APPID mismatch") + require(declared_length == len(frame) - 14, "process-bus declared length mismatch") + require(reserved1 == 0 and reserved2 == 0, "reserved process-bus fields must be zero") + + tag, apdu_value, end = read_tlv(frame, 22) + require(tag == 0x60, "SV APDU application tag mismatch") + require(end == len(frame), "trailing bytes after SV APDU") + + tag, no_asdu, cursor = read_tlv(apdu_value, 0) + require(tag == 0x80, "missing noASDU field") + require(int.from_bytes(no_asdu, "big") == 1, "first-trial profile must contain one ASDU") + tag, sequence, cursor = read_tlv(apdu_value, cursor) + require(tag == 0xA2, "missing seqASDU field") + require(cursor == len(apdu_value), "unexpected fields after seqASDU") + + tag, asdu, asdu_end = read_tlv(sequence, 0) + require(tag == 0x30, "ASDU must use universal SEQUENCE tag") + require(asdu_end == len(sequence), "first-trial profile must contain exactly one ASDU") + parse_asdu(asdu, expected_count) + + +def parse_pcap(raw: bytes) -> list[tuple[int, bytes]]: + require(len(raw) >= 24, "PCAP global header missing") + magic = raw[:4] + if magic == b"\xd4\xc3\xb2\xa1": + endian = "<" + nanoseconds = False + elif magic == b"\xa1\xb2\xc3\xd4": + endian = ">" + nanoseconds = False + elif magic == b"\x4d\x3c\xb2\xa1": + endian = "<" + nanoseconds = True + elif magic == b"\xa1\xb2\x3c\x4d": + endian = ">" + nanoseconds = True + else: + raise ValueError("unsupported PCAP magic") + + _, major, minor, _, _, snaplen, network = struct.unpack(endian + "IHHIIII", raw[:24]) + require((major, minor) == (2, 4), "unexpected PCAP version") + require(snaplen >= 1_536, "PCAP snaplen is too small") + require(network == 1, "PCAP link type is not Ethernet") + + records: list[tuple[int, bytes]] = [] + offset = 24 + while offset < len(raw): + require(offset + 16 <= len(raw), "truncated PCAP record header") + seconds, fraction, included, original = struct.unpack( + endian + "IIII", raw[offset : offset + 16] + ) + offset += 16 + require(included == original, "truncated PCAP packet is not accepted") + require(offset + included <= len(raw), "truncated PCAP packet data") + frame = raw[offset : offset + included] + offset += included + if nanoseconds: + require(fraction % 1_000 == 0, "nanosecond timestamp is not microsecond-aligned") + fraction //= 1_000 + timestamp_us = seconds * 1_000_000 + fraction + records.append((timestamp_us, frame)) + return records + + +def first_difference(left: bytes, right: bytes) -> int | None: + limit = min(len(left), len(right)) + for index in range(limit): + if left[index] != right[index]: + return index + return None if len(left) == len(right) else limit + + +def verify(path: Path, expected_frames: int, report_path: Path | None) -> dict[str, object]: + raw = path.read_bytes() + expected = reference_pcap(expected_frames) + if raw != expected: + mismatch = first_difference(raw, expected) + raise ValueError( + "PCAP differs from independent exact-byte reference" + + ("" if mismatch is None else f" at byte offset {mismatch}") + ) + + records = parse_pcap(raw) + require(len(records) == expected_frames, "unexpected PCAP frame count") + + previous_timestamp: int | None = None + first_frame_hash = "" + last_frame_hash = "" + for index, (timestamp_us, frame) in enumerate(records): + expected_timestamp = CAPTURE_START_US + index * SAMPLE_INTERVAL_US + require(timestamp_us == expected_timestamp, "PCAP timestamp cadence mismatch") + if previous_timestamp is not None: + require(timestamp_us - previous_timestamp == SAMPLE_INTERVAL_US, "packet interval mismatch") + previous_timestamp = timestamp_us + + expected_count = index % SAMPLE_COUNT_WRAP + parse_frame(frame, expected_count) + frame_hash = hashlib.sha256(frame).hexdigest() + if index == 0: + first_frame_hash = frame_hash + last_frame_hash = frame_hash + + report: dict[str, object] = { + "status": "SIMULATED/PASSED", + "verifier": "independent-python-exact-byte-reference", + "pcap": str(path), + "pcap_sha256": hashlib.sha256(raw).hexdigest(), + "frames": len(records), + "sample_rate_hz": SAMPLE_RATE_HZ, + "interval_us": SAMPLE_INTERVAL_US, + "sample_count_wrap": SAMPLE_COUNT_WRAP, + "ether_type": f"0x{ETHERTYPE_SV:04x}", + "appid": f"0x{APPID:04x}", + "destination_mac": DESTINATION_MAC.hex(":"), + "source_mac": SOURCE_MAC.hex(":"), + "sv_id": SV_ID.decode("ascii"), + "dataset": DATASET.decode("ascii"), + "payload_bytes": CHANNEL_COUNT * 8, + "first_frame_sha256": first_frame_hash, + "last_frame_sha256": last_frame_hash, + } + + if report_path is not None: + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return report + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Independently verify the deterministic arstack61850 SV host-simulation PCAP." + ) + parser.add_argument("pcap", type=Path) + parser.add_argument("--expected-frames", type=int, default=8_000) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + + try: + require(args.expected_frames > 0, "expected frame count must be positive") + report = verify(args.pcap, args.expected_frames, args.report) + except (OSError, ValueError) as exc: + print(f"SV PCAP QA FAILED: {exc}", file=sys.stderr) + return 1 + + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 23f9bc8f1366b0d25a0e4d1307aacff7be869dda Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:18:05 +0700 Subject: [PATCH 027/188] Verify ESP32-P4 artifact from resolved build metadata --- .github/workflows/esp-idf-ci.yml | 45 +++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index 43a1c201e..ea32bab60 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -45,9 +45,41 @@ jobs: test -f "$build/partition_table/partition-table.bin" test -f "$build/flash_args" test -f "$build/flasher_args.json" - test -f "$app/sdkconfig" - grep -q '^CONFIG_IDF_TARGET_ESP32P4=y$' "$app/sdkconfig" + test -f "$build/project_description.json" + test -f "$build/config/sdkconfig.h" + test -f "$app/sdkconfig.defaults" python3 -m json.tool "$build/flasher_args.json" >/dev/null + python3 - <<'PY' + import json + import re + from pathlib import Path + + build = Path('ports/esp_idf/sv_trial_app/build') + description = json.loads((build / 'project_description.json').read_text(encoding='utf-8')) + target = description.get('target') + if target != 'esp32p4': + raise SystemExit(f'expected ESP-IDF target esp32p4, got {target!r}') + + config = (build / 'config/sdkconfig.h').read_text(encoding='utf-8') + p4_symbol = re.search(r'^#define\s+CONFIG_IDF_TARGET_ESP32P4\s+1\s*$', config, re.MULTILINE) + target_string = re.search(r'^#define\s+CONFIG_IDF_TARGET\s+"esp32p4"\s*$', config, re.MULTILINE) + if p4_symbol is None and target_string is None: + raise SystemExit('resolved ESP-IDF config does not identify ESP32-P4') + + evidence = { + 'status': 'FLASHABLE/READY', + 'target': target, + 'project_name': description.get('project_name'), + 'project_version': description.get('project_version'), + 'elf_file': description.get('app_elf'), + 'build_dir': str(build), + 'config_evidence': 'build/config/sdkconfig.h', + } + (build / 'ARSTACK_BUILD_PROFILE.json').write_text( + json.dumps(evidence, indent=2, sort_keys=True) + '\n', + encoding='utf-8') + print(json.dumps(evidence, indent=2, sort_keys=True)) + PY ( cd "$app" sha256sum \ @@ -57,7 +89,10 @@ jobs: build/partition_table/partition-table.bin \ build/flash_args \ build/flasher_args.json \ - sdkconfig > build/SHA256SUMS + build/project_description.json \ + build/config/sdkconfig.h \ + build/ARSTACK_BUILD_PROFILE.json \ + sdkconfig.defaults > build/SHA256SUMS ) echo "FLASHABLE/READY: complete ESP32-P4 firmware set verified." @@ -75,7 +110,9 @@ jobs: ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin ports/esp_idf/sv_trial_app/build/flash_args ports/esp_idf/sv_trial_app/build/flasher_args.json + ports/esp_idf/sv_trial_app/build/project_description.json + ports/esp_idf/sv_trial_app/build/config/sdkconfig.h + ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_PROFILE.json ports/esp_idf/sv_trial_app/build/SHA256SUMS - ports/esp_idf/sv_trial_app/sdkconfig ports/esp_idf/sv_trial_app/sdkconfig.defaults ports/esp_idf/sv_trial_app/README.md From d5d235c6450c1aa846f67be75f8763e07995bfc2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:24:35 +0700 Subject: [PATCH 028/188] Validate ESP32-P4 flash manifest directly --- .github/workflows/esp-idf-ci.yml | 54 ++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index ea32bab60..c32cd4234 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -45,35 +45,47 @@ jobs: test -f "$build/partition_table/partition-table.bin" test -f "$build/flash_args" test -f "$build/flasher_args.json" - test -f "$build/project_description.json" - test -f "$build/config/sdkconfig.h" test -f "$app/sdkconfig.defaults" python3 -m json.tool "$build/flasher_args.json" >/dev/null python3 - <<'PY' import json - import re from pathlib import Path build = Path('ports/esp_idf/sv_trial_app/build') - description = json.loads((build / 'project_description.json').read_text(encoding='utf-8')) - target = description.get('target') - if target != 'esp32p4': - raise SystemExit(f'expected ESP-IDF target esp32p4, got {target!r}') + manifest = json.loads((build / 'flasher_args.json').read_text(encoding='utf-8')) + extra = manifest.get('extra_esptool_args', {}) + chip = extra.get('chip') + if chip != 'esp32p4': + raise SystemExit(f'expected flasher chip esp32p4, got {chip!r}') - config = (build / 'config/sdkconfig.h').read_text(encoding='utf-8') - p4_symbol = re.search(r'^#define\s+CONFIG_IDF_TARGET_ESP32P4\s+1\s*$', config, re.MULTILINE) - target_string = re.search(r'^#define\s+CONFIG_IDF_TARGET\s+"esp32p4"\s*$', config, re.MULTILINE) - if p4_symbol is None and target_string is None: - raise SystemExit('resolved ESP-IDF config does not identify ESP32-P4') + expected = { + '0x2000': 'bootloader/bootloader.bin', + '0x8000': 'partition_table/partition-table.bin', + '0x10000': 'arstack61850_esp32p4_sv_trial.bin', + } + flash_files = manifest.get('flash_files', {}) + if flash_files != expected: + raise SystemExit( + f'unexpected flash layout: expected {expected!r}, got {flash_files!r}') + for offset, relative in expected.items(): + candidate = build / relative + if not candidate.is_file(): + raise SystemExit(f'flash file {relative!r} at {offset} is missing') + if candidate.stat().st_size <= 0: + raise SystemExit(f'flash file {relative!r} at {offset} is empty') + + flash_args = (build / 'flash_args').read_text(encoding='utf-8') + for offset, relative in expected.items(): + if f'{offset} {relative}' not in flash_args: + raise SystemExit(f'flash_args missing {offset} {relative}') evidence = { 'status': 'FLASHABLE/READY', - 'target': target, - 'project_name': description.get('project_name'), - 'project_version': description.get('project_version'), - 'elf_file': description.get('app_elf'), - 'build_dir': str(build), - 'config_evidence': 'build/config/sdkconfig.h', + 'target': chip, + 'flash_files': expected, + 'flasher_manifest': 'build/flasher_args.json', + 'flash_args': 'build/flash_args', + 'sdkconfig_defaults': 'sdkconfig.defaults', } (build / 'ARSTACK_BUILD_PROFILE.json').write_text( json.dumps(evidence, indent=2, sort_keys=True) + '\n', @@ -89,12 +101,10 @@ jobs: build/partition_table/partition-table.bin \ build/flash_args \ build/flasher_args.json \ - build/project_description.json \ - build/config/sdkconfig.h \ build/ARSTACK_BUILD_PROFILE.json \ sdkconfig.defaults > build/SHA256SUMS ) - echo "FLASHABLE/READY: complete ESP32-P4 firmware set verified." + echo "FLASHABLE/READY: ESP32-P4 flasher manifest and complete firmware set verified." - name: Upload ESP32-P4 flashable firmware uses: actions/upload-artifact@v4 @@ -110,8 +120,6 @@ jobs: ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin ports/esp_idf/sv_trial_app/build/flash_args ports/esp_idf/sv_trial_app/build/flasher_args.json - ports/esp_idf/sv_trial_app/build/project_description.json - ports/esp_idf/sv_trial_app/build/config/sdkconfig.h ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_PROFILE.json ports/esp_idf/sv_trial_app/build/SHA256SUMS ports/esp_idf/sv_trial_app/sdkconfig.defaults From a2a5df6eb9f9a00ba2cace158ef530312d348e1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:24:53 +0700 Subject: [PATCH 029/188] Document manifest-based flashable gate --- docs/EMBEDDED_QA_GATES.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/EMBEDDED_QA_GATES.md b/docs/EMBEDDED_QA_GATES.md index 470b31160..466251d59 100644 --- a/docs/EMBEDDED_QA_GATES.md +++ b/docs/EMBEDDED_QA_GATES.md @@ -32,14 +32,23 @@ Required evidence: - the narrow arstack61850 ESP-IDF component compiles and links; - the compile-only integration smoke app builds; - the flashable first-trial SV firmware builds and links; -- generated `sdkconfig` identifies `CONFIG_IDF_TARGET_ESP32P4=y`; +- the resulting ESP-IDF flasher manifest identifies chip `esp32p4`; - no host-only TCP/filesystem/capture services leak into the embedded component boundary. This gate proves that the code is accepted by the real ESP32-P4 RISC-V compiler/linker and ESP-IDF component system. ## Gate C — flashable artifact completeness -The ESP-IDF workflow must fail if any required deployment output is missing or malformed. +The ESP-IDF workflow must fail if any required deployment output is missing, empty, malformed, targets another chip, or disagrees with the expected first-trial flash layout. + +The workflow validates the ESP-IDF-generated `flasher_args.json` directly and requires: + +- `extra_esptool_args.chip == esp32p4`; +- bootloader at `0x2000`; +- partition table at `0x8000`; +- application at `0x10000`; +- every file referenced by that manifest exists and is non-empty; +- the text `flash_args` describes the same three-image layout. The retained `esp32p4-sv-trial-firmware-*` artifact contains: @@ -49,13 +58,13 @@ The retained `esp32p4-sv-trial-firmware-*` artifact contains: - bootloader image; - partition-table image; - `flash_args`; -- valid `flasher_args.json`; -- generated project-level `sdkconfig`; -- `sdkconfig.defaults`; -- `SHA256SUMS` covering the deployment-critical images/configuration; +- validated `flasher_args.json`; +- `sdkconfig.defaults` used as the repository-controlled configuration baseline; +- generated `ARSTACK_BUILD_PROFILE.json` recording `FLASHABLE/READY`, target chip, and flash layout; +- `SHA256SUMS` covering the deployment-critical images, manifests, and configuration baseline; - firmware-specific flashing/behavior README. -Passing Gate C means a developer can download a CI-produced firmware set instead of rebuilding it locally, and can verify the retained files before flashing. +Passing Gate C means a developer can download a CI-produced firmware set instead of rebuilding it locally, verify the retained files, and use the ESP-IDF-generated flash layout for the target board. ## Gate D — physical ESP32-P4 Ethernet acceptance From 9269a5751070bfa3ee34142bfc66c2fedb0f1a54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:33:01 +0700 Subject: [PATCH 030/188] Harden ESP32-P4 flash artifact gate --- .github/workflows/esp-idf-ci.yml | 93 ++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index c32cd4234..cfc7c3b05 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -32,20 +32,31 @@ jobs: target: esp32p4 path: ports/esp_idf/sv_trial_app + - name: Inventory ESP-IDF firmware outputs + if: always() + shell: bash + run: | + set -euo pipefail + build=ports/esp_idf/sv_trial_app/build + mkdir -p "$build" + find "$build" -maxdepth 4 -type f -printf '%P\t%s bytes\n' \ + | LC_ALL=C sort \ + | tee "$build/ARSTACK_BUILD_FILE_LIST.txt" + - name: Verify flashable firmware outputs shell: bash run: | set -euo pipefail app=ports/esp_idf/sv_trial_app build="$app/build" - test -f "$build/arstack61850_esp32p4_sv_trial.bin" - test -f "$build/arstack61850_esp32p4_sv_trial.elf" - test -f "$build/arstack61850_esp32p4_sv_trial.map" - test -f "$build/bootloader/bootloader.bin" - test -f "$build/partition_table/partition-table.bin" - test -f "$build/flash_args" - test -f "$build/flasher_args.json" - test -f "$app/sdkconfig.defaults" + test -s "$build/arstack61850_esp32p4_sv_trial.bin" + test -s "$build/arstack61850_esp32p4_sv_trial.elf" + test -s "$build/arstack61850_esp32p4_sv_trial.map" + test -s "$build/bootloader/bootloader.bin" + test -s "$build/partition_table/partition-table.bin" + test -s "$build/flash_args" + test -s "$build/flasher_args.json" + test -s "$app/sdkconfig.defaults" python3 -m json.tool "$build/flasher_args.json" >/dev/null python3 - <<'PY' import json @@ -53,38 +64,49 @@ jobs: build = Path('ports/esp_idf/sv_trial_app/build') manifest = json.loads((build / 'flasher_args.json').read_text(encoding='utf-8')) - extra = manifest.get('extra_esptool_args', {}) - chip = extra.get('chip') + chip = manifest.get('extra_esptool_args', {}).get('chip') if chip != 'esp32p4': raise SystemExit(f'expected flasher chip esp32p4, got {chip!r}') expected = { - '0x2000': 'bootloader/bootloader.bin', - '0x8000': 'partition_table/partition-table.bin', - '0x10000': 'arstack61850_esp32p4_sv_trial.bin', + 0x2000: 'bootloader/bootloader.bin', + 0x8000: 'partition_table/partition-table.bin', + 0x10000: 'arstack61850_esp32p4_sv_trial.bin', } - flash_files = manifest.get('flash_files', {}) + raw_flash_files = manifest.get('flash_files') + if not isinstance(raw_flash_files, dict): + raise SystemExit('flasher manifest has no flash_files object') + + try: + flash_files = { + int(str(offset), 0): str(relative) + for offset, relative in raw_flash_files.items() + } + except (TypeError, ValueError) as exc: + raise SystemExit(f'invalid flasher offset: {exc}') from exc + if flash_files != expected: + pretty_actual = {hex(k): v for k, v in sorted(flash_files.items())} + pretty_expected = {hex(k): v for k, v in sorted(expected.items())} raise SystemExit( - f'unexpected flash layout: expected {expected!r}, got {flash_files!r}') - for offset, relative in expected.items(): + f'unexpected flash layout: expected {pretty_expected!r}, got {pretty_actual!r}') + + file_evidence = {} + for offset, relative in sorted(expected.items()): candidate = build / relative if not candidate.is_file(): - raise SystemExit(f'flash file {relative!r} at {offset} is missing') - if candidate.stat().st_size <= 0: - raise SystemExit(f'flash file {relative!r} at {offset} is empty') - - flash_args = (build / 'flash_args').read_text(encoding='utf-8') - for offset, relative in expected.items(): - if f'{offset} {relative}' not in flash_args: - raise SystemExit(f'flash_args missing {offset} {relative}') + raise SystemExit(f'flash file {relative!r} at {hex(offset)} is missing') + size = candidate.stat().st_size + if size <= 0: + raise SystemExit(f'flash file {relative!r} at {hex(offset)} is empty') + file_evidence[hex(offset)] = {'path': relative, 'bytes': size} evidence = { 'status': 'FLASHABLE/READY', 'target': chip, - 'flash_files': expected, - 'flasher_manifest': 'build/flasher_args.json', - 'flash_args': 'build/flash_args', + 'flash_files': file_evidence, + 'authoritative_manifest': 'build/flasher_args.json', + 'companion_flash_args': 'build/flash_args', 'sdkconfig_defaults': 'sdkconfig.defaults', } (build / 'ARSTACK_BUILD_PROFILE.json').write_text( @@ -102,10 +124,26 @@ jobs: build/flash_args \ build/flasher_args.json \ build/ARSTACK_BUILD_PROFILE.json \ + build/ARSTACK_BUILD_FILE_LIST.txt \ sdkconfig.defaults > build/SHA256SUMS ) echo "FLASHABLE/READY: ESP32-P4 flasher manifest and complete firmware set verified." + - name: Upload failed Gate-C diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: esp32p4-gate-c-diagnostics-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: warn + retention-days: 7 + path: | + ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_FILE_LIST.txt + ports/esp_idf/sv_trial_app/build/flasher_args.json + ports/esp_idf/sv_trial_app/build/flash_args + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.bin + ports/esp_idf/sv_trial_app/build/bootloader/bootloader.bin + ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin + - name: Upload ESP32-P4 flashable firmware uses: actions/upload-artifact@v4 with: @@ -121,6 +159,7 @@ jobs: ports/esp_idf/sv_trial_app/build/flash_args ports/esp_idf/sv_trial_app/build/flasher_args.json ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_PROFILE.json + ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_FILE_LIST.txt ports/esp_idf/sv_trial_app/build/SHA256SUMS ports/esp_idf/sv_trial_app/sdkconfig.defaults ports/esp_idf/sv_trial_app/README.md From 467ebaab7dd1265d129f62dc7171d6fecf2639ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:33:27 +0700 Subject: [PATCH 031/188] Clarify authoritative ESP-IDF flash manifest gate --- docs/EMBEDDED_QA_GATES.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/EMBEDDED_QA_GATES.md b/docs/EMBEDDED_QA_GATES.md index 466251d59..08c443148 100644 --- a/docs/EMBEDDED_QA_GATES.md +++ b/docs/EMBEDDED_QA_GATES.md @@ -41,14 +41,17 @@ This gate proves that the code is accepted by the real ESP32-P4 RISC-V compiler/ The ESP-IDF workflow must fail if any required deployment output is missing, empty, malformed, targets another chip, or disagrees with the expected first-trial flash layout. -The workflow validates the ESP-IDF-generated `flasher_args.json` directly and requires: +The authoritative deployment source for this gate is ESP-IDF's generated `flasher_args.json`. Offset strings are normalized to numeric addresses before comparison so equivalent formatting cannot create a false failure. The gate requires: - `extra_esptool_args.chip == esp32p4`; - bootloader at `0x2000`; - partition table at `0x8000`; - application at `0x10000`; -- every file referenced by that manifest exists and is non-empty; -- the text `flash_args` describes the same three-image layout. +- every file referenced by that three-image manifest exists and is non-empty; +- the generated `flash_args` companion exists and is non-empty; +- the repository-controlled `sdkconfig.defaults` baseline exists and is non-empty. + +`flash_args` is retained for flashing convenience but is not separately reparsed as a second authority; duplicating ESP-IDF's own manifest interpretation would make the CI brittle without adding independent deployment evidence. The retained `esp32p4-sv-trial-firmware-*` artifact contains: @@ -60,10 +63,13 @@ The retained `esp32p4-sv-trial-firmware-*` artifact contains: - `flash_args`; - validated `flasher_args.json`; - `sdkconfig.defaults` used as the repository-controlled configuration baseline; -- generated `ARSTACK_BUILD_PROFILE.json` recording `FLASHABLE/READY`, target chip, and flash layout; -- `SHA256SUMS` covering the deployment-critical images, manifests, and configuration baseline; +- generated `ARSTACK_BUILD_PROFILE.json` recording `FLASHABLE/READY`, target chip, image sizes, and flash layout; +- generated `ARSTACK_BUILD_FILE_LIST.txt` inventory for reproducibility/debugging; +- `SHA256SUMS` covering the deployment-critical images, manifests, inventory, and configuration baseline; - firmware-specific flashing/behavior README. +If Gate C fails, CI uploads a short-lived `esp32p4-gate-c-diagnostics-*` artifact containing the actual generated manifest, flash arguments, inventory, and core images. This keeps failures inspectable without weakening the success criteria. + Passing Gate C means a developer can download a CI-produced firmware set instead of rebuilding it locally, verify the retained files, and use the ESP-IDF-generated flash layout for the target board. ## Gate D — physical ESP32-P4 Ethernet acceptance From 2473ab72c79aec6e9e4fba99cd760f4c8e11c6c3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:39:19 +0700 Subject: [PATCH 032/188] Make ESP32-P4 artifact inventory deterministic --- .github/workflows/esp-idf-ci.yml | 44 ++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index cfc7c3b05..4978bd563 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -32,17 +32,6 @@ jobs: target: esp32p4 path: ports/esp_idf/sv_trial_app - - name: Inventory ESP-IDF firmware outputs - if: always() - shell: bash - run: | - set -euo pipefail - build=ports/esp_idf/sv_trial_app/build - mkdir -p "$build" - find "$build" -maxdepth 4 -type f -printf '%P\t%s bytes\n' \ - | LC_ALL=C sort \ - | tee "$build/ARSTACK_BUILD_FILE_LIST.txt" - - name: Verify flashable firmware outputs shell: bash run: | @@ -57,12 +46,14 @@ jobs: test -s "$build/flash_args" test -s "$build/flasher_args.json" test -s "$app/sdkconfig.defaults" + test -s "$app/README.md" python3 -m json.tool "$build/flasher_args.json" >/dev/null python3 - <<'PY' import json from pathlib import Path - build = Path('ports/esp_idf/sv_trial_app/build') + app = Path('ports/esp_idf/sv_trial_app') + build = app / 'build' manifest = json.loads((build / 'flasher_args.json').read_text(encoding='utf-8')) chip = manifest.get('extra_esptool_args', {}).get('chip') if chip != 'esp32p4': @@ -91,7 +82,7 @@ jobs: raise SystemExit( f'unexpected flash layout: expected {pretty_expected!r}, got {pretty_actual!r}') - file_evidence = {} + flash_evidence = {} for offset, relative in sorted(expected.items()): candidate = build / relative if not candidate.is_file(): @@ -99,12 +90,31 @@ jobs: size = candidate.stat().st_size if size <= 0: raise SystemExit(f'flash file {relative!r} at {hex(offset)} is empty') - file_evidence[hex(offset)] = {'path': relative, 'bytes': size} + flash_evidence[hex(offset)] = {'path': relative, 'bytes': size} + + required = [ + build / 'arstack61850_esp32p4_sv_trial.bin', + build / 'arstack61850_esp32p4_sv_trial.elf', + build / 'arstack61850_esp32p4_sv_trial.map', + build / 'bootloader/bootloader.bin', + build / 'partition_table/partition-table.bin', + build / 'flash_args', + build / 'flasher_args.json', + app / 'sdkconfig.defaults', + app / 'README.md', + ] + inventory = [] + for candidate in required: + if not candidate.is_file() or candidate.stat().st_size <= 0: + raise SystemExit(f'required deployment file is missing or empty: {candidate}') + inventory.append(f'{candidate.relative_to(app)}\t{candidate.stat().st_size} bytes') + (build / 'ARSTACK_BUILD_FILE_LIST.txt').write_text( + '\n'.join(inventory) + '\n', encoding='utf-8') evidence = { 'status': 'FLASHABLE/READY', 'target': chip, - 'flash_files': file_evidence, + 'flash_files': flash_evidence, 'authoritative_manifest': 'build/flasher_args.json', 'companion_flash_args': 'build/flash_args', 'sdkconfig_defaults': 'sdkconfig.defaults', @@ -125,7 +135,8 @@ jobs: build/flasher_args.json \ build/ARSTACK_BUILD_PROFILE.json \ build/ARSTACK_BUILD_FILE_LIST.txt \ - sdkconfig.defaults > build/SHA256SUMS + sdkconfig.defaults \ + README.md > build/SHA256SUMS ) echo "FLASHABLE/READY: ESP32-P4 flasher manifest and complete firmware set verified." @@ -138,6 +149,7 @@ jobs: retention-days: 7 path: | ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_FILE_LIST.txt + ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_PROFILE.json ports/esp_idf/sv_trial_app/build/flasher_args.json ports/esp_idf/sv_trial_app/build/flash_args ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.bin From 29ffc1f7672d3fb6e7245af77c033f6805d75c55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:45:55 +0700 Subject: [PATCH 033/188] Write ESP32-P4 QA metadata outside Docker build tree --- .github/workflows/esp-idf-ci.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index 4978bd563..62a4ae163 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -38,6 +38,8 @@ jobs: set -euo pipefail app=ports/esp_idf/sv_trial_app build="$app/build" + qa="$app/ci-artifacts" + mkdir -p "$qa" test -s "$build/arstack61850_esp32p4_sv_trial.bin" test -s "$build/arstack61850_esp32p4_sv_trial.elf" test -s "$build/arstack61850_esp32p4_sv_trial.map" @@ -54,6 +56,9 @@ jobs: app = Path('ports/esp_idf/sv_trial_app') build = app / 'build' + qa = app / 'ci-artifacts' + qa.mkdir(parents=True, exist_ok=True) + manifest = json.loads((build / 'flasher_args.json').read_text(encoding='utf-8')) chip = manifest.get('extra_esptool_args', {}).get('chip') if chip != 'esp32p4': @@ -108,7 +113,7 @@ jobs: if not candidate.is_file() or candidate.stat().st_size <= 0: raise SystemExit(f'required deployment file is missing or empty: {candidate}') inventory.append(f'{candidate.relative_to(app)}\t{candidate.stat().st_size} bytes') - (build / 'ARSTACK_BUILD_FILE_LIST.txt').write_text( + (qa / 'ARSTACK_BUILD_FILE_LIST.txt').write_text( '\n'.join(inventory) + '\n', encoding='utf-8') evidence = { @@ -119,7 +124,7 @@ jobs: 'companion_flash_args': 'build/flash_args', 'sdkconfig_defaults': 'sdkconfig.defaults', } - (build / 'ARSTACK_BUILD_PROFILE.json').write_text( + (qa / 'ARSTACK_BUILD_PROFILE.json').write_text( json.dumps(evidence, indent=2, sort_keys=True) + '\n', encoding='utf-8') print(json.dumps(evidence, indent=2, sort_keys=True)) @@ -133,10 +138,10 @@ jobs: build/partition_table/partition-table.bin \ build/flash_args \ build/flasher_args.json \ - build/ARSTACK_BUILD_PROFILE.json \ - build/ARSTACK_BUILD_FILE_LIST.txt \ + ci-artifacts/ARSTACK_BUILD_PROFILE.json \ + ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt \ sdkconfig.defaults \ - README.md > build/SHA256SUMS + README.md > ci-artifacts/SHA256SUMS ) echo "FLASHABLE/READY: ESP32-P4 flasher manifest and complete firmware set verified." @@ -148,8 +153,9 @@ jobs: if-no-files-found: warn retention-days: 7 path: | - ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_FILE_LIST.txt - ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_PROFILE.json + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_PROFILE.json + ports/esp_idf/sv_trial_app/ci-artifacts/SHA256SUMS ports/esp_idf/sv_trial_app/build/flasher_args.json ports/esp_idf/sv_trial_app/build/flash_args ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.bin @@ -170,8 +176,8 @@ jobs: ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin ports/esp_idf/sv_trial_app/build/flash_args ports/esp_idf/sv_trial_app/build/flasher_args.json - ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_PROFILE.json - ports/esp_idf/sv_trial_app/build/ARSTACK_BUILD_FILE_LIST.txt - ports/esp_idf/sv_trial_app/build/SHA256SUMS + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_PROFILE.json + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt + ports/esp_idf/sv_trial_app/ci-artifacts/SHA256SUMS ports/esp_idf/sv_trial_app/sdkconfig.defaults ports/esp_idf/sv_trial_app/README.md From 12e32c4aa5c12eadb6660a3322907676e856c803 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 20:53:03 +0700 Subject: [PATCH 034/188] Record accepted ESP32-P4 simulation and flashable gates --- MIGRATION_CHECKLIST.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 48b17916d..872e7dd4d 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -19,7 +19,9 @@ behavioral oracle until laboratory interoperability is complete. - [x] Synthetic PCAP equivalence, sanitizer, mutation, and libFuzzer hardening. - [x] Allocation-bounded SV publisher runtime with caller-owned Ethernet buffer, successful-TX counter progression, and no-catch-up pacing. - [x] Platform-neutral raw-Ethernet/clock HAL plus ESP-IDF `esp_eth_transmit()` adapter. -- [~] ESP32-P4 ESP-IDF cross-compile acceptance for the first SV transmit slice. +- [x] Deterministic 8,000-frame / two-second SV host simulation at 4 kHz with independent exact-byte Python PCAP oracle on strict GCC/Clang embedded profiles. +- [x] ESP32-P4 ESP-IDF v6.0.2 cross-compile/link acceptance for both the integration smoke app and real first-trial SV firmware. +- [x] ESP32-P4 `FLASHABLE/READY` CI artifact gate: ESP-IDF manifest targets `esp32p4`, validates the `0x2000` bootloader / `0x8000` partition / `0x10000` application layout, retains BIN/ELF/map/flash metadata, and publishes verified SHA-256 checksums. - [ ] Real IED or vendor-simulator GOOSE/SV capture accepted. - [~] Real-time SV publisher timing-health validation: deterministic host/CI pacing implemented; physical ESP32-P4 evidence pending. - [ ] Exception-free embedded codec build; the first ESP-IDF trial temporarily enables C++ exceptions for shared legacy validation/convenience APIs while the publisher hot path remains `noexcept`. From d8850cb3c1b559c286e2437f36b97036171b917e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 21:36:24 +0700 Subject: [PATCH 035/188] Document P4 injector and S3 I/O IED reference architecture --- docs/REFERENCE_PRODUCT_ARCHITECTURE.md | 300 +++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 docs/REFERENCE_PRODUCT_ARCHITECTURE.md diff --git a/docs/REFERENCE_PRODUCT_ARCHITECTURE.md b/docs/REFERENCE_PRODUCT_ARCHITECTURE.md new file mode 100644 index 000000000..9a144fcc1 --- /dev/null +++ b/docs/REFERENCE_PRODUCT_ARCHITECTURE.md @@ -0,0 +1,300 @@ +# ARStack61850 reference product architecture + +This document defines the near-term reference products and the long-term architecture direction for ARStack61850. + +The project goal is not to build one board-specific demo. The goal is a smart, portable IEC 61850 stack whose protocol core, deterministic wire behavior, configuration model, QA evidence, and application-facing APIs remain reusable across desktop systems, ESP32-class MCUs, and later industrial MCU/MPU targets. + +## Design rule: one core, multiple products + +The same portable C++ protocol core must serve all reference products. + +Platform-specific code is restricted to small HAL/adaptation layers for: + +- monotonic/UTC clocks; +- raw Ethernet transmit/receive; +- TCP byte streams/listeners; +- GPIO/relay/digital-I/O access; +- persistent configuration storage; +- task/thread scheduling; +- optional USB/serial, Wi-Fi, RS485, and Ethernet management transports. + +Application code may select different protocol subsets and capacities, but it must not fork or reimplement IEC 61850 wire codecs per board. + +## Short-term reference topology + +```text + ARStack61850 PC Workbench + Windows CLI first, GUI/workbench later + | + management/control protocol + USB serial / TCP / Wi-Fi + +-----------------+-----------------+ + | | + v v + ESP32-P4 Process-Bus Injector ESP32-S3 I/O IED + Waveshare ESP32-P4-ETH Waveshare Relay-6CH + | | + wired Ethernet 6 relay outputs + | isolated RS485 + v | + IEC 61850 process bus physical I/O / sensors + SV first, then GOOSE + | + +-------> Wireshark / test IED / future S3 wired IED +``` + +The PC is the engineering/control plane. The P4 is the deterministic process-bus traffic engine. The S3 is the I/O/IED execution node. The portable C++ core remains common to all three. + +## Product A — Windows reference CLI / workbench + +### First role + +Provide a hardware-independent reference environment for the exact same stack used by embedded targets. + +Initial executable direction: + +```text +ariec61850_sv_loopback.exe +``` + +Required modes: + +1. `software` — publisher -> in-memory raw-Ethernet adapter -> decoder/verifier; +2. `pcap` — publisher -> PCAP -> reopen/independent verification; +3. `live-npcap` — later, raw Layer-2 transmit/capture through a selected Windows Ethernet adapter; +4. `device-control` — later, configure and control the P4 injector and S3 IED. + +Required test/fault modes should grow to include normal stream, drop, duplicate, corruption, late scheduling, counter wrap, rate change, dataset/payload profiles, and replayable scenarios. + +The Windows implementation is a reference/oracle and engineering tool, not a separate protocol implementation. + +## Product B — ESP32-P4 process-bus injector + +### Reference hardware + +Waveshare ESP32-P4-ETH. + +### Primary role + +A PC-controlled IEC 61850 process-bus injector/generator for laboratory, commissioning-assistance, interoperability, regression, and education use. + +### First accepted slice + +- native ESP32-P4 EMAC/RMII; +- ESP-IDF as primary platform; +- Layer-2 Sampled Values publisher; +- first profile: 50 Hz / 80 samples-per-cycle / 4,000 sample instants per second; +- deterministic synthetic 8-channel payload; +- untagged first proof; +- APPID/MAC/svID configurable at application level; +- caller-owned frame buffers; +- no publisher-generated catch-up burst; +- CI-produced flashable firmware. + +### Injector evolution + +The P4 should become a scenario-driven engine rather than a hard-coded publisher. + +Planned capabilities: + +- SV waveform/scenario profiles; +- magnitude, phase, frequency, harmonic, DC offset and transient scripting; +- COMTRADE-to-SV playback; +- configurable APPID, svID, destination MAC, VLAN and sample profile; +- controlled drop/duplicate/corrupt/jitter fault injection; +- GOOSE publisher and subscriber test modes; +- PCAP replay/compare workflows where technically appropriate; +- start/stop/arm/trigger commands from the PC; +- runtime telemetry for frame counts, encode/TX failures, missed/coalesced ticks, lateness and heap watermark; +- synchronized triggering and PTP/time synchronization only after separate timing evidence exists. + +### Control-plane rule + +The management/control plane must be separated conceptually from the process-bus data plane. + +For early development, USB serial is preferred for injector commands because it cannot consume or perturb the Ethernet process-bus path. TCP or other management transports can be added later behind the same command model. + +No control-plane transport is allowed to change IEC 61850 process-bus wire behavior in the portable core. + +## Product C — ESP32-S3 I/O IED + +### First reference hardware + +Waveshare ESP32-S3-Relay-6CH, SKU 26756. + +The board provides six relay outputs, isolated RS485, Wi-Fi/Bluetooth, USB, Pico-compatible expansion, wide-range industrial DC input, and isolation/protection around the relay/control sections. + +The current 6CH board does not provide onboard wired Ethernet. Therefore its first IEC 61850 proof is deliberately separated into two stages. + +### Stage C1 — static MMS I/O IED over IP/Wi-Fi + +Use ESP-IDF and the portable TCP/MMS/server subset to prove a real I/O-backed IED model without waiting for a wired-Ethernet expansion. + +Initial surface: + +- static compile-time IED model; +- MMS TCP server; +- TPKT/COTP/session/presentation/ACSE association; +- MMS Initiate; +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `Read`; +- bounded, explicitly authorized control/write path for relay outputs; +- six physical relay channels mapped through a small static I/O data model, initially GGIO-style generic I/O unless a more specific application model is intentionally selected; +- physical output state readback where electrically/firmware-available; +- local safety defaults: outputs OFF after invalid configuration/recovery unless a documented application policy says otherwise; +- PC CLI interoperability tests and reproducible CI server simulations. + +Wi-Fi is acceptable for this functional MMS/server proof. It is not used as evidence of deterministic station-bus or protection-grade GOOSE behavior. + +### Stage C2 — wired Ethernet IED + +For station-bus/GOOSE work, add a supported wired Ethernet path through the network HAL. + +Candidate directions: + +- external SPI Ethernet controller such as W5500 if a clean, electrically suitable expansion is selected for the Relay-6CH platform; or +- a Waveshare ESP32-S3 industrial Ethernet I/O variant with onboard W5500 for the wired reference build. + +The protocol core and IED model must remain unchanged when switching from Wi-Fi/TCP proof to wired Ethernet. Only the network and board-I/O adapters should change. + +### IED evolution + +After the static read/control IED is stable: + +- DataSet support; +- URCB reporting; +- bounded GOOSE publisher/subscriber; +- command/control model refinement; +- input expansion and status/event timestamps; +- RS485/Modbus gateway points where useful; +- SCL-driven static model generation; +- BRCB after buffering/reservation semantics are proven; +- persistent configuration with versioned schema and safe rollback. + +## Shared PC-to-device control model + +The P4 injector and S3 IED should expose one versioned management model to the PC instead of ad-hoc command strings per firmware. + +The command model should be transport-independent and support at least: + +- device identity/capabilities; +- firmware/core versions; +- get/set configuration; +- start/stop/arm/trigger; +- scenario/profile selection; +- runtime statistics; +- fault injection controls where applicable; +- health/diagnostics; +- structured error responses; +- configuration export/import; +- explicit protocol-version negotiation. + +Early transport may be line-delimited JSON over USB serial for development speed. The schema should be defined independently so the same commands can later travel over TCP/WebSocket/USB without changing device behavior. + +## QA ladder + +Every feature should advance through explicit evidence levels. + +### Q0 — pure portable unit/regression + +- codecs; +- state machines; +- capacity/error paths; +- golden vectors; +- sanitizers/fuzzing where applicable. + +### Q1 — host-system simulation + +- Windows/Linux executable; +- deterministic virtual clock; +- in-memory transport; +- independent wire oracle; +- PCAP artifacts; +- injected drops/duplicates/corruption/late events. + +### Q2 — target cross-compile + +- real ESP-IDF target compiler/linker; +- board application links; +- image/footprint report; +- flash manifest produced. + +### Q3 — flashable artifact + +- complete CI artifact; +- checksums; +- bootloader/partition/app images; +- board/profile metadata; +- flashing instructions. + +### Q4 — physical functional acceptance + +- board boots; +- required interfaces operate; +- packets/associations observed by another device; +- I/O actually changes/readbacks; +- short soak accepted. + +### Q5 — physical timing/interoperability acceptance + +- sustained traffic; +- jitter/drop evidence; +- multi-vendor or independent-tool interoperability; +- long soak; +- synchronization evidence where claimed. + +No software-only or cross-compile result may be promoted to Q4/Q5 language. + +## Long-term target — smart portable IEC 61850 platform + +The long-term product is a coherent IEC 61850 platform, not a collection of protocol demos. + +Target capability families: + +### Process bus + +- SV publisher/subscriber; +- GOOSE publisher/subscriber; +- VLAN/priority; +- stream supervision; +- scenario/replay tooling; +- deterministic embedded runtimes; +- synchronization/PTP integration on hardware that can support it properly. + +### Station bus / MMS + +- client and server association; +- discovery and typed data model; +- read/write/control; +- DataSets; +- URCB/BRCB reporting; +- file service; +- robust reconnect/recovery; +- authorization/safety boundaries. + +### Engineering + +- SCL parse/validate/edit/generate; +- static embedded-model generation from SCL; +- COMTRADE integration; +- PCAP evidence/analysis; +- live discovery and parity checking; +- deterministic simulator/virtual IED; +- engineering workbench. + +### Portable runtime profiles + +- desktop Windows/Linux; +- ESP32-P4 process-bus profile; +- ESP32-S3 I/O IED profile; +- later STM32/NXP/other industrial MCU targets; +- future MPU/Linux target if large dynamic models or heavier engineering services require it. + +## Quality bar + +"Portable" means the IEC 61850 core does not know whether it runs on WinSock, lwIP, ESP-IDF, W5500, or another vendor HAL. + +"Smart" means configuration, model generation, scenario generation, diagnostics, evidence collection, and interoperability workflows reduce engineering effort while remaining deterministic and inspectable. + +"Top global" is treated as an engineering quality target, not a marketing claim. The evidence required includes clean architecture, standards-aware behavior, bounded resource use, deterministic tests, fuzz/sanitizer coverage, reproducible firmware, physical interoperability, timing measurements, documentation, long-soak results, and eventually independent/certification evidence for any conformance level that is claimed. From e43bd43e16cd0c088a0a5f6754bf61dd0957c79b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 21:37:02 +0700 Subject: [PATCH 036/188] Align embedded roadmap with P4 injector and S3 I/O IED targets --- embedded/ROADMAP.md | 236 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 182 insertions(+), 54 deletions(-) diff --git a/embedded/ROADMAP.md b/embedded/ROADMAP.md index 38acf028d..38dffb0dc 100644 --- a/embedded/ROADMAP.md +++ b/embedded/ROADMAP.md @@ -1,74 +1,151 @@ # ESP32-first embedded roadmap -The embedded roadmap is product-driven. The first hardware proof should be small enough to finish and measure. +The embedded roadmap is product-driven. The first hardware proofs are deliberately small, measurable, and reusable as foundations for a portable IEC 61850 product family. + +See [`docs/REFERENCE_PRODUCT_ARCHITECTURE.md`](../docs/REFERENCE_PRODUCT_ARCHITECTURE.md) for the reference-product topology and long-term architecture. + +## Reference products + +Short-term roles are fixed as: + +- **Windows PC** — reference CLI/workbench, loopback QA, scenario/configuration controller, evidence collection; +- **ESP32-P4 / Waveshare ESP32-P4-ETH** — IEC 61850 process-bus injector, SV first and GOOSE next; +- **ESP32-S3 / Waveshare ESP32-S3-Relay-6CH** — static I/O IED, six physical relay outputs and isolated RS485, MMS/IP first and wired station-bus/GOOSE after a supported Ethernet path is added. + +The protocol core must remain common across these products. Board-specific code belongs only in HAL/platform/application adapters. ## E0 — portability boundary -Status: in progress. +Status: accepted for the current embedded slice, with hard-profile work still remaining in E5. -Acceptance: +Accepted: - standalone `embedded/` CMake build; - GCC and Clang host-sim compile; -- RTTI disabled; -- host TCP/PCAP/COMTRADE/SCL/CLI dependencies rejected by CI; -- all embedded objects participate in a full-link smoke executable; -- existing desktop C++ CI remains unchanged and green. +- RTTI disabled in the embedded profile; +- host TCP/filesystem/capture services excluded from the first MCU component boundary; +- all selected embedded objects participate in a full-link smoke executable; +- desktop C++ CI remains independent; +- platform-neutral raw-Ethernet/clock abstraction exists; +- ESP-IDF component boundary exists. -Not yet accepted: +Still deferred to the hard embedded profile: -- `-fno-exceptions`; -- deterministic/no-heap wire encoding; -- ESP-IDF compilation; -- real ESP32 Ethernet traffic. +- global `-fno-exceptions` acceptance; +- removal/splitting of remaining exception-dependent convenience/decode APIs from the MCU slice; +- explicit footprint budgets for every future embedded protocol subset. ## E1 — allocation-bounded SV wire path -This is the next protocol refactor because it directly enables the smallest useful ESP32 product. +Status: accepted for the first-trial publisher slice. + +Accepted: + +- caller-owned Ethernet frame buffer; +- allocation-bounded steady-state SV encode/transmit path; +- no exception propagation from the publisher hot path; +- existing host API retained; +- VLAN/non-VLAN framing remains explicit; +- sample counter wrap behavior tested; +- deterministic 8,000-frame / two-second simulation at 4 kHz; +- exact-byte independent Python PCAP oracle; +- GCC + Clang embedded-host simulation evidence retained by CI. + +Semantic follow-up before mature process-bus acceptance: -Target API direction: +- finalize and document `smpCnt` progression when a transmit fails or nominal sample instants are intentionally skipped after scheduler lateness. -```cpp -EncodeResult encode_into( - const SampledValuesFrame& frame, - std::span destination) noexcept; +## E2 — Windows SV loopback reference CLI + +Status: next implementation slice. + +Initial executable direction: + +```text +ariec61850_sv_loopback.exe ``` -Acceptance: +Required first modes: -- caller-owned Ethernet frame buffer; -- no heap allocation on the steady-state SV encode/transmit path; -- no exceptions on that path; -- byte-for-byte parity with existing C++/ARIEC61850 golden SV vectors; -- existing `std::vector` host API retained as a compatibility wrapper; -- VLAN/non-VLAN framing remains explicit; -- sample counter wrap behavior remains tested. +1. `software` — `SampledValuesPublisher` -> in-memory `RawEthernetPort` -> decoder/verifier; +2. `pcap` — generated stream -> PCAP -> reopen/independent verification; +3. machine-readable JSON summary plus human console output. + +Fault/regression modes should then cover: + +- drop; +- duplicate; +- corrupt; +- late poll/tick; +- counter wrap; +- rate/profile change. + +Later add `live-npcap` raw Layer-2 transmit/capture through a selected Windows Ethernet adapter. + +This CLI becomes both a pre-hardware QA tool and the desktop behavioral reference against which the ESP32-P4 injector is compared. + +## E3 — ESP32-P4 SV injector MVP -## E2 — ESP32 SV Publisher MVP +Status: software/cross-compile/flashable gates accepted; physical Ethernet acceptance pending. Platform layer: -- ESP-IDF/FreeRTOS owns task scheduling and network driver; -- `RawEthernetPort` binds to the platform raw Ethernet TX function; -- fixed frame buffers are owned by the application; -- sample source is pluggable (synthetic first, ADC later). +- ESP-IDF/FreeRTOS owns task scheduling and Ethernet driver; +- `RawEthernetPort` binds to `esp_eth_transmit()`; +- fixed frame buffers are application-owned; +- sample source is pluggable; +- management plane is conceptually separated from the process-bus data plane. -Minimum demonstration: +Accepted before hardware: + +- real ESP32-P4 ESP-IDF v6.0.2 cross-compile/link; +- flashable firmware image produced by CI; +- ESP-IDF flasher manifest targets `esp32p4`; +- bootloader, partition table, application image, ELF/map, checksums and build-profile evidence retained; +- first synthetic SV profile is reproducible by host simulation. + +Physical minimum demonstration: 1. configure destination MAC, APPID and svID; -2. generate deterministic sampled values; -3. publish valid IEC 61850 SV Layer-2 frames; -4. decode them with the desktop arstack61850 decoder and an independent IEC 61850 tool; -5. capture PCAP and compare payload fields against the host golden path; -6. run a sustained-rate test appropriate to the selected SV profile. +2. initialize native ESP32-P4 EMAC/RMII and PHY; +3. generate deterministic sampled values; +4. publish valid IEC 61850 SV Layer-2 frames; +5. decode them with Wireshark/desktop tools; +6. capture PCAP and compare fields against the host golden path; +7. measure sustained 4,000-frame/s behavior, TX failures, missed/coalesced ticks, lateness, and heap watermark; +8. short, 10-minute, then one-hour soak. + +A common 50 Hz / 80 samples-per-cycle profile implies 4,000 sample instants per second. That is the first stress target, not a blanket standards claim for every SV application. + +### P4 injector evolution + +After physical SV acceptance: + +- PC-controlled start/stop/arm/trigger; +- versioned configuration/scenario protocol, USB serial first; +- configurable SV identities/profile/VLAN; +- waveform and transient scenarios; +- COMTRADE-to-SV playback; +- controlled drop/duplicate/corrupt/jitter injection; +- GOOSE publisher/subscriber laboratory modes; +- PCAP compare/replay workflows; +- synchronized triggering and PTP only after separate hardware/timing evidence exists. -A common 50 Hz / 80 samples-per-cycle profile implies 4,000 sample instants per second. That is a useful stress target, not a blanket standards claim for every SV application. +## E4 — ESP32-S3 I/O IED MVP -Timing quality must be reported honestly. A lab ESP32 publisher is not called protection-grade or time-synchronized until jitter and synchronization/PTP evidence support that claim. +Status: planned after the Windows loopback slice and P4 first physical proof are stable enough to serve as test peers. -## E3 — simple ESP32 IEC 61850 I/O IED +First hardware: -Start with a deliberately static device model. +- Waveshare ESP32-S3-Relay-6CH, SKU 26756; +- six physical relay outputs; +- isolated RS485; +- Wi-Fi/Bluetooth/USB management capability; +- industrial DC input and isolation/protection hardware. + +The Relay-6CH board has no onboard wired Ethernet, so development is split deliberately. + +### E4.1 — MMS/static IED over IP/Wi-Fi Initial server surface: @@ -78,32 +155,83 @@ Initial server surface: - `GetVariableAccessAttributes`; - `Read`; - compile-time/static Logical Device / Logical Node / Data Object table; -- GPIO-backed SPS/DPS/measurement points. +- six relay outputs exposed through a small static GGIO-style I/O model; +- bounded, explicitly authorized relay control/write path; +- safe startup/recovery output policy; +- PC CLI interoperability and host-sim regression. + +Wi-Fi proves MMS/application behavior but is not evidence for deterministic station-bus or protection-grade GOOSE. + +### E4.2 — wired Ethernet IED + +Add a supported wired network adapter behind the same portable HAL, for example a suitable SPI Ethernet controller, or use a Waveshare S3 industrial Ethernet I/O variant with onboard W5500 as the wired reference platform. Then add, in controlled slices: -- bounded `Write` for configuration points where appropriate; -- control model for outputs; - one small static DataSet; -- URCB reporting first; +- URCB reporting; +- bounded GOOSE publisher/subscriber; +- refined control model; +- input/status/event timestamp expansion; +- RS485/Modbus gateway points where useful; +- SCL-driven static model generation; - BRCB only after reservation/buffering semantics are proven. -Dynamic DataSet and large live-discovery structures are not initial MCU requirements. +Dynamic DataSet and large dynamic discovery structures are not initial MCU requirements. -## E4 — hard embedded profile +## E5 — shared PC-to-device control plane + +Status: planned. + +The P4 injector and S3 IED must not invent unrelated ad-hoc command protocols. + +Define one versioned, transport-independent management schema for: + +- identity and capabilities; +- stack/firmware version; +- configuration get/set/export/import; +- start/stop/arm/trigger; +- scenario/profile selection; +- runtime statistics and health; +- explicit structured errors; +- fault injection where applicable; +- protocol-version negotiation. + +Early development transport may be line-delimited JSON over USB serial. Later transports may include TCP/WebSocket without changing command semantics. + +## E6 — hard embedded profile Acceptance: -- `-fno-exceptions` enabled in CI; +- `-fno-exceptions` enabled for the intended embedded protocol slices; - `-fno-rtti` remains enabled; - no uncontrolled heap use in protocol steady state; - explicit compile-time capacities; -- platform clock/TCP/raw-Ethernet HAL only; -- ESP-IDF build in CI or reproducible local toolchain build; -- RAM/flash footprint report. +- platform clock/TCP/raw-Ethernet/I/O HAL only; +- ESP-IDF target builds in CI; +- per-profile RAM/flash/stack footprint reports; +- long-soak resource stability; +- fault/recovery behavior documented and regression-tested. + +## E7 — industrial portability targets + +After ESP32 proofs are stable, port only HAL/platform integration to additional hardware. + +Candidate families include STM32, NXP and other industrial MCU/MPU platforms selected according to Ethernet MAC/DMA capability, timer determinism, memory, isolation, watchdog/recovery, synchronization/PTP requirements, lifecycle, and toolchain quality. + +For higher-end protection/process-bus ambitions, hardware is selected to satisfy the timing/conformance goal. The IEC 61850 core must not be rewritten around a vendor SDK. + +## Long-term outcome -## E5 — STM32/NXP industrial target +ARStack61850 should converge into one coherent, smart, portable IEC 61850 platform spanning: -After the ESP32 proof is stable, port only the HAL and platform integration. The protocol core must remain unchanged. +- SV and GOOSE process bus; +- MMS client/server, discovery, reporting, control and file service; +- SCL engineering and static embedded-model generation; +- COMTRADE and PCAP workflows; +- deterministic simulator/virtual IED; +- Windows/Linux engineering workbench; +- constrained MCU profiles and larger industrial targets; +- reproducible QA from host simulation through physical interoperability and timing evidence. -For higher-end protection/process-bus ambitions, select hardware based on Ethernet MAC/DMA, timer determinism, memory, and synchronization/PTP requirements rather than rewriting the stack around a vendor SDK. +"Top global" is treated as an engineering quality target: clean architecture, standards-aware behavior, deterministic tests, bounded resources, fuzz/sanitizer coverage, reproducible artifacts, physical interoperability, timing evidence, long-soak results, clear documentation, and independent/certification evidence for any conformance level that is eventually claimed. From b2d02652e9a8e06310e71dde944c5e1419fd58b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:10:09 +0700 Subject: [PATCH 037/188] Add deterministic fixed-point SV injector waveform engine --- .../sampled_values/deterministic_injector.hpp | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 include/ariec61850/sampled_values/deterministic_injector.hpp diff --git a/include/ariec61850/sampled_values/deterministic_injector.hpp b/include/ariec61850/sampled_values/deterministic_injector.hpp new file mode 100644 index 000000000..87fe27a56 --- /dev/null +++ b/include/ariec61850/sampled_values/deterministic_injector.hpp @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t injector_channel_count = 8U; +inline constexpr std::uint32_t injector_gain_one = 10'000U; + +struct InjectorSignalProfile final { + bool enabled{true}; + std::int32_t rms_counts{}; + std::int32_t dc_counts{}; + std::int32_t phase_millidegrees{}; + std::uint32_t frequency_millihz{50'000U}; + std::uint16_t harmonic_permyriad{}; + std::uint8_t harmonic_order{2U}; + std::uint16_t clip_permyriad{}; + std::uint32_t quality{}; +}; + +enum class InjectorSegmentTransition : std::uint8_t { + step, + linear_from_previous, +}; + +struct InjectorScenarioSegment final { + // Zero means hold this segment indefinitely. + std::uint32_t duration_samples{}; + InjectorSegmentTransition transition{InjectorSegmentTransition::step}; + std::array channels{}; +}; + +struct InjectorSample final { + std::uint64_t sample_index{}; + std::size_t segment_index{}; + std::array values{}; + std::array qualities{}; +}; + +class DeterministicSvInjector final { +public: + DeterministicSvInjector( + const std::span segments, + const std::uint32_t sample_rate_hz, + const bool loop) noexcept + : segments_(segments), sample_rate_hz_(sample_rate_hz), loop_(loop) { + valid_ = validate_configuration(); + reset(); + } + + [[nodiscard]] bool valid() const noexcept { return valid_; } + [[nodiscard]] bool finished() const noexcept { return finished_; } + [[nodiscard]] std::uint64_t sample_index() const noexcept { return sample_index_; } + [[nodiscard]] std::size_t segment_index() const noexcept { return segment_index_; } + + void reset() noexcept { + sample_index_ = 0U; + segment_index_ = 0U; + sample_in_segment_ = 0U; + finished_ = !valid_; + phase_remainder_.fill(0U); + phase_.fill(0U); + if (!valid_) { + return; + } + for (std::size_t channel = 0U; channel < injector_channel_count; ++channel) { + phase_[channel] = phase_from_millidegrees( + segments_.front().channels[channel].phase_millidegrees); + } + } + + [[nodiscard]] bool step(InjectorSample& output) noexcept { + if (!valid_ || finished_) { + return false; + } + + output.sample_index = sample_index_; + output.segment_index = segment_index_; + + for (std::size_t channel = 0U; channel < injector_channel_count; ++channel) { + const auto profile = effective_profile(channel); + output.values[channel] = evaluate_channel(profile, channel); + output.qualities[channel] = profile.quality; + advance_phase(profile.frequency_millihz, channel); + } + + ++sample_index_; + advance_segment(); + return true; + } + +private: + static constexpr std::uint64_t phase_turn = 0x1'0000'0000ULL; + static constexpr std::int64_t sqrt_two_q30 = 1'518'500'250LL; + static constexpr std::array sine_q15_table{ + 0, 804, 1608, 2410, 3212, 4011, 4808, 5602, 6393, 7179, 7962, 8739, 9512, 10278, 11039, 11793, + 12539, 13279, 14010, 14732, 15446, 16151, 16846, 17530, 18204, 18868, 19519, 20159, 20787, 21403, 22005, 22594, + 23170, 23731, 24279, 24811, 25329, 25832, 26319, 26790, 27245, 27683, 28105, 28510, 28898, 29268, 29621, 29956, + 30273, 30571, 30852, 31113, 31356, 31580, 31785, 31971, 32137, 32285, 32412, 32521, 32609, 32678, 32728, 32757, + 32767, 32757, 32728, 32678, 32609, 32521, 32412, 32285, 32137, 31971, 31785, 31580, 31356, 31113, 30852, 30571, + 30273, 29956, 29621, 29268, 28898, 28510, 28105, 27683, 27245, 26790, 26319, 25832, 25329, 24811, 24279, 23731, + 23170, 22594, 22005, 21403, 20787, 20159, 19519, 18868, 18204, 17530, 16846, 16151, 15446, 14732, 14010, 13279, + 12539, 11793, 11039, 10278, 9512, 8739, 7962, 7179, 6393, 5602, 4808, 4011, 3212, 2410, 1608, 804, + 0, -804, -1608, -2410, -3212, -4011, -4808, -5602, -6393, -7179, -7962, -8739, -9512, -10278, -11039, -11793, + -12539, -13279, -14010, -14732, -15446, -16151, -16846, -17530, -18204, -18868, -19519, -20159, -20787, -21403, -22005, -22594, + -23170, -23731, -24279, -24811, -25329, -25832, -26319, -26790, -27245, -27683, -28105, -28510, -28898, -29268, -29621, -29956, + -30273, -30571, -30852, -31113, -31356, -31580, -31785, -31971, -32137, -32285, -32412, -32521, -32609, -32678, -32728, -32757, + -32767, -32757, -32728, -32678, -32609, -32521, -32412, -32285, -32137, -31971, -31785, -31580, -31356, -31113, -30852, -30571, + -30273, -29956, -29621, -29268, -28898, -28510, -28105, -27683, -27245, -26790, -26319, -25832, -25329, -24811, -24279, -23731, + -23170, -22594, -22005, -21403, -20787, -20159, -19519, -18868, -18204, -17530, -16846, -16151, -15446, -14732, -14010, -13279, + -12539, -11793, -11039, -10278, -9512, -8739, -7962, -7179, -6393, -5602, -4808, -4011, -3212, -2410, -1608, -804, + }; + + [[nodiscard]] bool validate_configuration() const noexcept { + if (segments_.empty() || sample_rate_hz_ == 0U || sample_rate_hz_ > 1'000'000U) { + return false; + } + const auto maximum_frequency_millihz = + static_cast(sample_rate_hz_) * 500ULL; + for (const auto& segment : segments_) { + for (const auto& channel : segment.channels) { + if (static_cast(channel.frequency_millihz) > + maximum_frequency_millihz || + channel.harmonic_permyriad > injector_gain_one || + channel.harmonic_order < 2U || + channel.harmonic_order > 63U || + channel.clip_permyriad > injector_gain_one) { + return false; + } + } + } + return true; + } + + [[nodiscard]] static std::uint32_t phase_from_millidegrees( + const std::int32_t millidegrees) noexcept { + constexpr std::int64_t full_circle = 360'000LL; + auto normalized = static_cast(millidegrees) % full_circle; + if (normalized < 0LL) { + normalized += full_circle; + } + const auto numerator = static_cast(normalized) * phase_turn; + return static_cast( + numerator / static_cast(full_circle)); + } + + [[nodiscard]] static std::int32_t sine_q15(const std::uint32_t phase) noexcept { + const auto index = static_cast(phase >> 24U); + const auto next = static_cast( + index + static_cast(1U)); + const auto fraction = static_cast((phase >> 8U) & 0xFFFFU); + const auto left = static_cast(sine_q15_table[index]); + const auto right = static_cast(sine_q15_table[next]); + const auto delta = static_cast(right - left); + const auto interpolated = static_cast(left) + + (delta * static_cast(fraction)) / 65'536LL; + return static_cast(interpolated); + } + + [[nodiscard]] static std::int32_t interpolate_i32( + const std::int32_t start, + const std::int32_t finish, + const std::uint32_t position, + const std::uint32_t denominator) noexcept { + if (denominator == 0U || start == finish) { + return finish; + } + const auto delta = static_cast(finish) - + static_cast(start); + const auto value = static_cast(start) + + (delta * static_cast(position)) / + static_cast(denominator); + return clamp_i32(value); + } + + [[nodiscard]] static std::uint32_t interpolate_u32( + const std::uint32_t start, + const std::uint32_t finish, + const std::uint32_t position, + const std::uint32_t denominator) noexcept { + if (denominator == 0U || start == finish) { + return finish; + } + const auto delta = static_cast(finish) - + static_cast(start); + const auto value = static_cast(start) + + (delta * static_cast(position)) / + static_cast(denominator); + return value <= 0LL ? 0U : static_cast(value); + } + + [[nodiscard]] InjectorSignalProfile effective_profile( + const std::size_t channel) const noexcept { + auto profile = segments_[segment_index_].channels[channel]; + const auto& segment = segments_[segment_index_]; + if (segment.transition != InjectorSegmentTransition::linear_from_previous || + segment_index_ == 0U || segment.duration_samples <= 1U) { + return profile; + } + + const auto& previous = segments_[segment_index_ - 1U].channels[channel]; + const auto denominator = segment.duration_samples - 1U; + profile.rms_counts = interpolate_i32( + previous.rms_counts, profile.rms_counts, sample_in_segment_, denominator); + profile.dc_counts = interpolate_i32( + previous.dc_counts, profile.dc_counts, sample_in_segment_, denominator); + profile.frequency_millihz = interpolate_u32( + previous.frequency_millihz, + profile.frequency_millihz, + sample_in_segment_, + denominator); + profile.harmonic_permyriad = static_cast(interpolate_u32( + previous.harmonic_permyriad, + profile.harmonic_permyriad, + sample_in_segment_, + denominator)); + profile.clip_permyriad = static_cast(interpolate_u32( + previous.clip_permyriad, + profile.clip_permyriad, + sample_in_segment_, + denominator)); + return profile; + } + + [[nodiscard]] static std::int32_t clamp_i32(const std::int64_t value) noexcept { + if (value > static_cast( + std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + if (value < static_cast( + std::numeric_limits::min())) { + return std::numeric_limits::min(); + } + return static_cast(value); + } + + [[nodiscard]] std::int32_t evaluate_channel( + const InjectorSignalProfile& profile, + const std::size_t channel) const noexcept { + if (!profile.enabled || profile.rms_counts == 0) { + return profile.dc_counts; + } + + const auto peak = + (static_cast(profile.rms_counts) * sqrt_two_q30) >> 30U; + const auto fundamental_q15 = sine_q15(phase_[channel]); + auto sample = + (peak * static_cast(fundamental_q15)) / 32'767LL; + + if (profile.harmonic_permyriad > 0U) { + const auto harmonic_phase = static_cast( + static_cast(phase_[channel]) * + static_cast(profile.harmonic_order)); + const auto harmonic_q15 = sine_q15(harmonic_phase); + const auto harmonic_peak = + (peak * static_cast(profile.harmonic_permyriad)) / + static_cast(injector_gain_one); + sample += + (harmonic_peak * static_cast(harmonic_q15)) / 32'767LL; + } + + sample += static_cast(profile.dc_counts); + + if (profile.clip_permyriad > 0U) { + const auto absolute_peak = peak < 0LL ? -peak : peak; + const auto limit = + (absolute_peak * static_cast(profile.clip_permyriad)) / + static_cast(injector_gain_one); + if (sample > limit) { + sample = limit; + } else if (sample < -limit) { + sample = -limit; + } + } + + return clamp_i32(sample); + } + + void advance_phase( + const std::uint32_t frequency_millihz, + const std::size_t channel) noexcept { + const auto denominator = + static_cast(sample_rate_hz_) * 1'000ULL; + const auto numerator = + static_cast(frequency_millihz) * phase_turn + + phase_remainder_[channel]; + const auto increment = numerator / denominator; + phase_remainder_[channel] = numerator % denominator; + phase_[channel] += static_cast(increment); + } + + void adjust_phase_offsets( + const InjectorScenarioSegment& previous, + const InjectorScenarioSegment& next) noexcept { + for (std::size_t channel = 0U; channel < injector_channel_count; ++channel) { + const auto delta = + static_cast(next.channels[channel].phase_millidegrees) - + static_cast(previous.channels[channel].phase_millidegrees); + auto normalized = delta % 360'000LL; + if (normalized < 0LL) { + normalized += 360'000LL; + } + const auto delta_phase = static_cast( + (static_cast(normalized) * phase_turn) / 360'000ULL); + phase_[channel] += delta_phase; + } + } + + void advance_segment() noexcept { + const auto duration = segments_[segment_index_].duration_samples; + if (duration == 0U) { + ++sample_in_segment_; + return; + } + + ++sample_in_segment_; + if (sample_in_segment_ < duration) { + return; + } + + const auto previous_index = segment_index_; + sample_in_segment_ = 0U; + if (segment_index_ + 1U < segments_.size()) { + ++segment_index_; + adjust_phase_offsets( + segments_[previous_index], segments_[segment_index_]); + return; + } + + if (loop_) { + segment_index_ = 0U; + adjust_phase_offsets( + segments_[previous_index], segments_[segment_index_]); + return; + } + + finished_ = true; + } + + std::span segments_{}; + std::uint32_t sample_rate_hz_{}; + bool loop_{}; + bool valid_{}; + bool finished_{}; + std::uint64_t sample_index_{}; + std::size_t segment_index_{}; + std::uint32_t sample_in_segment_{}; + std::array phase_{}; + std::array phase_remainder_{}; +}; + +} // namespace ar::iec61850::sampled_values From f9448de0a53eb1eb8c8182290fcb4f9e179e1b4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:12:24 +0700 Subject: [PATCH 038/188] Add deterministic 4I4V SV injector presets --- .../sampled_values/injector_presets.hpp | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 include/ariec61850/sampled_values/injector_presets.hpp diff --git a/include/ariec61850/sampled_values/injector_presets.hpp b/include/ariec61850/sampled_values/injector_presets.hpp new file mode 100644 index 000000000..18c2df8e3 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_presets.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/deterministic_injector.hpp" + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Canonical first-trial channel order shared by Windows host simulation and the +// ESP32-P4 injector application: Ia, Ib, Ic, In, Va, Vb, Vc, Vn. +[[nodiscard]] inline std::array +make_balanced_4i4v_profile( + const std::int32_t current_rms_counts = 1'000, + const std::int32_t voltage_rms_counts = 5'774, + const std::uint32_t frequency_millihz = 50'000U, + const std::uint32_t quality = 0U) noexcept { + std::array channels{}; + + channels[0] = {true, current_rms_counts, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + channels[1] = {true, current_rms_counts, 0, -120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[2] = {true, current_rms_counts, 0, 120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[3] = {false, 0, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + + channels[4] = {true, voltage_rms_counts, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + channels[5] = {true, voltage_rms_counts, 0, -120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[6] = {true, voltage_rms_counts, 0, 120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[7] = {false, 0, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + + return channels; +} + +[[nodiscard]] inline InjectorScenarioSegment make_hold_segment( + const std::array& channels, + const std::uint32_t duration_samples = 0U) noexcept { + return { + duration_samples, + InjectorSegmentTransition::step, + channels}; +} + +} // namespace ar::iec61850::sampled_values From b34415f3ce73d99a6ca4f1167cd68fa6ed4131a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:12:43 +0700 Subject: [PATCH 039/188] Add embedded deterministic SV injector smoke --- embedded/sv_injector_smoke.cpp | 116 +++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 embedded/sv_injector_smoke.cpp diff --git a/embedded/sv_injector_smoke.cpp b/embedded/sv_injector_smoke.cpp new file mode 100644 index 000000000..de9758f1f --- /dev/null +++ b/embedded/sv_injector_smoke.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" + +#include +#include + +namespace { + +using namespace ar::iec61850::sampled_values; + +bool exact_cycle_smoke() noexcept { + const auto channels = make_balanced_4i4v_profile(1'000, 5'774, 50'000U); + const std::array segments{ + make_hold_segment(channels)}; + DeterministicSvInjector injector(segments, 4'000U, false); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + std::array values{}; + for (std::size_t index = 0U; index < values.size(); ++index) { + if (!injector.step(sample)) { + return false; + } + values[index] = sample.values[0]; + } + + return values[0] == 0 && + values[20] >= 1'408 && values[20] <= 1'420 && + values[40] >= -2 && values[40] <= 2 && + values[60] <= -1'408 && values[60] >= -1'420 && + values[80] >= -2 && values[80] <= 2; +} + +bool reset_is_byte_reproducible() noexcept { + const auto channels = make_balanced_4i4v_profile(); + const std::array segments{ + make_hold_segment(channels)}; + DeterministicSvInjector injector(segments, 4'000U, true); + if (!injector.valid()) { + return false; + } + + std::array first{}; + std::array second{}; + InjectorSample sample{}; + for (auto& value : first) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[4]; + } + + injector.reset(); + for (auto& value : second) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[4]; + } + return first == second; +} + +bool linear_segment_uses_integer_sample_timeline() noexcept { + auto low = make_balanced_4i4v_profile(1'000, 1'000, 0U); + auto high = low; + for (auto& channel : low) { + channel.phase_millidegrees = 90'000; + } + for (auto& channel : high) { + channel.phase_millidegrees = 90'000; + } + high[0].rms_counts = 4'000; + + const std::array segments{ + InjectorScenarioSegment{1U, InjectorSegmentTransition::step, low}, + InjectorScenarioSegment{3U, InjectorSegmentTransition::linear_from_previous, high}}; + DeterministicSvInjector injector(segments, 4'000U, false); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + std::array values{}; + for (auto& value : values) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[0]; + } + + return values[0] >= 1'408 && values[0] <= 1'420 && + values[1] >= 1'408 && values[1] <= 1'420 && + values[2] > values[1] && + values[3] > values[2] && + values[3] >= 5'650 && values[3] <= 5'665 && + injector.finished(); +} + +} // namespace + +int main() { + if (!exact_cycle_smoke()) { + return 1; + } + if (!reset_is_byte_reproducible()) { + return 2; + } + if (!linear_segment_uses_integer_sample_timeline()) { + return 3; + } + return 0; +} From 8435f0787074e424eecb825e462ccaf5dbf87b6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:14:12 +0700 Subject: [PATCH 040/188] Add Windows-ready deterministic SV injector loopback CLI --- tools/sv_injector.cpp | 507 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 tools/sv_injector.cpp diff --git a/tools/sv_injector.cpp b/tools/sv_injector.cpp new file mode 100644 index 000000000..d21e35b64 --- /dev/null +++ b/tools/sv_injector.cpp @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kSampleRateHz = 4'000U; +constexpr std::uint64_t kIntervalUs = 250U; +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::uint64_t kVirtualStartUs = 1'000'000U; +constexpr std::size_t kPayloadBytes = + sampled_values::injector_channel_count * + sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; + +const ethernet::MacAddress kDestination{ + std::array{0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}}; +const ethernet::MacAddress kSource{ + std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}}; + +struct Options final { + std::string scenario{"normal"}; + std::optional frames; + std::uint64_t drop_every{}; + std::string pcap_path; + bool json{}; + bool capabilities{}; + bool help{}; +}; + +struct ScenarioDefinition final { + std::vector segments; + std::uint64_t default_frames{}; + bool loop{true}; +}; + +struct LoopbackEvidence final { + std::uint64_t tx_frames{}; + std::uint64_t rx_frames{}; + std::uint64_t injected_drops{}; + std::uint64_t decode_errors{}; + std::uint64_t identity_errors{}; + std::uint64_t payload_errors{}; + std::uint64_t sample_count_errors{}; + std::uint64_t gap_events{}; + std::uint64_t pcap_errors{}; +}; + +struct LoopbackContext final { + LoopbackEvidence evidence{}; + std::uint64_t drop_every{}; + std::uint16_t expected_sample_count{}; + std::array expected_payload{}; + std::optional previous_received_count; + std::uint64_t virtual_timestamp_us{}; + capture::PcapWriter* pcap_writer{}; +}; + +[[nodiscard]] std::uint64_t parse_bounded_u64( + const std::string_view option, + const std::string& value, + const std::uint64_t minimum, + const std::uint64_t maximum) { + std::size_t consumed = 0U; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed < minimum || parsed > maximum) { + throw std::invalid_argument( + std::string{option} + " requires an integer in range " + + std::to_string(minimum) + ".." + std::to_string(maximum) + '.'); + } + return static_cast(parsed); +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + auto require_value = [&](const std::string_view option) -> std::string { + if (index + 1 >= argc) { + throw std::invalid_argument(std::string{option} + " requires a value."); + } + ++index; + return argv[index]; + }; + + if (argument == "--scenario") { + options.scenario = require_value("--scenario"); + } else if (argument == "--frames") { + options.frames = parse_bounded_u64( + "--frames", require_value("--frames"), 1U, 10'000'000U); + } else if (argument == "--drop-every") { + options.drop_every = parse_bounded_u64( + "--drop-every", require_value("--drop-every"), 0U, 10'000'000U); + if (options.drop_every == 1U) { + throw std::invalid_argument( + "--drop-every must be 0 (disabled) or at least 2."); + } + } else if (argument == "--pcap") { + options.pcap_path = require_value("--pcap"); + } else if (argument == "--json") { + options.json = true; + } else if (argument == "--capabilities") { + options.capabilities = true; + } else if (argument == "--help" || argument == "-h") { + options.help = true; + } else { + throw std::invalid_argument("Unknown option: " + argument); + } + } + return options; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_sv_injector [options]\n" + << "\nDeterministic Sampled Values software-loopback injector.\n" + << "No ESP32, Npcap, administrator privilege, or physical NIC is required.\n\n" + << "Options:\n" + << " --scenario NAME normal | protection-fault (default normal).\n" + << " --frames N Override generated frame count.\n" + << " --drop-every N Simulate one downstream loss every N TX frames (0=off).\n" + << " --pcap PATH Write received loopback frames to classic PCAP.\n" + << " --json Emit machine-readable evidence.\n" + << " --capabilities Emit the app/device control capability contract.\n" + << " --help Show this help.\n"; +} + +void print_capabilities() { + std::cout + << "{\n" + << " \"schemaVersion\": \"arstack-sv-injector-control-v1\",\n" + << " \"engine\": \"sample-index-fixed-point\",\n" + << " \"modes\": [\"software-loopback\"],\n" + << " \"scenarios\": [\"normal\", \"protection-fault\"],\n" + << " \"commandsPlannedForDeviceTransport\": " + "[\"capabilities\", \"configure\", \"arm\", \"start\", \"stop\", \"status\", \"stats\"],\n" + << " \"profile\": {\"sampleRateHz\": 4000, \"sampleCountWrap\": 4000, " + "\"channels\": 8, \"payload\": \"INT32+quality\"}\n" + << "}\n"; +} + +[[nodiscard]] ScenarioDefinition make_scenario(const std::string_view name) { + auto balanced = sampled_values::make_balanced_4i4v_profile(); + if (name == "normal") { + return {{sampled_values::make_hold_segment(balanced)}, 8'000U, true}; + } + + if (name == "protection-fault") { + auto fault = balanced; + for (std::size_t channel = 0U; channel < 3U; ++channel) { + fault[channel].rms_counts *= 4; + } + for (std::size_t channel = 4U; channel < 7U; ++channel) { + fault[channel].rms_counts /= 4; + } + + std::vector segments; + segments.reserve(3U); + segments.push_back(sampled_values::make_hold_segment(balanced, 2'000U)); + segments.push_back(sampled_values::make_hold_segment(fault, 800U)); + segments.push_back(sampled_values::make_hold_segment(balanced, 2'000U)); + return {std::move(segments), 4'800U, true}; + } + + throw std::invalid_argument( + "Unknown scenario '" + std::string{name} + + "'. Supported: normal, protection-fault."); +} + +[[nodiscard]] sampled_values::SampledValuesFrame make_frame() { + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_INJECTOR"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{kSampleRateHz}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(kPayloadBytes, 0U); + + return { + kDestination, + kSource, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{std::move(asdu)}}}; +} + +[[nodiscard]] bool frame_identity_matches( + const sampled_values::SampledValuesFrame& frame) noexcept { + if (frame.destination != kDestination || frame.source != kSource || + frame.vlan.has_value() || frame.app_id != 0x4001U || + frame.reserved1 != 0U || frame.reserved2 != 0U || + frame.pdu.asdus.size() != 1U) { + return false; + } + + const auto& asdu = frame.pdu.asdus.front(); + return asdu.sv_id == "ARSTACK61850_INJECTOR" && + asdu.data_set_reference == "ARSTACK61850/LLN0$PhsMeas1" && + asdu.configuration_revision == 1U && + asdu.sample_synchronization == 0U && + asdu.sample_rate == std::optional{std::uint16_t{kSampleRateHz}} && + asdu.sample_mode == std::optional{std::uint16_t{1U}}; +} + +embedded::IoResult loopback_transmit( + void* context, + const std::span bytes) noexcept { + auto* loopback = static_cast(context); + if (loopback == nullptr || bytes.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + ++loopback->evidence.tx_frames; + if (loopback->drop_every > 0U && + (loopback->evidence.tx_frames % loopback->drop_every) == 0U) { + ++loopback->evidence.injected_drops; + // The producer/TX path succeeded; the loss is injected after TX so the + // logical sample timeline continues and the receiver observes a gap. + return {embedded::IoStatus::ok, bytes.size()}; + } + + sampled_values::SampledValuesFrame decoded; + if (!sampled_values::SampledValuesFrameCodec::try_decode(bytes, decoded)) { + ++loopback->evidence.decode_errors; + return {embedded::IoStatus::ok, bytes.size()}; + } + + ++loopback->evidence.rx_frames; + if (!frame_identity_matches(decoded)) { + ++loopback->evidence.identity_errors; + } + + if (decoded.pdu.asdus.size() == 1U) { + const auto& asdu = decoded.pdu.asdus.front(); + if (asdu.sample_count != loopback->expected_sample_count) { + ++loopback->evidence.sample_count_errors; + } + if (asdu.sample_payload.size() != loopback->expected_payload.size() || + !std::equal( + asdu.sample_payload.begin(), + asdu.sample_payload.end(), + loopback->expected_payload.begin(), + loopback->expected_payload.end())) { + ++loopback->evidence.payload_errors; + } + + if (loopback->previous_received_count.has_value()) { + const auto previous = *loopback->previous_received_count; + const auto expected_next = static_cast( + previous + 1U == kSampleCountWrap ? 0U : previous + 1U); + if (asdu.sample_count != expected_next) { + if (asdu.sample_count == loopback->expected_sample_count) { + ++loopback->evidence.gap_events; + } else { + ++loopback->evidence.sample_count_errors; + } + } + } + loopback->previous_received_count = asdu.sample_count; + } + + if (loopback->pcap_writer != nullptr) { + try { + const auto timestamp = std::chrono::system_clock::time_point{ + std::chrono::microseconds{loopback->virtual_timestamp_us}}; + loopback->pcap_writer->write_packet(timestamp, bytes); + } catch (...) { + ++loopback->evidence.pcap_errors; + return {embedded::IoStatus::io_error, 0U}; + } + } + + return {embedded::IoStatus::ok, bytes.size()}; +} + +[[nodiscard]] bool write_payload( + sampled_values::SampledValueAsdu& asdu, + const sampled_values::InjectorSample& sample) noexcept { + if (asdu.sample_payload.size() != kPayloadBytes) { + return false; + } + const auto payload = std::span{ + asdu.sample_payload.data(), asdu.sample_payload.size()}; + for (std::size_t channel = 0U; + channel < sampled_values::injector_channel_count; + ++channel) { + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, + channel, + sample.values[channel], + sample.qualities[channel])) { + return false; + } + } + return true; +} + +void print_human( + const Options& options, + const std::uint64_t frames, + const LoopbackEvidence& evidence, + const sampled_values::SampledValuesPublisherStatistics& publisher, + const bool passed) { + std::cout + << "ARStack61850 deterministic SV injector\n\n" + << "Mode : SOFTWARE LOOPBACK\n" + << "Scenario : " << options.scenario << '\n' + << "Engine : sample-index fixed-point\n" + << "Sample rate : " << kSampleRateHz << " Hz\n" + << "Virtual interval : " << kIntervalUs << " us\n" + << "Frames requested : " << frames << "\n\n" + << "TX frames : " << evidence.tx_frames << '\n' + << "RX frames : " << evidence.rx_frames << '\n' + << "Injected drops : " << evidence.injected_drops << '\n' + << "Observed gaps : " << evidence.gap_events << '\n' + << "Decode errors : " << evidence.decode_errors << '\n' + << "Identity errors : " << evidence.identity_errors << '\n' + << "Payload errors : " << evidence.payload_errors << '\n' + << "smpCnt errors : " << evidence.sample_count_errors << '\n' + << "Encode failures : " << publisher.encode_failures << '\n' + << "TX failures : " << publisher.transmit_failures << '\n' + << "Late virtual poll : " << publisher.late_polls << '\n'; + if (!options.pcap_path.empty()) { + std::cout << "PCAP : " << options.pcap_path << '\n'; + } + std::cout << "\nRESULT : " + << (passed ? "LOOPBACK/PASSED" : "LOOPBACK/FAILED") << '\n'; +} + +void print_json( + const Options& options, + const std::uint64_t frames, + const LoopbackEvidence& evidence, + const sampled_values::SampledValuesPublisherStatistics& publisher, + const bool passed) { + std::cout + << '{' + << "\"schemaVersion\":\"arstack-sv-injector-evidence-v1\"," + << "\"status\":\"" << (passed ? "LOOPBACK/PASSED" : "LOOPBACK/FAILED") << "\"," + << "\"mode\":\"software-loopback\"," + << "\"scenario\":\"" << options.scenario << "\"," + << "\"engine\":\"sample-index-fixed-point\"," + << "\"sampleRateHz\":" << kSampleRateHz << ',' + << "\"intervalUs\":" << kIntervalUs << ',' + << "\"framesRequested\":" << frames << ',' + << "\"txFrames\":" << evidence.tx_frames << ',' + << "\"rxFrames\":" << evidence.rx_frames << ',' + << "\"injectedDrops\":" << evidence.injected_drops << ',' + << "\"gapEvents\":" << evidence.gap_events << ',' + << "\"decodeErrors\":" << evidence.decode_errors << ',' + << "\"identityErrors\":" << evidence.identity_errors << ',' + << "\"payloadErrors\":" << evidence.payload_errors << ',' + << "\"sampleCountErrors\":" << evidence.sample_count_errors << ',' + << "\"encodeFailures\":" << publisher.encode_failures << ',' + << "\"transmitFailures\":" << publisher.transmit_failures << ',' + << "\"lateVirtualPolls\":" << publisher.late_polls << ',' + << "\"pcapErrors\":" << evidence.pcap_errors + << "}\n"; +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.help) { + print_usage(); + return 0; + } + if (options.capabilities) { + print_capabilities(); + return 0; + } + + auto scenario = make_scenario(options.scenario); + const auto frames = options.frames.value_or(scenario.default_frames); + sampled_values::DeterministicSvInjector injector( + scenario.segments, kSampleRateHz, scenario.loop); + if (!injector.valid()) { + throw std::runtime_error("Deterministic injector configuration is invalid."); + } + + std::ofstream pcap_output; + std::optional pcap_writer; + if (!options.pcap_path.empty()) { + pcap_output.open(options.pcap_path, std::ios::binary | std::ios::trunc); + if (!pcap_output) { + throw std::runtime_error("Cannot open PCAP output: " + options.pcap_path); + } + pcap_writer.emplace(pcap_output); + } + + LoopbackContext loopback; + loopback.drop_every = options.drop_every; + loopback.pcap_writer = pcap_writer.has_value() ? &*pcap_writer : nullptr; + embedded::RawEthernetPort port{&loopback, &loopback_transmit}; + + auto frame = make_frame(); + std::array frame_buffer{}; + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + if (!publisher.valid()) { + throw std::runtime_error("SampledValuesPublisher configuration is invalid."); + } + + sampled_values::InjectorSample sample{}; + for (std::uint64_t index = 0U; index < frames; ++index) { + if (!injector.step(sample)) { + throw std::runtime_error("Scenario finished before requested frame count."); + } + if (sample.sample_index != index) { + throw std::runtime_error("Injector sample-index timeline diverged."); + } + + auto& asdu = frame.pdu.asdus.front(); + if (!write_payload(asdu, sample)) { + throw std::runtime_error("Could not build INT32+quality SV payload."); + } + + loopback.expected_sample_count = static_cast( + sample.sample_index % static_cast(kSampleCountWrap)); + std::copy( + asdu.sample_payload.begin(), + asdu.sample_payload.end(), + loopback.expected_payload.begin()); + loopback.virtual_timestamp_us = + kVirtualStartUs + sample.sample_index * kIntervalUs; + + const auto result = publisher.poll(loopback.virtual_timestamp_us); + if (!result.sent() || + result.sample_count != loopback.expected_sample_count) { + throw std::runtime_error("Publisher did not emit the expected virtual sample."); + } + } + + if (pcap_output.is_open()) { + pcap_output.flush(); + if (!pcap_output) { + ++loopback.evidence.pcap_errors; + } + } + + const auto expected_rx = frames - loopback.evidence.injected_drops; + const auto expected_visible_gaps = options.drop_every == 0U + ? 0U + : (frames - 1U) / options.drop_every; + const auto& statistics = publisher.statistics(); + const auto passed = + loopback.evidence.tx_frames == frames && + loopback.evidence.rx_frames == expected_rx && + loopback.evidence.decode_errors == 0U && + loopback.evidence.identity_errors == 0U && + loopback.evidence.payload_errors == 0U && + loopback.evidence.sample_count_errors == 0U && + loopback.evidence.gap_events == expected_visible_gaps && + loopback.evidence.pcap_errors == 0U && + statistics.frames_sent == frames && + statistics.encode_failures == 0U && + statistics.transmit_failures == 0U && + statistics.late_polls == 0U; + + if (options.json) { + print_json(options, frames, loopback.evidence, statistics, passed); + } else { + print_human(options, frames, loopback.evidence, statistics, passed); + } + return passed ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_injector: " << error.what() << '\n'; + return 1; + } +} From 4092b2212444a7861ffc882f4a8060918c5ceb50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:14:57 +0700 Subject: [PATCH 041/188] Document deterministic SV injector control contract --- docs/SV_INJECTOR_CONTROL.md | 163 ++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/SV_INJECTOR_CONTROL.md diff --git a/docs/SV_INJECTOR_CONTROL.md b/docs/SV_INJECTOR_CONTROL.md new file mode 100644 index 000000000..65607c070 --- /dev/null +++ b/docs/SV_INJECTOR_CONTROL.md @@ -0,0 +1,163 @@ +# Deterministic SV injector control model + +ARStack61850 treats the Sampled Values injector as one scenario engine with multiple transports and execution targets, not as separate Windows and ESP32 implementations. + +## Why this differs from the ARSVIN publisher + +ARSVIN remains a useful feature and UX reference: SCL-driven identity, manual/ramp/sequence sources, waveform shaping, COMTRADE replay, quality modes, timing-health evidence, PCAP and live Npcap workflows are all valuable product capabilities. + +The deterministic injector changes the execution model. Waveform state is advanced by an integer **logical sample index**, not by UI time, wall-clock elapsed time, or the number of frames that a host scheduler happened to send successfully. + +```text +scenario configuration + | + v +logical sample index -----> fixed-point waveform engine + | | + | v + | 8 x INT32 + quality + | | + v v +sample counter -------------> SV frame encoder + | + +-------------+-------------+ + | | + software loopback ESP32-P4 EMAC + / PCAP reference physical injector +``` + +The scheduler answers **when** to attempt a frame. The deterministic engine answers **which sample** and **what values** belong to that logical instant. Keeping those responsibilities separate is required for reproducible PC-vs-MCU comparison and controlled loss/jitter testing. + +## First control schema + +Schema identifier: + +```text +arstack-sv-injector-control-v1 +``` + +The first Windows CLI exposes the capability contract with: + +```powershell +ariec61850_sv_injector --capabilities +``` + +Initial command semantics reserved for the PC-to-device control plane are: + +- `capabilities` — protocol, engine, profile and device feature discovery; +- `configure` — replace validated inactive configuration; +- `arm` — freeze the active scenario/configuration for a run; +- `start` — begin from a defined logical sample index; +- `stop` — stop generation without mutating the stored configuration; +- `status` — lifecycle and active-profile state; +- `stats` — frame, scheduler, transport and resource telemetry. + +The transport is intentionally not part of the command semantics. Windows may use an in-process dispatcher, while the first ESP32-P4 control transport can use line-delimited JSON over USB serial. TCP/WebSocket or a GUI can be added later without redefining injector behavior. + +## First deterministic profile + +The first common PC/P4 profile remains deliberately narrow: + +- 4,000 sample instants/s; +- 50 Hz nominal waveform; +- 80 samples/cycle; +- one ASDU per Ethernet frame; +- `smpCnt` wrap at 4,000 for the first profile; +- channel order `Ia, Ib, Ic, In, Va, Vb, Vc, Vn`; +- one INT32 sample plus 32-bit quality word per channel; +- 64-byte `seqData` payload; +- APPID `0x4001`; +- destination `01:0C:CD:04:00:01`; +- untagged first proof; +- `smpSynch=0` until synchronization evidence exists. + +The waveform engine uses a fixed-point phase accumulator, fractional phase remainder, a fixed Q15 sine table and integer scenario interpolation. Its steady-state generation path does not require floating-point math or heap allocation. + +## Windows reference CLI + +First executable: + +```text +ariec61850_sv_injector +``` + +Examples: + +```powershell +# Two deterministic seconds at 4 kHz. +.\ariec61850_sv_injector.exe --scenario normal --frames 8000 + +# Protection-style prefault / fault / recovery sequence. +.\ariec61850_sv_injector.exe --scenario protection-fault + +# Simulate a downstream loss after every 1000 successful TX frames. +.\ariec61850_sv_injector.exe --frames 8000 --drop-every 1000 + +# Retain exactly what the software receiver observed. +.\ariec61850_sv_injector.exe --frames 8000 --pcap sv-loopback.pcap + +# Machine-readable app/CI evidence. +.\ariec61850_sv_injector.exe --frames 8000 --json +``` + +The initial `software-loopback` transport does not need Npcap, administrator rights, a physical NIC or an ESP32. The encoded raw Ethernet frame is synchronously passed through `RawEthernetPort`, decoded again, and checked for stream identity, payload equality and sample-counter continuity. + +`--drop-every` injects loss **after** the producer transport reports success. This deliberately leaves the logical sample timeline advancing so the receiver must observe a `smpCnt` gap rather than a repeated old sample. + +## Built-in scenarios in the first slice + +### `normal` + +Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. The CLI defaults to 8,000 frames (two virtual seconds). + +### `protection-fault` + +A deterministic 4,800-sample sequence: + +1. prefault: 2,000 samples / 0.5 s; +2. fault: 800 samples / 0.2 s, phase currents 4x and phase voltages 0.25x; +3. recovery: 2,000 samples / 0.5 s. + +The first preset is a regression scenario, not a calibrated secondary-injection claim. + +## Next source modes + +Once the fixed-point engine and software-loopback gate are green on Windows, GCC, Clang and the embedded profile, add sources in controlled slices: + +1. explicit per-channel manual phasors; +2. deterministic ramp/state sequencer; +3. harmonic/DC/clipping controls; +4. COMTRADE-to-SV playback using sample-index mapping; +5. quality-state scripting; +6. controlled duplicate/corrupt/jitter scheduling faults; +7. SCL-derived stream identity and payload mapping; +8. multi-ASDU packing; +9. GOOSE injector scenarios. + +COMTRADE playback must retain the original record/sample mapping independently from transmission timing; the network scheduler must never decide which COMTRADE sample is logically next. + +## ESP32-P4 integration rule + +The current ESP32-P4 firmware still uses its first synthetic payload generator. It should adopt `DeterministicSvInjector` only after the host and embedded regression gates accept the new engine. + +The P4 application will then own only: + +- ESP-IDF timer/task scheduling; +- EMAC/RMII Ethernet transport; +- USB-serial management transport; +- configuration persistence where required; +- telemetry and health reporting. + +The scenario/waveform engine, payload semantics and SV wire codec remain shared with the Windows reference path. + +## Evidence levels + +Do not conflate these results: + +- `LOOPBACK/PASSED` — deterministic logical stream and software receive path accepted; +- `SIMULATED/PASSED` — host/embedded regression and independent wire evidence accepted; +- `ESP32P4/CROSS-COMPILED` — real P4 toolchain accepted; +- `FLASHABLE/READY` — complete P4 firmware artifact accepted; +- `HARDWARE/PASSED` — physical Ethernet/timing evidence recorded. + +A deterministic logical waveform does not by itself make Windows, FreeRTOS scheduling, Ethernet transmission or clock synchronization deterministic. Those remain separate measurable properties. From d50f7163f4e766ebb196d097d09392c7e114dda4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:16:20 +0700 Subject: [PATCH 042/188] Wire deterministic SV injector CLI into CMake and CTest --- CMakeLists.txt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 02992fed8..39c5e1a8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ project(ARIEC61850Cpp LANGUAGES CXX) option(ARIEC61850_BUILD_TESTS "Build ARIEC61850 C++ regression tests" ON) -option(ARIEC61850_BUILD_TOOLS "Build read-only evidence tools" ON) +option(ARIEC61850_BUILD_TOOLS "Build CLI and evidence tools" ON) option(ARIEC61850_BUILD_FUZZERS "Build LLVM libFuzzer targets" OFF) option(ARIEC61850_ENABLE_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) option(ARIEC61850_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" ON) @@ -130,6 +130,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_sv_injector tools/sv_injector.cpp) + target_link_libraries(ariec61850_sv_injector PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_sv_injector PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_sv_injector) + ariec61850_apply_sanitizers(ariec61850_sv_injector) endif() if(ARIEC61850_BUILD_TESTS) @@ -274,6 +280,9 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_pagination_probe_help COMMAND ariec61850_pagination_probe --help) + add_test( + NAME ariec61850_sv_injector_loopback_smoke + COMMAND ariec61850_sv_injector --frames 160 --json) endif() endif() From 92a9f938d2844e8c2376bf478d5cc4d016574e12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:16:45 +0700 Subject: [PATCH 043/188] Add deterministic injector to embedded regression profile --- embedded/CMakeLists.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index 1340e1abe..40581982c 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -104,6 +104,19 @@ target_compile_definitions(ariec61850_embedded_sv_publisher_smoke PRIVATE ARIEC61850_HOST_SERVICES=0) ariec61850_apply_embedded_flags(ariec61850_embedded_sv_publisher_smoke) +# Keep the deterministic waveform/scenario engine inside the same MCU boundary. +# This catches accidental host-service or floating-point-library dependencies +# before the engine is integrated into the ESP32-P4 firmware application. +add_executable(ariec61850_embedded_sv_injector_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_injector_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_injector_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_injector_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_injector_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_injector_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_injector_smoke) + # Produce a deterministic two-second classic-PCAP capture using only the # publisher/encoder path. A separate Python implementation verifies the complete # wire image byte-for-byte in CI, so encoder bugs cannot be hidden by the C++ @@ -121,6 +134,7 @@ ariec61850_apply_embedded_flags(ariec61850_embedded_sv_stream_pcap) enable_testing() add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_smoke) add_test(NAME ariec61850_embedded_sv_publisher_smoke COMMAND ariec61850_embedded_sv_publisher_smoke) +add_test(NAME ariec61850_embedded_sv_injector_smoke COMMAND ariec61850_embedded_sv_injector_smoke) add_test( NAME ariec61850_embedded_sv_stream_pcap COMMAND ariec61850_embedded_sv_stream_pcap "${CMAKE_BINARY_DIR}/sv_trial_2s.pcap") From a21d4796f8537ab51b71158f353d664c045305db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 22:19:56 +0700 Subject: [PATCH 044/188] Publish Windows deterministic SV injector artifact --- .github/workflows/cpp-ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index ccb9ee052..b0f205eac 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -66,3 +66,29 @@ jobs: python scripts/run-phase4c-physical-acceptance.py --help | Out-Null python -m py_compile scripts/run-phase4c-timeout-recovery.py python scripts/run-phase4c-timeout-recovery.py --help | Out-Null + - name: Build deterministic SV injector evidence bundle + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $out = 'sv-injector-artifact' + New-Item -ItemType Directory -Force -Path $out | Out-Null + $exe = 'build/Release/ariec61850_sv_injector.exe' + if (-not (Test-Path $exe)) { throw "Missing $exe" } + & $exe --capabilities | Out-File -Encoding utf8 "$out/capabilities.json" + & $exe --scenario normal --frames 8000 --pcap "$out/sv-loopback-normal-2s.pcap" --json | + Out-File -Encoding utf8 "$out/normal-2s-evidence.json" + if ($LASTEXITCODE -ne 0) { throw "Normal 8,000-frame injector evidence run failed: $LASTEXITCODE" } + & $exe --scenario protection-fault --json | + Out-File -Encoding utf8 "$out/protection-fault-evidence.json" + if ($LASTEXITCODE -ne 0) { throw "Protection-fault injector evidence run failed: $LASTEXITCODE" } + Copy-Item $exe "$out/ariec61850_sv_injector.exe" + Get-FileHash "$out/ariec61850_sv_injector.exe", "$out/sv-loopback-normal-2s.pcap", "$out/normal-2s-evidence.json", "$out/protection-fault-evidence.json", "$out/capabilities.json" -Algorithm SHA256 | + ForEach-Object { "{0} {1}" -f $_.Hash.ToLowerInvariant(), (Split-Path $_.Path -Leaf) } | + Out-File -Encoding ascii "$out/SHA256SUMS" + - name: Upload Windows SV injector + uses: actions/upload-artifact@v4 + with: + name: windows-sv-injector-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: sv-injector-artifact/ From c028692094b2252e0f1a56cf3e022906fc7c9685 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 02:38:43 +0700 Subject: [PATCH 045/188] Add runtime-loaded Npcap live Ethernet transport --- tools/npcap_live.hpp | 305 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 tools/npcap_live.hpp diff --git a/tools/npcap_live.hpp b/tools/npcap_live.hpp new file mode 100644 index 000000000..131e879cd --- /dev/null +++ b/tools/npcap_live.hpp @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif + +namespace ar::iec61850::tools { + +struct LiveEthernetAdapter final { + std::string name; + std::string description; +}; + +class NpcapLivePort final { +public: + NpcapLivePort() noexcept { +#ifdef _WIN32 + load(); +#else + error_ = "Npcap live mode is available only on Windows."; +#endif + } + + ~NpcapLivePort() { close(); unload(); } + + NpcapLivePort(const NpcapLivePort&) = delete; + NpcapLivePort& operator=(const NpcapLivePort&) = delete; + NpcapLivePort(NpcapLivePort&&) = delete; + NpcapLivePort& operator=(NpcapLivePort&&) = delete; + + [[nodiscard]] bool runtime_available() const noexcept { +#ifdef _WIN32 + return module_ != nullptr && find_all_devs_ != nullptr && + free_all_devs_ != nullptr && open_live_ != nullptr && + send_packet_ != nullptr && get_error_ != nullptr && close_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] bool opened() const noexcept { +#ifdef _WIN32 + return handle_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] const std::string& error() const noexcept { return error_; } + [[nodiscard]] const std::string& opened_name() const noexcept { return opened_name_; } + [[nodiscard]] const std::string& opened_description() const noexcept { + return opened_description_; + } + + [[nodiscard]] bool list_adapters(std::vector& adapters) { + adapters.clear(); +#ifdef _WIN32 + if (!runtime_available()) { + return false; + } + + char error_buffer[kErrorBufferSize]{}; + PcapIf* devices = nullptr; + if (find_all_devs_(&devices, error_buffer) != 0) { + error_ = error_buffer[0] == '\0' + ? "Npcap could not enumerate adapters." + : std::string{error_buffer}; + return false; + } + + for (auto* current = devices; current != nullptr; current = current->next) { + if (current->name == nullptr) { + continue; + } + adapters.push_back({ + current->name, + current->description == nullptr ? std::string{} : current->description}); + } + free_all_devs_(devices); + error_.clear(); + return true; +#else + return false; +#endif + } + + [[nodiscard]] bool open(const std::string_view selector) { +#ifdef _WIN32 + close(); + if (!runtime_available()) { + return false; + } + if (selector.empty()) { + error_ = "Live mode requires --interface NAME."; + return false; + } + + std::vector adapters; + if (!list_adapters(adapters)) { + return false; + } + + const auto selector_lower = lower(selector); + std::vector partial_matches; + const LiveEthernetAdapter* selected = nullptr; + for (const auto& adapter : adapters) { + if (adapter.name == selector) { + selected = &adapter; + break; + } + const auto name_lower = lower(adapter.name); + const auto description_lower = lower(adapter.description); + if (name_lower.find(selector_lower) != std::string::npos || + description_lower.find(selector_lower) != std::string::npos) { + partial_matches.push_back(&adapter); + } + } + + if (selected == nullptr) { + if (partial_matches.size() == 1U) { + selected = partial_matches.front(); + } else if (partial_matches.empty()) { + error_ = "No Npcap adapter matched --interface '" + + std::string{selector} + "'. Use --list-interfaces."; + return false; + } else { + error_ = "The --interface selector '" + std::string{selector} + + "' is ambiguous. Use the exact Npcap device name from --list-interfaces."; + return false; + } + } + + char error_buffer[kErrorBufferSize]{}; + handle_ = open_live_(selected->name.c_str(), 65'536, 1, 1, error_buffer); + if (handle_ == nullptr) { + error_ = error_buffer[0] == '\0' + ? "Npcap could not open the selected adapter." + : std::string{error_buffer}; + return false; + } + + opened_name_ = selected->name; + opened_description_ = selected->description; + error_.clear(); + return true; +#else + (void)selector; + return false; +#endif + } + + void close() noexcept { +#ifdef _WIN32 + if (handle_ != nullptr && close_ != nullptr) { + close_(handle_); + } + handle_ = nullptr; +#endif + opened_name_.clear(); + opened_description_.clear(); + } + + [[nodiscard]] embedded::RawEthernetPort raw_port() noexcept { + return {this, &transmit_callback}; + } + +private: +#ifdef _WIN32 + struct Pcap; + struct PcapAddr; + struct PcapIf final { + PcapIf* next; + char* name; + char* description; + PcapAddr* addresses; + unsigned int flags; + }; + + static constexpr std::size_t kErrorBufferSize = 256U; + + using FindAllDevs = int(__cdecl*)(PcapIf**, char*); + using FreeAllDevs = void(__cdecl*)(PcapIf*); + using OpenLive = Pcap*(__cdecl*)(const char*, int, int, int, char*); + using SendPacket = int(__cdecl*)(Pcap*, const unsigned char*, int); + using GetError = const char*(__cdecl*)(Pcap*); + using Close = void(__cdecl*)(Pcap*); + + template + [[nodiscard]] Function symbol(const char* name) noexcept { + const auto raw = GetProcAddress(module_, name); + return std::bit_cast(raw); + } + + void load() noexcept { + module_ = LoadLibraryA("wpcap.dll"); + if (module_ == nullptr) { + error_ = "wpcap.dll was not found. Install Npcap, then retry."; + return; + } + + find_all_devs_ = symbol("pcap_findalldevs"); + free_all_devs_ = symbol("pcap_freealldevs"); + open_live_ = symbol("pcap_open_live"); + send_packet_ = symbol("pcap_sendpacket"); + get_error_ = symbol("pcap_geterr"); + close_ = symbol("pcap_close"); + + if (!runtime_available()) { + error_ = "wpcap.dll is present but required Npcap symbols are missing."; + unload(); + return; + } + error_.clear(); + } + + void unload() noexcept { + if (module_ != nullptr) { + FreeLibrary(module_); + } + module_ = nullptr; + find_all_devs_ = nullptr; + free_all_devs_ = nullptr; + open_live_ = nullptr; + send_packet_ = nullptr; + get_error_ = nullptr; + close_ = nullptr; + } + + [[nodiscard]] static std::string lower(const std::string_view value) { + std::string result{value}; + std::transform( + result.begin(), result.end(), result.begin(), + [](const char character) { + return static_cast( + std::tolower(static_cast(character))); + }); + return result; + } +#endif + + static embedded::IoResult transmit_callback( + void* context, + const std::span bytes) noexcept { + auto* self = static_cast(context); + if (self == nullptr || bytes.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } +#ifdef _WIN32 + if (!self->opened() || self->send_packet_ == nullptr || + bytes.size() > static_cast(std::numeric_limits::max())) { + return {embedded::IoStatus::io_error, 0U}; + } + + const auto result = self->send_packet_( + self->handle_, bytes.data(), static_cast(bytes.size())); + if (result != 0) { + const auto* message = self->get_error_ == nullptr + ? nullptr + : self->get_error_(self->handle_); + self->error_ = message == nullptr || message[0] == '\0' + ? "Npcap pcap_sendpacket failed." + : std::string{message}; + return {embedded::IoStatus::io_error, 0U}; + } + return {embedded::IoStatus::ok, bytes.size()}; +#else + (void)bytes; + return {embedded::IoStatus::not_supported, 0U}; +#endif + } + + std::string error_; + std::string opened_name_; + std::string opened_description_; + +#ifdef _WIN32 + HMODULE module_{}; + Pcap* handle_{}; + FindAllDevs find_all_devs_{}; + FreeAllDevs free_all_devs_{}; + OpenLive open_live_{}; + SendPacket send_packet_{}; + GetError get_error_{}; + Close close_{}; +#endif +}; + +} // namespace ar::iec61850::tools From 585996b5176da36070bbd6a4b34aff0a84f08496 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 02:39:21 +0700 Subject: [PATCH 046/188] Fix portable Npcap stub status --- tools/npcap_live.hpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/npcap_live.hpp b/tools/npcap_live.hpp index 131e879cd..3d0d1f342 100644 --- a/tools/npcap_live.hpp +++ b/tools/npcap_live.hpp @@ -281,8 +281,7 @@ class NpcapLivePort final { } return {embedded::IoStatus::ok, bytes.size()}; #else - (void)bytes; - return {embedded::IoStatus::not_supported, 0U}; + return {embedded::IoStatus::io_error, 0U}; #endif } From dff17d85501fe2e453ff957a5871e288ceb03ce1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 02:40:55 +0700 Subject: [PATCH 047/188] Add continuous real-time Npcap SV live mode --- tools/sv_injector.cpp | 589 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 482 insertions(+), 107 deletions(-) diff --git a/tools/sv_injector.cpp b/tools/sv_injector.cpp index d21e35b64..64151d1bc 100644 --- a/tools/sv_injector.cpp +++ b/tools/sv_injector.cpp @@ -9,14 +9,17 @@ #include "ariec61850/sampled_values/injector_presets.hpp" #include "ariec61850/sampled_values/payload_writer.hpp" #include "ariec61850/sampled_values/publisher.hpp" +#include "npcap_live.hpp" #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -24,6 +27,7 @@ #include #include #include +#include #include namespace { @@ -43,11 +47,28 @@ const ethernet::MacAddress kDestination{ const ethernet::MacAddress kSource{ std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}}; +volatile std::sig_atomic_t gStopRequested = 0; + +void handle_interrupt(const int signal) noexcept { + if (signal == SIGINT) { + gStopRequested = 1; + } +} + +enum class RunMode : std::uint8_t { + loopback, + live, +}; + struct Options final { + RunMode mode{RunMode::loopback}; std::string scenario{"normal"}; std::optional frames; std::uint64_t drop_every{}; std::string pcap_path; + std::string interface_selector; + bool continuous{}; + bool list_interfaces{}; bool json{}; bool capabilities{}; bool help{}; @@ -81,6 +102,10 @@ struct LoopbackContext final { capture::PcapWriter* pcap_writer{}; }; +[[nodiscard]] std::string_view mode_name(const RunMode mode) noexcept { + return mode == RunMode::live ? "live" : "loopback"; +} + [[nodiscard]] std::uint64_t parse_bounded_u64( const std::string_view option, const std::string& value, @@ -96,6 +121,16 @@ struct LoopbackContext final { return static_cast(parsed); } +[[nodiscard]] RunMode parse_mode(const std::string_view value) { + if (value == "loopback") { + return RunMode::loopback; + } + if (value == "live") { + return RunMode::live; + } + throw std::invalid_argument("--mode must be loopback or live."); +} + [[nodiscard]] Options parse_options(const int argc, char** argv) { Options options; for (int index = 1; index < argc; ++index) { @@ -108,7 +143,9 @@ struct LoopbackContext final { return argv[index]; }; - if (argument == "--scenario") { + if (argument == "--mode") { + options.mode = parse_mode(require_value("--mode")); + } else if (argument == "--scenario") { options.scenario = require_value("--scenario"); } else if (argument == "--frames") { options.frames = parse_bounded_u64( @@ -122,6 +159,12 @@ struct LoopbackContext final { } } else if (argument == "--pcap") { options.pcap_path = require_value("--pcap"); + } else if (argument == "--interface") { + options.interface_selector = require_value("--interface"); + } else if (argument == "--continuous") { + options.continuous = true; + } else if (argument == "--list-interfaces") { + options.list_interfaces = true; } else if (argument == "--json") { options.json = true; } else if (argument == "--capabilities") { @@ -132,22 +175,46 @@ struct LoopbackContext final { throw std::invalid_argument("Unknown option: " + argument); } } + + if (options.continuous && options.frames.has_value()) { + throw std::invalid_argument("--continuous and --frames are mutually exclusive."); + } + if (options.mode == RunMode::loopback && options.continuous) { + throw std::invalid_argument( + "--continuous is reserved for --mode live; loopback remains finite for regression safety."); + } + if (options.mode == RunMode::loopback && !options.interface_selector.empty()) { + throw std::invalid_argument("--interface requires --mode live."); + } + if (options.mode == RunMode::live && options.drop_every != 0U) { + throw std::invalid_argument( + "--drop-every currently applies only to software loopback."); + } return options; } void print_usage() { std::cout << "Usage: ariec61850_sv_injector [options]\n" - << "\nDeterministic Sampled Values software-loopback injector.\n" - << "No ESP32, Npcap, administrator privilege, or physical NIC is required.\n\n" + << "\nDeterministic IEC 61850 Sampled Values injector.\n" + << "Loopback is the default reference/oracle mode. Windows live mode uses\n" + << "Npcap to transmit raw SV Ethernet frames on a selected NIC.\n\n" << "Options:\n" - << " --scenario NAME normal | protection-fault (default normal).\n" - << " --frames N Override generated frame count.\n" - << " --drop-every N Simulate one downstream loss every N TX frames (0=off).\n" - << " --pcap PATH Write received loopback frames to classic PCAP.\n" - << " --json Emit machine-readable evidence.\n" - << " --capabilities Emit the app/device control capability contract.\n" - << " --help Show this help.\n"; + << " --mode MODE loopback | live (default loopback).\n" + << " --scenario NAME normal | protection-fault (default normal).\n" + << " --frames N Finite frame count; defaults to scenario length.\n" + << " --continuous Live mode: transmit until Ctrl+C.\n" + << " --interface NAME Live mode: exact or unique Npcap adapter selector.\n" + << " --list-interfaces List Npcap adapters and exit.\n" + << " --drop-every N Loopback: downstream loss every N TX frames (0=off).\n" + << " --pcap PATH Save observed loopback or transmitted live frames.\n" + << " --json Emit machine-readable final evidence.\n" + << " --capabilities Emit the app/device control capability contract.\n" + << " --help Show this help.\n\n" + << "Live examples:\n" + << " ariec61850_sv_injector --list-interfaces\n" + << " ariec61850_sv_injector --mode live --interface \"Ethernet\" --frames 8000\n" + << " ariec61850_sv_injector --mode live --interface \"Ethernet\" --continuous\n"; } void print_capabilities() { @@ -155,7 +222,12 @@ void print_capabilities() { << "{\n" << " \"schemaVersion\": \"arstack-sv-injector-control-v1\",\n" << " \"engine\": \"sample-index-fixed-point\",\n" +#ifdef _WIN32 + << " \"modes\": [\"software-loopback\", \"windows-npcap-live\"],\n" +#else << " \"modes\": [\"software-loopback\"],\n" +#endif + << " \"liveTiming\": \"best-effort-no-catch-up\",\n" << " \"scenarios\": [\"normal\", \"protection-fault\"],\n" << " \"commandsPlannedForDeviceTransport\": " "[\"capabilities\", \"configure\", \"arm\", \"start\", \"stop\", \"status\", \"stats\"],\n" @@ -242,8 +314,6 @@ embedded::IoResult loopback_transmit( if (loopback->drop_every > 0U && (loopback->evidence.tx_frames % loopback->drop_every) == 0U) { ++loopback->evidence.injected_drops; - // The producer/TX path succeeded; the loss is injected after TX so the - // logical sample timeline continues and the receiver observes a gap. return {embedded::IoStatus::ok, bytes.size()}; } @@ -323,7 +393,29 @@ embedded::IoResult loopback_transmit( return true; } -void print_human( +[[nodiscard]] std::uint64_t monotonic_us() noexcept { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); +} + +void wait_until_monotonic_us(const std::uint64_t deadline_us) { + while (gStopRequested == 0) { + const auto now = monotonic_us(); + if (now >= deadline_us) { + return; + } + const auto remaining = deadline_us - now; + if (remaining > 2'000U) { + std::this_thread::sleep_for( + std::chrono::microseconds{static_cast(remaining - 1'000U)}); + } else if (remaining > 100U) { + std::this_thread::yield(); + } + } +} + +void print_loopback_human( const Options& options, const std::uint64_t frames, const LoopbackEvidence& evidence, @@ -355,7 +447,7 @@ void print_human( << (passed ? "LOOPBACK/PASSED" : "LOOPBACK/FAILED") << '\n'; } -void print_json( +void print_loopback_json( const Options& options, const std::uint64_t frames, const LoopbackEvidence& evidence, @@ -386,120 +478,403 @@ void print_json( << "}\n"; } -} // namespace +[[nodiscard]] int run_loopback(const Options& options) { + auto scenario = make_scenario(options.scenario); + const auto frames = options.frames.value_or(scenario.default_frames); + sampled_values::DeterministicSvInjector injector( + scenario.segments, kSampleRateHz, scenario.loop); + if (!injector.valid()) { + throw std::runtime_error("Deterministic injector configuration is invalid."); + } -int main(const int argc, char** argv) { - try { - const auto options = parse_options(argc, argv); - if (options.help) { - print_usage(); - return 0; + std::ofstream pcap_output; + std::optional pcap_writer; + if (!options.pcap_path.empty()) { + pcap_output.open(options.pcap_path, std::ios::binary | std::ios::trunc); + if (!pcap_output) { + throw std::runtime_error("Cannot open PCAP output: " + options.pcap_path); } - if (options.capabilities) { - print_capabilities(); - return 0; + pcap_writer.emplace(pcap_output); + } + + LoopbackContext loopback; + loopback.drop_every = options.drop_every; + loopback.pcap_writer = pcap_writer.has_value() ? &*pcap_writer : nullptr; + embedded::RawEthernetPort port{&loopback, &loopback_transmit}; + + auto frame = make_frame(); + std::array frame_buffer{}; + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + if (!publisher.valid()) { + throw std::runtime_error("SampledValuesPublisher configuration is invalid."); + } + + sampled_values::InjectorSample sample{}; + for (std::uint64_t index = 0U; index < frames; ++index) { + if (!injector.step(sample)) { + throw std::runtime_error("Scenario finished before requested frame count."); + } + if (sample.sample_index != index) { + throw std::runtime_error("Injector sample-index timeline diverged."); } - auto scenario = make_scenario(options.scenario); - const auto frames = options.frames.value_or(scenario.default_frames); - sampled_values::DeterministicSvInjector injector( - scenario.segments, kSampleRateHz, scenario.loop); - if (!injector.valid()) { - throw std::runtime_error("Deterministic injector configuration is invalid."); + auto& asdu = frame.pdu.asdus.front(); + if (!write_payload(asdu, sample)) { + throw std::runtime_error("Could not build INT32+quality SV payload."); } - std::ofstream pcap_output; - std::optional pcap_writer; - if (!options.pcap_path.empty()) { - pcap_output.open(options.pcap_path, std::ios::binary | std::ios::trunc); - if (!pcap_output) { - throw std::runtime_error("Cannot open PCAP output: " + options.pcap_path); - } - pcap_writer.emplace(pcap_output); + loopback.expected_sample_count = static_cast( + sample.sample_index % static_cast(kSampleCountWrap)); + std::copy( + asdu.sample_payload.begin(), + asdu.sample_payload.end(), + loopback.expected_payload.begin()); + loopback.virtual_timestamp_us = + kVirtualStartUs + sample.sample_index * kIntervalUs; + + const auto result = publisher.poll(loopback.virtual_timestamp_us); + if (!result.sent() || + result.sample_count != loopback.expected_sample_count) { + throw std::runtime_error("Publisher did not emit the expected virtual sample."); } + } - LoopbackContext loopback; - loopback.drop_every = options.drop_every; - loopback.pcap_writer = pcap_writer.has_value() ? &*pcap_writer : nullptr; - embedded::RawEthernetPort port{&loopback, &loopback_transmit}; - - auto frame = make_frame(); - std::array frame_buffer{}; - sampled_values::SampledValuesPublisher publisher( - frame, - frame_buffer, - port, - sampled_values::SampledValuesPublisherConfig{ - kSampleRateHz, - std::uint16_t{kSampleCountWrap}, - 0U, - true}); - if (!publisher.valid()) { - throw std::runtime_error("SampledValuesPublisher configuration is invalid."); + if (pcap_output.is_open()) { + pcap_output.flush(); + if (!pcap_output) { + ++loopback.evidence.pcap_errors; } + } - sampled_values::InjectorSample sample{}; - for (std::uint64_t index = 0U; index < frames; ++index) { - if (!injector.step(sample)) { - throw std::runtime_error("Scenario finished before requested frame count."); - } - if (sample.sample_index != index) { - throw std::runtime_error("Injector sample-index timeline diverged."); + const auto expected_rx = frames - loopback.evidence.injected_drops; + const auto expected_visible_gaps = options.drop_every == 0U + ? 0U + : (frames - 1U) / options.drop_every; + const auto& statistics = publisher.statistics(); + const auto passed = + loopback.evidence.tx_frames == frames && + loopback.evidence.rx_frames == expected_rx && + loopback.evidence.decode_errors == 0U && + loopback.evidence.identity_errors == 0U && + loopback.evidence.payload_errors == 0U && + loopback.evidence.sample_count_errors == 0U && + loopback.evidence.gap_events == expected_visible_gaps && + loopback.evidence.pcap_errors == 0U && + statistics.frames_sent == frames && + statistics.encode_failures == 0U && + statistics.transmit_failures == 0U && + statistics.late_polls == 0U; + + if (options.json) { + print_loopback_json(options, frames, loopback.evidence, statistics, passed); + } else { + print_loopback_human(options, frames, loopback.evidence, statistics, passed); + } + return passed ? 0 : 2; +} + +void print_adapters() { + tools::NpcapLivePort live; + std::vector adapters; + if (!live.list_adapters(adapters)) { + throw std::runtime_error(live.error()); + } + + std::cout << "Npcap adapters:\n"; + for (std::size_t index = 0U; index < adapters.size(); ++index) { + const auto& adapter = adapters[index]; + std::cout << '[' << index << "] " + << (adapter.description.empty() ? "(no description)" : adapter.description) + << '\n' + << " " << adapter.name << '\n'; + } + if (adapters.empty()) { + std::cout << "(none)\n"; + } +} + +void print_live_human( + const Options& options, + const tools::NpcapLivePort& live, + const std::uint64_t frames_requested, + const sampled_values::SampledValuesPublisherStatistics& statistics, + const double elapsed_seconds, + const bool passed) { + const auto rate = elapsed_seconds > 0.0 + ? static_cast(statistics.frames_sent) / elapsed_seconds + : 0.0; + + std::cout + << "\nARStack61850 deterministic SV injector\n\n" + << "Mode : LIVE ETHERNET / NPCAP\n" + << "Scenario : " << options.scenario << '\n' + << "Engine : sample-index fixed-point\n" + << "Timing : real-time best-effort, no catch-up bursts\n" + << "Sample rate goal : " << kSampleRateHz << " Hz\n" + << "Interval goal : " << kIntervalUs << " us\n" + << "Adapter : " << live.opened_name() << '\n'; + if (!live.opened_description().empty()) { + std::cout << "Adapter desc : " << live.opened_description() << '\n'; + } + if (options.continuous) { + std::cout << "Run length : continuous until Ctrl+C\n"; + } else { + std::cout << "Frames requested : " << frames_requested << '\n'; + } + std::cout + << "\nTX frames : " << statistics.frames_sent << '\n' + << "Encode failures : " << statistics.encode_failures << '\n' + << "TX failures : " << statistics.transmit_failures << '\n' + << "Late polls : " << statistics.late_polls << '\n' + << "Max lateness : " << statistics.maximum_lateness_us << " us\n" + << "Elapsed : " << std::fixed << std::setprecision(3) + << elapsed_seconds << " s\n" + << "Observed TX rate : " << std::fixed << std::setprecision(1) + << rate << " frames/s\n"; + if (!options.pcap_path.empty()) { + std::cout << "TX PCAP : " << options.pcap_path << '\n'; + } + std::cout + << "External verify : subscriber/Wireshark (eth.type == 0x88ba)\n" + << "\nRESULT : " + << (passed + ? (options.continuous ? "LIVE/STOPPED" : "LIVE/PASSED") + : "LIVE/FAILED") + << '\n'; +} + +void print_live_json( + const Options& options, + const tools::NpcapLivePort& live, + const std::uint64_t frames_requested, + const sampled_values::SampledValuesPublisherStatistics& statistics, + const double elapsed_seconds, + const bool passed) { + const auto rate = elapsed_seconds > 0.0 + ? static_cast(statistics.frames_sent) / elapsed_seconds + : 0.0; + std::cout + << '{' + << "\"schemaVersion\":\"arstack-sv-injector-live-evidence-v1\"," + << "\"status\":\"" + << (passed + ? (options.continuous ? "LIVE/STOPPED" : "LIVE/PASSED") + : "LIVE/FAILED") + << "\"," + << "\"mode\":\"windows-npcap-live\"," + << "\"scenario\":\"" << options.scenario << "\"," + << "\"engine\":\"sample-index-fixed-point\"," + << "\"timing\":\"best-effort-no-catch-up\"," + << "\"sampleRateHz\":" << kSampleRateHz << ',' + << "\"intervalUs\":" << kIntervalUs << ',' + << "\"continuous\":" << (options.continuous ? "true" : "false") << ',' + << "\"framesRequested\":" << frames_requested << ',' + << "\"txFrames\":" << statistics.frames_sent << ',' + << "\"encodeFailures\":" << statistics.encode_failures << ',' + << "\"transmitFailures\":" << statistics.transmit_failures << ',' + << "\"latePolls\":" << statistics.late_polls << ',' + << "\"maximumLatenessUs\":" << statistics.maximum_lateness_us << ',' + << "\"elapsedSeconds\":" << std::fixed << std::setprecision(6) + << elapsed_seconds << ',' + << "\"observedTxRate\":" << std::fixed << std::setprecision(3) << rate << ',' + << "\"adapter\":\"" << live.opened_name() << "\"" + << "}\n"; +} + +[[nodiscard]] int run_live(const Options& options) { +#ifndef _WIN32 + (void)options; + throw std::runtime_error("Live Npcap mode is available only on Windows."); +#else + if (options.interface_selector.empty()) { + throw std::invalid_argument( + "--mode live requires --interface NAME. Use --list-interfaces first."); + } + + tools::NpcapLivePort live; + if (!live.open(options.interface_selector)) { + throw std::runtime_error(live.error()); + } + + auto scenario = make_scenario(options.scenario); + const auto frames_requested = options.continuous + ? 0U + : options.frames.value_or(scenario.default_frames); + sampled_values::DeterministicSvInjector injector( + scenario.segments, kSampleRateHz, scenario.loop); + if (!injector.valid()) { + throw std::runtime_error("Deterministic injector configuration is invalid."); + } + + std::ofstream pcap_output; + std::optional pcap_writer; + if (!options.pcap_path.empty()) { + pcap_output.open(options.pcap_path, std::ios::binary | std::ios::trunc); + if (!pcap_output) { + throw std::runtime_error("Cannot open PCAP output: " + options.pcap_path); + } + pcap_writer.emplace(pcap_output); + } + + auto frame = make_frame(); + std::array frame_buffer{}; + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + live.raw_port(), + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + if (!publisher.valid()) { + throw std::runtime_error("SampledValuesPublisher configuration is invalid."); + } + + if (!options.json) { + std::cout + << "Starting live SV transmission on:\n " << live.opened_name() << '\n'; + if (!live.opened_description().empty()) { + std::cout << " " << live.opened_description() << '\n'; + } + std::cout + << "Profile: 50 Hz, 4000 samples/s, APPID 0x4001, dst 01:0C:CD:04:00:01\n"; + if (options.continuous) { + std::cout << "Press Ctrl+C to stop cleanly.\n"; + } + } + + gStopRequested = 0; + const auto previous_handler = std::signal(SIGINT, handle_interrupt); + const auto started_at = std::chrono::steady_clock::now(); + + sampled_values::InjectorSample sample{}; + std::uint64_t index = 0U; + while (gStopRequested == 0 && + (options.continuous || index < frames_requested)) { + if (publisher.started()) { + wait_until_monotonic_us(publisher.next_due_us()); + if (gStopRequested != 0) { + break; } + } + + if (!injector.step(sample)) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Scenario finished before requested frame count."); + } + if (sample.sample_index != index) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Injector sample-index timeline diverged."); + } - auto& asdu = frame.pdu.asdus.front(); - if (!write_payload(asdu, sample)) { - throw std::runtime_error("Could not build INT32+quality SV payload."); + auto& asdu = frame.pdu.asdus.front(); + if (!write_payload(asdu, sample)) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Could not build INT32+quality SV payload."); + } + + sampled_values::SampledValuesPublishResult result{}; + do { + const auto now_us = monotonic_us(); + result = publisher.poll(now_us); + if (result.status == sampled_values::SampledValuesPublishStatus::not_due) { + wait_until_monotonic_us(result.next_due_us); } + } while (result.status == sampled_values::SampledValuesPublishStatus::not_due && + gStopRequested == 0); - loopback.expected_sample_count = static_cast( - sample.sample_index % static_cast(kSampleCountWrap)); - std::copy( - asdu.sample_payload.begin(), - asdu.sample_payload.end(), - loopback.expected_payload.begin()); - loopback.virtual_timestamp_us = - kVirtualStartUs + sample.sample_index * kIntervalUs; - - const auto result = publisher.poll(loopback.virtual_timestamp_us); - if (!result.sent() || - result.sample_count != loopback.expected_sample_count) { - throw std::runtime_error("Publisher did not emit the expected virtual sample."); + if (gStopRequested != 0) { + break; + } + if (!result.sent()) { + std::signal(SIGINT, previous_handler); + if (result.status == sampled_values::SampledValuesPublishStatus::transmit_failed) { + throw std::runtime_error( + "Npcap transmit failed: " + + (live.error().empty() ? std::string{"unknown error"} : live.error())); } + throw std::runtime_error("Live publisher failed to encode/transmit a frame."); + } + + const auto expected_sample_count = static_cast( + sample.sample_index % static_cast(kSampleCountWrap)); + if (result.sample_count != expected_sample_count) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Live publisher smpCnt diverged from logical sample index."); } - if (pcap_output.is_open()) { - pcap_output.flush(); - if (!pcap_output) { - ++loopback.evidence.pcap_errors; + if (pcap_writer.has_value()) { + try { + pcap_writer->write_packet( + std::chrono::system_clock::now(), + std::span{frame_buffer.data(), result.frame_bytes}); + } catch (...) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Could not write live TX PCAP evidence."); } } - const auto expected_rx = frames - loopback.evidence.injected_drops; - const auto expected_visible_gaps = options.drop_every == 0U - ? 0U - : (frames - 1U) / options.drop_every; - const auto& statistics = publisher.statistics(); - const auto passed = - loopback.evidence.tx_frames == frames && - loopback.evidence.rx_frames == expected_rx && - loopback.evidence.decode_errors == 0U && - loopback.evidence.identity_errors == 0U && - loopback.evidence.payload_errors == 0U && - loopback.evidence.sample_count_errors == 0U && - loopback.evidence.gap_events == expected_visible_gaps && - loopback.evidence.pcap_errors == 0U && - statistics.frames_sent == frames && - statistics.encode_failures == 0U && - statistics.transmit_failures == 0U && - statistics.late_polls == 0U; - - if (options.json) { - print_json(options, frames, loopback.evidence, statistics, passed); - } else { - print_human(options, frames, loopback.evidence, statistics, passed); + ++index; + } + + const auto stopped_at = std::chrono::steady_clock::now(); + std::signal(SIGINT, previous_handler); + + if (pcap_output.is_open()) { + pcap_output.flush(); + if (!pcap_output) { + throw std::runtime_error("Could not flush live TX PCAP evidence."); + } + } + + const auto elapsed_seconds = + std::chrono::duration(stopped_at - started_at).count(); + const auto& statistics = publisher.statistics(); + const auto finite_complete = options.continuous || statistics.frames_sent == frames_requested; + const auto passed = finite_complete && + statistics.encode_failures == 0U && + statistics.transmit_failures == 0U; + + if (options.json) { + print_live_json( + options, live, frames_requested, statistics, elapsed_seconds, passed); + } else { + print_live_human( + options, live, frames_requested, statistics, elapsed_seconds, passed); + } + return passed ? 0 : 2; +#endif +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.help) { + print_usage(); + return 0; + } + if (options.capabilities) { + print_capabilities(); + return 0; + } + if (options.list_interfaces) { + print_adapters(); + return 0; } - return passed ? 0 : 2; + return options.mode == RunMode::live + ? run_live(options) + : run_loopback(options); } catch (const std::exception& error) { std::cerr << "ariec61850_sv_injector: " << error.what() << '\n'; return 1; From 2393e612338f7ce9380ca1896443c604a4d528cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 02:41:35 +0700 Subject: [PATCH 048/188] Keep Npcap wrapper portable on non-Windows builds --- tools/npcap_live.hpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/npcap_live.hpp b/tools/npcap_live.hpp index 3d0d1f342..c2d9de2a9 100644 --- a/tools/npcap_live.hpp +++ b/tools/npcap_live.hpp @@ -38,7 +38,12 @@ class NpcapLivePort final { #endif } - ~NpcapLivePort() { close(); unload(); } + ~NpcapLivePort() { + close(); +#ifdef _WIN32 + unload(); +#endif + } NpcapLivePort(const NpcapLivePort&) = delete; NpcapLivePort& operator=(const NpcapLivePort&) = delete; From 0064fe22fe1aff4c9bb9b3fae643aa8ece2fafd7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 02:43:44 +0700 Subject: [PATCH 049/188] Remove unused live-mode helper before strict CI --- tools/sv_injector.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tools/sv_injector.cpp b/tools/sv_injector.cpp index 64151d1bc..d965b17b9 100644 --- a/tools/sv_injector.cpp +++ b/tools/sv_injector.cpp @@ -102,10 +102,6 @@ struct LoopbackContext final { capture::PcapWriter* pcap_writer{}; }; -[[nodiscard]] std::string_view mode_name(const RunMode mode) noexcept { - return mode == RunMode::live ? "live" : "loopback"; -} - [[nodiscard]] std::uint64_t parse_bounded_u64( const std::string_view option, const std::string& value, From 4060d94737cd39d2edb2a2cf50a5f3653d4787be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 02:44:28 +0700 Subject: [PATCH 050/188] Document Windows continuous Npcap SV live mode --- docs/SV_INJECTOR_CONTROL.md | 122 ++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 13 deletions(-) diff --git a/docs/SV_INJECTOR_CONTROL.md b/docs/SV_INJECTOR_CONTROL.md index 65607c070..cecfd3753 100644 --- a/docs/SV_INJECTOR_CONTROL.md +++ b/docs/SV_INJECTOR_CONTROL.md @@ -20,10 +20,10 @@ logical sample index -----> fixed-point waveform engine v v sample counter -------------> SV frame encoder | - +-------------+-------------+ - | | - software loopback ESP32-P4 EMAC - / PCAP reference physical injector + +-------------------+-------------------+ + | | | + software loopback Windows Npcap live ESP32-P4 EMAC + / PCAP reference physical NIC physical injector ``` The scheduler answers **when** to attempt a frame. The deterministic engine answers **which sample** and **what values** belong to that logical instant. Keeping those responsibilities separate is required for reproducible PC-vs-MCU comparison and controlled loss/jitter testing. @@ -36,7 +36,7 @@ Schema identifier: arstack-sv-injector-control-v1 ``` -The first Windows CLI exposes the capability contract with: +The Windows CLI exposes the capability contract with: ```powershell ariec61850_sv_injector --capabilities @@ -75,16 +75,16 @@ The waveform engine uses a fixed-point phase accumulator, fractional phase remai ## Windows reference CLI -First executable: +Executable: ```text ariec61850_sv_injector ``` -Examples: +### Software loopback / oracle ```powershell -# Two deterministic seconds at 4 kHz. +# Two deterministic virtual seconds at 4 kHz. .\ariec61850_sv_injector.exe --scenario normal --frames 8000 # Protection-style prefault / fault / recovery sequence. @@ -100,15 +100,95 @@ Examples: .\ariec61850_sv_injector.exe --frames 8000 --json ``` -The initial `software-loopback` transport does not need Npcap, administrator rights, a physical NIC or an ESP32. The encoded raw Ethernet frame is synchronously passed through `RawEthernetPort`, decoded again, and checked for stream identity, payload equality and sample-counter continuity. +The `software-loopback` transport does not need Npcap, administrator rights, a physical NIC or an ESP32. The encoded raw Ethernet frame is synchronously passed through `RawEthernetPort`, decoded again, and checked for stream identity, payload equality and sample-counter continuity. `--drop-every` injects loss **after** the producer transport reports success. This deliberately leaves the logical sample timeline advancing so the receiver must observe a `smpCnt` gap rather than a repeated old sample. +Loopback deliberately remains finite. `--continuous` is rejected in loopback mode so accidental endless CI/regression runs cannot occur. + +### Windows live Ethernet / Npcap + +Live mode loads `wpcap.dll` at runtime, so the executable still builds without the Npcap SDK. Npcap must be installed on the Windows machine that performs live transmission. + +First list the adapters visible to Npcap: + +```powershell +.\ariec61850_sv_injector.exe --list-interfaces +``` + +Then run a finite live test by using either the exact Npcap device name or a unique substring from its description: + +```powershell +.\ariec61850_sv_injector.exe ` + --mode live ` + --interface "Ethernet" ` + --scenario normal ` + --frames 8000 +``` + +For a subscriber/analyzer test, run continuously until `Ctrl+C`: + +```powershell +.\ariec61850_sv_injector.exe ` + --mode live ` + --interface "Ethernet" ` + --scenario normal ` + --continuous +``` + +The live path is: + +```text +DeterministicSvInjector + | + v +INT32 + quality payload + | + v +SampledValuesPublisher + | + v +Npcap pcap_sendpacket + | + v +Windows physical NIC + | + +----> external SV subscriber + +----> Wireshark +``` + +The intended Wireshark display filter is: + +```text +eth.type == 0x88ba +``` + +The first live stream identity is: + +- destination MAC `01:0C:CD:04:00:01`; +- source MAC `02:00:00:00:00:02`; +- APPID `0x4001`; +- `svID = ARSTACK61850_INJECTOR`; +- dataset `ARSTACK61850/LLN0$PhsMeas1`; +- one ASDU per frame; +- 4,000 frames/s target; +- 250 us nominal frame interval; +- `smpCnt` 0..3999 and wrap; +- 4I+4V INT32 + quality payload. + +Live mode uses the real monotonic Windows clock and `SampledValuesPublisher` no-catch-up pacing. If a wake-up is late, it emits at most one frame and anchors the next 250 us deadline to the actual attempt time; it does **not** burst several overdue packets back-to-back. Final telemetry reports late polls, maximum lateness and observed TX rate. + +This is intentionally described as **best-effort real-time**, not deterministic Windows timing. The deterministic claim applies to the logical sample/waveform sequence. Physical NIC timing must be measured externally. + +`--pcap PATH` can also save the transmitted live frames with wall-clock timestamps, but writing evidence on the same process can perturb host timing. For timing characterization, prefer an independent Wireshark/Npcap capture on the receiving side. + +Depending on how Npcap was installed, packet injection may require an elevated terminal. If `--list-interfaces` works but `pcap_sendpacket` is rejected, retry from an Administrator PowerShell and inspect the returned Npcap error. + ## Built-in scenarios in the first slice ### `normal` -Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. The CLI defaults to 8,000 frames (two virtual seconds). +Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. The CLI defaults to 8,000 frames (two virtual seconds in loopback; approximately two wall-clock seconds in a healthy live run). ### `protection-fault` @@ -118,11 +198,25 @@ A deterministic 4,800-sample sequence: 2. fault: 800 samples / 0.2 s, phase currents 4x and phase voltages 0.25x; 3. recovery: 2,000 samples / 0.5 s. -The first preset is a regression scenario, not a calibrated secondary-injection claim. +The scenario loops when live `--continuous` is selected. The first preset is a regression/lab scenario, not a calibrated secondary-injection claim. + +## PC subscriber acceptance + +Before replacing the PC publisher with ESP32-P4, the recommended interoperability gate is: + +1. start the subscriber/analyzer on the selected Ethernet adapter; +2. start `ariec61850_sv_injector --mode live ... --continuous`; +3. confirm the subscriber sees APPID `0x4001`, `svID`, dataset and 4I+4V payload; +4. confirm `smpCnt` continuity and wrap; +5. confirm Wireshark sees EtherType `0x88BA` and approximately 4,000 frames/s; +6. stop the injector with `Ctrl+C` and retain its lateness/rate telemetry; +7. use this PC publisher result as the behavioral oracle for the later ESP32-P4 stream. + +A live transmitter success result proves that Npcap accepted the frames. It does not by itself prove that a remote subscriber received every packet; receive-side evidence remains required. ## Next source modes -Once the fixed-point engine and software-loopback gate are green on Windows, GCC, Clang and the embedded profile, add sources in controlled slices: +Once the fixed-point engine and live/loopback gates are green, add sources in controlled slices: 1. explicit per-channel manual phasors; 2. deterministic ramp/state sequencer; @@ -138,7 +232,7 @@ COMTRADE playback must retain the original record/sample mapping independently f ## ESP32-P4 integration rule -The current ESP32-P4 firmware still uses its first synthetic payload generator. It should adopt `DeterministicSvInjector` only after the host and embedded regression gates accept the new engine. +The current ESP32-P4 firmware still uses its first synthetic payload generator. It should adopt `DeterministicSvInjector` after the host/live regression gates accept the new engine and transport behavior. The P4 application will then own only: @@ -155,6 +249,8 @@ The scenario/waveform engine, payload semantics and SV wire codec remain shared Do not conflate these results: - `LOOPBACK/PASSED` — deterministic logical stream and software receive path accepted; +- `LIVE/PASSED` — Windows/Npcap accepted a finite real-NIC transmit run; external receive evidence is still separate; +- `LIVE/STOPPED` — continuous Windows/Npcap run stopped cleanly with `Ctrl+C` and no local encode/TX failures; - `SIMULATED/PASSED` — host/embedded regression and independent wire evidence accepted; - `ESP32P4/CROSS-COMPILED` — real P4 toolchain accepted; - `FLASHABLE/READY` — complete P4 firmware artifact accepted; From 84d7448cc54e448bc0e35bfcd94c7adc73898c11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 02:46:29 +0700 Subject: [PATCH 051/188] Guard Windows-only live helpers for strict Linux CI --- tools/sv_injector.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/sv_injector.cpp b/tools/sv_injector.cpp index d965b17b9..5d11e1ada 100644 --- a/tools/sv_injector.cpp +++ b/tools/sv_injector.cpp @@ -47,6 +47,7 @@ const ethernet::MacAddress kDestination{ const ethernet::MacAddress kSource{ std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}}; +#ifdef _WIN32 volatile std::sig_atomic_t gStopRequested = 0; void handle_interrupt(const int signal) noexcept { @@ -54,6 +55,7 @@ void handle_interrupt(const int signal) noexcept { gStopRequested = 1; } } +#endif enum class RunMode : std::uint8_t { loopback, @@ -389,6 +391,7 @@ embedded::IoResult loopback_transmit( return true; } +#ifdef _WIN32 [[nodiscard]] std::uint64_t monotonic_us() noexcept { const auto now = std::chrono::steady_clock::now().time_since_epoch(); return static_cast( @@ -410,6 +413,7 @@ void wait_until_monotonic_us(const std::uint64_t deadline_us) { } } } +#endif void print_loopback_human( const Options& options, @@ -597,6 +601,7 @@ void print_adapters() { } } +#ifdef _WIN32 void print_live_human( const Options& options, const tools::NpcapLivePort& live, @@ -684,6 +689,7 @@ void print_live_json( << "\"adapter\":\"" << live.opened_name() << "\"" << "}\n"; } +#endif [[nodiscard]] int run_live(const Options& options) { #ifndef _WIN32 From 2c18b4a4af9c77807b5374552d014f0a2be4572c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:24:52 +0700 Subject: [PATCH 052/188] Add transport-independent SV injector lifecycle controller --- .../sampled_values/injector_controller.hpp | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 include/ariec61850/sampled_values/injector_controller.hpp diff --git a/include/ariec61850/sampled_values/injector_controller.hpp b/include/ariec61850/sampled_values/injector_controller.hpp new file mode 100644 index 000000000..52ae91a91 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_controller.hpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::sampled_values { + +enum class InjectorScenarioKind : std::uint8_t { + normal, + protection_fault, +}; + +enum class InjectorControlState : std::uint8_t { + idle, + configured, + armed, + running, + stopped, + fault, +}; + +enum class InjectorControlStatus : std::uint8_t { + ok, + invalid_state, + invalid_configuration, +}; + +struct InjectorControlConfig final { + std::uint32_t sample_rate_hz{4'000U}; + InjectorScenarioKind scenario{InjectorScenarioKind::normal}; +}; + +struct InjectorControlSnapshot final { + InjectorControlState state{InjectorControlState::idle}; + InjectorControlConfig configuration{}; + std::uint32_t configuration_revision{}; + std::uint32_t armed_revision{}; + std::uint64_t run_sequence{}; +}; + +// Transport-independent injector lifecycle state machine. +// +// JSON, USB serial, TCP, GUI, and ESP-IDF console code must stay outside this +// class. They translate commands into these operations so Windows and embedded +// targets share the same lifecycle semantics. +class InjectorController final { +public: + [[nodiscard]] InjectorControlSnapshot snapshot() const noexcept { + return { + state_, + configuration_, + configuration_revision_, + armed_revision_, + run_sequence_}; + } + + [[nodiscard]] InjectorControlState state() const noexcept { return state_; } + [[nodiscard]] bool running() const noexcept { + return state_ == InjectorControlState::running; + } + + [[nodiscard]] InjectorControlStatus configure( + const InjectorControlConfig configuration) noexcept { + if (state_ == InjectorControlState::armed || + state_ == InjectorControlState::running) { + return InjectorControlStatus::invalid_state; + } + if (!valid(configuration)) { + return InjectorControlStatus::invalid_configuration; + } + + configuration_ = configuration; + ++configuration_revision_; + if (configuration_revision_ == 0U) { + ++configuration_revision_; + } + armed_revision_ = 0U; + state_ = InjectorControlState::configured; + return InjectorControlStatus::ok; + } + + [[nodiscard]] InjectorControlStatus arm() noexcept { + if (state_ != InjectorControlState::configured && + state_ != InjectorControlState::stopped) { + return InjectorControlStatus::invalid_state; + } + if (configuration_revision_ == 0U) { + return InjectorControlStatus::invalid_configuration; + } + + armed_revision_ = configuration_revision_; + state_ = InjectorControlState::armed; + return InjectorControlStatus::ok; + } + + [[nodiscard]] InjectorControlStatus start() noexcept { + if (state_ != InjectorControlState::armed || + armed_revision_ == 0U || + armed_revision_ != configuration_revision_) { + return InjectorControlStatus::invalid_state; + } + + ++run_sequence_; + if (run_sequence_ == 0U) { + ++run_sequence_; + } + state_ = InjectorControlState::running; + return InjectorControlStatus::ok; + } + + [[nodiscard]] InjectorControlStatus stop() noexcept { + if (state_ != InjectorControlState::armed && + state_ != InjectorControlState::running) { + return InjectorControlStatus::invalid_state; + } + state_ = InjectorControlState::stopped; + return InjectorControlStatus::ok; + } + + void set_fault() noexcept { + state_ = InjectorControlState::fault; + } + +private: + [[nodiscard]] static bool valid( + const InjectorControlConfig configuration) noexcept { + return configuration.sample_rate_hz > 0U && + configuration.sample_rate_hz <= 1'000'000U; + } + + InjectorControlConfig configuration_{}; + std::uint32_t configuration_revision_{}; + std::uint32_t armed_revision_{}; + std::uint64_t run_sequence_{}; + InjectorControlState state_{InjectorControlState::idle}; +}; + +} // namespace ar::iec61850::sampled_values From 3e2674f89745f43ed174a06bc6b2b9636dad6c11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:25:05 +0700 Subject: [PATCH 053/188] Add embedded injector controller state-machine smoke --- embedded/sv_injector_controller_smoke.cpp | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 embedded/sv_injector_controller_smoke.cpp diff --git a/embedded/sv_injector_controller_smoke.cpp b/embedded/sv_injector_controller_smoke.cpp new file mode 100644 index 000000000..0d605a4fa --- /dev/null +++ b/embedded/sv_injector_controller_smoke.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/injector_controller.hpp" + +#include + +namespace { + +using namespace ar::iec61850::sampled_values; + +[[nodiscard]] int fail(const int code) noexcept { return code; } + +} // namespace + +int main() { + InjectorController controller; + if (controller.state() != InjectorControlState::idle) { + return fail(1); + } + if (controller.start() != InjectorControlStatus::invalid_state) { + return fail(2); + } + + if (controller.configure({0U, InjectorScenarioKind::normal}) != + InjectorControlStatus::invalid_configuration) { + return fail(3); + } + if (controller.configure({4'000U, InjectorScenarioKind::normal}) != + InjectorControlStatus::ok) { + return fail(4); + } + + const auto configured = controller.snapshot(); + if (configured.state != InjectorControlState::configured || + configured.configuration_revision != 1U || + configured.armed_revision != 0U) { + return fail(5); + } + + if (controller.arm() != InjectorControlStatus::ok) { + return fail(6); + } + const auto armed = controller.snapshot(); + if (armed.state != InjectorControlState::armed || + armed.armed_revision != armed.configuration_revision) { + return fail(7); + } + if (controller.configure({4'000U, InjectorScenarioKind::protection_fault}) != + InjectorControlStatus::invalid_state) { + return fail(8); + } + + if (controller.start() != InjectorControlStatus::ok || !controller.running()) { + return fail(9); + } + const auto first_run = controller.snapshot(); + if (first_run.run_sequence != 1U) { + return fail(10); + } + if (controller.arm() != InjectorControlStatus::invalid_state) { + return fail(11); + } + + if (controller.stop() != InjectorControlStatus::ok || + controller.state() != InjectorControlState::stopped) { + return fail(12); + } + if (controller.arm() != InjectorControlStatus::ok || + controller.start() != InjectorControlStatus::ok) { + return fail(13); + } + if (controller.snapshot().run_sequence != 2U) { + return fail(14); + } + + controller.set_fault(); + if (controller.state() != InjectorControlState::fault) { + return fail(15); + } + if (controller.start() != InjectorControlStatus::invalid_state) { + return fail(16); + } + + if (controller.configure({4'000U, InjectorScenarioKind::normal}) != + InjectorControlStatus::ok) { + return fail(17); + } + const auto recovered = controller.snapshot(); + if (recovered.state != InjectorControlState::configured || + recovered.configuration_revision != 2U || + recovered.armed_revision != 0U) { + return fail(18); + } + + return 0; +} From be79d6f7efd3c7851fea111c200b0d74cfb50f27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:25:27 +0700 Subject: [PATCH 054/188] Gate injector controller in embedded profile --- embedded/CMakeLists.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index 40581982c..85e344134 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -117,6 +117,18 @@ target_compile_definitions(ariec61850_embedded_sv_injector_smoke PRIVATE ARIEC61850_HOST_SERVICES=0) ariec61850_apply_embedded_flags(ariec61850_embedded_sv_injector_smoke) +# Exercise the transport-independent configure/arm/start/stop/fault lifecycle +# under the same MCU warning/RTTI boundary before USB-serial transport is added. +add_executable(ariec61850_embedded_sv_injector_controller_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_injector_controller_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_injector_controller_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_injector_controller_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_injector_controller_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_injector_controller_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_injector_controller_smoke) + # Produce a deterministic two-second classic-PCAP capture using only the # publisher/encoder path. A separate Python implementation verifies the complete # wire image byte-for-byte in CI, so encoder bugs cannot be hidden by the C++ @@ -135,6 +147,7 @@ enable_testing() add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_smoke) add_test(NAME ariec61850_embedded_sv_publisher_smoke COMMAND ariec61850_embedded_sv_publisher_smoke) add_test(NAME ariec61850_embedded_sv_injector_smoke COMMAND ariec61850_embedded_sv_injector_smoke) +add_test(NAME ariec61850_embedded_sv_injector_controller_smoke COMMAND ariec61850_embedded_sv_injector_controller_smoke) add_test( NAME ariec61850_embedded_sv_stream_pcap COMMAND ariec61850_embedded_sv_stream_pcap "${CMAKE_BINARY_DIR}/sv_trial_2s.pcap") From 4eac218c9df21a09a27a68c297c3039bb3183787 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:26:19 +0700 Subject: [PATCH 055/188] Run deterministic 4I+4V injector engine on ESP32-P4 --- ports/esp_idf/sv_trial_app/main/main.cpp | 132 ++++++++++++++++++----- 1 file changed, 108 insertions(+), 24 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/main/main.cpp b/ports/esp_idf/sv_trial_app/main/main.cpp index a3fbf0a14..59f98e849 100644 --- a/ports/esp_idf/sv_trial_app/main/main.cpp +++ b/ports/esp_idf/sv_trial_app/main/main.cpp @@ -2,7 +2,10 @@ #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/ports/esp_idf/ethernet_port.hpp" +#include "ariec61850/sampled_values/deterministic_injector.hpp" #include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/injector_controller.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" #include "ariec61850/sampled_values/payload_writer.hpp" #include "ariec61850/sampled_values/publisher.hpp" @@ -29,9 +32,9 @@ using namespace ar::iec61850; constexpr std::uint32_t kSampleRateHz = 4'000U; constexpr std::uint64_t kPublishIntervalUs = 250U; constexpr std::uint16_t kSampleCountWrap = 4'000U; -constexpr std::size_t kChannelCount = 8U; constexpr std::size_t kPayloadBytes = - kChannelCount * sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; + sampled_values::injector_channel_count * + sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; // Waveshare ESP32-P4-ETH: IP101 PHY on RMII. ESP32-P4's // ETH_ESP32_EMAC_DEFAULT_CONFIG() supplies the matching default RMII data-plane @@ -47,6 +50,7 @@ const char* kTag = "arstack_sv_trial"; EventGroupHandle_t g_link_events{}; TaskHandle_t g_publisher_task{}; esp_eth_handle_t g_eth_handle{}; +sampled_values::InjectorController g_injector_controller{}; void ethernet_event_handler( void* argument, @@ -128,7 +132,7 @@ sampled_values::SampledValuesFrame make_sv_frame( 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; sampled_values::SampledValueAsdu asdu; - asdu.sv_id = "ARSTACK61850_P4_TRIAL"; + asdu.sv_id = "ARSTACK61850_INJECTOR"; asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; asdu.configuration_revision = 1U; asdu.sample_synchronization = 0U; @@ -146,24 +150,23 @@ sampled_values::SampledValuesFrame make_sv_frame( sampled_values::SampledValuesPdu{{asdu}}}; } -bool update_synthetic_payload( +bool write_injector_payload( sampled_values::SampledValueAsdu& asdu, - const std::uint16_t sample_count) noexcept { + const sampled_values::InjectorSample& sample) noexcept { if (asdu.sample_payload.size() != kPayloadBytes) { return false; } const auto payload = std::span{ asdu.sample_payload.data(), asdu.sample_payload.size()}; - const auto count = static_cast(sample_count); - - for (std::size_t channel = 0U; channel < kChannelCount; ++channel) { - // Deterministic synthetic values make captures easy to validate without - // floating-point work or ADC dependencies in this first transport proof. - const auto value = - count * 1'000 + static_cast(channel) * 100'000; + for (std::size_t channel = 0U; + channel < sampled_values::injector_channel_count; + ++channel) { if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( - payload, channel, value, 0U)) { + payload, + channel, + sample.values[channel], + sample.qualities[channel])) { return false; } } @@ -208,8 +211,29 @@ void publisher_task(void* argument) { 0U, true}); - if (!publisher.valid()) { - ESP_LOGE(kTag, "SV publisher configuration is invalid"); + const auto profile = sampled_values::make_balanced_4i4v_profile(); + const std::array scenario{ + sampled_values::make_hold_segment(profile)}; + sampled_values::DeterministicSvInjector injector{ + scenario, + kSampleRateHz, + true}; + + if (!publisher.valid() || !injector.valid()) { + ESP_LOGE(kTag, "SV publisher/injector configuration is invalid"); + g_injector_controller.set_fault(); + vTaskDelete(nullptr); + return; + } + + if (g_injector_controller.configure({ + kSampleRateHz, + sampled_values::InjectorScenarioKind::normal}) != + sampled_values::InjectorControlStatus::ok || + g_injector_controller.arm() != sampled_values::InjectorControlStatus::ok || + g_injector_controller.start() != sampled_values::InjectorControlStatus::ok) { + ESP_LOGE(kTag, "Injector lifecycle initialization failed"); + g_injector_controller.set_fault(); vTaskDelete(nullptr); return; } @@ -222,16 +246,19 @@ void publisher_task(void* argument) { ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer)); ESP_ERROR_CHECK(esp_timer_start_periodic(timer, kPublishIntervalUs)); + const auto control = g_injector_controller.snapshot(); ESP_LOGI( kTag, - "SV publisher active: 4000/s, APPID=0x4001, dst=01:0c:cd:04:00:01, payload=%u bytes", - static_cast(kPayloadBytes)); + "Deterministic SV injector active: 4000/s, 50Hz, 4I+4V, APPID=0x4001, rev=%u run=%" PRIu64, + static_cast(control.configuration_revision), + control.run_sequence); std::uint64_t timer_notifications{}; std::uint64_t coalesced_notifications{}; std::uint64_t report_notifications{}; + std::optional pending_sample; - while (true) { + while (g_injector_controller.running()) { const auto notifications = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); timer_notifications += notifications; report_notifications += notifications; @@ -243,24 +270,63 @@ void publisher_task(void* argument) { continue; } - auto& asdu = frame.pdu.asdus.front(); - if (!update_synthetic_payload(asdu, publisher.next_sample_count())) { - ESP_LOGE(kTag, "Synthetic payload update failed"); - continue; + if (!pending_sample.has_value()) { + sampled_values::InjectorSample next_sample{}; + if (!injector.step(next_sample)) { + ESP_LOGE(kTag, "Deterministic injector stopped unexpectedly"); + g_injector_controller.set_fault(); + break; + } + pending_sample = next_sample; + + const auto expected_sample_count = static_cast( + next_sample.sample_index % static_cast(kSampleCountWrap)); + if (publisher.next_sample_count() != expected_sample_count) { + ESP_LOGE( + kTag, + "Logical sample/smpCnt divergence: sample=%" PRIu64 " expected=%u publisher=%u", + next_sample.sample_index, + static_cast(expected_sample_count), + static_cast(publisher.next_sample_count())); + g_injector_controller.set_fault(); + break; + } + + if (!write_injector_payload(frame.pdu.asdus.front(), next_sample)) { + ESP_LOGE(kTag, "Deterministic payload update failed"); + g_injector_controller.set_fault(); + break; + } } const auto result = publisher.poll(clock.monotonic_us()); - (void)result; + if (result.status == sampled_values::SampledValuesPublishStatus::sent) { + pending_sample.reset(); + } else if (result.status != sampled_values::SampledValuesPublishStatus::not_due) { + ESP_LOGE( + kTag, + "SV publish fault: status=%u io=%u encode=%u", + static_cast(result.status), + static_cast(result.io_status), + static_cast(result.encode_status)); + g_injector_controller.set_fault(); + break; + } if (report_notifications >= kSampleRateHz) { report_notifications -= kSampleRateHz; const auto& stats = publisher.statistics(); + const auto snapshot = g_injector_controller.snapshot(); ESP_LOGI( kTag, - "QA sent=%" PRIu64 " txFail=%" PRIu64 " encFail=%" PRIu64 + "QA state=%u run=%" PRIu64 " sample=%" PRIu64 + " sent=%" PRIu64 " txFail=%" PRIu64 " encFail=%" PRIu64 " late=%" PRIu64 " maxLateUs=%" PRIu64 " timerTicks=%" PRIu64 " coalesced=%" PRIu64 " heap=%u minHeap=%u", + static_cast(snapshot.state), + snapshot.run_sequence, + injector.sample_index(), stats.frames_sent, stats.transmit_failures, stats.encode_failures, @@ -272,6 +338,24 @@ void publisher_task(void* argument) { static_cast(esp_get_minimum_free_heap_size())); } } + + if (timer != nullptr) { + (void)esp_timer_stop(timer); + (void)esp_timer_delete(timer); + } + + const auto final_state = g_injector_controller.snapshot(); + const auto& stats = publisher.statistics(); + ESP_LOGW( + kTag, + "SV injector stopped: state=%u sent=%" PRIu64 " txFail=%" PRIu64 + " encFail=%" PRIu64 " sample=%" PRIu64, + static_cast(final_state.state), + stats.frames_sent, + stats.transmit_failures, + stats.encode_failures, + injector.sample_index()); + vTaskDelete(nullptr); } } // namespace From bd50c016538212b1e33466d6dff61c45ba573720 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:27:36 +0700 Subject: [PATCH 056/188] Add bounded injector control command parser --- .../injector_control_protocol.hpp | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 include/ariec61850/sampled_values/injector_control_protocol.hpp diff --git a/include/ariec61850/sampled_values/injector_control_protocol.hpp b/include/ariec61850/sampled_values/injector_control_protocol.hpp new file mode 100644 index 000000000..ab8347207 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_control_protocol.hpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/injector_controller.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class InjectorControlCommandKind : std::uint8_t { + capabilities, + configure, + arm, + start, + stop, + status, + stats, +}; + +enum class InjectorControlParseStatus : std::uint8_t { + ok, + malformed, + missing_command, + unsupported_command, + missing_scenario, + unsupported_scenario, +}; + +struct InjectorControlCommand final { + InjectorControlCommandKind kind{InjectorControlCommandKind::status}; + InjectorScenarioKind scenario{InjectorScenarioKind::normal}; +}; + +struct InjectorControlParseResult final { + InjectorControlParseStatus status{InjectorControlParseStatus::malformed}; + InjectorControlCommand command{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == InjectorControlParseStatus::ok; + } +}; + +namespace detail { + +[[nodiscard]] inline std::string_view trim_ascii( + std::string_view value) noexcept { + while (!value.empty() && + std::isspace(static_cast(value.front())) != 0) { + value.remove_prefix(1U); + } + while (!value.empty() && + std::isspace(static_cast(value.back())) != 0) { + value.remove_suffix(1U); + } + return value; +} + +[[nodiscard]] inline bool json_string_value( + const std::string_view object, + const std::string_view key, + std::string_view& output) noexcept { + const auto quoted_key_length = key.size() + 2U; + if (quoted_key_length > object.size()) { + return false; + } + + std::size_t position = 0U; + while (position + quoted_key_length <= object.size()) { + const auto quote = object.find('"', position); + if (quote == std::string_view::npos || + quote + quoted_key_length > object.size()) { + return false; + } + if (object.substr(quote + 1U, key.size()) != key || + object[quote + 1U + key.size()] != '"') { + position = quote + 1U; + continue; + } + + auto cursor = quote + quoted_key_length; + while (cursor < object.size() && + std::isspace(static_cast(object[cursor])) != 0) { + ++cursor; + } + if (cursor >= object.size() || object[cursor] != ':') { + return false; + } + ++cursor; + while (cursor < object.size() && + std::isspace(static_cast(object[cursor])) != 0) { + ++cursor; + } + if (cursor >= object.size() || object[cursor] != '"') { + return false; + } + ++cursor; + + const auto end = object.find('"', cursor); + if (end == std::string_view::npos) { + return false; + } + if (object.substr(cursor, end - cursor).find('\\') != + std::string_view::npos) { + return false; + } + output = object.substr(cursor, end - cursor); + return true; + } + return false; +} + +} // namespace detail + +// Parses the intentionally narrow control-plane JSON subset used on MCU serial +// links. Each command is one JSON object on one line. Escaped string values, +// nested objects, arrays, and arbitrary JSON are deliberately outside v1. +[[nodiscard]] inline InjectorControlParseResult parse_injector_control_command( + std::string_view line) noexcept { + line = detail::trim_ascii(line); + if (line.size() < 2U || line.front() != '{' || line.back() != '}') { + return {InjectorControlParseStatus::malformed, {}}; + } + + std::string_view command_name; + if (!detail::json_string_value(line, "command", command_name)) { + return {InjectorControlParseStatus::missing_command, {}}; + } + + InjectorControlCommand command{}; + if (command_name == "capabilities") { + command.kind = InjectorControlCommandKind::capabilities; + } else if (command_name == "configure") { + command.kind = InjectorControlCommandKind::configure; + std::string_view scenario_name; + if (!detail::json_string_value(line, "scenario", scenario_name)) { + return {InjectorControlParseStatus::missing_scenario, {}}; + } + if (scenario_name == "normal") { + command.scenario = InjectorScenarioKind::normal; + } else if (scenario_name == "protection-fault") { + command.scenario = InjectorScenarioKind::protection_fault; + } else { + return {InjectorControlParseStatus::unsupported_scenario, {}}; + } + } else if (command_name == "arm") { + command.kind = InjectorControlCommandKind::arm; + } else if (command_name == "start") { + command.kind = InjectorControlCommandKind::start; + } else if (command_name == "stop") { + command.kind = InjectorControlCommandKind::stop; + } else if (command_name == "status") { + command.kind = InjectorControlCommandKind::status; + } else if (command_name == "stats") { + command.kind = InjectorControlCommandKind::stats; + } else { + return {InjectorControlParseStatus::unsupported_command, {}}; + } + + return {InjectorControlParseStatus::ok, command}; +} + +} // namespace ar::iec61850::sampled_values From 45e113f28647f075e4f76dd97e49be7887ede23c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:27:57 +0700 Subject: [PATCH 057/188] Exercise bounded injector control protocol parser --- embedded/sv_injector_controller_smoke.cpp | 65 ++++++++++++++++------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/embedded/sv_injector_controller_smoke.cpp b/embedded/sv_injector_controller_smoke.cpp index 0d605a4fa..5a0a964f4 100644 --- a/embedded/sv_injector_controller_smoke.cpp +++ b/embedded/sv_injector_controller_smoke.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/injector_control_protocol.hpp" #include "ariec61850/sampled_values/injector_controller.hpp" #include @@ -13,83 +14,111 @@ using namespace ar::iec61850::sampled_values; } // namespace int main() { + const auto status_command = parse_injector_control_command( + R"({"command":"status"})"); + if (!status_command.success() || + status_command.command.kind != InjectorControlCommandKind::status) { + return fail(1); + } + + const auto configure_command = parse_injector_control_command( + R"( { "command" : "configure", "scenario" : "protection-fault" } )"); + if (!configure_command.success() || + configure_command.command.kind != InjectorControlCommandKind::configure || + configure_command.command.scenario != InjectorScenarioKind::protection_fault) { + return fail(2); + } + + if (parse_injector_control_command(R"({"command":"configure"})").status != + InjectorControlParseStatus::missing_scenario) { + return fail(3); + } + if (parse_injector_control_command(R"({"command":"launch"})").status != + InjectorControlParseStatus::unsupported_command) { + return fail(4); + } + if (parse_injector_control_command("status").status != + InjectorControlParseStatus::malformed) { + return fail(5); + } + InjectorController controller; if (controller.state() != InjectorControlState::idle) { - return fail(1); + return fail(6); } if (controller.start() != InjectorControlStatus::invalid_state) { - return fail(2); + return fail(7); } if (controller.configure({0U, InjectorScenarioKind::normal}) != InjectorControlStatus::invalid_configuration) { - return fail(3); + return fail(8); } if (controller.configure({4'000U, InjectorScenarioKind::normal}) != InjectorControlStatus::ok) { - return fail(4); + return fail(9); } const auto configured = controller.snapshot(); if (configured.state != InjectorControlState::configured || configured.configuration_revision != 1U || configured.armed_revision != 0U) { - return fail(5); + return fail(10); } if (controller.arm() != InjectorControlStatus::ok) { - return fail(6); + return fail(11); } const auto armed = controller.snapshot(); if (armed.state != InjectorControlState::armed || armed.armed_revision != armed.configuration_revision) { - return fail(7); + return fail(12); } if (controller.configure({4'000U, InjectorScenarioKind::protection_fault}) != InjectorControlStatus::invalid_state) { - return fail(8); + return fail(13); } if (controller.start() != InjectorControlStatus::ok || !controller.running()) { - return fail(9); + return fail(14); } const auto first_run = controller.snapshot(); if (first_run.run_sequence != 1U) { - return fail(10); + return fail(15); } if (controller.arm() != InjectorControlStatus::invalid_state) { - return fail(11); + return fail(16); } if (controller.stop() != InjectorControlStatus::ok || controller.state() != InjectorControlState::stopped) { - return fail(12); + return fail(17); } if (controller.arm() != InjectorControlStatus::ok || controller.start() != InjectorControlStatus::ok) { - return fail(13); + return fail(18); } if (controller.snapshot().run_sequence != 2U) { - return fail(14); + return fail(19); } controller.set_fault(); if (controller.state() != InjectorControlState::fault) { - return fail(15); + return fail(20); } if (controller.start() != InjectorControlStatus::invalid_state) { - return fail(16); + return fail(21); } if (controller.configure({4'000U, InjectorScenarioKind::normal}) != InjectorControlStatus::ok) { - return fail(17); + return fail(22); } const auto recovered = controller.snapshot(); if (recovered.state != InjectorControlState::configured || recovered.configuration_revision != 2U || recovered.armed_revision != 0U) { - return fail(18); + return fail(23); } return 0; From 44811f9e8eb8c02f715103456d3cf2ad0085b710 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:29:18 +0700 Subject: [PATCH 058/188] Add USB serial JSON control plane to P4 SV injector --- ports/esp_idf/sv_trial_app/main/main.cpp | 410 +++++++++++++++++++---- 1 file changed, 352 insertions(+), 58 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/main/main.cpp b/ports/esp_idf/sv_trial_app/main/main.cpp index 59f98e849..bf392f961 100644 --- a/ports/esp_idf/sv_trial_app/main/main.cpp +++ b/ports/esp_idf/sv_trial_app/main/main.cpp @@ -4,6 +4,7 @@ #include "ariec61850/ports/esp_idf/ethernet_port.hpp" #include "ariec61850/sampled_values/deterministic_injector.hpp" #include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/injector_control_protocol.hpp" #include "ariec61850/sampled_values/injector_controller.hpp" #include "ariec61850/sampled_values/injector_presets.hpp" #include "ariec61850/sampled_values/payload_writer.hpp" @@ -16,14 +17,17 @@ #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" +#include "freertos/queue.h" #include "freertos/task.h" #include #include #include #include +#include #include #include +#include namespace { @@ -35,6 +39,8 @@ constexpr std::uint16_t kSampleCountWrap = 4'000U; constexpr std::size_t kPayloadBytes = sampled_values::injector_channel_count * sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; +constexpr std::size_t kControlLineBytes = 256U; +constexpr std::size_t kControlQueueDepth = 16U; // Waveshare ESP32-P4-ETH: IP101 PHY on RMII. ESP32-P4's // ETH_ESP32_EMAC_DEFAULT_CONFIG() supplies the matching default RMII data-plane @@ -44,14 +50,125 @@ constexpr int kPhyResetGpio = 51; constexpr EventBits_t kEthernetLinkUpBit = BIT0; constexpr UBaseType_t kPublisherTaskPriority = 18U; -constexpr std::uint32_t kPublisherTaskStackBytes = 8'192U; +constexpr UBaseType_t kControlTaskPriority = 8U; +constexpr std::uint32_t kPublisherTaskStackBytes = 9'216U; +constexpr std::uint32_t kControlTaskStackBytes = 4'096U; const char* kTag = "arstack_sv_trial"; EventGroupHandle_t g_link_events{}; TaskHandle_t g_publisher_task{}; esp_eth_handle_t g_eth_handle{}; +QueueHandle_t g_control_queue{}; sampled_values::InjectorController g_injector_controller{}; +[[nodiscard]] const char* state_name( + const sampled_values::InjectorControlState state) noexcept { + switch (state) { + case sampled_values::InjectorControlState::idle: + return "IDLE"; + case sampled_values::InjectorControlState::configured: + return "CONFIGURED"; + case sampled_values::InjectorControlState::armed: + return "ARMED"; + case sampled_values::InjectorControlState::running: + return "RUNNING"; + case sampled_values::InjectorControlState::stopped: + return "STOPPED"; + case sampled_values::InjectorControlState::fault: + return "FAULT"; + } + return "UNKNOWN"; +} + +[[nodiscard]] const char* scenario_name( + const sampled_values::InjectorScenarioKind scenario) noexcept { + return scenario == sampled_values::InjectorScenarioKind::protection_fault + ? "protection-fault" + : "normal"; +} + +[[nodiscard]] const char* command_name( + const sampled_values::InjectorControlCommandKind command) noexcept { + switch (command) { + case sampled_values::InjectorControlCommandKind::capabilities: + return "capabilities"; + case sampled_values::InjectorControlCommandKind::configure: + return "configure"; + case sampled_values::InjectorControlCommandKind::arm: + return "arm"; + case sampled_values::InjectorControlCommandKind::start: + return "start"; + case sampled_values::InjectorControlCommandKind::stop: + return "stop"; + case sampled_values::InjectorControlCommandKind::status: + return "status"; + case sampled_values::InjectorControlCommandKind::stats: + return "stats"; + } + return "unknown"; +} + +void print_control_error( + const char* command, + const char* error) noexcept { + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"%s\",\"ok\":false,\"error\":\"%s\"}\n", + command, + error); + std::fflush(stdout); +} + +void print_control_ack( + const sampled_values::InjectorControlCommandKind command) noexcept { + const auto snapshot = g_injector_controller.snapshot(); + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"%s\",\"ok\":true,\"state\":\"%s\"," + "\"scenario\":\"%s\",\"configurationRevision\":%u," + "\"armedRevision\":%u,\"runSequence\":%" PRIu64 "}\n", + command_name(command), + state_name(snapshot.state), + scenario_name(snapshot.configuration.scenario), + static_cast(snapshot.configuration_revision), + static_cast(snapshot.armed_revision), + snapshot.run_sequence); + std::fflush(stdout); +} + +void print_capabilities() noexcept { + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"capabilities\",\"ok\":true," + "\"transport\":\"usb-serial-jtag-stdio\"," + "\"engine\":\"sample-index-fixed-point\"," + "\"commands\":[\"capabilities\",\"configure\",\"arm\",\"start\"," + "\"stop\",\"status\",\"stats\"]," + "\"scenarios\":[\"normal\",\"protection-fault\"]," + "\"profile\":{\"sampleRateHz\":4000,\"sampleCountWrap\":4000," + "\"channels\":8,\"payload\":\"INT32+quality\",\"appid\":16385}}\n"); + std::fflush(stdout); +} + +void print_status() noexcept { + const auto snapshot = g_injector_controller.snapshot(); + const auto link_up = + (xEventGroupGetBits(g_link_events) & kEthernetLinkUpBit) != 0U; + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"status\",\"ok\":true,\"state\":\"%s\"," + "\"scenario\":\"%s\",\"configurationRevision\":%u," + "\"armedRevision\":%u,\"runSequence\":%" PRIu64 "," + "\"ethernetLinkUp\":%s}\n", + state_name(snapshot.state), + scenario_name(snapshot.configuration.scenario), + static_cast(snapshot.configuration_revision), + static_cast(snapshot.armed_revision), + snapshot.run_sequence, + link_up ? "true" : "false"); + std::fflush(stdout); +} + void ethernet_event_handler( void* argument, esp_event_base_t event_base, @@ -107,8 +224,6 @@ esp_err_t initialize_ethernet(esp_eth_handle_t* out_handle) { return ESP_FAIL; } - // Generic IEEE 802.3 PHY support is sufficient for the first IP101 - // transmit proof and avoids depending on vendor-only PHY features. esp_eth_phy_t* phy = esp_eth_phy_new_generic(&phy_config); if (phy == nullptr) { mac->del(mac); @@ -150,6 +265,22 @@ sampled_values::SampledValuesFrame make_sv_frame( sampled_values::SampledValuesPdu{{asdu}}}; } +[[nodiscard]] std::array +make_protection_fault_scenario() noexcept { + const auto normal = sampled_values::make_balanced_4i4v_profile(); + auto fault = normal; + for (std::size_t channel = 0U; channel < 3U; ++channel) { + fault[channel].rms_counts *= 4; + } + for (std::size_t channel = 4U; channel < 7U; ++channel) { + fault[channel].rms_counts /= 4; + } + return { + sampled_values::make_hold_segment(normal, 2'000U), + sampled_values::make_hold_segment(fault, 800U), + sampled_values::make_hold_segment(normal, 2'000U)}; +} + bool write_injector_payload( sampled_values::SampledValueAsdu& asdu, const sampled_values::InjectorSample& sample) noexcept { @@ -180,19 +311,77 @@ void sv_timer_callback(void* argument) { } } +void control_task(void* argument) { + (void)argument; + std::array line{}; + std::size_t length{}; + + ESP_LOGI( + kTag, + "Control plane ready on primary stdio; send one JSON command per line"); + print_capabilities(); + + while (true) { + bool consumed_character = false; + while (true) { + const auto character = std::getchar(); + if (character == EOF) { + std::clearerr(stdin); + break; + } + consumed_character = true; + if (character == '\r') { + continue; + } + if (character == '\n') { + if (length == 0U) { + continue; + } + const auto parsed = sampled_values::parse_injector_control_command( + std::string_view{line.data(), length}); + length = 0U; + if (!parsed.success()) { + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"parse\",\"ok\":false,\"parseStatus\":%u}\n", + static_cast(parsed.status)); + std::fflush(stdout); + continue; + } + if (xQueueSend( + g_control_queue, + &parsed.command, + pdMS_TO_TICKS(100)) != pdPASS) { + print_control_error(command_name(parsed.command.kind), "control-queue-full"); + } + continue; + } + + if (length + 1U >= line.size()) { + length = 0U; + print_control_error("parse", "line-too-long"); + continue; + } + line[length] = static_cast(character); + ++length; + } + + if (!consumed_character) { + vTaskDelay(pdMS_TO_TICKS(10)); + } else { + taskYIELD(); + } + } +} + void publisher_task(void* argument) { (void)argument; g_publisher_task = xTaskGetCurrentTaskHandle(); - ESP_LOGI(kTag, "Waiting for Ethernet link before starting SV publisher"); - (void)xEventGroupWaitBits( - g_link_events, - kEthernetLinkUpBit, - pdFALSE, - pdTRUE, - portMAX_DELAY); - std::array source_mac{}; + while ((xEventGroupGetBits(g_link_events) & kEthernetLinkUpBit) == 0U) { + vTaskDelay(pdMS_TO_TICKS(10)); + } ESP_ERROR_CHECK(esp_eth_ioctl(g_eth_handle, ETH_CMD_G_MAC_ADDR, source_mac.data())); auto frame = make_sv_frame(source_mac); @@ -211,16 +400,15 @@ void publisher_task(void* argument) { 0U, true}); - const auto profile = sampled_values::make_balanced_4i4v_profile(); - const std::array scenario{ - sampled_values::make_hold_segment(profile)}; - sampled_values::DeterministicSvInjector injector{ - scenario, - kSampleRateHz, - true}; + const std::array normal_scenario{ + sampled_values::make_hold_segment( + sampled_values::make_balanced_4i4v_profile())}; + const auto protection_fault_scenario = make_protection_fault_scenario(); + std::optional injector; + std::optional pending_sample; - if (!publisher.valid() || !injector.valid()) { - ESP_LOGE(kTag, "SV publisher/injector configuration is invalid"); + if (!publisher.valid()) { + ESP_LOGE(kTag, "SV publisher configuration is invalid"); g_injector_controller.set_fault(); vTaskDelete(nullptr); return; @@ -229,10 +417,8 @@ void publisher_task(void* argument) { if (g_injector_controller.configure({ kSampleRateHz, sampled_values::InjectorScenarioKind::normal}) != - sampled_values::InjectorControlStatus::ok || - g_injector_controller.arm() != sampled_values::InjectorControlStatus::ok || - g_injector_controller.start() != sampled_values::InjectorControlStatus::ok) { - ESP_LOGE(kTag, "Injector lifecycle initialization failed"); + sampled_values::InjectorControlStatus::ok) { + ESP_LOGE(kTag, "Initial injector configuration failed"); g_injector_controller.set_fault(); vTaskDelete(nullptr); return; @@ -246,19 +432,16 @@ void publisher_task(void* argument) { ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer)); ESP_ERROR_CHECK(esp_timer_start_periodic(timer, kPublishIntervalUs)); - const auto control = g_injector_controller.snapshot(); ESP_LOGI( kTag, - "Deterministic SV injector active: 4000/s, 50Hz, 4I+4V, APPID=0x4001, rev=%u run=%" PRIu64, - static_cast(control.configuration_revision), - control.run_sequence); + "SV injector CONFIGURED and waiting for control: arm -> start"); + print_status(); std::uint64_t timer_notifications{}; std::uint64_t coalesced_notifications{}; std::uint64_t report_notifications{}; - std::optional pending_sample; - while (g_injector_controller.running()) { + while (true) { const auto notifications = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); timer_notifications += notifications; report_notifications += notifications; @@ -266,16 +449,116 @@ void publisher_task(void* argument) { coalesced_notifications += static_cast(notifications - 1U); } + sampled_values::InjectorControlCommand command{}; + while (xQueueReceive(g_control_queue, &command, 0U) == pdPASS) { + sampled_values::InjectorControlStatus control_status{ + sampled_values::InjectorControlStatus::ok}; + + switch (command.kind) { + case sampled_values::InjectorControlCommandKind::capabilities: + print_capabilities(); + continue; + case sampled_values::InjectorControlCommandKind::status: + print_status(); + continue; + case sampled_values::InjectorControlCommandKind::stats: { + const auto& stats = publisher.statistics(); + const auto snapshot = g_injector_controller.snapshot(); + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"stats\",\"ok\":true,\"state\":\"%s\"," + "\"framesSent\":%" PRIu64 ",\"encodeFailures\":%" PRIu64 "," + "\"transmitFailures\":%" PRIu64 ",\"latePolls\":%" PRIu64 "," + "\"maximumLatenessUs\":%" PRIu64 ",\"timerTicks\":%" PRIu64 "," + "\"coalescedTicks\":%" PRIu64 "}\n", + state_name(snapshot.state), + stats.frames_sent, + stats.encode_failures, + stats.transmit_failures, + stats.late_polls, + stats.maximum_lateness_us, + timer_notifications, + coalesced_notifications); + std::fflush(stdout); + continue; + } + case sampled_values::InjectorControlCommandKind::configure: + control_status = g_injector_controller.configure({ + kSampleRateHz, + command.scenario}); + break; + case sampled_values::InjectorControlCommandKind::arm: + control_status = g_injector_controller.arm(); + break; + case sampled_values::InjectorControlCommandKind::start: + control_status = g_injector_controller.start(); + if (control_status == sampled_values::InjectorControlStatus::ok) { + const auto snapshot = g_injector_controller.snapshot(); + publisher.reset(0U); + pending_sample.reset(); + if (snapshot.configuration.scenario == + sampled_values::InjectorScenarioKind::protection_fault) { + injector.emplace( + std::span{ + protection_fault_scenario}, + kSampleRateHz, + true); + } else { + injector.emplace( + std::span{ + normal_scenario}, + kSampleRateHz, + true); + } + if (!injector->valid()) { + g_injector_controller.set_fault(); + injector.reset(); + print_control_error("start", "invalid-injector-scenario"); + continue; + } + } + break; + case sampled_values::InjectorControlCommandKind::stop: + control_status = g_injector_controller.stop(); + if (control_status == sampled_values::InjectorControlStatus::ok) { + pending_sample.reset(); + injector.reset(); + publisher.reset(0U); + } + break; + } + + if (control_status == sampled_values::InjectorControlStatus::ok) { + print_control_ack(command.kind); + } else if (control_status == + sampled_values::InjectorControlStatus::invalid_configuration) { + print_control_error(command_name(command.kind), "invalid-configuration"); + } else { + print_control_error(command_name(command.kind), "invalid-state"); + } + } + + if (!g_injector_controller.running()) { + continue; + } if ((xEventGroupGetBits(g_link_events) & kEthernetLinkUpBit) == 0U) { continue; } + if (!injector.has_value()) { + ESP_LOGE(kTag, "RUNNING state without active deterministic injector"); + g_injector_controller.set_fault(); + print_control_error("runtime", "missing-injector"); + continue; + } if (!pending_sample.has_value()) { sampled_values::InjectorSample next_sample{}; - if (!injector.step(next_sample)) { + if (!injector->step(next_sample)) { ESP_LOGE(kTag, "Deterministic injector stopped unexpectedly"); g_injector_controller.set_fault(); - break; + injector.reset(); + print_control_error("runtime", "injector-finished"); + continue; } pending_sample = next_sample; @@ -289,13 +572,19 @@ void publisher_task(void* argument) { static_cast(expected_sample_count), static_cast(publisher.next_sample_count())); g_injector_controller.set_fault(); - break; + injector.reset(); + pending_sample.reset(); + print_control_error("runtime", "sample-counter-divergence"); + continue; } if (!write_injector_payload(frame.pdu.asdus.front(), next_sample)) { ESP_LOGE(kTag, "Deterministic payload update failed"); g_injector_controller.set_fault(); - break; + injector.reset(); + pending_sample.reset(); + print_control_error("runtime", "payload-write-failed"); + continue; } } @@ -310,7 +599,10 @@ void publisher_task(void* argument) { static_cast(result.io_status), static_cast(result.encode_status)); g_injector_controller.set_fault(); - break; + injector.reset(); + pending_sample.reset(); + print_control_error("runtime", "publish-failed"); + continue; } if (report_notifications >= kSampleRateHz) { @@ -319,14 +611,14 @@ void publisher_task(void* argument) { const auto snapshot = g_injector_controller.snapshot(); ESP_LOGI( kTag, - "QA state=%u run=%" PRIu64 " sample=%" PRIu64 + "QA state=%s run=%" PRIu64 " sample=%" PRIu64 " sent=%" PRIu64 " txFail=%" PRIu64 " encFail=%" PRIu64 " late=%" PRIu64 " maxLateUs=%" PRIu64 " timerTicks=%" PRIu64 " coalesced=%" PRIu64 " heap=%u minHeap=%u", - static_cast(snapshot.state), + state_name(snapshot.state), snapshot.run_sequence, - injector.sample_index(), + injector.has_value() ? injector->sample_index() : 0U, stats.frames_sent, stats.transmit_failures, stats.encode_failures, @@ -338,24 +630,6 @@ void publisher_task(void* argument) { static_cast(esp_get_minimum_free_heap_size())); } } - - if (timer != nullptr) { - (void)esp_timer_stop(timer); - (void)esp_timer_delete(timer); - } - - const auto final_state = g_injector_controller.snapshot(); - const auto& stats = publisher.statistics(); - ESP_LOGW( - kTag, - "SV injector stopped: state=%u sent=%" PRIu64 " txFail=%" PRIu64 - " encFail=%" PRIu64 " sample=%" PRIu64, - static_cast(final_state.state), - stats.frames_sent, - stats.transmit_failures, - stats.encode_failures, - injector.sample_index()); - vTaskDelete(nullptr); } } // namespace @@ -367,6 +641,14 @@ extern "C" void app_main(void) { return; } + g_control_queue = xQueueCreate( + static_cast(kControlQueueDepth), + sizeof(sampled_values::InjectorControlCommand)); + if (g_control_queue == nullptr) { + ESP_LOGE(kTag, "Failed to create injector control queue"); + return; + } + ESP_ERROR_CHECK(esp_event_loop_create_default()); ESP_ERROR_CHECK(initialize_ethernet(&g_eth_handle)); ESP_ERROR_CHECK(esp_event_handler_register( @@ -376,14 +658,26 @@ extern "C" void app_main(void) { nullptr)); ESP_ERROR_CHECK(esp_eth_start(g_eth_handle)); - const auto created = xTaskCreate( + const auto publisher_created = xTaskCreate( &publisher_task, "arstack_sv_pub", kPublisherTaskStackBytes, nullptr, kPublisherTaskPriority, nullptr); - if (created != pdPASS) { + if (publisher_created != pdPASS) { ESP_LOGE(kTag, "Failed to create SV publisher task"); + return; + } + + const auto control_created = xTaskCreate( + &control_task, + "arstack_sv_ctl", + kControlTaskStackBytes, + nullptr, + kControlTaskPriority, + nullptr); + if (control_created != pdPASS) { + ESP_LOGE(kTag, "Failed to create SV control task"); } } From d9f9ab950c5e54fa2ad168ae71fcea7e6cad1e37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:29:29 +0700 Subject: [PATCH 059/188] Use USB Serial JTAG as P4 injector control console --- ports/esp_idf/sv_trial_app/sdkconfig.defaults | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ports/esp_idf/sv_trial_app/sdkconfig.defaults b/ports/esp_idf/sv_trial_app/sdkconfig.defaults index 1cd3bcce0..7931a0904 100644 --- a/ports/esp_idf/sv_trial_app/sdkconfig.defaults +++ b/ports/esp_idf/sv_trial_app/sdkconfig.defaults @@ -3,3 +3,7 @@ CONFIG_COMPILER_CXX_EXCEPTIONS=y # First physical proof uses the ESP32-P4 internal EMAC/RMII path. CONFIG_ETH_USE_ESP32_EMAC=y + +# PC-to-device injector control uses the ESP32-P4 built-in USB Serial/JTAG +# controller as the primary stdio transport. One JSON command is sent per line. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y From 4914ed2bc828bec869e650aac09b1b7a5283e63a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:30:13 +0700 Subject: [PATCH 060/188] Document P4 deterministic injector USB control --- ports/esp_idf/sv_trial_app/README.md | 117 +++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/README.md b/ports/esp_idf/sv_trial_app/README.md index 1de046c28..4dd96ea08 100644 --- a/ports/esp_idf/sv_trial_app/README.md +++ b/ports/esp_idf/sv_trial_app/README.md @@ -1,6 +1,6 @@ -# ESP32-P4 Sampled Values first-trial firmware +# ESP32-P4 Sampled Values injector firmware -This is the first flashable arstack61850 firmware target. It is intentionally narrower than the full desktop stack and proves the active Sampled Values transmit vertical slice on ESP32-P4. +This is the first flashable arstack61850 Sampled Values injector target for ESP32-P4. The firmware now uses the same fixed-point `DeterministicSvInjector` waveform engine and canonical 4I+4V profile as the Windows reference injector. ## Hardware profile @@ -13,22 +13,90 @@ This is the first flashable arstack61850 firmware target. It is intentionally na - Sampled Values: 50 Hz / 80 samples per cycle / 4,000 sample instants per second - APPID: `0x4001` - destination MAC: `01:0C:CD:04:00:01` +- `svID`: `ARSTACK61850_INJECTOR` +- dataset: `ARSTACK61850/LLN0$PhsMeas1` - untagged Ethernet first proof - one ASDU per Ethernet frame -- 8 synthetic `INT32 + quality` pairs (64-byte `seqOfData`) +- channel order: `Ia, Ib, Ic, In, Va, Vb, Vc, Vn` +- 8 x `INT32 + quality` pairs (64-byte `seqOfData`) -The synthetic sample values are deterministic and are only for transport/wire validation. They are not IEC 61869-9 instrument-transformer scaling. +The first profile uses raw engineering counts shared with the Windows oracle. It is not yet a claim of IEC 61869-9 calibrated instrument-transformer scaling. + +## Deterministic execution model + +The logical waveform advances by integer sample index through the shared fixed-point engine. The 250 us ESP timer and publisher task decide when a frame may be attempted; they do not decide which waveform sample logically comes next. + +If a timer notification arrives before the publisher is due, the firmware retains the same pending sample rather than advancing the waveform. If encode or physical TX fails, the first control slice enters `FAULT` instead of silently allowing waveform sample index and `smpCnt` to diverge. + +## USB Serial/JTAG control plane + +The built-in ESP32-P4 USB Serial/JTAG controller is the primary stdio/control transport. Connect the board's USB Serial/JTAG port to the PC. On Windows it appears as a `COMx` device. + +The protocol is line-delimited JSON, schema: + +```text +arstack-sv-injector-control-v1 +``` + +Each request is one JSON object terminated by a newline. Responses are one-line JSON prefixed by `ARCTRL ` so a PC application can separate control responses from ESP-IDF log output. + +Supported commands: + +```json +{"command":"capabilities"} +{"command":"status"} +{"command":"configure","scenario":"normal"} +{"command":"configure","scenario":"protection-fault"} +{"command":"arm"} +{"command":"start"} +{"command":"stop"} +{"command":"stats"} +``` + +Lifecycle: + +```text +IDLE -> CONFIGURED -> ARMED -> RUNNING -> STOPPED + | | + +-> STOPPED + | + FAULT +``` + +On boot the firmware validates and stores the `normal` profile, then waits in `CONFIGURED`. A normal run is therefore: + +```json +{"command":"status"} +{"command":"arm"} +{"command":"start"} +``` + +Stop with: + +```json +{"command":"stop"} +``` + +To change scenario after stopping: + +```json +{"command":"configure","scenario":"protection-fault"} +{"command":"arm"} +{"command":"start"} +``` + +A `configure` command is rejected while `ARMED` or `RUNNING`. `start` is rejected unless the active configuration has been armed. A fault can be recovered by sending a new valid `configure`, then `arm`, then `start`. ## Build -From an ESP-IDF v6.0.2 shell: +From an ESP-IDF v6.0.2 shell in this directory: ```bash idf.py set-target esp32p4 idf.py build ``` -GitHub Actions builds the same project with ESP-IDF v6.0.2. A successful workflow uploads the complete flashable build set as the `esp32p4-sv-trial-firmware` artifact, including the application image, bootloader, partition table, ELF, map, `flash_args`, `flasher_args.json`, and the generated sdkconfig. +GitHub Actions builds the same project with ESP-IDF v6.0.2. A successful workflow uploads the complete flashable build set as the `esp32p4-sv-trial-firmware` artifact, including the application image, bootloader, partition table, ELF, map, `flash_args`, `flasher_args.json`, generated sdkconfig, checksums, and build evidence. ## Flash @@ -38,28 +106,43 @@ With the repository and ESP-IDF environment available: idf.py -p flash monitor ``` -A downloaded CI artifact can also be flashed with Espressif's flashing tools using the included `flash_args`/`flasher_args.json`. Keep the extracted directory structure intact so the relative binary paths remain valid. +A downloaded CI artifact can also be flashed with Espressif's flashing tools using the included `flash_args`/`flasher_args.json`. Keep the extracted directory structure intact so relative binary paths remain valid. -## Expected serial output +## Expected runtime behavior After boot the firmware: 1. initializes the ESP32-P4 EMAC and PHY; -2. waits for an Ethernet link; -3. starts a 250 us periodic ESP timer; -4. wakes a dedicated high-priority publisher task; -5. publishes at most one SV frame for each processed timer notification; -6. reports counters approximately once per second. - -The QA line reports frames sent, encode/TX failures, late polls, maximum lateness, timer notifications, coalesced timer notifications, current free heap, and minimum-ever free heap. +2. initializes the deterministic 4I+4V control configuration; +3. starts the 250 us timer and the high-priority publisher task; +4. exposes the JSON control plane on primary USB Serial/JTAG stdio; +5. remains `CONFIGURED` until `arm` and `start` are received; +6. while `RUNNING`, emits at most one SV frame for each processed timer opportunity; +7. pauses transmission while Ethernet link is down without consuming logical waveform samples; +8. reports publisher/timer/heap telemetry and responds to `stats` on demand. + +The `stats` response includes frames sent, encode/TX failures, late polls, maximum lateness, timer notifications, and coalesced notifications. Periodic ESP-IDF QA logs additionally include heap watermarks. + +## First physical acceptance + +1. flash the firmware; +2. connect USB Serial/JTAG and Ethernet; +3. verify `status` reports `CONFIGURED` and Ethernet link state; +4. send `arm`, then `start`; +5. capture `eth.type == 0x88ba` in Wireshark or Process Bus Insight; +6. confirm APPID `0x4001`, `svID`, dataset, 4I+4V waveform, 50 Hz, and `smpCnt` continuity; +7. compare values/phase sequence against the Windows reference injector; +8. request `stats` and retain the telemetry as evidence; +9. send `stop` and verify the stream ceases. ## What CI proves without hardware A green repository can prove: -- portable protocol core builds with GCC and Clang; +- portable protocol/control core builds with strict GCC and Clang warnings; +- deterministic waveform and injector lifecycle/parser smoke tests pass; - host-sim publisher behavior passes CTest; - ESP32-P4 RISC-V cross-compilation and final link succeed under ESP-IDF v6.0.2; - a complete flashable firmware image set is produced. -CI **cannot** prove PHY electrical bring-up, real cable transmission, DMA behavior under sustained 4 kHz traffic, scheduler jitter on silicon, or Wireshark-observed packet continuity. Those remain explicit hardware gates. +CI cannot prove PHY electrical bring-up, USB cable/driver behavior, actual serial command exchange, real Ethernet transmission, DMA behavior under sustained 4 kHz traffic, scheduler jitter on silicon, or external subscriber interoperability. Those remain explicit hardware gates. From f311a45a858852eb22dc957208d326339761ec71 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:31:24 +0700 Subject: [PATCH 061/188] Add Windows serial line transport for P4 injector control --- tools/windows_serial_control.hpp | 235 +++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tools/windows_serial_control.hpp diff --git a/tools/windows_serial_control.hpp b/tools/windows_serial_control.hpp new file mode 100644 index 000000000..1d1372e4d --- /dev/null +++ b/tools/windows_serial_control.hpp @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace ar::iec61850::tools { + +class WindowsSerialControlPort final { +public: + WindowsSerialControlPort() = default; + ~WindowsSerialControlPort() { close(); } + + WindowsSerialControlPort(const WindowsSerialControlPort&) = delete; + WindowsSerialControlPort& operator=(const WindowsSerialControlPort&) = delete; + WindowsSerialControlPort(WindowsSerialControlPort&&) = delete; + WindowsSerialControlPort& operator=(WindowsSerialControlPort&&) = delete; + + [[nodiscard]] bool open(const std::string_view device) { + close(); +#ifdef _WIN32 + if (device.empty()) { + error_ = "Serial device is empty."; + return false; + } + + std::string path{device}; + if (!path.starts_with("\\\\.\\")) { + path = "\\\\.\\" + path; + } + + handle_ = CreateFileA( + path.c_str(), + GENERIC_READ | GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + if (handle_ == INVALID_HANDLE_VALUE) { + handle_ = nullptr; + error_ = "CreateFile failed for " + std::string{device} + + " (Windows error " + std::to_string(GetLastError()) + ")."; + return false; + } + + DCB dcb{}; + dcb.DCBlength = sizeof(dcb); + if (GetCommState(handle_, &dcb) == 0) { + set_last_error("GetCommState failed"); + close(); + return false; + } + dcb.BaudRate = CBR_115200; + dcb.ByteSize = 8U; + dcb.Parity = NOPARITY; + dcb.StopBits = ONESTOPBIT; + dcb.fBinary = TRUE; + if (SetCommState(handle_, &dcb) == 0) { + set_last_error("SetCommState failed"); + close(); + return false; + } + + COMMTIMEOUTS timeouts{}; + timeouts.ReadIntervalTimeout = MAXDWORD; + timeouts.ReadTotalTimeoutMultiplier = 0U; + timeouts.ReadTotalTimeoutConstant = 25U; + timeouts.WriteTotalTimeoutMultiplier = 0U; + timeouts.WriteTotalTimeoutConstant = 1'000U; + if (SetCommTimeouts(handle_, &timeouts) == 0) { + set_last_error("SetCommTimeouts failed"); + close(); + return false; + } + + (void)PurgeComm(handle_, PURGE_RXCLEAR | PURGE_TXCLEAR); + device_ = std::string{device}; + error_.clear(); + return true; +#else + (void)device; + error_ = "Serial injector control is available only on Windows."; + return false; +#endif + } + + void close() noexcept { +#ifdef _WIN32 + if (handle_ != nullptr) { + CloseHandle(handle_); + handle_ = nullptr; + } +#endif + device_.clear(); + } + + [[nodiscard]] bool opened() const noexcept { +#ifdef _WIN32 + return handle_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] const std::string& error() const noexcept { return error_; } + [[nodiscard]] const std::string& device() const noexcept { return device_; } + + [[nodiscard]] bool write_line(const std::string_view line) { +#ifdef _WIN32 + if (!opened()) { + error_ = "Serial port is not open."; + return false; + } + + std::string framed{line}; + framed.push_back('\n'); + std::size_t offset = 0U; + while (offset < framed.size()) { + DWORD written = 0U; + const auto remaining = framed.size() - offset; + const auto chunk = remaining > static_cast(MAXDWORD) + ? MAXDWORD + : static_cast(remaining); + if (WriteFile( + handle_, + framed.data() + offset, + chunk, + &written, + nullptr) == 0) { + set_last_error("WriteFile failed"); + return false; + } + if (written == 0U) { + error_ = "Serial write made no progress."; + return false; + } + offset += static_cast(written); + } + return true; +#else + (void)line; + error_ = "Serial injector control is available only on Windows."; + return false; +#endif + } + + [[nodiscard]] bool read_arctrl_line( + std::string& response, + const std::uint32_t timeout_ms = 3'000U) { + response.clear(); +#ifdef _WIN32 + if (!opened()) { + error_ = "Serial port is not open."; + return false; + } + + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds{timeout_ms}; + std::string line; + line.reserve(512U); + while (std::chrono::steady_clock::now() < deadline) { + char buffer[128]{}; + DWORD received = 0U; + if (ReadFile( + handle_, + buffer, + static_cast(sizeof(buffer)), + &received, + nullptr) == 0) { + set_last_error("ReadFile failed"); + return false; + } + + if (received == 0U) { + std::this_thread::sleep_for(std::chrono::milliseconds{5}); + continue; + } + + for (DWORD index = 0U; index < received; ++index) { + const auto character = buffer[index]; + if (character == '\r') { + continue; + } + if (character == '\n') { + if (line.starts_with("ARCTRL ")) { + response = line.substr(7U); + error_.clear(); + return true; + } + line.clear(); + continue; + } + if (line.size() < 4'096U) { + line.push_back(character); + } else { + line.clear(); + } + } + } + + error_ = "Timed out waiting for ARCTRL response from " + device_ + '.'; + return false; +#else + (void)timeout_ms; + error_ = "Serial injector control is available only on Windows."; + return false; +#endif + } + +private: +#ifdef _WIN32 + void set_last_error(const std::string_view prefix) { + error_ = std::string{prefix} + " (Windows error " + + std::to_string(GetLastError()) + ")."; + } + + HANDLE handle_{}; +#endif + std::string error_; + std::string device_; +}; + +} // namespace ar::iec61850::tools From b401075969bdae5af34b1437db0694d7810f367c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:31:44 +0700 Subject: [PATCH 062/188] Add Windows COM client for P4 SV injector control --- tools/sv_device_ctl.cpp | 152 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tools/sv_device_ctl.cpp diff --git a/tools/sv_device_ctl.cpp b/tools/sv_device_ctl.cpp new file mode 100644 index 000000000..8bbb4c04e --- /dev/null +++ b/tools/sv_device_ctl.cpp @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "windows_serial_control.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::tools::WindowsSerialControlPort; + +struct Options final { + std::string device; + std::string command; + std::string scenario; + std::uint32_t timeout_ms{3'000U}; + bool help{}; +}; + +[[nodiscard]] std::uint32_t parse_timeout(const std::string& value) { + std::size_t consumed = 0U; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed < 100U || parsed > 60'000U) { + throw std::invalid_argument("--timeout-ms requires 100..60000."); + } + return static_cast(parsed); +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + auto require_value = [&](const std::string_view option) -> std::string { + if (index + 1 >= argc) { + throw std::invalid_argument(std::string{option} + " requires a value."); + } + ++index; + return argv[index]; + }; + + if (argument == "--device") { + options.device = require_value("--device"); + } else if (argument == "--timeout-ms") { + options.timeout_ms = parse_timeout(require_value("--timeout-ms")); + } else if (argument == "--help" || argument == "-h") { + options.help = true; + } else if (options.command.empty()) { + options.command = argument; + } else if (options.scenario.empty()) { + options.scenario = argument; + } else { + throw std::invalid_argument("Unexpected argument: " + argument); + } + } + + if (!options.help && options.device.empty()) { + throw std::invalid_argument("--device COMx is required."); + } + if (!options.help && options.command.empty()) { + throw std::invalid_argument("A device command is required."); + } + return options; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_sv_device_ctl --device COMx [scenario] [options]\n\n" + << "Control an ESP32-P4 ARStack61850 SV injector over USB Serial/JTAG.\n\n" + << "Commands:\n" + << " capabilities\n" + << " status\n" + << " configure normal\n" + << " configure protection-fault\n" + << " arm\n" + << " start\n" + << " stop\n" + << " stats\n\n" + << "Options:\n" + << " --device COMx Windows COM port for ESP32-P4 USB Serial/JTAG.\n" + << " --timeout-ms N Response timeout, 100..60000 (default 3000).\n" + << " --help Show this help.\n\n" + << "Examples:\n" + << " ariec61850_sv_device_ctl --device COM7 status\n" + << " ariec61850_sv_device_ctl --device COM7 arm\n" + << " ariec61850_sv_device_ctl --device COM7 start\n" + << " ariec61850_sv_device_ctl --device COM7 stats\n" + << " ariec61850_sv_device_ctl --device COM7 stop\n"; +} + +[[nodiscard]] std::string make_request(const Options& options) { + const auto& command = options.command; + if (command == "capabilities" || command == "status" || + command == "arm" || command == "start" || + command == "stop" || command == "stats") { + if (!options.scenario.empty()) { + throw std::invalid_argument(command + " does not accept a scenario argument."); + } + return "{\"command\":\"" + command + "\"}"; + } + + if (command == "configure") { + if (options.scenario != "normal" && + options.scenario != "protection-fault") { + throw std::invalid_argument( + "configure requires scenario normal or protection-fault."); + } + return "{\"command\":\"configure\",\"scenario\":\"" + + options.scenario + "\"}"; + } + + throw std::invalid_argument( + "Unsupported command '" + command + + "'. Use capabilities, status, configure, arm, start, stop, or stats."); +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.help) { + print_usage(); + return 0; + } + + const auto request = make_request(options); + WindowsSerialControlPort serial; + if (!serial.open(options.device)) { + throw std::runtime_error(serial.error()); + } + if (!serial.write_line(request)) { + throw std::runtime_error(serial.error()); + } + + std::string response; + if (!serial.read_arctrl_line(response, options.timeout_ms)) { + throw std::runtime_error(serial.error()); + } + + // Output only the JSON object. This keeps the executable easy to consume + // from PowerShell, a future GUI, or automated test tooling. + std::cout << response << '\n'; + return response.find("\"ok\":true") != std::string::npos ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_device_ctl: " << error.what() << '\n'; + return 1; + } +} From 4f7e9f39a6c69fd25a2ad0a1e28079c1f4f343c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:32:31 +0700 Subject: [PATCH 063/188] Add standalone CMake project for SV device control client --- tools/device_ctl/CMakeLists.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tools/device_ctl/CMakeLists.txt diff --git a/tools/device_ctl/CMakeLists.txt b/tools/device_ctl/CMakeLists.txt new file mode 100644 index 000000000..8a769e07b --- /dev/null +++ b/tools/device_ctl/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850SvDeviceCtl + VERSION 0.1.0 + DESCRIPTION "Windows USB serial control client for the ESP32-P4 SV injector" + LANGUAGES CXX) + +add_executable(ariec61850_sv_device_ctl + ${CMAKE_CURRENT_LIST_DIR}/../sv_device_ctl.cpp) + +target_compile_features(ariec61850_sv_device_ctl PRIVATE cxx_std_20) +target_include_directories(ariec61850_sv_device_ctl PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/..) + +if(MSVC) + target_compile_options(ariec61850_sv_device_ctl PRIVATE + /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_sv_device_ctl PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() From d1d39f6226fefc003d9dbadba4d60126a447a723 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:32:47 +0700 Subject: [PATCH 064/188] CI-gate Windows SV device control client --- .github/workflows/sv-device-control-ci.yml | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/sv-device-control-ci.yml diff --git a/.github/workflows/sv-device-control-ci.yml b/.github/workflows/sv-device-control-ci.yml new file mode 100644 index 000000000..a8e117b7d --- /dev/null +++ b/.github/workflows/sv-device-control-ci.yml @@ -0,0 +1,50 @@ +name: SV Device Control CI + +on: + push: + branches: + - agent/esp32p4-sv-publisher-first-trial + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows / MSVC + - os: ubuntu-24.04 + name: Linux / GCC portability + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: cmake -S tools/device_ctl -B build-device-ctl + + - name: Build + run: cmake --build build-device-ctl --config Release --parallel + + - name: CLI help smoke (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: .\build-device-ctl\Release\ariec61850_sv_device_ctl.exe --help + + - name: CLI help smoke (Linux) + if: runner.os == 'Linux' + run: ./build-device-ctl/ariec61850_sv_device_ctl --help + + - name: Upload Windows device controller + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: windows-sv-device-control-${{ github.sha }} + path: build-device-ctl/Release/ariec61850_sv_device_ctl.exe + if-no-files-found: error From 67f08d9d840e4b57f1389f9e6a95c2b8cdec8bf1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 05:34:16 +0700 Subject: [PATCH 065/188] Document implemented P4 USB injector control plane --- docs/SV_INJECTOR_CONTROL.md | 288 ++++++++++++++++-------------------- 1 file changed, 128 insertions(+), 160 deletions(-) diff --git a/docs/SV_INJECTOR_CONTROL.md b/docs/SV_INJECTOR_CONTROL.md index cecfd3753..3bb17d8c3 100644 --- a/docs/SV_INJECTOR_CONTROL.md +++ b/docs/SV_INJECTOR_CONTROL.md @@ -2,11 +2,9 @@ ARStack61850 treats the Sampled Values injector as one scenario engine with multiple transports and execution targets, not as separate Windows and ESP32 implementations. -## Why this differs from the ARSVIN publisher +## Execution model -ARSVIN remains a useful feature and UX reference: SCL-driven identity, manual/ramp/sequence sources, waveform shaping, COMTRADE replay, quality modes, timing-health evidence, PCAP and live Npcap workflows are all valuable product capabilities. - -The deterministic injector changes the execution model. Waveform state is advanced by an integer **logical sample index**, not by UI time, wall-clock elapsed time, or the number of frames that a host scheduler happened to send successfully. +Waveform state advances by integer **logical sample index**, not by UI time, wall-clock elapsed time, or the number of frames that a host scheduler happened to send successfully. ```text scenario configuration @@ -23,12 +21,12 @@ sample counter -------------> SV frame encoder +-------------------+-------------------+ | | | software loopback Windows Npcap live ESP32-P4 EMAC - / PCAP reference physical NIC physical injector + / PCAP reference NIC oracle physical injector ``` -The scheduler answers **when** to attempt a frame. The deterministic engine answers **which sample** and **what values** belong to that logical instant. Keeping those responsibilities separate is required for reproducible PC-vs-MCU comparison and controlled loss/jitter testing. +The scheduler answers **when** to attempt a frame. The deterministic engine answers **which sample** and **what values** belong to that logical instant. -## First control schema +## Control schema Schema identifier: @@ -36,44 +34,48 @@ Schema identifier: arstack-sv-injector-control-v1 ``` -The Windows CLI exposes the capability contract with: +Commands: -```powershell -ariec61850_sv_injector --capabilities -``` +- `capabilities` +- `configure` +- `arm` +- `start` +- `stop` +- `status` +- `stats` -Initial command semantics reserved for the PC-to-device control plane are: +Lifecycle: -- `capabilities` — protocol, engine, profile and device feature discovery; -- `configure` — replace validated inactive configuration; -- `arm` — freeze the active scenario/configuration for a run; -- `start` — begin from a defined logical sample index; -- `stop` — stop generation without mutating the stored configuration; -- `status` — lifecycle and active-profile state; -- `stats` — frame, scheduler, transport and resource telemetry. +```text +IDLE -> CONFIGURED -> ARMED -> RUNNING -> STOPPED + | | + +-> STOPPED + | + FAULT +``` -The transport is intentionally not part of the command semantics. Windows may use an in-process dispatcher, while the first ESP32-P4 control transport can use line-delimited JSON over USB serial. TCP/WebSocket or a GUI can be added later without redefining injector behavior. +`configure` replaces validated inactive configuration. It is rejected while `ARMED` or `RUNNING`. `arm` freezes the current configuration revision. `start` is accepted only from `ARMED`. `stop` ends an armed/running session without deleting the stored configuration. A new valid `configure` can recover from `FAULT`. ## First deterministic profile -The first common PC/P4 profile remains deliberately narrow: - -- 4,000 sample instants/s; -- 50 Hz nominal waveform; -- 80 samples/cycle; -- one ASDU per Ethernet frame; -- `smpCnt` wrap at 4,000 for the first profile; -- channel order `Ia, Ib, Ic, In, Va, Vb, Vc, Vn`; -- one INT32 sample plus 32-bit quality word per channel; -- 64-byte `seqData` payload; -- APPID `0x4001`; -- destination `01:0C:CD:04:00:01`; -- untagged first proof; -- `smpSynch=0` until synchronization evidence exists. - -The waveform engine uses a fixed-point phase accumulator, fractional phase remainder, a fixed Q15 sine table and integer scenario interpolation. Its steady-state generation path does not require floating-point math or heap allocation. - -## Windows reference CLI +- 4,000 sample instants/s +- 50 Hz nominal waveform +- 80 samples/cycle +- one ASDU per Ethernet frame +- `smpCnt` wrap at 4,000 +- channel order `Ia, Ib, Ic, In, Va, Vb, Vc, Vn` +- one INT32 sample plus 32-bit quality word per channel +- 64-byte `seqData` +- APPID `0x4001` +- destination `01:0C:CD:04:00:01` +- `svID = ARSTACK61850_INJECTOR` +- dataset `ARSTACK61850/LLN0$PhsMeas1` +- untagged first proof +- `smpSynch=0` until synchronization evidence exists + +The waveform engine uses a fixed-point phase accumulator, fractional phase remainder, fixed Q15 sine table and integer scenario interpolation. Its steady-state generation path does not require floating-point math or heap allocation. + +## Windows reference injector Executable: @@ -81,54 +83,20 @@ Executable: ariec61850_sv_injector ``` -### Software loopback / oracle +### Software loopback ```powershell -# Two deterministic virtual seconds at 4 kHz. .\ariec61850_sv_injector.exe --scenario normal --frames 8000 - -# Protection-style prefault / fault / recovery sequence. .\ariec61850_sv_injector.exe --scenario protection-fault - -# Simulate a downstream loss after every 1000 successful TX frames. .\ariec61850_sv_injector.exe --frames 8000 --drop-every 1000 - -# Retain exactly what the software receiver observed. .\ariec61850_sv_injector.exe --frames 8000 --pcap sv-loopback.pcap - -# Machine-readable app/CI evidence. -.\ariec61850_sv_injector.exe --frames 8000 --json ``` -The `software-loopback` transport does not need Npcap, administrator rights, a physical NIC or an ESP32. The encoded raw Ethernet frame is synchronously passed through `RawEthernetPort`, decoded again, and checked for stream identity, payload equality and sample-counter continuity. - -`--drop-every` injects loss **after** the producer transport reports success. This deliberately leaves the logical sample timeline advancing so the receiver must observe a `smpCnt` gap rather than a repeated old sample. - -Loopback deliberately remains finite. `--continuous` is rejected in loopback mode so accidental endless CI/regression runs cannot occur. - -### Windows live Ethernet / Npcap - -Live mode loads `wpcap.dll` at runtime, so the executable still builds without the Npcap SDK. Npcap must be installed on the Windows machine that performs live transmission. - -First list the adapters visible to Npcap: +### Windows Npcap live ```powershell .\ariec61850_sv_injector.exe --list-interfaces -``` - -Then run a finite live test by using either the exact Npcap device name or a unique substring from its description: -```powershell -.\ariec61850_sv_injector.exe ` - --mode live ` - --interface "Ethernet" ` - --scenario normal ` - --frames 8000 -``` - -For a subscriber/analyzer test, run continuously until `Ctrl+C`: - -```powershell .\ariec61850_sv_injector.exe ` --mode live ` --interface "Ethernet" ` @@ -136,124 +104,124 @@ For a subscriber/analyzer test, run continuously until `Ctrl+C`: --continuous ``` -The live path is: +The Windows live path uses real monotonic host timing with no catch-up burst. Logical waveform sequence remains deterministic, while physical Windows scheduling is explicitly best-effort. -```text -DeterministicSvInjector - | - v -INT32 + quality payload - | - v -SampledValuesPublisher - | - v -Npcap pcap_sendpacket - | - v -Windows physical NIC - | - +----> external SV subscriber - +----> Wireshark -``` +A manual interoperability run has now shown Process Bus Insight decoding the live Windows stream on the Microsoft KM-TEST Npcap adapter with APPID `0x4001`, 50 Hz, the canonical 4I+4V mapping, and balanced 120-degree three-phase phasors. Treat this as **Windows live subscriber acceptance**, not ESP32-P4 hardware acceptance. -The intended Wireshark display filter is: +## ESP32-P4 device control -```text -eth.type == 0x88ba -``` +The ESP32-P4 firmware now uses the same `DeterministicSvInjector` and canonical 4I+4V profile as the Windows oracle. The earlier count-coded synthetic payload generator has been removed from the active P4 trial path. -The first live stream identity is: +The built-in ESP32-P4 USB Serial/JTAG controller is the primary control transport. ESP-IDF standard input/output is mapped to this interface, which appears as a `COMx` device on Windows. -- destination MAC `01:0C:CD:04:00:01`; -- source MAC `02:00:00:00:00:02`; -- APPID `0x4001`; -- `svID = ARSTACK61850_INJECTOR`; -- dataset `ARSTACK61850/LLN0$PhsMeas1`; -- one ASDU per frame; -- 4,000 frames/s target; -- 250 us nominal frame interval; -- `smpCnt` 0..3999 and wrap; -- 4I+4V INT32 + quality payload. +Requests are one JSON object per line: -Live mode uses the real monotonic Windows clock and `SampledValuesPublisher` no-catch-up pacing. If a wake-up is late, it emits at most one frame and anchors the next 250 us deadline to the actual attempt time; it does **not** burst several overdue packets back-to-back. Final telemetry reports late polls, maximum lateness and observed TX rate. +```json +{"command":"capabilities"} +{"command":"status"} +{"command":"configure","scenario":"normal"} +{"command":"configure","scenario":"protection-fault"} +{"command":"arm"} +{"command":"start"} +{"command":"stop"} +{"command":"stats"} +``` -This is intentionally described as **best-effort real-time**, not deterministic Windows timing. The deterministic claim applies to the logical sample/waveform sequence. Physical NIC timing must be measured externally. +Responses are one-line records prefixed with: -`--pcap PATH` can also save the transmitted live frames with wall-clock timestamps, but writing evidence on the same process can perturb host timing. For timing characterization, prefer an independent Wireshark/Npcap capture on the receiving side. +```text +ARCTRL +``` -Depending on how Npcap was installed, packet injection may require an elevated terminal. If `--list-interfaces` works but `pcap_sendpacket` is rejected, retry from an Administrator PowerShell and inspect the returned Npcap error. +so a PC controller can distinguish control responses from ESP-IDF log output. -## Built-in scenarios in the first slice +On boot the P4 validates the `normal` profile and waits in `CONFIGURED`. A normal run is: -### `normal` +```json +{"command":"status"} +{"command":"arm"} +{"command":"start"} +``` -Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. The CLI defaults to 8,000 frames (two virtual seconds in loopback; approximately two wall-clock seconds in a healthy live run). +The publisher retains a pending logical sample if a scheduler poll occurs before the next frame is due. This prevents a timer wake-up from consuming a waveform sample without transmission. Encode failure, physical TX failure, missing injector state, or logical sample/`smpCnt` divergence transitions the device to `FAULT` rather than silently continuing with inconsistent state. -### `protection-fault` +## Windows P4 control client -A deterministic 4,800-sample sequence: +A standalone Windows client is provided: -1. prefault: 2,000 samples / 0.5 s; -2. fault: 800 samples / 0.2 s, phase currents 4x and phase voltages 0.25x; -3. recovery: 2,000 samples / 0.5 s. +```text +ariec61850_sv_device_ctl +``` -The scenario loops when live `--continuous` is selected. The first preset is a regression/lab scenario, not a calibrated secondary-injection claim. +It is built from `tools/device_ctl` and uses the same JSON contract over the P4 USB Serial/JTAG COM port. -## PC subscriber acceptance +Examples: -Before replacing the PC publisher with ESP32-P4, the recommended interoperability gate is: +```powershell +.\ariec61850_sv_device_ctl.exe --device COM7 status +.\ariec61850_sv_device_ctl.exe --device COM7 arm +.\ariec61850_sv_device_ctl.exe --device COM7 start +.\ariec61850_sv_device_ctl.exe --device COM7 stats +.\ariec61850_sv_device_ctl.exe --device COM7 stop -1. start the subscriber/analyzer on the selected Ethernet adapter; -2. start `ariec61850_sv_injector --mode live ... --continuous`; -3. confirm the subscriber sees APPID `0x4001`, `svID`, dataset and 4I+4V payload; -4. confirm `smpCnt` continuity and wrap; -5. confirm Wireshark sees EtherType `0x88BA` and approximately 4,000 frames/s; -6. stop the injector with `Ctrl+C` and retain its lateness/rate telemetry; -7. use this PC publisher result as the behavioral oracle for the later ESP32-P4 stream. +.\ariec61850_sv_device_ctl.exe --device COM7 configure protection-fault +``` -A live transmitter success result proves that Npcap accepted the frames. It does not by itself prove that a remote subscriber received every packet; receive-side evidence remains required. +The client removes the `ARCTRL ` prefix and writes the returned JSON object to stdout, making it suitable for PowerShell, automated tests, and a future GUI. -## Next source modes +## Built-in scenarios -Once the fixed-point engine and live/loopback gates are green, add sources in controlled slices: +### `normal` -1. explicit per-channel manual phasors; -2. deterministic ramp/state sequencer; -3. harmonic/DC/clipping controls; -4. COMTRADE-to-SV playback using sample-index mapping; -5. quality-state scripting; -6. controlled duplicate/corrupt/jitter scheduling faults; -7. SCL-derived stream identity and payload mapping; -8. multi-ASDU packing; -9. GOOSE injector scenarios. +Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. -COMTRADE playback must retain the original record/sample mapping independently from transmission timing; the network scheduler must never decide which COMTRADE sample is logically next. +### `protection-fault` -## ESP32-P4 integration rule +A deterministic 4,800-sample sequence: -The current ESP32-P4 firmware still uses its first synthetic payload generator. It should adopt `DeterministicSvInjector` after the host/live regression gates accept the new engine and transport behavior. +1. prefault: 2,000 samples / 0.5 s +2. fault: 800 samples / 0.2 s, phase currents 4x and phase voltages 0.25x +3. recovery: 2,000 samples / 0.5 s -The P4 application will then own only: +The sequence loops while the device remains running. -- ESP-IDF timer/task scheduling; -- EMAC/RMII Ethernet transport; -- USB-serial management transport; -- configuration persistence where required; -- telemetry and health reporting. +## Physical P4 acceptance -The scenario/waveform engine, payload semantics and SV wire codec remain shared with the Windows reference path. +1. flash the CI-produced P4 firmware +2. connect USB Serial/JTAG and Ethernet +3. query `status`; expect `CONFIGURED` +4. issue `arm`, then `start` +5. capture `eth.type == 0x88ba` in Process Bus Insight/Wireshark +6. verify APPID, `svID`, dataset, 4I+4V waveform and `smpCnt` continuity +7. compare phase/value behavior against the Windows reference stream +8. request `stats` and retain timer/TX/lateness evidence +9. issue `stop` and verify the stream ceases +10. repeat with short, 10-minute, and one-hour runs ## Evidence levels Do not conflate these results: -- `LOOPBACK/PASSED` — deterministic logical stream and software receive path accepted; -- `LIVE/PASSED` — Windows/Npcap accepted a finite real-NIC transmit run; external receive evidence is still separate; -- `LIVE/STOPPED` — continuous Windows/Npcap run stopped cleanly with `Ctrl+C` and no local encode/TX failures; -- `SIMULATED/PASSED` — host/embedded regression and independent wire evidence accepted; -- `ESP32P4/CROSS-COMPILED` — real P4 toolchain accepted; -- `FLASHABLE/READY` — complete P4 firmware artifact accepted; -- `HARDWARE/PASSED` — physical Ethernet/timing evidence recorded. +- `LOOPBACK/PASSED` — deterministic logical stream and software receive path accepted +- Windows live subscriber accepted — external host analyzer decoded the real Npcap stream +- `SIMULATED/PASSED` — embedded-host behavior and independent wire oracle accepted +- `ESP32P4/CROSS-COMPILED` — ESP-IDF toolchain compiled and linked the P4 firmware +- `FLASHABLE/READY` — complete P4 firmware artifact accepted +- P4 USB control accepted — physical COM request/response observed on hardware +- `HARDWARE/PASSED` — physical P4 Ethernet/timing/subscriber evidence recorded + +A deterministic logical waveform does not by itself make Windows, FreeRTOS scheduling, Ethernet transmission, USB transport, or clock synchronization deterministic. Those remain separate measurable properties. + +## Next source modes -A deterministic logical waveform does not by itself make Windows, FreeRTOS scheduling, Ethernet transmission or clock synchronization deterministic. Those remain separate measurable properties. +After P4 control and physical SV acceptance: + +1. explicit per-channel manual phasors +2. deterministic ramp/state sequencer +3. harmonic/DC/clipping controls +4. COMTRADE-to-SV sample-index playback +5. quality-state scripting +6. controlled duplicate/corrupt/jitter scheduling faults +7. SCL-derived stream identity and payload mapping +8. multi-ASDU packing +9. GOOSE injector scenarios From fe611e286570426a5b723757008c881f7de3c266 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:26:28 +0700 Subject: [PATCH 066/188] Add realtime SV source program ring --- .../injector_runtime_program.hpp | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 include/ariec61850/sampled_values/injector_runtime_program.hpp diff --git a/include/ariec61850/sampled_values/injector_runtime_program.hpp b/include/ariec61850/sampled_values/injector_runtime_program.hpp new file mode 100644 index 000000000..d25529649 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_runtime_program.hpp @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/deterministic_injector.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t injector_runtime_program_capacity = 32U; + +enum class InjectorRuntimeSourceMode : std::uint8_t { + manual, + ramp, + sequence, +}; + +struct InjectorSequenceState final { + std::uint32_t duration_samples{}; + InjectorSegmentTransition transition{InjectorSegmentTransition::step}; + std::array channels{}; +}; + +// Fixed-capacity source program used for sample-boundary-safe runtime changes. +// +// The DeterministicSvInjector owns no copy of these segments; it reads this +// caller-owned ring. The publisher task is therefore the single writer and can +// stage a new manual profile, ramp, or finite sequence without resetting the +// logical sample index or smpCnt. The current hold slot is changed from an +// indefinite duration to one sample; the engine then advances naturally into +// the newly staged slots and applies its existing phase-offset transition. +class InjectorRuntimeProgram final { +public: + using ChannelProfiles = + std::array; + + explicit InjectorRuntimeProgram(const ChannelProfiles& initial) noexcept { + reset(initial); + } + + void reset(const ChannelProfiles& initial) noexcept { + for (auto& segment : segments_) { + segment.duration_samples = 0U; + segment.transition = InjectorSegmentTransition::step; + segment.channels = initial; + } + revision_ = 1U; + mode_ = InjectorRuntimeSourceMode::manual; + } + + [[nodiscard]] std::span segments() const noexcept { + return segments_; + } + + [[nodiscard]] std::uint32_t revision() const noexcept { return revision_; } + [[nodiscard]] InjectorRuntimeSourceMode mode() const noexcept { return mode_; } + + [[nodiscard]] const ChannelProfiles& profile_at( + const std::size_t segment_index) const noexcept { + return segments_[segment_index % segments_.size()].channels; + } + + [[nodiscard]] bool stage_manual( + const std::size_t current_segment, + const ChannelProfiles& target) noexcept { + if (current_segment >= segments_.size()) { + return false; + } + + const auto target_index = next_index(current_segment); + prepare_hold(target_index, target); + release_current(current_segment); + bump_revision(); + mode_ = InjectorRuntimeSourceMode::manual; + return true; + } + + [[nodiscard]] bool stage_ramp( + const std::size_t current_segment, + const ChannelProfiles& target, + const std::uint32_t duration_samples) noexcept { + if (current_segment >= segments_.size() || duration_samples < 2U) { + return false; + } + + const auto ramp_index = next_index(current_segment); + const auto hold_index = next_index(ramp_index); + + segments_[ramp_index].duration_samples = duration_samples; + segments_[ramp_index].transition = InjectorSegmentTransition::linear_from_previous; + segments_[ramp_index].channels = target; + prepare_hold(hold_index, target); + release_current(current_segment); + bump_revision(); + mode_ = InjectorRuntimeSourceMode::ramp; + return true; + } + + [[nodiscard]] bool stage_sequence( + const std::size_t current_segment, + const std::span states) noexcept { + if (current_segment >= segments_.size() || states.empty() || + states.size() + 1U >= segments_.size()) { + return false; + } + + auto index = next_index(current_segment); + for (const auto& state : states) { + if (state.duration_samples == 0U) { + return false; + } + segments_[index].duration_samples = state.duration_samples; + segments_[index].transition = state.transition; + segments_[index].channels = state.channels; + index = next_index(index); + } + + // A finite sequence always ends in an indefinite hold of its final + // state. Repeating/triggered programs are intentionally a separate + // orchestration concern so a malformed edit can never create an + // accidental endless test. + prepare_hold(index, states.back().channels); + release_current(current_segment); + bump_revision(); + mode_ = InjectorRuntimeSourceMode::sequence; + return true; + } + +private: + [[nodiscard]] std::size_t next_index(const std::size_t index) const noexcept { + return index + 1U == segments_.size() ? 0U : index + 1U; + } + + void release_current(const std::size_t current_segment) noexcept { + // duration=0 may have accumulated an arbitrarily large + // sample_in_segment in the injector. Setting it to one guarantees the + // very next completed sample advances to the newly staged slot. + segments_[current_segment].duration_samples = 1U; + } + + void prepare_hold( + const std::size_t index, + const ChannelProfiles& channels) noexcept { + segments_[index].duration_samples = 0U; + segments_[index].transition = InjectorSegmentTransition::step; + segments_[index].channels = channels; + } + + void bump_revision() noexcept { + ++revision_; + if (revision_ == 0U) { + ++revision_; + } + } + + std::array segments_{}; + std::uint32_t revision_{}; + InjectorRuntimeSourceMode mode_{InjectorRuntimeSourceMode::manual}; +}; + +} // namespace ar::iec61850::sampled_values From 85b15069d691c3e947e9fa7a21ae14c1447934cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:26:56 +0700 Subject: [PATCH 067/188] Test realtime manual ramp and sequence sources --- embedded/sv_runtime_program_smoke.cpp | 153 ++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 embedded/sv_runtime_program_smoke.cpp diff --git a/embedded/sv_runtime_program_smoke.cpp b/embedded/sv_runtime_program_smoke.cpp new file mode 100644 index 000000000..948365d54 --- /dev/null +++ b/embedded/sv_runtime_program_smoke.cpp @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/injector_runtime_program.hpp" + +#include +#include + +namespace { + +using namespace ar::iec61850::sampled_values; + +[[nodiscard]] InjectorRuntimeProgram::ChannelProfiles constant_profile( + const std::int32_t ia_rms, + const std::int32_t ia_phase_millidegrees = 90'000) noexcept { + auto channels = make_balanced_4i4v_profile(ia_rms, 5'774, 0U); + for (auto& channel : channels) { + channel.frequency_millihz = 0U; + } + channels[0].phase_millidegrees = ia_phase_millidegrees; + return channels; +} + +[[nodiscard]] bool near(const std::int32_t value, const std::int32_t expected) noexcept { + const auto difference = value > expected ? value - expected : expected - value; + return difference <= 8; +} + +bool hot_manual_update_preserves_timeline() noexcept { + InjectorRuntimeProgram program(constant_profile(1'000)); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + for (std::uint64_t index = 0U; index < 10U; ++index) { + if (!injector.step(sample) || sample.sample_index != index || + !near(sample.values[0], 1'414)) { + return false; + } + } + + if (!program.stage_manual(injector.segment_index(), constant_profile(2'000))) { + return false; + } + + // One final sample completes the active hold slot; the next sample uses the + // staged profile without resetting the global sample index. + if (!injector.step(sample) || sample.sample_index != 10U || + !near(sample.values[0], 1'414)) { + return false; + } + if (!injector.step(sample) || sample.sample_index != 11U || + !near(sample.values[0], 2'828)) { + return false; + } + + // Phase changes are also boundary-applied through the engine's existing + // inter-segment phase-offset logic. + if (!program.stage_manual(injector.segment_index(), constant_profile(2'000, 0))) { + return false; + } + if (!injector.step(sample) || sample.sample_index != 12U || + !near(sample.values[0], 2'828)) { + return false; + } + return injector.step(sample) && sample.sample_index == 13U && + near(sample.values[0], 0); +} + +bool ramp_is_sample_indexed_and_holds_target() noexcept { + InjectorRuntimeProgram program(constant_profile(1'000)); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + if (!injector.step(sample)) { + return false; + } + if (!program.stage_ramp( + injector.segment_index(), constant_profile(4'000), 3U)) { + return false; + } + + std::array observed{}; + for (auto& value : observed) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[0]; + } + + return near(observed[0], 1'414) && + near(observed[1], 1'414) && + near(observed[2], 3'535) && + near(observed[3], 5'656) && + near(observed[4], 5'656) && + program.mode() == InjectorRuntimeSourceMode::ramp; +} + +bool finite_sequence_ends_in_hold() noexcept { + InjectorRuntimeProgram program(constant_profile(1'000)); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + if (!injector.valid()) { + return false; + } + + const std::array states{ + InjectorSequenceState{2U, InjectorSegmentTransition::step, constant_profile(2'000)}, + InjectorSequenceState{2U, InjectorSegmentTransition::step, constant_profile(3'000)}}; + + InjectorSample sample{}; + if (!injector.step(sample) || + !program.stage_sequence(injector.segment_index(), states)) { + return false; + } + + std::array observed{}; + for (auto& value : observed) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[0]; + } + + return near(observed[0], 1'414) && + near(observed[1], 2'828) && + near(observed[2], 2'828) && + near(observed[3], 4'242) && + near(observed[4], 4'242) && + near(observed[5], 4'242) && + near(observed[6], 4'242) && + program.mode() == InjectorRuntimeSourceMode::sequence; +} + +} // namespace + +int main() { + if (!hot_manual_update_preserves_timeline()) { + return 1; + } + if (!ramp_is_sample_indexed_and_holds_target()) { + return 2; + } + if (!finite_sequence_ends_in_hold()) { + return 3; + } + return 0; +} From db420232714dd2a4a27835e75b88e6786e0d5ff1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:27:58 +0700 Subject: [PATCH 068/188] Gate realtime SV runtime programs in embedded CI --- embedded/CMakeLists.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index 85e344134..e3819c2fa 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -129,6 +129,19 @@ target_compile_definitions(ariec61850_embedded_sv_injector_controller_smoke PRIV ARIEC61850_HOST_SERVICES=0) ariec61850_apply_embedded_flags(ariec61850_embedded_sv_injector_controller_smoke) +# Realtime source-program smoke: hot manual edits, sample-indexed ramps, and +# finite state sequences must preserve the logical sample timeline and remain +# inside the allocation-bounded MCU profile. +add_executable(ariec61850_embedded_sv_runtime_program_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_runtime_program_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_runtime_program_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_runtime_program_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_runtime_program_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_runtime_program_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_runtime_program_smoke) + # Produce a deterministic two-second classic-PCAP capture using only the # publisher/encoder path. A separate Python implementation verifies the complete # wire image byte-for-byte in CI, so encoder bugs cannot be hidden by the C++ @@ -148,6 +161,7 @@ add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_sm add_test(NAME ariec61850_embedded_sv_publisher_smoke COMMAND ariec61850_embedded_sv_publisher_smoke) add_test(NAME ariec61850_embedded_sv_injector_smoke COMMAND ariec61850_embedded_sv_injector_smoke) add_test(NAME ariec61850_embedded_sv_injector_controller_smoke COMMAND ariec61850_embedded_sv_injector_controller_smoke) +add_test(NAME ariec61850_embedded_sv_runtime_program_smoke COMMAND ariec61850_embedded_sv_runtime_program_smoke) add_test( NAME ariec61850_embedded_sv_stream_pcap COMMAND ariec61850_embedded_sv_stream_pcap "${CMAKE_BINARY_DIR}/sv_trial_2s.pcap") From e558ef0880703811b401ab0aace3a07b0a1512d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:28:46 +0700 Subject: [PATCH 069/188] Add sparse realtime SV channel edits --- .../injector_runtime_program.hpp | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/include/ariec61850/sampled_values/injector_runtime_program.hpp b/include/ariec61850/sampled_values/injector_runtime_program.hpp index d25529649..e62da24aa 100644 --- a/include/ariec61850/sampled_values/injector_runtime_program.hpp +++ b/include/ariec61850/sampled_values/injector_runtime_program.hpp @@ -18,6 +18,33 @@ enum class InjectorRuntimeSourceMode : std::uint8_t { sequence, }; +enum InjectorSignalField : std::uint16_t { + injector_field_enabled = 1U << 0U, + injector_field_rms = 1U << 1U, + injector_field_dc = 1U << 2U, + injector_field_phase = 1U << 3U, + injector_field_frequency = 1U << 4U, + injector_field_harmonic = 1U << 5U, + injector_field_harmonic_order = 1U << 6U, + injector_field_clip = 1U << 7U, + injector_field_quality = 1U << 8U, +}; + +struct InjectorChannelEdit final { + std::uint8_t channel_index{}; + std::uint16_t fields{}; + bool enabled{true}; + std::int32_t rms_counts{}; + std::int32_t dc_counts{}; + std::int32_t phase_millidegrees{}; + std::uint32_t frequency_millihz{50'000U}; + std::uint16_t harmonic_permyriad{}; + std::uint8_t harmonic_order{2U}; + std::uint16_t clip_permyriad{}; + std::uint32_t quality{}; + std::uint32_t duration_samples{}; +}; + struct InjectorSequenceState final { std::uint32_t duration_samples{}; InjectorSegmentTransition transition{InjectorSegmentTransition::step}; @@ -63,6 +90,44 @@ class InjectorRuntimeProgram final { return segments_[segment_index % segments_.size()].channels; } + [[nodiscard]] static bool apply_edit( + ChannelProfiles& profiles, + const InjectorChannelEdit& edit) noexcept { + if (edit.channel_index >= injector_channel_count || edit.fields == 0U) { + return false; + } + + auto& channel = profiles[edit.channel_index]; + if ((edit.fields & injector_field_enabled) != 0U) { + channel.enabled = edit.enabled; + } + if ((edit.fields & injector_field_rms) != 0U) { + channel.rms_counts = edit.rms_counts; + } + if ((edit.fields & injector_field_dc) != 0U) { + channel.dc_counts = edit.dc_counts; + } + if ((edit.fields & injector_field_phase) != 0U) { + channel.phase_millidegrees = edit.phase_millidegrees; + } + if ((edit.fields & injector_field_frequency) != 0U) { + channel.frequency_millihz = edit.frequency_millihz; + } + if ((edit.fields & injector_field_harmonic) != 0U) { + channel.harmonic_permyriad = edit.harmonic_permyriad; + } + if ((edit.fields & injector_field_harmonic_order) != 0U) { + channel.harmonic_order = edit.harmonic_order; + } + if ((edit.fields & injector_field_clip) != 0U) { + channel.clip_permyriad = edit.clip_permyriad; + } + if ((edit.fields & injector_field_quality) != 0U) { + channel.quality = edit.quality; + } + return true; + } + [[nodiscard]] bool stage_manual( const std::size_t current_segment, const ChannelProfiles& target) noexcept { @@ -78,6 +143,19 @@ class InjectorRuntimeProgram final { return true; } + [[nodiscard]] bool stage_manual_edit( + const std::size_t current_segment, + const InjectorChannelEdit& edit) noexcept { + if (current_segment >= segments_.size()) { + return false; + } + auto target = profile_at(current_segment); + if (!apply_edit(target, edit)) { + return false; + } + return stage_manual(current_segment, target); + } + [[nodiscard]] bool stage_ramp( const std::size_t current_segment, const ChannelProfiles& target, @@ -99,6 +177,19 @@ class InjectorRuntimeProgram final { return true; } + [[nodiscard]] bool stage_ramp_edit( + const std::size_t current_segment, + const InjectorChannelEdit& edit) noexcept { + if (current_segment >= segments_.size() || edit.duration_samples < 2U) { + return false; + } + auto target = profile_at(current_segment); + if (!apply_edit(target, edit)) { + return false; + } + return stage_ramp(current_segment, target, edit.duration_samples); + } + [[nodiscard]] bool stage_sequence( const std::size_t current_segment, const std::span states) noexcept { From e3545646e67720f655b824ed862d08fabaf1fc37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:29:25 +0700 Subject: [PATCH 070/188] Add realtime channel and ramp control commands --- .../injector_control_protocol.hpp | 278 +++++++++++++++++- 1 file changed, 267 insertions(+), 11 deletions(-) diff --git a/include/ariec61850/sampled_values/injector_control_protocol.hpp b/include/ariec61850/sampled_values/injector_control_protocol.hpp index ab8347207..cec6eba17 100644 --- a/include/ariec61850/sampled_values/injector_control_protocol.hpp +++ b/include/ariec61850/sampled_values/injector_control_protocol.hpp @@ -2,10 +2,12 @@ #pragma once #include "ariec61850/sampled_values/injector_controller.hpp" +#include "ariec61850/sampled_values/injector_runtime_program.hpp" #include #include #include +#include #include namespace ar::iec61850::sampled_values { @@ -18,6 +20,8 @@ enum class InjectorControlCommandKind : std::uint8_t { stop, status, stats, + set_channel, + ramp_channel, }; enum class InjectorControlParseStatus : std::uint8_t { @@ -27,11 +31,16 @@ enum class InjectorControlParseStatus : std::uint8_t { unsupported_command, missing_scenario, unsupported_scenario, + missing_channel, + unsupported_channel, + missing_value, + invalid_value, }; struct InjectorControlCommand final { InjectorControlCommandKind kind{InjectorControlCommandKind::status}; InjectorScenarioKind scenario{InjectorScenarioKind::normal}; + InjectorChannelEdit channel_edit{}; }; struct InjectorControlParseResult final { @@ -45,6 +54,12 @@ struct InjectorControlParseResult final { namespace detail { +enum class JsonIntegerStatus : std::uint8_t { + missing, + ok, + malformed, +}; + [[nodiscard]] inline std::string_view trim_ascii( std::string_view value) noexcept { while (!value.empty() && @@ -58,10 +73,10 @@ namespace detail { return value; } -[[nodiscard]] inline bool json_string_value( +[[nodiscard]] inline bool find_json_value_cursor( const std::string_view object, const std::string_view key, - std::string_view& output) noexcept { + std::size_t& cursor) noexcept { const auto quoted_key_length = key.size() + 2U; if (quoted_key_length > object.size()) { return false; @@ -80,7 +95,7 @@ namespace detail { continue; } - auto cursor = quote + quoted_key_length; + cursor = quote + quoted_key_length; while (cursor < object.size() && std::isspace(static_cast(object[cursor])) != 0) { ++cursor; @@ -93,23 +108,250 @@ namespace detail { std::isspace(static_cast(object[cursor])) != 0) { ++cursor; } - if (cursor >= object.size() || object[cursor] != '"') { - return false; + return cursor < object.size(); + } + return false; +} + +[[nodiscard]] inline bool json_string_value( + const std::string_view object, + const std::string_view key, + std::string_view& output) noexcept { + std::size_t cursor{}; + if (!find_json_value_cursor(object, key, cursor) || object[cursor] != '"') { + return false; + } + ++cursor; + + const auto end = object.find('"', cursor); + if (end == std::string_view::npos) { + return false; + } + if (object.substr(cursor, end - cursor).find('\\') != + std::string_view::npos) { + return false; + } + output = object.substr(cursor, end - cursor); + return true; +} + +[[nodiscard]] inline JsonIntegerStatus json_integer_value( + const std::string_view object, + const std::string_view key, + std::int64_t& output) noexcept { + std::size_t cursor{}; + if (!find_json_value_cursor(object, key, cursor)) { + return JsonIntegerStatus::missing; + } + + bool negative = false; + if (object[cursor] == '-') { + negative = true; + ++cursor; + } + if (cursor >= object.size() || + std::isdigit(static_cast(object[cursor])) == 0) { + return JsonIntegerStatus::malformed; + } + + std::uint64_t magnitude{}; + constexpr auto positive_limit = + static_cast(std::numeric_limits::max()); + constexpr auto negative_limit = positive_limit + 1ULL; + const auto limit = negative ? negative_limit : positive_limit; + + while (cursor < object.size() && + std::isdigit(static_cast(object[cursor])) != 0) { + const auto digit = static_cast(object[cursor] - '0'); + if (magnitude > (limit - digit) / 10ULL) { + return JsonIntegerStatus::malformed; } + magnitude = magnitude * 10ULL + digit; ++cursor; + } + + while (cursor < object.size() && + std::isspace(static_cast(object[cursor])) != 0) { + ++cursor; + } + if (cursor < object.size() && object[cursor] != ',' && object[cursor] != '}') { + return JsonIntegerStatus::malformed; + } + + if (negative) { + if (magnitude == negative_limit) { + output = std::numeric_limits::min(); + } else { + output = -static_cast(magnitude); + } + } else { + output = static_cast(magnitude); + } + return JsonIntegerStatus::ok; +} + +[[nodiscard]] inline bool parse_channel_index( + const std::string_view name, + std::uint8_t& index) noexcept { + constexpr std::string_view names[injector_channel_count]{ + "Ia", "Ib", "Ic", "In", "Va", "Vb", "Vc", "Vn"}; + for (std::size_t candidate = 0U; candidate < injector_channel_count; ++candidate) { + if (name == names[candidate]) { + index = static_cast(candidate); + return true; + } + } + return false; +} - const auto end = object.find('"', cursor); - if (end == std::string_view::npos) { +[[nodiscard]] inline InjectorControlParseStatus parse_channel_edit( + const std::string_view line, + InjectorChannelEdit& edit, + const bool require_duration) noexcept { + std::string_view channel_name; + if (!json_string_value(line, "channel", channel_name)) { + return InjectorControlParseStatus::missing_channel; + } + if (!parse_channel_index(channel_name, edit.channel_index)) { + return InjectorControlParseStatus::unsupported_channel; + } + + auto parse_signed = [&](const std::string_view key, + const std::uint16_t bit, + std::int32_t& target) noexcept -> bool { + std::int64_t value{}; + const auto status = json_integer_value(line, key, value); + if (status == JsonIntegerStatus::missing) { + return true; + } + if (status != JsonIntegerStatus::ok || + value < std::numeric_limits::min() || + value > std::numeric_limits::max()) { return false; } - if (object.substr(cursor, end - cursor).find('\\') != - std::string_view::npos) { + target = static_cast(value); + edit.fields = static_cast(edit.fields | bit); + return true; + }; + + auto parse_unsigned = [&](const std::string_view key, + const std::uint16_t bit, + const std::uint64_t maximum, + std::uint32_t& target) noexcept -> bool { + std::int64_t value{}; + const auto status = json_integer_value(line, key, value); + if (status == JsonIntegerStatus::missing) { + return true; + } + if (status != JsonIntegerStatus::ok || value < 0 || + static_cast(value) > maximum) { return false; } - output = object.substr(cursor, end - cursor); + target = static_cast(value); + edit.fields = static_cast(edit.fields | bit); return true; + }; + + std::int64_t enabled{}; + const auto enabled_status = json_integer_value(line, "enabled", enabled); + if (enabled_status == JsonIntegerStatus::malformed || + (enabled_status == JsonIntegerStatus::ok && enabled != 0 && enabled != 1)) { + return InjectorControlParseStatus::invalid_value; } - return false; + if (enabled_status == JsonIntegerStatus::ok) { + edit.enabled = enabled != 0; + edit.fields = static_cast( + edit.fields | injector_field_enabled); + } + + if (!parse_signed("rms", injector_field_rms, edit.rms_counts) || + !parse_signed("dc", injector_field_dc, edit.dc_counts) || + !parse_signed("phaseMilliDeg", injector_field_phase, edit.phase_millidegrees)) { + return InjectorControlParseStatus::invalid_value; + } + + std::uint32_t frequency{}; + if (!parse_unsigned( + "frequencyMilliHz", + injector_field_frequency, + 2'000'000ULL, + frequency)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_frequency) != 0U) { + edit.frequency_millihz = frequency; + } + + std::uint32_t harmonic{}; + if (!parse_unsigned( + "harmonicPermyriad", + injector_field_harmonic, + injector_gain_one, + harmonic)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_harmonic) != 0U) { + edit.harmonic_permyriad = static_cast(harmonic); + } + + std::uint32_t harmonic_order{}; + if (!parse_unsigned( + "harmonicOrder", + injector_field_harmonic_order, + 63U, + harmonic_order)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_harmonic_order) != 0U) { + if (harmonic_order < 2U) { + return InjectorControlParseStatus::invalid_value; + } + edit.harmonic_order = static_cast(harmonic_order); + } + + std::uint32_t clip{}; + if (!parse_unsigned( + "clipPermyriad", + injector_field_clip, + injector_gain_one, + clip)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_clip) != 0U) { + edit.clip_permyriad = static_cast(clip); + } + + std::uint32_t quality{}; + if (!parse_unsigned( + "quality", + injector_field_quality, + std::numeric_limits::max(), + quality)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_quality) != 0U) { + edit.quality = quality; + } + + if (edit.fields == 0U) { + return InjectorControlParseStatus::missing_value; + } + + if (require_duration) { + std::int64_t duration{}; + const auto duration_status = + json_integer_value(line, "durationSamples", duration); + if (duration_status == JsonIntegerStatus::missing) { + return InjectorControlParseStatus::missing_value; + } + if (duration_status != JsonIntegerStatus::ok || duration < 2 || + duration > 100'000'000LL) { + return InjectorControlParseStatus::invalid_value; + } + edit.duration_samples = static_cast(duration); + } + + return InjectorControlParseStatus::ok; } } // namespace detail @@ -155,6 +397,20 @@ namespace detail { command.kind = InjectorControlCommandKind::status; } else if (command_name == "stats") { command.kind = InjectorControlCommandKind::stats; + } else if (command_name == "set-channel") { + command.kind = InjectorControlCommandKind::set_channel; + const auto status = detail::parse_channel_edit( + line, command.channel_edit, false); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } + } else if (command_name == "ramp-channel") { + command.kind = InjectorControlCommandKind::ramp_channel; + const auto status = detail::parse_channel_edit( + line, command.channel_edit, true); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } } else { return {InjectorControlParseStatus::unsupported_command, {}}; } From 95fec56583e5616241e915d15315fbecaef100a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:29:54 +0700 Subject: [PATCH 071/188] Test realtime SV control commands --- embedded/sv_injector_controller_smoke.cpp | 76 +++++++++++++++++------ 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/embedded/sv_injector_controller_smoke.cpp b/embedded/sv_injector_controller_smoke.cpp index 5a0a964f4..600a7eea7 100644 --- a/embedded/sv_injector_controller_smoke.cpp +++ b/embedded/sv_injector_controller_smoke.cpp @@ -42,83 +42,123 @@ int main() { return fail(5); } + const auto live_edit = parse_injector_control_command( + R"({"command":"set-channel","channel":"Ia","rms":2500,"phaseMilliDeg":15000,"frequencyMilliHz":50000})"); + if (!live_edit.success() || + live_edit.command.kind != InjectorControlCommandKind::set_channel || + live_edit.command.channel_edit.channel_index != 0U || + live_edit.command.channel_edit.rms_counts != 2'500 || + live_edit.command.channel_edit.phase_millidegrees != 15'000 || + live_edit.command.channel_edit.frequency_millihz != 50'000U || + (live_edit.command.channel_edit.fields & injector_field_rms) == 0U || + (live_edit.command.channel_edit.fields & injector_field_phase) == 0U || + (live_edit.command.channel_edit.fields & injector_field_frequency) == 0U) { + return fail(6); + } + + const auto ramp_edit = parse_injector_control_command( + R"({"command":"ramp-channel","channel":"Va","rms":9000,"durationSamples":4000})"); + if (!ramp_edit.success() || + ramp_edit.command.kind != InjectorControlCommandKind::ramp_channel || + ramp_edit.command.channel_edit.channel_index != 4U || + ramp_edit.command.channel_edit.duration_samples != 4'000U || + ramp_edit.command.channel_edit.rms_counts != 9'000) { + return fail(7); + } + + if (parse_injector_control_command( + R"({"command":"set-channel","channel":"Ix","rms":1000})") + .status != InjectorControlParseStatus::unsupported_channel) { + return fail(8); + } + if (parse_injector_control_command( + R"({"command":"set-channel","channel":"Ia"})") + .status != InjectorControlParseStatus::missing_value) { + return fail(9); + } + if (parse_injector_control_command( + R"({"command":"ramp-channel","channel":"Ia","rms":2000,"durationSamples":1})") + .status != InjectorControlParseStatus::invalid_value) { + return fail(10); + } + InjectorController controller; if (controller.state() != InjectorControlState::idle) { - return fail(6); + return fail(11); } if (controller.start() != InjectorControlStatus::invalid_state) { - return fail(7); + return fail(12); } if (controller.configure({0U, InjectorScenarioKind::normal}) != InjectorControlStatus::invalid_configuration) { - return fail(8); + return fail(13); } if (controller.configure({4'000U, InjectorScenarioKind::normal}) != InjectorControlStatus::ok) { - return fail(9); + return fail(14); } const auto configured = controller.snapshot(); if (configured.state != InjectorControlState::configured || configured.configuration_revision != 1U || configured.armed_revision != 0U) { - return fail(10); + return fail(15); } if (controller.arm() != InjectorControlStatus::ok) { - return fail(11); + return fail(16); } const auto armed = controller.snapshot(); if (armed.state != InjectorControlState::armed || armed.armed_revision != armed.configuration_revision) { - return fail(12); + return fail(17); } if (controller.configure({4'000U, InjectorScenarioKind::protection_fault}) != InjectorControlStatus::invalid_state) { - return fail(13); + return fail(18); } if (controller.start() != InjectorControlStatus::ok || !controller.running()) { - return fail(14); + return fail(19); } const auto first_run = controller.snapshot(); if (first_run.run_sequence != 1U) { - return fail(15); + return fail(20); } if (controller.arm() != InjectorControlStatus::invalid_state) { - return fail(16); + return fail(21); } if (controller.stop() != InjectorControlStatus::ok || controller.state() != InjectorControlState::stopped) { - return fail(17); + return fail(22); } if (controller.arm() != InjectorControlStatus::ok || controller.start() != InjectorControlStatus::ok) { - return fail(18); + return fail(23); } if (controller.snapshot().run_sequence != 2U) { - return fail(19); + return fail(24); } controller.set_fault(); if (controller.state() != InjectorControlState::fault) { - return fail(20); + return fail(25); } if (controller.start() != InjectorControlStatus::invalid_state) { - return fail(21); + return fail(26); } if (controller.configure({4'000U, InjectorScenarioKind::normal}) != InjectorControlStatus::ok) { - return fail(22); + return fail(27); } const auto recovered = controller.snapshot(); if (recovered.state != InjectorControlState::configured || recovered.configuration_revision != 2U || recovered.armed_revision != 0U) { - return fail(23); + return fail(28); } return 0; From dbef6969789756265691cba482dc8f4d61a824db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:30:40 +0700 Subject: [PATCH 072/188] Guard realtime edits to stable hold slots --- .../sampled_values/injector_runtime_program.hpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/include/ariec61850/sampled_values/injector_runtime_program.hpp b/include/ariec61850/sampled_values/injector_runtime_program.hpp index e62da24aa..077101629 100644 --- a/include/ariec61850/sampled_values/injector_runtime_program.hpp +++ b/include/ariec61850/sampled_values/injector_runtime_program.hpp @@ -85,6 +85,11 @@ class InjectorRuntimeProgram final { [[nodiscard]] std::uint32_t revision() const noexcept { return revision_; } [[nodiscard]] InjectorRuntimeSourceMode mode() const noexcept { return mode_; } + [[nodiscard]] bool is_hold_segment(const std::size_t segment_index) const noexcept { + return segment_index < segments_.size() && + segments_[segment_index].duration_samples == 0U; + } + [[nodiscard]] const ChannelProfiles& profile_at( const std::size_t segment_index) const noexcept { return segments_[segment_index % segments_.size()].channels; @@ -131,7 +136,7 @@ class InjectorRuntimeProgram final { [[nodiscard]] bool stage_manual( const std::size_t current_segment, const ChannelProfiles& target) noexcept { - if (current_segment >= segments_.size()) { + if (!is_hold_segment(current_segment)) { return false; } @@ -146,7 +151,7 @@ class InjectorRuntimeProgram final { [[nodiscard]] bool stage_manual_edit( const std::size_t current_segment, const InjectorChannelEdit& edit) noexcept { - if (current_segment >= segments_.size()) { + if (!is_hold_segment(current_segment)) { return false; } auto target = profile_at(current_segment); @@ -160,7 +165,7 @@ class InjectorRuntimeProgram final { const std::size_t current_segment, const ChannelProfiles& target, const std::uint32_t duration_samples) noexcept { - if (current_segment >= segments_.size() || duration_samples < 2U) { + if (!is_hold_segment(current_segment) || duration_samples < 2U) { return false; } @@ -180,7 +185,7 @@ class InjectorRuntimeProgram final { [[nodiscard]] bool stage_ramp_edit( const std::size_t current_segment, const InjectorChannelEdit& edit) noexcept { - if (current_segment >= segments_.size() || edit.duration_samples < 2U) { + if (!is_hold_segment(current_segment) || edit.duration_samples < 2U) { return false; } auto target = profile_at(current_segment); @@ -193,7 +198,7 @@ class InjectorRuntimeProgram final { [[nodiscard]] bool stage_sequence( const std::size_t current_segment, const std::span states) noexcept { - if (current_segment >= segments_.size() || states.empty() || + if (!is_hold_segment(current_segment) || states.empty() || states.size() + 1U >= segments_.size()) { return false; } From 91191403c5e4b625853b2e8078302ad94cb5311c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:32:01 +0700 Subject: [PATCH 073/188] Enable realtime SV value edits on ESP32-P4 --- ports/esp_idf/sv_trial_app/main/main.cpp | 91 +++++++++++++++++++++--- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/main/main.cpp b/ports/esp_idf/sv_trial_app/main/main.cpp index bf392f961..4bcc06844 100644 --- a/ports/esp_idf/sv_trial_app/main/main.cpp +++ b/ports/esp_idf/sv_trial_app/main/main.cpp @@ -7,6 +7,7 @@ #include "ariec61850/sampled_values/injector_control_protocol.hpp" #include "ariec61850/sampled_values/injector_controller.hpp" #include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/injector_runtime_program.hpp" #include "ariec61850/sampled_values/payload_writer.hpp" #include "ariec61850/sampled_values/publisher.hpp" @@ -60,6 +61,9 @@ TaskHandle_t g_publisher_task{}; esp_eth_handle_t g_eth_handle{}; QueueHandle_t g_control_queue{}; sampled_values::InjectorController g_injector_controller{}; +// Keep the fixed-capacity source ring out of the publisher task stack. It is +// mutated only by the publisher task after commands cross the FreeRTOS queue. +std::optional g_runtime_program{}; [[nodiscard]] const char* state_name( const sampled_values::InjectorControlState state) noexcept { @@ -87,6 +91,19 @@ sampled_values::InjectorController g_injector_controller{}; : "normal"; } +[[nodiscard]] const char* runtime_mode_name( + const sampled_values::InjectorRuntimeSourceMode mode) noexcept { + switch (mode) { + case sampled_values::InjectorRuntimeSourceMode::manual: + return "manual"; + case sampled_values::InjectorRuntimeSourceMode::ramp: + return "ramp"; + case sampled_values::InjectorRuntimeSourceMode::sequence: + return "sequence"; + } + return "unknown"; +} + [[nodiscard]] const char* command_name( const sampled_values::InjectorControlCommandKind command) noexcept { switch (command) { @@ -104,6 +121,10 @@ sampled_values::InjectorController g_injector_controller{}; return "status"; case sampled_values::InjectorControlCommandKind::stats: return "stats"; + case sampled_values::InjectorControlCommandKind::set_channel: + return "set-channel"; + case sampled_values::InjectorControlCommandKind::ramp_channel: + return "ramp-channel"; } return "unknown"; } @@ -126,13 +147,20 @@ void print_control_ack( "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," "\"command\":\"%s\",\"ok\":true,\"state\":\"%s\"," "\"scenario\":\"%s\",\"configurationRevision\":%u," - "\"armedRevision\":%u,\"runSequence\":%" PRIu64 "}\n", + "\"armedRevision\":%u,\"runSequence\":%" PRIu64, command_name(command), state_name(snapshot.state), scenario_name(snapshot.configuration.scenario), static_cast(snapshot.configuration_revision), static_cast(snapshot.armed_revision), snapshot.run_sequence); + if (g_runtime_program.has_value()) { + std::printf( + ",\"sourceMode\":\"%s\",\"sourceRevision\":%u", + runtime_mode_name(g_runtime_program->mode()), + static_cast(g_runtime_program->revision())); + } + std::printf("}\n"); std::fflush(stdout); } @@ -143,7 +171,8 @@ void print_capabilities() noexcept { "\"transport\":\"usb-serial-jtag-stdio\"," "\"engine\":\"sample-index-fixed-point\"," "\"commands\":[\"capabilities\",\"configure\",\"arm\",\"start\"," - "\"stop\",\"status\",\"stats\"]," + "\"stop\",\"status\",\"stats\",\"set-channel\",\"ramp-channel\"]," + "\"sourceModes\":[\"manual\",\"ramp\",\"sequence\"]," "\"scenarios\":[\"normal\",\"protection-fault\"]," "\"profile\":{\"sampleRateHz\":4000,\"sampleCountWrap\":4000," "\"channels\":8,\"payload\":\"INT32+quality\",\"appid\":16385}}\n"); @@ -159,13 +188,20 @@ void print_status() noexcept { "\"command\":\"status\",\"ok\":true,\"state\":\"%s\"," "\"scenario\":\"%s\",\"configurationRevision\":%u," "\"armedRevision\":%u,\"runSequence\":%" PRIu64 "," - "\"ethernetLinkUp\":%s}\n", + "\"ethernetLinkUp\":%s", state_name(snapshot.state), scenario_name(snapshot.configuration.scenario), static_cast(snapshot.configuration_revision), static_cast(snapshot.armed_revision), snapshot.run_sequence, link_up ? "true" : "false"); + if (g_runtime_program.has_value()) { + std::printf( + ",\"sourceMode\":\"%s\",\"sourceRevision\":%u", + runtime_mode_name(g_runtime_program->mode()), + static_cast(g_runtime_program->revision())); + } + std::printf("}\n"); std::fflush(stdout); } @@ -400,9 +436,6 @@ void publisher_task(void* argument) { 0U, true}); - const std::array normal_scenario{ - sampled_values::make_hold_segment( - sampled_values::make_balanced_4i4v_profile())}; const auto protection_fault_scenario = make_protection_fault_scenario(); std::optional injector; std::optional pending_sample; @@ -470,7 +503,7 @@ void publisher_task(void* argument) { "\"framesSent\":%" PRIu64 ",\"encodeFailures\":%" PRIu64 "," "\"transmitFailures\":%" PRIu64 ",\"latePolls\":%" PRIu64 "," "\"maximumLatenessUs\":%" PRIu64 ",\"timerTicks\":%" PRIu64 "," - "\"coalescedTicks\":%" PRIu64 "}\n", + "\"coalescedTicks\":%" PRIu64, state_name(snapshot.state), stats.frames_sent, stats.encode_failures, @@ -479,9 +512,41 @@ void publisher_task(void* argument) { stats.maximum_lateness_us, timer_notifications, coalesced_notifications); + if (g_runtime_program.has_value()) { + std::printf( + ",\"sourceMode\":\"%s\",\"sourceRevision\":%u", + runtime_mode_name(g_runtime_program->mode()), + static_cast(g_runtime_program->revision())); + } + std::printf("}\n"); std::fflush(stdout); continue; } + case sampled_values::InjectorControlCommandKind::set_channel: + case sampled_values::InjectorControlCommandKind::ramp_channel: { + if (!g_injector_controller.running() || !injector.has_value() || + !g_runtime_program.has_value()) { + print_control_error(command_name(command.kind), "source-not-editable"); + continue; + } + const auto current_segment = injector->segment_index(); + if (!g_runtime_program->is_hold_segment(current_segment)) { + print_control_error(command_name(command.kind), "source-busy"); + continue; + } + const auto staged = command.kind == + sampled_values::InjectorControlCommandKind::set_channel + ? g_runtime_program->stage_manual_edit( + current_segment, command.channel_edit) + : g_runtime_program->stage_ramp_edit( + current_segment, command.channel_edit); + if (!staged) { + print_control_error(command_name(command.kind), "invalid-runtime-edit"); + continue; + } + print_control_ack(command.kind); + continue; + } case sampled_values::InjectorControlCommandKind::configure: control_status = g_injector_controller.configure({ kSampleRateHz, @@ -496,6 +561,7 @@ void publisher_task(void* argument) { const auto snapshot = g_injector_controller.snapshot(); publisher.reset(0U); pending_sample.reset(); + g_runtime_program.reset(); if (snapshot.configuration.scenario == sampled_values::InjectorScenarioKind::protection_fault) { injector.emplace( @@ -504,15 +570,17 @@ void publisher_task(void* argument) { kSampleRateHz, true); } else { + g_runtime_program.emplace( + sampled_values::make_balanced_4i4v_profile()); injector.emplace( - std::span{ - normal_scenario}, + g_runtime_program->segments(), kSampleRateHz, true); } if (!injector->valid()) { g_injector_controller.set_fault(); injector.reset(); + g_runtime_program.reset(); print_control_error("start", "invalid-injector-scenario"); continue; } @@ -523,6 +591,7 @@ void publisher_task(void* argument) { if (control_status == sampled_values::InjectorControlStatus::ok) { pending_sample.reset(); injector.reset(); + g_runtime_program.reset(); publisher.reset(0U); } break; @@ -557,6 +626,7 @@ void publisher_task(void* argument) { ESP_LOGE(kTag, "Deterministic injector stopped unexpectedly"); g_injector_controller.set_fault(); injector.reset(); + g_runtime_program.reset(); print_control_error("runtime", "injector-finished"); continue; } @@ -573,6 +643,7 @@ void publisher_task(void* argument) { static_cast(publisher.next_sample_count())); g_injector_controller.set_fault(); injector.reset(); + g_runtime_program.reset(); pending_sample.reset(); print_control_error("runtime", "sample-counter-divergence"); continue; @@ -582,6 +653,7 @@ void publisher_task(void* argument) { ESP_LOGE(kTag, "Deterministic payload update failed"); g_injector_controller.set_fault(); injector.reset(); + g_runtime_program.reset(); pending_sample.reset(); print_control_error("runtime", "payload-write-failed"); continue; @@ -600,6 +672,7 @@ void publisher_task(void* argument) { static_cast(result.encode_status)); g_injector_controller.set_fault(); injector.reset(); + g_runtime_program.reset(); pending_sample.reset(); print_control_error("runtime", "publish-failed"); continue; From bfc4c147101ff9d4e40a493eff111a56ef1f176b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:32:41 +0700 Subject: [PATCH 074/188] Document realtime SV source modes --- docs/SV_INJECTOR_SOURCE_MODES.md | 113 +++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/SV_INJECTOR_SOURCE_MODES.md diff --git a/docs/SV_INJECTOR_SOURCE_MODES.md b/docs/SV_INJECTOR_SOURCE_MODES.md new file mode 100644 index 000000000..909112692 --- /dev/null +++ b/docs/SV_INJECTOR_SOURCE_MODES.md @@ -0,0 +1,113 @@ +# Sampled Values injector source modes + +The injector is designed as a sample-index-driven signal source with multiple source modes behind one common control plane. User interfaces and transports may change, but they must not become the waveform clock. + +## Core rule + +The scheduler answers **when a frame may be transmitted**. The source engine answers **which logical sample and values belong to that sample index**. + +A control update therefore never rewinds `smpCnt`, never derives waveform phase from UI refresh timing, and never sends a burst of overdue logical samples to catch up with a delayed host or task. + +## Realtime manual source + +Manual mode is the interactive source for live phasor/value adjustment while the stream is running. + +Supported channel fields are designed around the existing fixed-point signal profile: + +- enabled/disabled state; +- RMS counts; +- DC counts; +- phase in millidegrees; +- frequency in millihertz; +- harmonic amplitude in permyriad; +- harmonic order; +- clipping in permyriad; +- IEC 61850 quality word. + +The control command may contain only the fields that changed. The publisher task copies the active hold profile, applies the sparse edit, stages the new profile in the fixed-capacity runtime ring, and changes to it on a logical sample boundary. + +Example control messages: + +```json +{"command":"set-channel","channel":"Ia","rms":2500} +{"command":"set-channel","channel":"Va","phaseMilliDeg":15000} +{"command":"set-channel","channel":"Ib","frequencyMilliHz":49500,"quality":0} +``` + +The first implementation accepts realtime edits only while the runtime source is in an indefinite hold slot. This prevents an edit in the middle of a ramp from silently jumping from the ramp target instead of the current effective value. Interruptible ramps will require an explicit effective-profile snapshot. + +## Ramp source + +Ramp mode uses the same integer sample timeline as the waveform engine. Duration is expressed in samples, not milliseconds from the UI thread. + +```json +{"command":"ramp-channel","channel":"Ia","rms":5000,"durationSamples":4000} +``` + +At 4,000 sample instants/s, a duration of 4,000 samples is one logical second. The runtime program creates: + +1. the current hold state; +2. a linear transition state with the requested sample duration; +3. an indefinite hold of the final target. + +The current implementation can ramp RMS, DC, frequency, harmonic amplitude and clipping through the deterministic engine's integer interpolation. Phase changes are applied at a state boundary through the existing phase-offset transition mechanism; a dedicated continuous phase-slew model can be added separately. + +## State sequencer + +`InjectorRuntimeProgram` provides a fixed-capacity finite state sequence primitive. Each state contains the complete eight-channel profile, a duration in logical samples, and either a step or linear transition. + +A finite program ends in an indefinite hold of the final state. This is deliberate: a malformed or incomplete upload must not accidentally create an endless test. Repeat counts, conditional transitions and trigger sources should be orchestration features layered above this bounded primitive. + +The initial public control protocol exposes manual and ramp edits first. A sequence upload transaction should be added as a separate bounded protocol slice with validation before activation. + +## Recorded waveform replay + +Recorded waveform replay is a different source type from generated phasors. The protocol core already has host-side COMTRADE parsing; device replay should not parse arbitrary files inside the realtime publisher task. + +The intended architecture is: + +```text +record file + | + v +host parser / channel mapping + | + v +normalized sample blocks + | + v +bounded upload or streaming transport + | + v +P4 replay ring + | + v +logical sample index -> SV payload +``` + +The host owns file parsing, engineering-unit/channel mapping, resampling policy and validation. The device owns bounded buffering, deterministic playback order, underrun detection and Ethernet transmission. + +The line-delimited JSON control channel is suitable for commands and configuration, but bulk replay samples should use a compact framed binary transport so control traffic cannot dominate the realtime path. + +## Future source families + +The same source boundary is intended to support additional modes without changing the SV encoder: + +- multi-channel manual phasors; +- multi-channel ramps and sweeps; +- finite and repeating state sequences; +- recorded waveform replay; +- harmonic/DC/clipping profiles; +- scripted quality changes; +- controlled network fault scenarios; +- SCL-derived stream identity and dataset mapping. + +Every mode must preserve the same separation between logical sample generation, physical scheduling and transport evidence. + +## Runtime safety model + +Runtime source changes are single-writer operations inside the publisher task. The control task only parses input and queues typed commands. This avoids concurrent mutation of waveform state. + +The fixed-capacity runtime ring is allocated outside the high-priority publisher task stack. No source edit requires steady-state heap growth. + +If source state, logical sample index or publisher `smpCnt` diverge, the device should fail closed into a reported fault instead of continuing with ambiguous test data. From 7057d6b53a5ace1c0f86dc18991bfb892101d243 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:33:15 +0700 Subject: [PATCH 075/188] Add realtime value commands to Windows device control --- tools/sv_device_ctl.cpp | 124 ++++++++++++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 19 deletions(-) diff --git a/tools/sv_device_ctl.cpp b/tools/sv_device_ctl.cpp index 8bbb4c04e..fcbdab3b9 100644 --- a/tools/sv_device_ctl.cpp +++ b/tools/sv_device_ctl.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace { @@ -15,8 +16,7 @@ using ar::iec61850::tools::WindowsSerialControlPort; struct Options final { std::string device; - std::string command; - std::string scenario; + std::vector positional; std::uint32_t timeout_ms{3'000U}; bool help{}; }; @@ -48,19 +48,15 @@ struct Options final { options.timeout_ms = parse_timeout(require_value("--timeout-ms")); } else if (argument == "--help" || argument == "-h") { options.help = true; - } else if (options.command.empty()) { - options.command = argument; - } else if (options.scenario.empty()) { - options.scenario = argument; } else { - throw std::invalid_argument("Unexpected argument: " + argument); + options.positional.push_back(argument); } } if (!options.help && options.device.empty()) { throw std::invalid_argument("--device COMx is required."); } - if (!options.help && options.command.empty()) { + if (!options.help && options.positional.empty()) { throw std::invalid_argument("A device command is required."); } return options; @@ -68,9 +64,9 @@ struct Options final { void print_usage() { std::cout - << "Usage: ariec61850_sv_device_ctl --device COMx [scenario] [options]\n\n" + << "Usage: ariec61850_sv_device_ctl --device COMx [args] [options]\n\n" << "Control an ESP32-P4 ARStack61850 SV injector over USB Serial/JTAG.\n\n" - << "Commands:\n" + << "Lifecycle commands:\n" << " capabilities\n" << " status\n" << " configure normal\n" @@ -79,6 +75,15 @@ void print_usage() { << " start\n" << " stop\n" << " stats\n\n" + << "Realtime source commands:\n" + << " set-channel \n" + << " ramp-channel \n\n" + << "Channels: Ia Ib Ic In Va Vb Vc Vn\n" + << "Fields: enabled rms dc phase frequency harmonic harmonic-order clip quality\n" + << " phase = millidegrees\n" + << " frequency = millihertz\n" + << " harmonic = permyriad\n" + << " clip = permyriad\n\n" << "Options:\n" << " --device COMx Windows COM port for ESP32-P4 USB Serial/JTAG.\n" << " --timeout-ms N Response timeout, 100..60000 (default 3000).\n" @@ -87,34 +92,115 @@ void print_usage() { << " ariec61850_sv_device_ctl --device COM7 status\n" << " ariec61850_sv_device_ctl --device COM7 arm\n" << " ariec61850_sv_device_ctl --device COM7 start\n" - << " ariec61850_sv_device_ctl --device COM7 stats\n" + << " ariec61850_sv_device_ctl --device COM7 set-channel Ia rms 2500\n" + << " ariec61850_sv_device_ctl --device COM7 set-channel Va phase 15000\n" + << " ariec61850_sv_device_ctl --device COM7 ramp-channel Ia rms 5000 4000\n" << " ariec61850_sv_device_ctl --device COM7 stop\n"; } +[[nodiscard]] bool valid_channel(const std::string_view channel) noexcept { + return channel == "Ia" || channel == "Ib" || channel == "Ic" || channel == "In" || + channel == "Va" || channel == "Vb" || channel == "Vc" || channel == "Vn"; +} + +[[nodiscard]] std::string protocol_field(const std::string_view field) { + if (field == "enabled") { + return "enabled"; + } + if (field == "rms") { + return "rms"; + } + if (field == "dc") { + return "dc"; + } + if (field == "phase") { + return "phaseMilliDeg"; + } + if (field == "frequency") { + return "frequencyMilliHz"; + } + if (field == "harmonic") { + return "harmonicPermyriad"; + } + if (field == "harmonic-order") { + return "harmonicOrder"; + } + if (field == "clip") { + return "clipPermyriad"; + } + if (field == "quality") { + return "quality"; + } + throw std::invalid_argument( + "Unknown field. Use enabled, rms, dc, phase, frequency, harmonic, " + "harmonic-order, clip, or quality."); +} + +void validate_integer_text(const std::string& value, const std::string_view name) { + if (value.empty()) { + throw std::invalid_argument(std::string{name} + " requires an integer."); + } + std::size_t cursor = value.front() == '-' ? 1U : 0U; + if (cursor == value.size()) { + throw std::invalid_argument(std::string{name} + " requires an integer."); + } + for (; cursor < value.size(); ++cursor) { + if (value[cursor] < '0' || value[cursor] > '9') { + throw std::invalid_argument(std::string{name} + " requires an integer."); + } + } +} + [[nodiscard]] std::string make_request(const Options& options) { - const auto& command = options.command; + const auto& arguments = options.positional; + const auto& command = arguments.front(); + if (command == "capabilities" || command == "status" || command == "arm" || command == "start" || command == "stop" || command == "stats") { - if (!options.scenario.empty()) { - throw std::invalid_argument(command + " does not accept a scenario argument."); + if (arguments.size() != 1U) { + throw std::invalid_argument(command + " does not accept positional arguments."); } return "{\"command\":\"" + command + "\"}"; } if (command == "configure") { - if (options.scenario != "normal" && - options.scenario != "protection-fault") { + if (arguments.size() != 2U || + (arguments[1] != "normal" && arguments[1] != "protection-fault")) { throw std::invalid_argument( "configure requires scenario normal or protection-fault."); } return "{\"command\":\"configure\",\"scenario\":\"" + - options.scenario + "\"}"; + arguments[1] + "\"}"; + } + + if (command == "set-channel" || command == "ramp-channel") { + const auto expected = command == "set-channel" ? 4U : 5U; + if (arguments.size() != expected) { + throw std::invalid_argument( + command == "set-channel" + ? "set-channel requires ." + : "ramp-channel requires ."); + } + if (!valid_channel(arguments[1])) { + throw std::invalid_argument("Channel must be Ia, Ib, Ic, In, Va, Vb, Vc, or Vn."); + } + const auto field = protocol_field(arguments[2]); + validate_integer_text(arguments[3], "value"); + + auto request = "{\"command\":\"" + command + "\",\"channel\":\"" + + arguments[1] + "\",\"" + field + "\":" + arguments[3]; + if (command == "ramp-channel") { + validate_integer_text(arguments[4], "durationSamples"); + request += ",\"durationSamples\":" + arguments[4]; + } + request += '}'; + return request; } throw std::invalid_argument( - "Unsupported command '" + command + - "'. Use capabilities, status, configure, arm, start, stop, or stats."); + "Unsupported command. Use capabilities, status, configure, arm, start, " + "stop, stats, set-channel, or ramp-channel."); } } // namespace From 0f2daf55699d56f7cc7adddb1ebae369664f9712 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:34:17 +0700 Subject: [PATCH 076/188] Add transactional SV state sequence builder --- .../injector_sequence_builder.hpp | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 include/ariec61850/sampled_values/injector_sequence_builder.hpp diff --git a/include/ariec61850/sampled_values/injector_sequence_builder.hpp b/include/ariec61850/sampled_values/injector_sequence_builder.hpp new file mode 100644 index 000000000..2342bf21b --- /dev/null +++ b/include/ariec61850/sampled_values/injector_sequence_builder.hpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/injector_runtime_program.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t injector_sequence_builder_capacity = 16U; + +class InjectorSequenceBuilder final { +public: + using ChannelProfiles = InjectorRuntimeProgram::ChannelProfiles; + + void begin(const ChannelProfiles& base) noexcept { + base_ = base; + working_ = base; + state_count_ = 0U; + active_ = true; + state_open_ = false; + pending_duration_samples_ = 0U; + pending_transition_ = InjectorSegmentTransition::step; + } + + void abort() noexcept { + active_ = false; + state_open_ = false; + state_count_ = 0U; + pending_duration_samples_ = 0U; + } + + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] bool state_open() const noexcept { return state_open_; } + [[nodiscard]] std::size_t state_count() const noexcept { return state_count_; } + + [[nodiscard]] bool begin_state( + const std::uint32_t duration_samples, + const InjectorSegmentTransition transition) noexcept { + if (!active_ || state_open_ || duration_samples == 0U || + state_count_ >= states_.size()) { + return false; + } + + working_ = state_count_ == 0U + ? base_ + : states_[state_count_ - 1U].channels; + pending_duration_samples_ = duration_samples; + pending_transition_ = transition; + state_open_ = true; + return true; + } + + [[nodiscard]] bool edit_channel(const InjectorChannelEdit& edit) noexcept { + return state_open_ && InjectorRuntimeProgram::apply_edit(working_, edit); + } + + [[nodiscard]] bool commit_state() noexcept { + if (!active_ || !state_open_ || state_count_ >= states_.size()) { + return false; + } + states_[state_count_] = { + pending_duration_samples_, + pending_transition_, + working_}; + ++state_count_; + state_open_ = false; + pending_duration_samples_ = 0U; + return true; + } + + [[nodiscard]] bool ready() const noexcept { + return active_ && !state_open_ && state_count_ > 0U; + } + + [[nodiscard]] std::span states() const noexcept { + return std::span{states_.data(), state_count_}; + } + +private: + ChannelProfiles base_{}; + ChannelProfiles working_{}; + std::array states_{}; + std::size_t state_count_{}; + std::uint32_t pending_duration_samples_{}; + InjectorSegmentTransition pending_transition_{InjectorSegmentTransition::step}; + bool active_{}; + bool state_open_{}; +}; + +} // namespace ar::iec61850::sampled_values From f5d5c0db6e25d6f35af07291d053babe1fa8085e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:34:44 +0700 Subject: [PATCH 077/188] Test transactional SV sequence builder --- embedded/sv_runtime_program_smoke.cpp | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/embedded/sv_runtime_program_smoke.cpp b/embedded/sv_runtime_program_smoke.cpp index 948365d54..ecf4d53f4 100644 --- a/embedded/sv_runtime_program_smoke.cpp +++ b/embedded/sv_runtime_program_smoke.cpp @@ -3,6 +3,7 @@ #include "ariec61850/sampled_values/deterministic_injector.hpp" #include "ariec61850/sampled_values/injector_presets.hpp" #include "ariec61850/sampled_values/injector_runtime_program.hpp" +#include "ariec61850/sampled_values/injector_sequence_builder.hpp" #include #include @@ -137,6 +138,58 @@ bool finite_sequence_ends_in_hold() noexcept { program.mode() == InjectorRuntimeSourceMode::sequence; } +bool sequence_builder_is_transactional() noexcept { + const auto base = constant_profile(1'000); + InjectorSequenceBuilder builder; + builder.begin(base); + + if (!builder.begin_state(2U, InjectorSegmentTransition::step)) { + return false; + } + InjectorChannelEdit first{}; + first.channel_index = 0U; + first.fields = injector_field_rms; + first.rms_counts = 2'000; + if (!builder.edit_channel(first) || builder.ready() || + !builder.commit_state()) { + return false; + } + + if (!builder.begin_state(3U, InjectorSegmentTransition::linear_from_previous)) { + return false; + } + InjectorChannelEdit second{}; + second.channel_index = 0U; + second.fields = injector_field_rms; + second.rms_counts = 4'000; + if (!builder.edit_channel(second) || !builder.commit_state() || + !builder.ready() || builder.state_count() != 2U) { + return false; + } + + const auto states = builder.states(); + if (states.size() != 2U || + states[0].channels[0].rms_counts != 2'000 || + states[1].channels[0].rms_counts != 4'000 || + states[1].transition != InjectorSegmentTransition::linear_from_previous) { + return false; + } + + InjectorRuntimeProgram program(base); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + InjectorSample sample{}; + if (!injector.step(sample) || + !program.stage_sequence(injector.segment_index(), states)) { + return false; + } + + // An abort discards only the builder transaction and cannot affect the + // already committed runtime program. + builder.abort(); + return !builder.active() && !builder.ready() && + program.mode() == InjectorRuntimeSourceMode::sequence; +} + } // namespace int main() { @@ -149,5 +202,8 @@ int main() { if (!finite_sequence_ends_in_hold()) { return 3; } + if (!sequence_builder_is_transactional()) { + return 4; + } return 0; } From aa39c404dc853c9dfe205b9e9168e4a965c470e5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:35:33 +0700 Subject: [PATCH 078/188] Add transactional SV sequence control protocol --- .../injector_control_protocol.hpp | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/include/ariec61850/sampled_values/injector_control_protocol.hpp b/include/ariec61850/sampled_values/injector_control_protocol.hpp index cec6eba17..1bf4716d8 100644 --- a/include/ariec61850/sampled_values/injector_control_protocol.hpp +++ b/include/ariec61850/sampled_values/injector_control_protocol.hpp @@ -22,6 +22,12 @@ enum class InjectorControlCommandKind : std::uint8_t { stats, set_channel, ramp_channel, + sequence_begin, + sequence_state_begin, + sequence_set_channel, + sequence_state_commit, + sequence_commit, + sequence_abort, }; enum class InjectorControlParseStatus : std::uint8_t { @@ -35,12 +41,15 @@ enum class InjectorControlParseStatus : std::uint8_t { unsupported_channel, missing_value, invalid_value, + unsupported_transition, }; struct InjectorControlCommand final { InjectorControlCommandKind kind{InjectorControlCommandKind::status}; InjectorScenarioKind scenario{InjectorScenarioKind::normal}; InjectorChannelEdit channel_edit{}; + std::uint32_t duration_samples{}; + InjectorSegmentTransition transition{InjectorSegmentTransition::step}; }; struct InjectorControlParseResult final { @@ -354,6 +363,35 @@ enum class JsonIntegerStatus : std::uint8_t { return InjectorControlParseStatus::ok; } +[[nodiscard]] inline InjectorControlParseStatus parse_sequence_state_begin( + const std::string_view line, + InjectorControlCommand& command) noexcept { + std::int64_t duration{}; + const auto duration_status = json_integer_value(line, "durationSamples", duration); + if (duration_status == JsonIntegerStatus::missing) { + return InjectorControlParseStatus::missing_value; + } + if (duration_status != JsonIntegerStatus::ok || duration < 1 || + duration > 100'000'000LL) { + return InjectorControlParseStatus::invalid_value; + } + command.duration_samples = static_cast(duration); + + std::string_view transition; + if (!json_string_value(line, "transition", transition)) { + command.transition = InjectorSegmentTransition::step; + return InjectorControlParseStatus::ok; + } + if (transition == "step") { + command.transition = InjectorSegmentTransition::step; + } else if (transition == "linear") { + command.transition = InjectorSegmentTransition::linear_from_previous; + } else { + return InjectorControlParseStatus::unsupported_transition; + } + return InjectorControlParseStatus::ok; +} + } // namespace detail // Parses the intentionally narrow control-plane JSON subset used on MCU serial @@ -411,6 +449,27 @@ enum class JsonIntegerStatus : std::uint8_t { if (status != InjectorControlParseStatus::ok) { return {status, {}}; } + } else if (command_name == "sequence-begin") { + command.kind = InjectorControlCommandKind::sequence_begin; + } else if (command_name == "sequence-state-begin") { + command.kind = InjectorControlCommandKind::sequence_state_begin; + const auto status = detail::parse_sequence_state_begin(line, command); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } + } else if (command_name == "sequence-set-channel") { + command.kind = InjectorControlCommandKind::sequence_set_channel; + const auto status = detail::parse_channel_edit( + line, command.channel_edit, false); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } + } else if (command_name == "sequence-state-commit") { + command.kind = InjectorControlCommandKind::sequence_state_commit; + } else if (command_name == "sequence-commit") { + command.kind = InjectorControlCommandKind::sequence_commit; + } else if (command_name == "sequence-abort") { + command.kind = InjectorControlCommandKind::sequence_abort; } else { return {InjectorControlParseStatus::unsupported_command, {}}; } From bf676a1b86826b5eb49cfefe32c370c5d535620f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:36:46 +0700 Subject: [PATCH 079/188] Wire transactional state sequencer into P4 control --- ports/esp_idf/sv_trial_app/main/main.cpp | 102 ++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/main/main.cpp b/ports/esp_idf/sv_trial_app/main/main.cpp index 4bcc06844..b7cda76ed 100644 --- a/ports/esp_idf/sv_trial_app/main/main.cpp +++ b/ports/esp_idf/sv_trial_app/main/main.cpp @@ -8,6 +8,7 @@ #include "ariec61850/sampled_values/injector_controller.hpp" #include "ariec61850/sampled_values/injector_presets.hpp" #include "ariec61850/sampled_values/injector_runtime_program.hpp" +#include "ariec61850/sampled_values/injector_sequence_builder.hpp" #include "ariec61850/sampled_values/payload_writer.hpp" #include "ariec61850/sampled_values/publisher.hpp" @@ -61,9 +62,10 @@ TaskHandle_t g_publisher_task{}; esp_eth_handle_t g_eth_handle{}; QueueHandle_t g_control_queue{}; sampled_values::InjectorController g_injector_controller{}; -// Keep the fixed-capacity source ring out of the publisher task stack. It is -// mutated only by the publisher task after commands cross the FreeRTOS queue. +// Keep fixed-capacity source/edit state out of the publisher task stack. Both +// objects are mutated only by the publisher task after commands cross the queue. std::optional g_runtime_program{}; +sampled_values::InjectorSequenceBuilder g_sequence_builder{}; [[nodiscard]] const char* state_name( const sampled_values::InjectorControlState state) noexcept { @@ -125,6 +127,18 @@ std::optional g_runtime_program{}; return "set-channel"; case sampled_values::InjectorControlCommandKind::ramp_channel: return "ramp-channel"; + case sampled_values::InjectorControlCommandKind::sequence_begin: + return "sequence-begin"; + case sampled_values::InjectorControlCommandKind::sequence_state_begin: + return "sequence-state-begin"; + case sampled_values::InjectorControlCommandKind::sequence_set_channel: + return "sequence-set-channel"; + case sampled_values::InjectorControlCommandKind::sequence_state_commit: + return "sequence-state-commit"; + case sampled_values::InjectorControlCommandKind::sequence_commit: + return "sequence-commit"; + case sampled_values::InjectorControlCommandKind::sequence_abort: + return "sequence-abort"; } return "unknown"; } @@ -160,6 +174,12 @@ void print_control_ack( runtime_mode_name(g_runtime_program->mode()), static_cast(g_runtime_program->revision())); } + if (g_sequence_builder.active()) { + std::printf( + ",\"sequenceDraftStates\":%u,\"sequenceStateOpen\":%s", + static_cast(g_sequence_builder.state_count()), + g_sequence_builder.state_open() ? "true" : "false"); + } std::printf("}\n"); std::fflush(stdout); } @@ -171,7 +191,9 @@ void print_capabilities() noexcept { "\"transport\":\"usb-serial-jtag-stdio\"," "\"engine\":\"sample-index-fixed-point\"," "\"commands\":[\"capabilities\",\"configure\",\"arm\",\"start\"," - "\"stop\",\"status\",\"stats\",\"set-channel\",\"ramp-channel\"]," + "\"stop\",\"status\",\"stats\",\"set-channel\",\"ramp-channel\"," + "\"sequence-begin\",\"sequence-state-begin\",\"sequence-set-channel\"," + "\"sequence-state-commit\",\"sequence-commit\",\"sequence-abort\"]," "\"sourceModes\":[\"manual\",\"ramp\",\"sequence\"]," "\"scenarios\":[\"normal\",\"protection-fault\"]," "\"profile\":{\"sampleRateHz\":4000,\"sampleCountWrap\":4000," @@ -201,6 +223,12 @@ void print_status() noexcept { runtime_mode_name(g_runtime_program->mode()), static_cast(g_runtime_program->revision())); } + if (g_sequence_builder.active()) { + std::printf( + ",\"sequenceDraftStates\":%u,\"sequenceStateOpen\":%s", + static_cast(g_sequence_builder.state_count()), + g_sequence_builder.state_open() ? "true" : "false"); + } std::printf("}\n"); std::fflush(stdout); } @@ -547,6 +575,68 @@ void publisher_task(void* argument) { print_control_ack(command.kind); continue; } + case sampled_values::InjectorControlCommandKind::sequence_begin: { + if (!g_injector_controller.running() || !injector.has_value() || + !g_runtime_program.has_value()) { + print_control_error("sequence-begin", "source-not-editable"); + continue; + } + const auto current_segment = injector->segment_index(); + if (!g_runtime_program->is_hold_segment(current_segment)) { + print_control_error("sequence-begin", "source-busy"); + continue; + } + g_sequence_builder.begin( + g_runtime_program->profile_at(current_segment)); + print_control_ack(command.kind); + continue; + } + case sampled_values::InjectorControlCommandKind::sequence_state_begin: + if (!g_sequence_builder.begin_state( + command.duration_samples, command.transition)) { + print_control_error("sequence-state-begin", "invalid-sequence-state"); + continue; + } + print_control_ack(command.kind); + continue; + case sampled_values::InjectorControlCommandKind::sequence_set_channel: + if (!g_sequence_builder.edit_channel(command.channel_edit)) { + print_control_error("sequence-set-channel", "no-open-sequence-state"); + continue; + } + print_control_ack(command.kind); + continue; + case sampled_values::InjectorControlCommandKind::sequence_state_commit: + if (!g_sequence_builder.commit_state()) { + print_control_error("sequence-state-commit", "no-open-sequence-state"); + continue; + } + print_control_ack(command.kind); + continue; + case sampled_values::InjectorControlCommandKind::sequence_commit: { + if (!g_injector_controller.running() || !injector.has_value() || + !g_runtime_program.has_value() || !g_sequence_builder.ready()) { + print_control_error("sequence-commit", "sequence-not-ready"); + continue; + } + const auto current_segment = injector->segment_index(); + if (!g_runtime_program->is_hold_segment(current_segment)) { + print_control_error("sequence-commit", "source-busy"); + continue; + } + if (!g_runtime_program->stage_sequence( + current_segment, g_sequence_builder.states())) { + print_control_error("sequence-commit", "sequence-stage-failed"); + continue; + } + g_sequence_builder.abort(); + print_control_ack(command.kind); + continue; + } + case sampled_values::InjectorControlCommandKind::sequence_abort: + g_sequence_builder.abort(); + print_control_ack(command.kind); + continue; case sampled_values::InjectorControlCommandKind::configure: control_status = g_injector_controller.configure({ kSampleRateHz, @@ -562,6 +652,7 @@ void publisher_task(void* argument) { publisher.reset(0U); pending_sample.reset(); g_runtime_program.reset(); + g_sequence_builder.abort(); if (snapshot.configuration.scenario == sampled_values::InjectorScenarioKind::protection_fault) { injector.emplace( @@ -592,6 +683,7 @@ void publisher_task(void* argument) { pending_sample.reset(); injector.reset(); g_runtime_program.reset(); + g_sequence_builder.abort(); publisher.reset(0U); } break; @@ -627,6 +719,7 @@ void publisher_task(void* argument) { g_injector_controller.set_fault(); injector.reset(); g_runtime_program.reset(); + g_sequence_builder.abort(); print_control_error("runtime", "injector-finished"); continue; } @@ -644,6 +737,7 @@ void publisher_task(void* argument) { g_injector_controller.set_fault(); injector.reset(); g_runtime_program.reset(); + g_sequence_builder.abort(); pending_sample.reset(); print_control_error("runtime", "sample-counter-divergence"); continue; @@ -654,6 +748,7 @@ void publisher_task(void* argument) { g_injector_controller.set_fault(); injector.reset(); g_runtime_program.reset(); + g_sequence_builder.abort(); pending_sample.reset(); print_control_error("runtime", "payload-write-failed"); continue; @@ -673,6 +768,7 @@ void publisher_task(void* argument) { g_injector_controller.set_fault(); injector.reset(); g_runtime_program.reset(); + g_sequence_builder.abort(); pending_sample.reset(); print_control_error("runtime", "publish-failed"); continue; From b8a0b9f6a9ee333e2dd262f369c9e1d65630cd48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:38:26 +0700 Subject: [PATCH 080/188] Test transactional sequence control parsing --- embedded/sv_injector_controller_smoke.cpp | 86 ++++++++++++++++++----- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/embedded/sv_injector_controller_smoke.cpp b/embedded/sv_injector_controller_smoke.cpp index 600a7eea7..6f75285b0 100644 --- a/embedded/sv_injector_controller_smoke.cpp +++ b/embedded/sv_injector_controller_smoke.cpp @@ -82,83 +82,133 @@ int main() { return fail(10); } + const auto sequence_begin = parse_injector_control_command( + R"({"command":"sequence-begin"})"); + if (!sequence_begin.success() || + sequence_begin.command.kind != InjectorControlCommandKind::sequence_begin) { + return fail(11); + } + + const auto state_begin = parse_injector_control_command( + R"({"command":"sequence-state-begin","durationSamples":800,"transition":"linear"})"); + if (!state_begin.success() || + state_begin.command.kind != InjectorControlCommandKind::sequence_state_begin || + state_begin.command.duration_samples != 800U || + state_begin.command.transition != InjectorSegmentTransition::linear_from_previous) { + return fail(12); + } + + const auto sequence_edit = parse_injector_control_command( + R"({"command":"sequence-set-channel","channel":"Ic","rms":4200,"phaseMilliDeg":120000})"); + if (!sequence_edit.success() || + sequence_edit.command.kind != InjectorControlCommandKind::sequence_set_channel || + sequence_edit.command.channel_edit.channel_index != 2U || + sequence_edit.command.channel_edit.rms_counts != 4'200 || + sequence_edit.command.channel_edit.phase_millidegrees != 120'000) { + return fail(13); + } + + if (parse_injector_control_command( + R"({"command":"sequence-state-begin","durationSamples":0})") + .status != InjectorControlParseStatus::invalid_value) { + return fail(14); + } + if (parse_injector_control_command( + R"({"command":"sequence-state-begin","durationSamples":80,"transition":"curve"})") + .status != InjectorControlParseStatus::unsupported_transition) { + return fail(15); + } + + const auto state_commit = parse_injector_control_command( + R"({"command":"sequence-state-commit"})"); + const auto sequence_commit = parse_injector_control_command( + R"({"command":"sequence-commit"})"); + const auto sequence_abort = parse_injector_control_command( + R"({"command":"sequence-abort"})"); + if (!state_commit.success() || !sequence_commit.success() || !sequence_abort.success() || + state_commit.command.kind != InjectorControlCommandKind::sequence_state_commit || + sequence_commit.command.kind != InjectorControlCommandKind::sequence_commit || + sequence_abort.command.kind != InjectorControlCommandKind::sequence_abort) { + return fail(16); + } + InjectorController controller; if (controller.state() != InjectorControlState::idle) { - return fail(11); + return fail(17); } if (controller.start() != InjectorControlStatus::invalid_state) { - return fail(12); + return fail(18); } if (controller.configure({0U, InjectorScenarioKind::normal}) != InjectorControlStatus::invalid_configuration) { - return fail(13); + return fail(19); } if (controller.configure({4'000U, InjectorScenarioKind::normal}) != InjectorControlStatus::ok) { - return fail(14); + return fail(20); } const auto configured = controller.snapshot(); if (configured.state != InjectorControlState::configured || configured.configuration_revision != 1U || configured.armed_revision != 0U) { - return fail(15); + return fail(21); } if (controller.arm() != InjectorControlStatus::ok) { - return fail(16); + return fail(22); } const auto armed = controller.snapshot(); if (armed.state != InjectorControlState::armed || armed.armed_revision != armed.configuration_revision) { - return fail(17); + return fail(23); } if (controller.configure({4'000U, InjectorScenarioKind::protection_fault}) != InjectorControlStatus::invalid_state) { - return fail(18); + return fail(24); } if (controller.start() != InjectorControlStatus::ok || !controller.running()) { - return fail(19); + return fail(25); } const auto first_run = controller.snapshot(); if (first_run.run_sequence != 1U) { - return fail(20); + return fail(26); } if (controller.arm() != InjectorControlStatus::invalid_state) { - return fail(21); + return fail(27); } if (controller.stop() != InjectorControlStatus::ok || controller.state() != InjectorControlState::stopped) { - return fail(22); + return fail(28); } if (controller.arm() != InjectorControlStatus::ok || controller.start() != InjectorControlStatus::ok) { - return fail(23); + return fail(29); } if (controller.snapshot().run_sequence != 2U) { - return fail(24); + return fail(30); } controller.set_fault(); if (controller.state() != InjectorControlState::fault) { - return fail(25); + return fail(31); } if (controller.start() != InjectorControlStatus::invalid_state) { - return fail(26); + return fail(32); } if (controller.configure({4'000U, InjectorScenarioKind::normal}) != InjectorControlStatus::ok) { - return fail(27); + return fail(33); } const auto recovered = controller.snapshot(); if (recovered.state != InjectorControlState::configured || recovered.configuration_revision != 2U || recovered.armed_revision != 0U) { - return fail(28); + return fail(34); } return 0; From 614d95d5bf69ed00b18f7a66d7084bc9b90abc37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:39:10 +0700 Subject: [PATCH 081/188] Add sequence CLI and request-generation smoke path --- tools/sv_device_ctl.cpp | 99 ++++++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/tools/sv_device_ctl.cpp b/tools/sv_device_ctl.cpp index fcbdab3b9..1ddc6bfbb 100644 --- a/tools/sv_device_ctl.cpp +++ b/tools/sv_device_ctl.cpp @@ -18,6 +18,7 @@ struct Options final { std::string device; std::vector positional; std::uint32_t timeout_ms{3'000U}; + bool emit_request{}; bool help{}; }; @@ -46,6 +47,8 @@ struct Options final { options.device = require_value("--device"); } else if (argument == "--timeout-ms") { options.timeout_ms = parse_timeout(require_value("--timeout-ms")); + } else if (argument == "--emit-request") { + options.emit_request = true; } else if (argument == "--help" || argument == "-h") { options.help = true; } else { @@ -53,12 +56,12 @@ struct Options final { } } - if (!options.help && options.device.empty()) { - throw std::invalid_argument("--device COMx is required."); - } if (!options.help && options.positional.empty()) { throw std::invalid_argument("A device command is required."); } + if (!options.help && !options.emit_request && options.device.empty()) { + throw std::invalid_argument("--device COMx is required unless --emit-request is used."); + } return options; } @@ -78,6 +81,13 @@ void print_usage() { << "Realtime source commands:\n" << " set-channel \n" << " ramp-channel \n\n" + << "Transactional sequence commands:\n" + << " sequence-begin\n" + << " sequence-state-begin [step|linear]\n" + << " sequence-set-channel \n" + << " sequence-state-commit\n" + << " sequence-commit\n" + << " sequence-abort\n\n" << "Channels: Ia Ib Ic In Va Vb Vc Vn\n" << "Fields: enabled rms dc phase frequency harmonic harmonic-order clip quality\n" << " phase = millidegrees\n" @@ -87,6 +97,7 @@ void print_usage() { << "Options:\n" << " --device COMx Windows COM port for ESP32-P4 USB Serial/JTAG.\n" << " --timeout-ms N Response timeout, 100..60000 (default 3000).\n" + << " --emit-request Print request JSON only; do not open a device.\n" << " --help Show this help.\n\n" << "Examples:\n" << " ariec61850_sv_device_ctl --device COM7 status\n" @@ -95,6 +106,11 @@ void print_usage() { << " ariec61850_sv_device_ctl --device COM7 set-channel Ia rms 2500\n" << " ariec61850_sv_device_ctl --device COM7 set-channel Va phase 15000\n" << " ariec61850_sv_device_ctl --device COM7 ramp-channel Ia rms 5000 4000\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-begin\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-state-begin 2000 step\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-set-channel Ia rms 4000\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-state-commit\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-commit\n" << " ariec61850_sv_device_ctl --device COM7 stop\n"; } @@ -151,13 +167,42 @@ void validate_integer_text(const std::string& value, const std::string_view name } } +[[nodiscard]] std::string channel_edit_request( + const std::string& command, + const std::vector& arguments, + const bool include_duration) { + const auto expected = include_duration ? 5U : 4U; + if (arguments.size() != expected) { + throw std::invalid_argument( + include_duration + ? command + " requires ." + : command + " requires ."); + } + if (!valid_channel(arguments[1])) { + throw std::invalid_argument("Channel must be Ia, Ib, Ic, In, Va, Vb, Vc, or Vn."); + } + const auto field = protocol_field(arguments[2]); + validate_integer_text(arguments[3], "value"); + + auto request = "{\"command\":\"" + command + "\",\"channel\":\"" + + arguments[1] + "\",\"" + field + "\":" + arguments[3]; + if (include_duration) { + validate_integer_text(arguments[4], "durationSamples"); + request += ",\"durationSamples\":" + arguments[4]; + } + request += '}'; + return request; +} + [[nodiscard]] std::string make_request(const Options& options) { const auto& arguments = options.positional; const auto& command = arguments.front(); if (command == "capabilities" || command == "status" || command == "arm" || command == "start" || - command == "stop" || command == "stats") { + command == "stop" || command == "stats" || + command == "sequence-begin" || command == "sequence-state-commit" || + command == "sequence-commit" || command == "sequence-abort") { if (arguments.size() != 1U) { throw std::invalid_argument(command + " does not accept positional arguments."); } @@ -174,33 +219,32 @@ void validate_integer_text(const std::string& value, const std::string_view name arguments[1] + "\"}"; } - if (command == "set-channel" || command == "ramp-channel") { - const auto expected = command == "set-channel" ? 4U : 5U; - if (arguments.size() != expected) { + if (command == "set-channel") { + return channel_edit_request(command, arguments, false); + } + if (command == "ramp-channel") { + return channel_edit_request(command, arguments, true); + } + if (command == "sequence-set-channel") { + return channel_edit_request(command, arguments, false); + } + + if (command == "sequence-state-begin") { + if (arguments.size() != 2U && arguments.size() != 3U) { throw std::invalid_argument( - command == "set-channel" - ? "set-channel requires ." - : "ramp-channel requires ."); + "sequence-state-begin requires [step|linear]."); } - if (!valid_channel(arguments[1])) { - throw std::invalid_argument("Channel must be Ia, Ib, Ic, In, Va, Vb, Vc, or Vn."); + validate_integer_text(arguments[1], "durationSamples"); + const auto transition = arguments.size() == 3U ? arguments[2] : "step"; + if (transition != "step" && transition != "linear") { + throw std::invalid_argument("transition must be step or linear."); } - const auto field = protocol_field(arguments[2]); - validate_integer_text(arguments[3], "value"); - - auto request = "{\"command\":\"" + command + "\",\"channel\":\"" + - arguments[1] + "\",\"" + field + "\":" + arguments[3]; - if (command == "ramp-channel") { - validate_integer_text(arguments[4], "durationSamples"); - request += ",\"durationSamples\":" + arguments[4]; - } - request += '}'; - return request; + return "{\"command\":\"sequence-state-begin\",\"durationSamples\":" + + arguments[1] + ",\"transition\":\"" + transition + "\"}"; } throw std::invalid_argument( - "Unsupported command. Use capabilities, status, configure, arm, start, " - "stop, stats, set-channel, or ramp-channel."); + "Unsupported command. Use lifecycle, realtime source, or sequence commands from --help."); } } // namespace @@ -214,6 +258,11 @@ int main(const int argc, char** argv) { } const auto request = make_request(options); + if (options.emit_request) { + std::cout << request << '\n'; + return 0; + } + WindowsSerialControlPort serial; if (!serial.open(options.device)) { throw std::runtime_error(serial.error()); From 1350f5ab2165587f14fcb08dbdf7eff7142c5f76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:39:24 +0700 Subject: [PATCH 082/188] Test realtime and sequence request generation --- .github/workflows/sv-device-control-ci.yml | 29 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sv-device-control-ci.yml b/.github/workflows/sv-device-control-ci.yml index a8e117b7d..70a50c335 100644 --- a/.github/workflows/sv-device-control-ci.yml +++ b/.github/workflows/sv-device-control-ci.yml @@ -32,14 +32,35 @@ jobs: - name: Build run: cmake --build build-device-ctl --config Release --parallel - - name: CLI help smoke (Windows) + - name: CLI contract smoke (Windows) if: runner.os == 'Windows' shell: pwsh - run: .\build-device-ctl\Release\ariec61850_sv_device_ctl.exe --help + run: | + $exe = '.\build-device-ctl\Release\ariec61850_sv_device_ctl.exe' + & $exe --help + $manual = & $exe --emit-request set-channel Ia rms 2500 + if ($manual -ne '{"command":"set-channel","channel":"Ia","rms":2500}') { throw "manual request mismatch: $manual" } + $ramp = & $exe --emit-request ramp-channel Va rms 9000 4000 + if ($ramp -ne '{"command":"ramp-channel","channel":"Va","rms":9000,"durationSamples":4000}') { throw "ramp request mismatch: $ramp" } + $state = & $exe --emit-request sequence-state-begin 800 linear + if ($state -ne '{"command":"sequence-state-begin","durationSamples":800,"transition":"linear"}') { throw "state request mismatch: $state" } + $edit = & $exe --emit-request sequence-set-channel Ic phase 120000 + if ($edit -ne '{"command":"sequence-set-channel","channel":"Ic","phaseMilliDeg":120000}') { throw "sequence edit mismatch: $edit" } + $commit = & $exe --emit-request sequence-commit + if ($commit -ne '{"command":"sequence-commit"}') { throw "sequence commit mismatch: $commit" } - - name: CLI help smoke (Linux) + - name: CLI contract smoke (Linux) if: runner.os == 'Linux' - run: ./build-device-ctl/ariec61850_sv_device_ctl --help + shell: bash + run: | + set -euo pipefail + exe=./build-device-ctl/ariec61850_sv_device_ctl + "$exe" --help + test "$("$exe" --emit-request set-channel Ia rms 2500)" = '{"command":"set-channel","channel":"Ia","rms":2500}' + test "$("$exe" --emit-request ramp-channel Va rms 9000 4000)" = '{"command":"ramp-channel","channel":"Va","rms":9000,"durationSamples":4000}' + test "$("$exe" --emit-request sequence-state-begin 800 linear)" = '{"command":"sequence-state-begin","durationSamples":800,"transition":"linear"}' + test "$("$exe" --emit-request sequence-set-channel Ic phase 120000)" = '{"command":"sequence-set-channel","channel":"Ic","phaseMilliDeg":120000}' + test "$("$exe" --emit-request sequence-commit)" = '{"command":"sequence-commit"}' - name: Upload Windows device controller if: runner.os == 'Windows' From 0e314eea168b6176f3f0c1be4b68bd24cc5a54f6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:41:39 +0700 Subject: [PATCH 083/188] Add portable recorded-waveform replay bundle format --- .../sampled_values/replay_bundle.hpp | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 include/ariec61850/sampled_values/replay_bundle.hpp diff --git a/include/ariec61850/sampled_values/replay_bundle.hpp b/include/ariec61850/sampled_values/replay_bundle.hpp new file mode 100644 index 000000000..9a4c8eab4 --- /dev/null +++ b/include/ariec61850/sampled_values/replay_bundle.hpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t replay_payload_bytes = + injector_channel_count * SampledValuesPayloadWriter::int32_quality_pair_bytes; +inline constexpr std::size_t replay_bundle_header_bytes = 32U; +inline constexpr std::array replay_bundle_magic{ + 'A', 'R', 'S', 'V', 'R', 'P', 'L', '1'}; + +struct ReplayBundleHeader final { + std::uint32_t version{1U}; + std::uint32_t sample_rate_hz{4'000U}; + std::uint32_t payload_bytes{static_cast(replay_payload_bytes)}; + std::uint32_t channel_count{static_cast(injector_channel_count)}; + std::uint64_t frame_count{}; +}; + +using ReplayPayloadFrame = std::array; + +namespace detail { + +inline void write_be32( + const std::span bytes, + const std::size_t offset, + const std::uint32_t value) noexcept { + bytes[offset] = static_cast((value >> 24U) & 0xFFU); + bytes[offset + 1U] = static_cast((value >> 16U) & 0xFFU); + bytes[offset + 2U] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 3U] = static_cast(value & 0xFFU); +} + +inline void write_be64( + const std::span bytes, + const std::size_t offset, + const std::uint64_t value) noexcept { + write_be32(bytes, offset, static_cast(value >> 32U)); + write_be32(bytes, offset + 4U, static_cast(value & 0xFFFF'FFFFULL)); +} + +[[nodiscard]] inline std::uint32_t read_be32( + const std::span bytes, + const std::size_t offset) noexcept { + return (static_cast(bytes[offset]) << 24U) | + (static_cast(bytes[offset + 1U]) << 16U) | + (static_cast(bytes[offset + 2U]) << 8U) | + static_cast(bytes[offset + 3U]); +} + +[[nodiscard]] inline std::uint64_t read_be64( + const std::span bytes, + const std::size_t offset) noexcept { + return (static_cast(read_be32(bytes, offset)) << 32U) | + static_cast(read_be32(bytes, offset + 4U)); +} + +} // namespace detail + +[[nodiscard]] inline bool encode_replay_bundle_header( + const ReplayBundleHeader& header, + const std::span output) noexcept { + if (output.size() < replay_bundle_header_bytes || + header.version != 1U || header.sample_rate_hz == 0U || + header.payload_bytes != replay_payload_bytes || + header.channel_count != injector_channel_count) { + return false; + } + + for (std::size_t index = 0U; index < replay_bundle_magic.size(); ++index) { + output[index] = replay_bundle_magic[index]; + } + detail::write_be32(output, 8U, header.version); + detail::write_be32(output, 12U, header.sample_rate_hz); + detail::write_be32(output, 16U, header.payload_bytes); + detail::write_be32(output, 20U, header.channel_count); + detail::write_be64(output, 24U, header.frame_count); + return true; +} + +[[nodiscard]] inline bool decode_replay_bundle_header( + const std::span input, + ReplayBundleHeader& header) noexcept { + if (input.size() < replay_bundle_header_bytes) { + return false; + } + for (std::size_t index = 0U; index < replay_bundle_magic.size(); ++index) { + if (input[index] != replay_bundle_magic[index]) { + return false; + } + } + + header.version = detail::read_be32(input, 8U); + header.sample_rate_hz = detail::read_be32(input, 12U); + header.payload_bytes = detail::read_be32(input, 16U); + header.channel_count = detail::read_be32(input, 20U); + header.frame_count = detail::read_be64(input, 24U); + + return header.version == 1U && header.sample_rate_hz > 0U && + header.payload_bytes == replay_payload_bytes && + header.channel_count == injector_channel_count; +} + +[[nodiscard]] inline std::uint64_t replay_bundle_expected_bytes( + const ReplayBundleHeader& header) noexcept { + if (header.payload_bytes != replay_payload_bytes) { + return 0U; + } + constexpr auto maximum = static_cast(-1); + const auto payload = static_cast(header.payload_bytes); + if (header.frame_count > + (maximum - static_cast(replay_bundle_header_bytes)) / payload) { + return 0U; + } + return static_cast(replay_bundle_header_bytes) + + header.frame_count * payload; +} + +} // namespace ar::iec61850::sampled_values From c1f7645bc3ee019a1253642e82b5d83881af2132 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:41:52 +0700 Subject: [PATCH 084/188] Add bounded recorded-waveform replay ring --- .../sampled_values/injector_replay_ring.hpp | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 include/ariec61850/sampled_values/injector_replay_ring.hpp diff --git a/include/ariec61850/sampled_values/injector_replay_ring.hpp b/include/ariec61850/sampled_values/injector_replay_ring.hpp new file mode 100644 index 000000000..628653931 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_replay_ring.hpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/replay_bundle.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +struct InjectorReplayStatistics final { + std::uint64_t frames_pushed{}; + std::uint64_t frames_popped{}; + std::uint64_t overflows{}; + std::uint64_t underruns{}; +}; + +template +class InjectorReplayRing final { +public: + static_assert(Capacity >= 2U); + + [[nodiscard]] constexpr std::size_t capacity() const noexcept { return Capacity; } + [[nodiscard]] std::size_t size() const noexcept { return size_; } + [[nodiscard]] std::size_t free_slots() const noexcept { return Capacity - size_; } + [[nodiscard]] bool empty() const noexcept { return size_ == 0U; } + [[nodiscard]] bool full() const noexcept { return size_ == Capacity; } + [[nodiscard]] const InjectorReplayStatistics& statistics() const noexcept { + return statistics_; + } + + void reset() noexcept { + read_index_ = 0U; + write_index_ = 0U; + size_ = 0U; + statistics_ = {}; + } + + [[nodiscard]] bool push(const ReplayPayloadFrame& frame) noexcept { + if (full()) { + ++statistics_.overflows; + return false; + } + frames_[write_index_] = frame; + write_index_ = next(write_index_); + ++size_; + ++statistics_.frames_pushed; + return true; + } + + [[nodiscard]] bool pop(ReplayPayloadFrame& frame) noexcept { + if (empty()) { + ++statistics_.underruns; + return false; + } + frame = frames_[read_index_]; + read_index_ = next(read_index_); + --size_; + ++statistics_.frames_popped; + return true; + } + +private: + [[nodiscard]] static constexpr std::size_t next( + const std::size_t index) noexcept { + return index + 1U == Capacity ? 0U : index + 1U; + } + + std::array frames_{}; + std::size_t read_index_{}; + std::size_t write_index_{}; + std::size_t size_{}; + InjectorReplayStatistics statistics_{}; +}; + +} // namespace ar::iec61850::sampled_values From 95a27745fdbc3019ab8455abfa068a8d9d2cd2e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:42:26 +0700 Subject: [PATCH 085/188] Test bounded recorded-waveform replay primitives --- embedded/sv_runtime_program_smoke.cpp | 51 +++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/embedded/sv_runtime_program_smoke.cpp b/embedded/sv_runtime_program_smoke.cpp index ecf4d53f4..737d9b0d6 100644 --- a/embedded/sv_runtime_program_smoke.cpp +++ b/embedded/sv_runtime_program_smoke.cpp @@ -2,8 +2,10 @@ #include "ariec61850/sampled_values/deterministic_injector.hpp" #include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/injector_replay_ring.hpp" #include "ariec61850/sampled_values/injector_runtime_program.hpp" #include "ariec61850/sampled_values/injector_sequence_builder.hpp" +#include "ariec61850/sampled_values/replay_bundle.hpp" #include #include @@ -47,8 +49,6 @@ bool hot_manual_update_preserves_timeline() noexcept { return false; } - // One final sample completes the active hold slot; the next sample uses the - // staged profile without resetting the global sample index. if (!injector.step(sample) || sample.sample_index != 10U || !near(sample.values[0], 1'414)) { return false; @@ -58,8 +58,6 @@ bool hot_manual_update_preserves_timeline() noexcept { return false; } - // Phase changes are also boundary-applied through the engine's existing - // inter-segment phase-offset logic. if (!program.stage_manual(injector.segment_index(), constant_profile(2'000, 0))) { return false; } @@ -183,13 +181,51 @@ bool sequence_builder_is_transactional() noexcept { return false; } - // An abort discards only the builder transaction and cannot affect the - // already committed runtime program. builder.abort(); return !builder.active() && !builder.ready() && program.mode() == InjectorRuntimeSourceMode::sequence; } +bool replay_bundle_and_ring_are_bounded() noexcept { + ReplayBundleHeader header{}; + header.sample_rate_hz = 4'000U; + header.frame_count = 2U; + + std::array encoded{}; + if (!encode_replay_bundle_header(header, encoded)) { + return false; + } + + ReplayBundleHeader decoded{}; + if (!decode_replay_bundle_header(encoded, decoded) || + decoded.version != 1U || decoded.sample_rate_hz != 4'000U || + decoded.frame_count != 2U || + replay_bundle_expected_bytes(decoded) != + replay_bundle_header_bytes + 2U * replay_payload_bytes) { + return false; + } + + InjectorReplayRing<2U> ring; + ReplayPayloadFrame first{}; + ReplayPayloadFrame second{}; + first[0] = 0x11U; + second[0] = 0x22U; + if (!ring.push(first) || !ring.push(second) || ring.push(first) || + ring.statistics().overflows != 1U) { + return false; + } + + ReplayPayloadFrame output{}; + if (!ring.pop(output) || output[0] != 0x11U || + !ring.pop(output) || output[0] != 0x22U || + ring.pop(output) || ring.statistics().underruns != 1U) { + return false; + } + + return ring.statistics().frames_pushed == 2U && + ring.statistics().frames_popped == 2U && ring.empty(); +} + } // namespace int main() { @@ -205,5 +241,8 @@ int main() { if (!sequence_builder_is_transactional()) { return 4; } + if (!replay_bundle_and_ring_are_bounded()) { + return 5; + } return 0; } From afb2b57761c45b8f63bf7e0224deaee194cb8770 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:43:12 +0700 Subject: [PATCH 086/188] Add COMTRADE to normalized SV replay bundle tool --- tools/sv_replay_prepare.cpp | 186 ++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tools/sv_replay_prepare.cpp diff --git a/tools/sv_replay_prepare.cpp b/tools/sv_replay_prepare.cpp new file mode 100644 index 000000000..6e3def683 --- /dev/null +++ b/tools/sv_replay_prepare.cpp @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/comtrade/reader.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/replay_bundle.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kTargetSampleRateHz = 4'000U; +constexpr std::array + kChannelNames{"Ia", "Ib", "Ic", "In", "Va", "Vb", "Vc", "Vn"}; + +[[nodiscard]] double unit_to_base_factor(const std::string_view unit) { + if (unit == "A" || unit == "V") { + return 1.0; + } + if (unit == "kA" || unit == "kV") { + return 1'000.0; + } + if (unit == "mA" || unit == "mV") { + return 0.001; + } + throw std::runtime_error( + "Mapped analog channel uses unsupported engineering unit '" + + std::string{unit} + "'. Expected A, kA, mA, V, kV, or mV."); +} + +[[nodiscard]] std::int32_t to_replay_count( + const double engineering_value, + const std::string_view unit) { + if (!std::isfinite(engineering_value)) { + throw std::runtime_error("Record contains a non-finite analog value."); + } + const auto base_value = engineering_value * unit_to_base_factor(unit); + if (!std::isfinite(base_value) || + base_value < static_cast(std::numeric_limits::min()) || + base_value > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Mapped analog value exceeds INT32 replay range."); + } + return static_cast(std::llround(base_value)); +} + +[[nodiscard]] sampled_values::ReplayPayloadFrame make_frame( + const comtrade::Dataset& dataset, + const comtrade::Sample& sample) { + sampled_values::ReplayPayloadFrame frame{}; + const auto bytes = std::span{frame.data(), frame.size()}; + + for (std::size_t output = 0U; output < kChannelNames.size(); ++output) { + std::int32_t value{}; + const auto found = dataset.default_channel_map.find(kChannelNames[output]); + if (found != dataset.default_channel_map.end()) { + const auto analog_index = found->second; + if (analog_index >= sample.analog_values.size() || + analog_index >= dataset.configuration.analog_channels.size()) { + throw std::runtime_error("Default channel map points outside analog data."); + } + value = to_replay_count( + sample.analog_values[analog_index], + dataset.configuration.analog_channels[analog_index].unit); + } + + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + bytes, output, value, 0U)) { + throw std::runtime_error("Could not encode normalized replay payload."); + } + } + return frame; +} + +void require_supported_sample_rate(const comtrade::Dataset& dataset) { + const auto rate = dataset.nominal_sample_rate_hz(); + if (!std::isfinite(rate) || + std::abs(rate - static_cast(kTargetSampleRateHz)) > 1.0e-6) { + throw std::runtime_error( + "Record sample rate must be exactly 4000 Hz for replay bundle v1. " + "Resampling is intentionally not implicit."); + } +} + +void write_bundle( + const comtrade::Dataset& dataset, + const std::filesystem::path& output_path) { + require_supported_sample_rate(dataset); + if (dataset.samples.empty()) { + throw std::runtime_error("Record contains no samples."); + } + + sampled_values::ReplayBundleHeader header{}; + header.sample_rate_hz = kTargetSampleRateHz; + header.frame_count = static_cast(dataset.samples.size()); + + std::array encoded_header{}; + if (!sampled_values::encode_replay_bundle_header(header, encoded_header)) { + throw std::runtime_error("Could not encode replay bundle header."); + } + + std::ofstream output(output_path, std::ios::binary | std::ios::trunc); + if (!output) { + throw std::runtime_error("Could not open replay bundle output."); + } + output.write( + reinterpret_cast(encoded_header.data()), + static_cast(encoded_header.size())); + + for (const auto& sample : dataset.samples) { + const auto frame = make_frame(dataset, sample); + output.write( + reinterpret_cast(frame.data()), + static_cast(frame.size())); + if (!output) { + throw std::runtime_error("Failed while writing replay bundle payload."); + } + } + output.flush(); + if (!output) { + throw std::runtime_error("Failed to flush replay bundle output."); + } +} + +void print_mapping(const comtrade::Dataset& dataset) { + std::cout << "Channel map :"; + for (const auto name : kChannelNames) { + const auto found = dataset.default_channel_map.find(name); + if (found == dataset.default_channel_map.end()) { + std::cout << ' ' << name << "=0"; + } else { + std::cout << ' ' << name << "=analog[" << found->second << ']'; + } + } + std::cout << '\n'; +} + +} // namespace + +int main(const int argc, char** argv) { + if (argc != 3) { + std::cerr + << "Usage: ariec61850_sv_replay_prepare \n" + << "Creates a normalized 4000-sample/s 8-channel SV replay bundle.\n"; + return 2; + } + + try { + const auto dataset = comtrade::Reader{}.load(argv[1]); + write_bundle(dataset, argv[2]); + const sampled_values::ReplayBundleHeader header{ + 1U, + kTargetSampleRateHz, + static_cast(sampled_values::replay_payload_bytes), + static_cast(sampled_values::injector_channel_count), + static_cast(dataset.samples.size())}; + + std::cout + << "ARStack61850 recorded-waveform replay preparation\n\n" + << "Input : " << argv[1] << '\n' + << "Output : " << argv[2] << '\n' + << "Source samples : " << dataset.samples.size() << '\n' + << "Sample rate : " << kTargetSampleRateHz << " Hz\n" + << "Payload/frame : " << sampled_values::replay_payload_bytes << " bytes\n" + << "Bundle bytes : " + << sampled_values::replay_bundle_expected_bytes(header) << '\n'; + print_mapping(dataset); + std::cout << "RESULT : REPLAY-BUNDLE/PREPARED\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_replay_prepare: " << error.what() << '\n'; + return 1; + } +} From 682e6108bf7cd057f992a089042dfb393d36d2c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:43:31 +0700 Subject: [PATCH 087/188] Add standalone replay preparation build --- tools/replay_prepare/CMakeLists.txt | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tools/replay_prepare/CMakeLists.txt diff --git a/tools/replay_prepare/CMakeLists.txt b/tools/replay_prepare/CMakeLists.txt new file mode 100644 index 000000000..f02eb7fea --- /dev/null +++ b/tools/replay_prepare/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850SvReplayPrepare 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}/../.." + "${CMAKE_CURRENT_BINARY_DIR}/arstack-core" + EXCLUDE_FROM_ALL) + +add_executable( + ariec61850_sv_replay_prepare + "${CMAKE_CURRENT_LIST_DIR}/../sv_replay_prepare.cpp") +target_link_libraries(ariec61850_sv_replay_prepare PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_sv_replay_prepare PRIVATE cxx_std_20) + +if(MSVC) + target_compile_options(ariec61850_sv_replay_prepare PRIVATE /W4 /permissive- /WX) +else() + target_compile_options(ariec61850_sv_replay_prepare PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() From 214a19ba68bad84aec8eae30cae4ebc96389135b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:43:45 +0700 Subject: [PATCH 088/188] Add recorded-waveform replay bundle CI --- .github/workflows/sv-replay-ci.yml | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/sv-replay-ci.yml diff --git a/.github/workflows/sv-replay-ci.yml b/.github/workflows/sv-replay-ci.yml new file mode 100644 index 000000000..402986495 --- /dev/null +++ b/.github/workflows/sv-replay-ci.yml @@ -0,0 +1,77 @@ +name: SV Replay CI + +on: + push: + branches: + - agent/esp32p4-sv-publisher-first-trial + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + replay-bundle: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows / MSVC + - os: ubuntu-24.04 + name: Linux / GCC + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: cmake -S tools/replay_prepare -B build-replay + + - name: Build + run: cmake --build build-replay --config Release --parallel + + - name: Prepare replay bundle (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: .\build-replay\Release\ariec61850_sv_replay_prepare.exe tests\fixtures\comtrade\simple_fault.cfg build-replay\simple_fault.arsvr + + - name: Prepare replay bundle (Linux) + if: runner.os == 'Linux' + shell: bash + run: ./build-replay/ariec61850_sv_replay_prepare tests/fixtures/comtrade/simple_fault.cfg build-replay/simple_fault.arsvr + + - name: Independent replay bundle oracle + shell: python + run: | + import pathlib + import struct + + path = pathlib.Path('build-replay/simple_fault.arsvr') + data = path.read_bytes() + assert data[:8] == b'ARSVRPL1', data[:8] + version, sample_rate, payload_bytes, channels = struct.unpack('>IIII', data[8:24]) + frames = struct.unpack('>Q', data[24:32])[0] + assert version == 1 + assert sample_rate == 4000 + assert payload_bytes == 64 + assert channels == 8 + assert frames == 40 + assert len(data) == 32 + frames * payload_bytes + + first = data[32:96] + pairs = [struct.unpack('>iI', first[i:i+8]) for i in range(0, 64, 8)] + values = [value for value, quality in pairs] + qualities = [quality for value, quality in pairs] + assert values == [0, -1, 1, 0, 0, -71, 71, 0], values + assert qualities == [0] * 8, qualities + print({'status': 'REPLAY-BUNDLE/PASSED', 'frames': frames, 'values0': values}) + + - name: Upload replay evidence + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: sv-replay-bundle-${{ github.sha }} + path: build-replay/simple_fault.arsvr + if-no-files-found: error From dd2ccd4cd52f3969d982147d8f3ca368ce8f9edd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:45:08 +0700 Subject: [PATCH 089/188] Fix replay preparation namespace scope --- tools/sv_replay_prepare.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/sv_replay_prepare.cpp b/tools/sv_replay_prepare.cpp index 6e3def683..00fcdb79e 100644 --- a/tools/sv_replay_prepare.cpp +++ b/tools/sv_replay_prepare.cpp @@ -150,6 +150,8 @@ void print_mapping(const comtrade::Dataset& dataset) { } // namespace int main(const int argc, char** argv) { + using namespace ar::iec61850; + if (argc != 3) { std::cerr << "Usage: ariec61850_sv_replay_prepare \n" From ee9444856ee23fd4e6ec90dfacbb16e87b371d36 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:46:24 +0700 Subject: [PATCH 090/188] Add live recorded-waveform SV replay oracle --- tools/sv_replay_live.cpp | 385 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 tools/sv_replay_live.cpp diff --git a/tools/sv_replay_live.cpp b/tools/sv_replay_live.cpp new file mode 100644 index 000000000..7f7608e08 --- /dev/null +++ b/tools/sv_replay_live.cpp @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/publisher.hpp" +#include "ariec61850/sampled_values/replay_bundle.hpp" +#include "npcap_live.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::size_t kFrameBufferBytes = 1'536U; +constexpr std::uint64_t kMaximumBundleBytes = 2ULL * 1024ULL * 1024ULL * 1024ULL; + +const ethernet::MacAddress kDestination{ + std::array{0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}}; +const ethernet::MacAddress kSource{ + std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}}; + +#ifdef _WIN32 +volatile std::sig_atomic_t gStopRequested = 0; + +void handle_interrupt(const int signal) noexcept { + if (signal == SIGINT) { + gStopRequested = 1; + } +} +#endif + +struct Options final { + std::filesystem::path bundle_path; + std::string interface_selector; + bool continuous{}; + bool validate{}; + bool list_interfaces{}; + bool help{}; +}; + +struct LoadedBundle final { + sampled_values::ReplayBundleHeader header{}; + std::vector bytes; + + [[nodiscard]] std::span payload( + const std::uint64_t frame_index) const { + if (frame_index >= header.frame_count) { + throw std::out_of_range("Replay frame index exceeds bundle."); + } + const auto offset = static_cast( + sampled_values::replay_bundle_header_bytes) + + frame_index * static_cast(header.payload_bytes); + const auto begin = static_cast(offset); + return std::span{ + bytes.data() + begin, + static_cast(header.payload_bytes)}; + } +}; + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + auto require_value = [&](const std::string_view option) -> std::string { + if (index + 1 >= argc) { + throw std::invalid_argument(std::string{option} + " requires a value."); + } + ++index; + return argv[index]; + }; + + if (argument == "--bundle") { + options.bundle_path = require_value("--bundle"); + } else if (argument == "--interface") { + options.interface_selector = require_value("--interface"); + } else if (argument == "--continuous") { + options.continuous = true; + } else if (argument == "--validate") { + options.validate = true; + } else if (argument == "--list-interfaces") { + options.list_interfaces = true; + } else if (argument == "--help" || argument == "-h") { + options.help = true; + } else { + throw std::invalid_argument("Unknown option: " + argument); + } + } + + if (!options.help && !options.list_interfaces && options.bundle_path.empty()) { + throw std::invalid_argument("--bundle PATH is required."); + } + if (!options.help && !options.list_interfaces && !options.validate && + options.interface_selector.empty()) { + throw std::invalid_argument( + "Live replay requires --interface NAME. Use --validate for offline bundle validation."); + } + return options; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_sv_replay_live --bundle FILE [options]\n\n" + << "Replay a normalized recorded-waveform bundle as IEC 61850 Sampled Values.\n\n" + << "Options:\n" + << " --bundle PATH Replay bundle produced by ariec61850_sv_replay_prepare.\n" + << " --interface NAME Exact or unique Npcap adapter selector.\n" + << " --continuous Repeat the bundle until Ctrl+C.\n" + << " --validate Validate bundle offline and exit.\n" + << " --list-interfaces List Npcap adapters and exit.\n" + << " --help Show this help.\n\n" + << "Examples:\n" + << " ariec61850_sv_replay_live --bundle fault.arsvr --validate\n" + << " ariec61850_sv_replay_live --bundle fault.arsvr --interface \"Ethernet\"\n" + << " ariec61850_sv_replay_live --bundle fault.arsvr --interface \"Ethernet\" --continuous\n"; +} + +[[nodiscard]] LoadedBundle load_bundle(const std::filesystem::path& path) { + std::error_code error; + const auto file_size = std::filesystem::file_size(path, error); + if (error) { + throw std::runtime_error("Could not read replay bundle size: " + error.message()); + } + if (file_size < sampled_values::replay_bundle_header_bytes || + file_size > kMaximumBundleBytes || + file_size > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Replay bundle size is outside supported host limits."); + } + + LoadedBundle bundle; + bundle.bytes.resize(static_cast(file_size)); + std::ifstream input(path, std::ios::binary); + if (!input) { + throw std::runtime_error("Could not open replay bundle."); + } + input.read( + reinterpret_cast(bundle.bytes.data()), + static_cast(bundle.bytes.size())); + if (!input || input.gcount() != static_cast(bundle.bytes.size())) { + throw std::runtime_error("Could not read complete replay bundle."); + } + + if (!sampled_values::decode_replay_bundle_header( + std::span{bundle.bytes.data(), bundle.bytes.size()}, + bundle.header)) { + throw std::runtime_error("Replay bundle header is invalid."); + } + if (bundle.header.sample_rate_hz != 4'000U) { + throw std::runtime_error("Replay live v1 requires a 4000 Hz bundle."); + } + if (bundle.header.frame_count == 0U) { + throw std::runtime_error("Replay bundle contains no frames."); + } + const auto expected = sampled_values::replay_bundle_expected_bytes(bundle.header); + if (expected == 0U || expected != static_cast(bundle.bytes.size())) { + throw std::runtime_error("Replay bundle byte count does not match its header."); + } + return bundle; +} + +[[nodiscard]] sampled_values::SampledValuesFrame make_frame() { + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_INJECTOR"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{4'000U}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(sampled_values::replay_payload_bytes, 0U); + + return { + kDestination, + kSource, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{std::move(asdu)}}}; +} + +void print_bundle_summary( + const std::filesystem::path& path, + const LoadedBundle& bundle) { + const auto duration = static_cast(bundle.header.frame_count) / + static_cast(bundle.header.sample_rate_hz); + std::cout + << "Replay bundle : " << path.string() << '\n' + << "Sample rate : " << bundle.header.sample_rate_hz << " Hz\n" + << "Frames : " << bundle.header.frame_count << '\n' + << "Payload/frame : " << bundle.header.payload_bytes << " bytes\n" + << "Duration : " << std::fixed << std::setprecision(6) + << duration << " s\n"; +} + +void list_adapters() { + tools::NpcapLivePort live; + std::vector adapters; + if (!live.list_adapters(adapters)) { + throw std::runtime_error(live.error()); + } + std::cout << "Npcap adapters:\n"; + for (std::size_t index = 0U; index < adapters.size(); ++index) { + std::cout << '[' << index << "] " + << (adapters[index].description.empty() + ? "(no description)" + : adapters[index].description) + << '\n' + << " " << adapters[index].name << '\n'; + } +} + +#ifdef _WIN32 +[[nodiscard]] std::uint64_t monotonic_us() noexcept { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); +} + +void wait_until(const std::uint64_t deadline_us) { + while (gStopRequested == 0) { + const auto now = monotonic_us(); + if (now >= deadline_us) { + return; + } + const auto remaining = deadline_us - now; + if (remaining > 2'000U) { + std::this_thread::sleep_for( + std::chrono::microseconds{static_cast(remaining - 1'000U)}); + } else if (remaining > 100U) { + std::this_thread::yield(); + } + } +} +#endif + +[[nodiscard]] int run_live(const Options& options, const LoadedBundle& bundle) { +#ifndef _WIN32 + (void)options; + (void)bundle; + throw std::runtime_error("Live Npcap replay is available only on Windows."); +#else + tools::NpcapLivePort live; + if (!live.open(options.interface_selector)) { + throw std::runtime_error(live.error()); + } + + auto frame = make_frame(); + std::array frame_buffer{}; + auto raw_port = live.raw_port(); + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + raw_port, + sampled_values::SampledValuesPublisherConfig{ + bundle.header.sample_rate_hz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + if (!publisher.valid()) { + throw std::runtime_error("SV replay publisher configuration is invalid."); + } + + gStopRequested = 0; + const auto previous_handler = std::signal(SIGINT, &handle_interrupt); + const auto started_at = std::chrono::steady_clock::now(); + std::uint64_t logical_index{}; + + while (gStopRequested == 0 && + (options.continuous || logical_index < bundle.header.frame_count)) { + if (publisher.started()) { + wait_until(publisher.next_due_us()); + if (gStopRequested != 0) { + break; + } + } + + const auto replay_index = logical_index % bundle.header.frame_count; + const auto payload = bundle.payload(replay_index); + auto& destination = frame.pdu.asdus.front().sample_payload; + std::copy(payload.begin(), payload.end(), destination.begin()); + + sampled_values::SampledValuesPublishResult result{}; + do { + result = publisher.poll(monotonic_us()); + if (result.status == sampled_values::SampledValuesPublishStatus::not_due) { + wait_until(result.next_due_us); + } + } while (result.status == sampled_values::SampledValuesPublishStatus::not_due && + gStopRequested == 0); + + if (gStopRequested != 0) { + break; + } + if (!result.sent()) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error( + result.status == sampled_values::SampledValuesPublishStatus::transmit_failed + ? "Npcap replay transmit failed: " + live.error() + : "Replay publisher failed to encode/transmit a frame."); + } + + const auto expected_count = static_cast( + logical_index % static_cast(kSampleCountWrap)); + if (result.sample_count != expected_count) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Replay smpCnt diverged from logical sample index."); + } + ++logical_index; + } + + const auto stopped_at = std::chrono::steady_clock::now(); + std::signal(SIGINT, previous_handler); + const auto elapsed = std::chrono::duration(stopped_at - started_at).count(); + const auto& stats = publisher.statistics(); + const auto rate = elapsed > 0.0 + ? static_cast(stats.frames_sent) / elapsed + : 0.0; + const auto passed = stats.encode_failures == 0U && + stats.transmit_failures == 0U && + (options.continuous || stats.frames_sent == bundle.header.frame_count); + + std::cout + << "\nARStack61850 recorded-waveform live replay\n\n" + << "Adapter : " << live.opened_name() << '\n' + << "Frames sent : " << stats.frames_sent << '\n' + << "Encode failures : " << stats.encode_failures << '\n' + << "TX failures : " << stats.transmit_failures << '\n' + << "Late polls : " << stats.late_polls << '\n' + << "Max lateness : " << stats.maximum_lateness_us << " us\n" + << "Observed TX rate : " << std::fixed << std::setprecision(1) + << rate << " frames/s\n" + << "RESULT : " + << (passed + ? (options.continuous ? "REPLAY-LIVE/STOPPED" : "REPLAY-LIVE/PASSED") + : "REPLAY-LIVE/FAILED") + << '\n'; + return passed ? 0 : 2; +#endif +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.help) { + print_usage(); + return 0; + } + if (options.list_interfaces) { + list_adapters(); + return 0; + } + + const auto bundle = load_bundle(options.bundle_path); + print_bundle_summary(options.bundle_path, bundle); + if (options.validate) { + std::cout << "RESULT : REPLAY-BUNDLE/VALID\n"; + return 0; + } + return run_live(options, bundle); + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_replay_live: " << error.what() << '\n'; + return 1; + } +} From c7cf46c590ade699ac20d15197b5e200309702ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:46:40 +0700 Subject: [PATCH 091/188] Build recorded-waveform live replay oracle --- tools/replay_prepare/CMakeLists.txt | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tools/replay_prepare/CMakeLists.txt b/tools/replay_prepare/CMakeLists.txt index f02eb7fea..8b06da000 100644 --- a/tools/replay_prepare/CMakeLists.txt +++ b/tools/replay_prepare/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(ARStack61850SvReplayPrepare LANGUAGES CXX) +project(ARStack61850SvReplayTools LANGUAGES CXX) set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) @@ -12,15 +12,21 @@ add_subdirectory( "${CMAKE_CURRENT_BINARY_DIR}/arstack-core" EXCLUDE_FROM_ALL) -add_executable( +function(arstack_replay_tool target source) + add_executable(${target} "${source}") + target_link_libraries(${target} PRIVATE ARIEC61850::core) + target_compile_features(${target} PRIVATE cxx_std_20) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /permissive- /WX) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endfunction() + +arstack_replay_tool( ariec61850_sv_replay_prepare "${CMAKE_CURRENT_LIST_DIR}/../sv_replay_prepare.cpp") -target_link_libraries(ariec61850_sv_replay_prepare PRIVATE ARIEC61850::core) -target_compile_features(ariec61850_sv_replay_prepare PRIVATE cxx_std_20) - -if(MSVC) - target_compile_options(ariec61850_sv_replay_prepare PRIVATE /W4 /permissive- /WX) -else() - target_compile_options(ariec61850_sv_replay_prepare PRIVATE - -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) -endif() +arstack_replay_tool( + ariec61850_sv_replay_live + "${CMAKE_CURRENT_LIST_DIR}/../sv_replay_live.cpp") From c4d8bed63451e99463fec18c8c9ef63097887978 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:47:03 +0700 Subject: [PATCH 092/188] Validate and publish live replay oracle --- .github/workflows/sv-replay-ci.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sv-replay-ci.yml b/.github/workflows/sv-replay-ci.yml index 402986495..1af00f5a3 100644 --- a/.github/workflows/sv-replay-ci.yml +++ b/.github/workflows/sv-replay-ci.yml @@ -32,15 +32,20 @@ jobs: - name: Build run: cmake --build build-replay --config Release --parallel - - name: Prepare replay bundle (Windows) + - name: Prepare and validate replay bundle (Windows) if: runner.os == 'Windows' shell: pwsh - run: .\build-replay\Release\ariec61850_sv_replay_prepare.exe tests\fixtures\comtrade\simple_fault.cfg build-replay\simple_fault.arsvr + run: | + .\build-replay\Release\ariec61850_sv_replay_prepare.exe tests\fixtures\comtrade\simple_fault.cfg build-replay\simple_fault.arsvr + .\build-replay\Release\ariec61850_sv_replay_live.exe --bundle build-replay\simple_fault.arsvr --validate - - name: Prepare replay bundle (Linux) + - name: Prepare and validate replay bundle (Linux) if: runner.os == 'Linux' shell: bash - run: ./build-replay/ariec61850_sv_replay_prepare tests/fixtures/comtrade/simple_fault.cfg build-replay/simple_fault.arsvr + run: | + set -euo pipefail + ./build-replay/ariec61850_sv_replay_prepare tests/fixtures/comtrade/simple_fault.cfg build-replay/simple_fault.arsvr + ./build-replay/ariec61850_sv_replay_live --bundle build-replay/simple_fault.arsvr --validate - name: Independent replay bundle oracle shell: python @@ -68,10 +73,13 @@ jobs: assert qualities == [0] * 8, qualities print({'status': 'REPLAY-BUNDLE/PASSED', 'frames': frames, 'values0': values}) - - name: Upload replay evidence + - name: Upload Windows replay tools and evidence if: runner.os == 'Windows' uses: actions/upload-artifact@v4 with: - name: sv-replay-bundle-${{ github.sha }} - path: build-replay/simple_fault.arsvr + name: windows-sv-replay-${{ github.sha }} + path: | + build-replay/Release/ariec61850_sv_replay_prepare.exe + build-replay/Release/ariec61850_sv_replay_live.exe + build-replay/simple_fault.arsvr if-no-files-found: error From 21535279ae918f76105218bf83861d78e6563182 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:47:34 +0700 Subject: [PATCH 093/188] Harden replay bundle overflow checks --- include/ariec61850/sampled_values/replay_bundle.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/sampled_values/replay_bundle.hpp b/include/ariec61850/sampled_values/replay_bundle.hpp index 9a4c8eab4..40ef01e93 100644 --- a/include/ariec61850/sampled_values/replay_bundle.hpp +++ b/include/ariec61850/sampled_values/replay_bundle.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace ar::iec61850::sampled_values { @@ -114,7 +115,7 @@ inline void write_be64( if (header.payload_bytes != replay_payload_bytes) { return 0U; } - constexpr auto maximum = static_cast(-1); + constexpr auto maximum = std::numeric_limits::max(); const auto payload = static_cast(header.payload_bytes); if (header.frame_count > (maximum - static_cast(replay_bundle_header_bytes)) / payload) { From a90106f30a1642cd4224e8da527906dce11a193d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:48:23 +0700 Subject: [PATCH 094/188] Document implemented realtime sequence and replay modes --- docs/SV_INJECTOR_SOURCE_MODES.md | 64 ++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/docs/SV_INJECTOR_SOURCE_MODES.md b/docs/SV_INJECTOR_SOURCE_MODES.md index 909112692..3a2956f83 100644 --- a/docs/SV_INJECTOR_SOURCE_MODES.md +++ b/docs/SV_INJECTOR_SOURCE_MODES.md @@ -34,7 +34,16 @@ Example control messages: {"command":"set-channel","channel":"Ib","frequencyMilliHz":49500,"quality":0} ``` -The first implementation accepts realtime edits only while the runtime source is in an indefinite hold slot. This prevents an edit in the middle of a ramp from silently jumping from the ramp target instead of the current effective value. Interruptible ramps will require an explicit effective-profile snapshot. +The first implementation accepts realtime edits only while the runtime source is in an indefinite hold slot. This prevents an edit in the middle of a ramp from silently jumping from the ramp target instead of the current effective value. Interruptible ramps require an explicit effective-profile snapshot and remain a later extension. + +The Windows device-control CLI provides the same sparse operations: + +```text +set-channel +ramp-channel +``` + +`--emit-request` prints the exact request JSON without opening a device. CI uses this mode to regression-test the public command contract on Windows and Linux. ## Ramp source @@ -56,38 +65,61 @@ The current implementation can ramp RMS, DC, frequency, harmonic amplitude and c `InjectorRuntimeProgram` provides a fixed-capacity finite state sequence primitive. Each state contains the complete eight-channel profile, a duration in logical samples, and either a step or linear transition. -A finite program ends in an indefinite hold of the final state. This is deliberate: a malformed or incomplete upload must not accidentally create an endless test. Repeat counts, conditional transitions and trigger sources should be orchestration features layered above this bounded primitive. +Sequence programming is transactional. Draft states do not affect the running source until the final commit succeeds: + +```text +sequence-begin +sequence-state-begin [step|linear] +sequence-set-channel +sequence-state-commit +... repeat state construction ... +sequence-commit +``` + +`sequence-abort` discards a draft. Each newly opened state inherits the previous committed draft state, so only changed channels need to be edited. The builder is fixed-capacity and validates the state before activation. -The initial public control protocol exposes manual and ramp edits first. A sequence upload transaction should be added as a separate bounded protocol slice with validation before activation. +A finite program ends in an indefinite hold of the final state. This is deliberate: a malformed or incomplete upload must not accidentally create an endless test. Repeat counts, conditional transitions and external trigger policies belong to a higher orchestration layer and can be added without changing the bounded state primitive. + +The ESP32-P4 control path implements this transaction inside the publisher-task ownership boundary. `sequence-commit` is the only operation that stages the prepared program into the active runtime ring. ## Recorded waveform replay -Recorded waveform replay is a different source type from generated phasors. The protocol core already has host-side COMTRADE parsing; device replay should not parse arbitrary files inside the realtime publisher task. +Recorded waveform replay is a different source type from generated phasors. File parsing does not belong inside the realtime publisher task. -The intended architecture is: +The implemented first replay pipeline is: ```text -record file +COMTRADE record | v -host parser / channel mapping +host parser + semantic channel map | v -normalized sample blocks +ARSVRPL1 normalized replay bundle +8 x (INT32 + quality), 64 bytes/sample | - v -bounded upload or streaming transport + +----> Windows live replay oracle | v -P4 replay ring +bounded device replay ring | v -logical sample index -> SV payload +future proven bulk upload/preload transport ``` -The host owns file parsing, engineering-unit/channel mapping, resampling policy and validation. The device owns bounded buffering, deterministic playback order, underrun detection and Ethernet transmission. +`ariec61850_sv_replay_prepare` loads a COMTRADE record, maps recognized analog channels into the canonical `Ia, Ib, Ic, In, Va, Vb, Vc, Vn` order, converts supported engineering units to base A/V counts, writes quality zero for the first slice, and emits an `ARSVRPL1` bundle. Missing mapped channels are zero-filled. + +Replay bundle v1 intentionally accepts only an exact 4,000 Hz source. It does not silently resample a recording. A future resampler must be a separately tested host-side transformation with explicit policy and evidence. + +The bundle header is fixed and portable. Each following frame contains the exact 64-byte `seqOfData` payload that can be copied into the SV ASDU without host-endian reinterpretation. -The line-delimited JSON control channel is suitable for commands and configuration, but bulk replay samples should use a compact framed binary transport so control traffic cannot dominate the realtime path. +`ariec61850_sv_replay_live` validates the bundle on Windows or Linux and, on Windows with Npcap, can replay it onto a selected Ethernet adapter at the same best-effort 4 kHz live pacing used by the reference publisher. `--continuous` repeats the recorded sample sequence until `Ctrl+C`. + +A separate Python oracle in CI validates the bundle magic, header, byte count, channel order, first payload values and quality words without using the C++ bundle decoder. + +The MCU side has a fixed-capacity `InjectorReplayRing` with explicit overflow and underrun counters. The ring is the intended boundary between bulk delivery/preload and the 4 kHz publisher task. + +At 4,000 samples/s and 64 payload bytes/sample, the replay payload alone is 256,000 bytes/s before transport framing. The existing line-delimited control protocol is therefore kept for commands and configuration; it is not treated as a proven bulk replay data plane. A high-throughput binary upload/preload transport must be measured before device replay is labeled realtime-capable. ## Future source families @@ -108,6 +140,8 @@ Every mode must preserve the same separation between logical sample generation, Runtime source changes are single-writer operations inside the publisher task. The control task only parses input and queues typed commands. This avoids concurrent mutation of waveform state. -The fixed-capacity runtime ring is allocated outside the high-priority publisher task stack. No source edit requires steady-state heap growth. +The fixed-capacity runtime ring and sequence builder are allocated outside the high-priority publisher task stack. No manual, ramp or sequence edit requires steady-state heap growth. + +Replay buffering is separately bounded and reports overflow/underrun rather than hiding missing samples. If source state, logical sample index or publisher `smpCnt` diverge, the device should fail closed into a reported fault instead of continuing with ambiguous test data. From 45f49bfee63bff6fa5c890631d44bb67e83314ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:49:14 +0700 Subject: [PATCH 095/188] Add atomic realtime profile edit builder --- .../injector_profile_builder.hpp | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 include/ariec61850/sampled_values/injector_profile_builder.hpp diff --git a/include/ariec61850/sampled_values/injector_profile_builder.hpp b/include/ariec61850/sampled_values/injector_profile_builder.hpp new file mode 100644 index 000000000..0f5f776da --- /dev/null +++ b/include/ariec61850/sampled_values/injector_profile_builder.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/injector_runtime_program.hpp" + +#include + +namespace ar::iec61850::sampled_values { + +class InjectorProfileBuilder final { +public: + using ChannelProfiles = InjectorRuntimeProgram::ChannelProfiles; + + void begin( + const ChannelProfiles& base, + const std::uint32_t source_revision, + const std::uint32_t ramp_duration_samples = 0U) noexcept { + target_ = base; + source_revision_ = source_revision; + ramp_duration_samples_ = ramp_duration_samples; + active_ = ramp_duration_samples == 0U || ramp_duration_samples >= 2U; + } + + void abort() noexcept { + active_ = false; + source_revision_ = 0U; + ramp_duration_samples_ = 0U; + } + + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] bool ramp() const noexcept { + return active_ && ramp_duration_samples_ >= 2U; + } + [[nodiscard]] std::uint32_t source_revision() const noexcept { + return source_revision_; + } + [[nodiscard]] std::uint32_t ramp_duration_samples() const noexcept { + return ramp_duration_samples_; + } + [[nodiscard]] const ChannelProfiles& target() const noexcept { return target_; } + + [[nodiscard]] bool edit_channel(const InjectorChannelEdit& edit) noexcept { + return active_ && InjectorRuntimeProgram::apply_edit(target_, edit); + } + +private: + ChannelProfiles target_{}; + std::uint32_t source_revision_{}; + std::uint32_t ramp_duration_samples_{}; + bool active_{}; +}; + +} // namespace ar::iec61850::sampled_values From 5d912c8a846a96899e45f7bfcafaad4880bca264 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:52:17 +0700 Subject: [PATCH 096/188] Gate atomic realtime profile builder in embedded CI --- embedded/sv_runtime_program_smoke.cpp | 46 ++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/embedded/sv_runtime_program_smoke.cpp b/embedded/sv_runtime_program_smoke.cpp index 737d9b0d6..8ce7d29e5 100644 --- a/embedded/sv_runtime_program_smoke.cpp +++ b/embedded/sv_runtime_program_smoke.cpp @@ -2,6 +2,7 @@ #include "ariec61850/sampled_values/deterministic_injector.hpp" #include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/injector_profile_builder.hpp" #include "ariec61850/sampled_values/injector_replay_ring.hpp" #include "ariec61850/sampled_values/injector_runtime_program.hpp" #include "ariec61850/sampled_values/injector_sequence_builder.hpp" @@ -186,6 +187,46 @@ bool sequence_builder_is_transactional() noexcept { program.mode() == InjectorRuntimeSourceMode::sequence; } +bool profile_builder_is_atomic_and_revisioned() noexcept { + const auto base = make_balanced_4i4v_profile(); + InjectorProfileBuilder builder; + builder.begin(base, 17U); + if (!builder.active() || builder.ramp() || builder.source_revision() != 17U) { + return false; + } + + for (std::uint8_t channel = 0U; channel < 3U; ++channel) { + InjectorChannelEdit edit{}; + edit.channel_index = channel; + edit.fields = injector_field_rms; + edit.rms_counts = 3'000; + if (!builder.edit_channel(edit)) { + return false; + } + } + if (builder.target()[0].rms_counts != 3'000 || + builder.target()[1].rms_counts != 3'000 || + builder.target()[2].rms_counts != 3'000 || + base[0].rms_counts == 3'000) { + return false; + } + + builder.abort(); + if (builder.active()) { + return false; + } + + builder.begin(base, 18U, 4'000U); + if (!builder.active() || !builder.ramp() || + builder.ramp_duration_samples() != 4'000U || + builder.source_revision() != 18U) { + return false; + } + + builder.begin(base, 19U, 1U); + return !builder.active(); +} + bool replay_bundle_and_ring_are_bounded() noexcept { ReplayBundleHeader header{}; header.sample_rate_hz = 4'000U; @@ -241,8 +282,11 @@ int main() { if (!sequence_builder_is_transactional()) { return 4; } - if (!replay_bundle_and_ring_are_bounded()) { + if (!profile_builder_is_atomic_and_revisioned()) { return 5; } + if (!replay_bundle_and_ring_are_bounded()) { + return 6; + } return 0; } From 0bede55679ab2c5bc969f96d27cf6e354408a1eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:54:58 +0700 Subject: [PATCH 097/188] Fix replay live Linux portability boundary --- tools/sv_replay_live.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/sv_replay_live.cpp b/tools/sv_replay_live.cpp index 7f7608e08..83ee4f2d8 100644 --- a/tools/sv_replay_live.cpp +++ b/tools/sv_replay_live.cpp @@ -24,22 +24,23 @@ #include #include #include +#include #include namespace { using namespace ar::iec61850; -constexpr std::uint16_t kSampleCountWrap = 4'000U; -constexpr std::size_t kFrameBufferBytes = 1'536U; constexpr std::uint64_t kMaximumBundleBytes = 2ULL * 1024ULL * 1024ULL * 1024ULL; +#ifdef _WIN32 +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::size_t kFrameBufferBytes = 1'536U; const ethernet::MacAddress kDestination{ std::array{0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}}; const ethernet::MacAddress kSource{ std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}}; -#ifdef _WIN32 volatile std::sig_atomic_t gStopRequested = 0; void handle_interrupt(const int signal) noexcept { @@ -177,6 +178,7 @@ void print_usage() { return bundle; } +#ifdef _WIN32 [[nodiscard]] sampled_values::SampledValuesFrame make_frame() { sampled_values::SampledValueAsdu asdu; asdu.sv_id = "ARSTACK61850_INJECTOR"; @@ -196,6 +198,7 @@ void print_usage() { 0U, sampled_values::SampledValuesPdu{{std::move(asdu)}}}; } +#endif void print_bundle_summary( const std::filesystem::path& path, From b6b04d85685d56b70b3a8a5315511ea2777d4343 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:57:37 +0700 Subject: [PATCH 098/188] Refresh vendor-neutral SV control model --- docs/SV_INJECTOR_CONTROL.md | 274 +++++++++++++++++++++++++----------- 1 file changed, 188 insertions(+), 86 deletions(-) diff --git a/docs/SV_INJECTOR_CONTROL.md b/docs/SV_INJECTOR_CONTROL.md index 3bb17d8c3..fcc76e54b 100644 --- a/docs/SV_INJECTOR_CONTROL.md +++ b/docs/SV_INJECTOR_CONTROL.md @@ -1,30 +1,30 @@ # Deterministic SV injector control model -ARStack61850 treats the Sampled Values injector as one scenario engine with multiple transports and execution targets, not as separate Windows and ESP32 implementations. +ARStack61850 treats the Sampled Values injector as one sample-index-driven signal engine with multiple source modes, transports and execution targets. The user interface, management transport and physical scheduler are deliberately separated from waveform state. ## Execution model -Waveform state advances by integer **logical sample index**, not by UI time, wall-clock elapsed time, or the number of frames that a host scheduler happened to send successfully. +Waveform/source state advances by integer **logical sample index**, not by UI refresh time, wall-clock elapsed time, or the number of frames a host scheduler happened to send successfully. ```text -scenario configuration - | - v -logical sample index -----> fixed-point waveform engine - | | - | v - | 8 x INT32 + quality - | | - v v -sample counter -------------> SV frame encoder - | - +-------------------+-------------------+ - | | | - software loopback Windows Npcap live ESP32-P4 EMAC - / PCAP reference NIC oracle physical injector -``` - -The scheduler answers **when** to attempt a frame. The deterministic engine answers **which sample** and **what values** belong to that logical instant. +source configuration / program + | + v +logical sample index -----> deterministic source engine + | | + | v + | 8 x INT32 + quality + | | + v v +sample counter ----------------> SV frame encoder + | + +--------------------+--------------------+ + | | | + software loopback Windows live NIC ESP32-P4 EMAC + / PCAP reference reference path physical injector +``` + +The source engine answers **which logical sample and values** belong to a sample index. The scheduler answers **when a physical frame may be attempted**. A late scheduler must not decide which waveform sample logically comes next. ## Control schema @@ -34,7 +34,16 @@ Schema identifier: arstack-sv-injector-control-v1 ``` -Commands: +Lifecycle: + +```text +IDLE -> CONFIGURED -> ARMED -> RUNNING -> STOPPED + | + v + FAULT +``` + +Lifecycle commands: - `capabilities` - `configure` @@ -44,22 +53,12 @@ Commands: - `status` - `stats` -Lifecycle: - -```text -IDLE -> CONFIGURED -> ARMED -> RUNNING -> STOPPED - | | - +-> STOPPED - | - FAULT -``` - `configure` replaces validated inactive configuration. It is rejected while `ARMED` or `RUNNING`. `arm` freezes the current configuration revision. `start` is accepted only from `ARMED`. `stop` ends an armed/running session without deleting the stored configuration. A new valid `configure` can recover from `FAULT`. ## First deterministic profile - 4,000 sample instants/s -- 50 Hz nominal waveform +- 50 Hz nominal generated waveform - 80 samples/cycle - one ASDU per Ethernet frame - `smpCnt` wrap at 4,000 @@ -73,7 +72,7 @@ IDLE -> CONFIGURED -> ARMED -> RUNNING -> STOPPED - untagged first proof - `smpSynch=0` until synchronization evidence exists -The waveform engine uses a fixed-point phase accumulator, fractional phase remainder, fixed Q15 sine table and integer scenario interpolation. Its steady-state generation path does not require floating-point math or heap allocation. +The generated-waveform engine uses a fixed-point phase accumulator, fractional phase remainder, fixed Q15 sine table and integer scenario interpolation. Its steady-state generation path does not require floating-point math or heap allocation. ## Windows reference injector @@ -83,7 +82,7 @@ Executable: ariec61850_sv_injector ``` -### Software loopback +Software loopback examples: ```powershell .\ariec61850_sv_injector.exe --scenario normal --frames 8000 @@ -92,7 +91,7 @@ ariec61850_sv_injector .\ariec61850_sv_injector.exe --frames 8000 --pcap sv-loopback.pcap ``` -### Windows Npcap live +Windows live examples: ```powershell .\ariec61850_sv_injector.exe --list-interfaces @@ -104,58 +103,101 @@ ariec61850_sv_injector --continuous ``` -The Windows live path uses real monotonic host timing with no catch-up burst. Logical waveform sequence remains deterministic, while physical Windows scheduling is explicitly best-effort. +The Windows live path uses a real monotonic host clock with no catch-up burst. Logical waveform sequence remains deterministic, while physical Windows scheduling is explicitly best-effort. -A manual interoperability run has now shown Process Bus Insight decoding the live Windows stream on the Microsoft KM-TEST Npcap adapter with APPID `0x4001`, 50 Hz, the canonical 4I+4V mapping, and balanced 120-degree three-phase phasors. Treat this as **Windows live subscriber acceptance**, not ESP32-P4 hardware acceptance. +A manual lab run has already shown an independent external SV analyzer decoding the Windows live stream on a local virtual Ethernet adapter with APPID `0x4001`, 50 Hz, the canonical 4I+4V mapping and balanced 120-degree three-phase phasors. Treat this as **Windows live subscriber acceptance**, not ESP32-P4 hardware acceptance. ## ESP32-P4 device control -The ESP32-P4 firmware now uses the same `DeterministicSvInjector` and canonical 4I+4V profile as the Windows oracle. The earlier count-coded synthetic payload generator has been removed from the active P4 trial path. +The ESP32-P4 firmware uses the same `DeterministicSvInjector` and canonical 4I+4V profile as the Windows reference path. -The built-in ESP32-P4 USB Serial/JTAG controller is the primary control transport. ESP-IDF standard input/output is mapped to this interface, which appears as a `COMx` device on Windows. +The initial management transport is line-delimited JSON over USB Serial/JTAG standard input/output. It appears as a serial device on the host operating system. -Requests are one JSON object per line: +Requests are one JSON object per line. Responses are one-line records prefixed with: + +```text +ARCTRL +``` + +so a PC controller can distinguish control responses from firmware log output. + +On boot the P4 validates the normal source and waits in `CONFIGURED`. A normal run is: ```json -{"command":"capabilities"} {"command":"status"} -{"command":"configure","scenario":"normal"} -{"command":"configure","scenario":"protection-fault"} {"command":"arm"} {"command":"start"} -{"command":"stop"} -{"command":"stats"} ``` -Responses are one-line records prefixed with: +The publisher retains a pending logical sample if a scheduler poll occurs before the next frame is due. This prevents an early wake-up from consuming a waveform sample without transmission. Encode failure, physical TX failure, missing injector state, or logical sample/`smpCnt` divergence transitions the device to `FAULT` rather than silently continuing with ambiguous test data. -```text -ARCTRL +## Realtime manual source + +The normal source can be edited while `RUNNING` without resetting the global logical sample index or `smpCnt`. + +Sparse control examples: + +```json +{"command":"set-channel","channel":"Ia","rms":2500} +{"command":"set-channel","channel":"Va","phaseMilliDeg":15000} +{"command":"set-channel","channel":"Ib","frequencyMilliHz":49500,"quality":0} ``` -so a PC controller can distinguish control responses from ESP-IDF log output. +Supported fields include: + +- enabled state +- RMS +- DC offset +- phase +- frequency +- harmonic amplitude/order +- clipping +- quality word -On boot the P4 validates the `normal` profile and waits in `CONFIGURED`. A normal run is: +The control task parses and queues typed commands. The publisher task is the single writer of the runtime source program. A sparse edit copies the active hold profile, changes only the requested fields and stages the new profile on a logical sample boundary. + +An edit is rejected as `source-busy` while a finite transition is actively in progress. This is intentional: an interruptible transition needs an explicit snapshot of the current effective interpolated state before a new target can be defined. + +## Sample-indexed ramp + +Ramp duration is expressed in logical samples, not UI milliseconds. ```json -{"command":"status"} -{"command":"arm"} -{"command":"start"} +{"command":"ramp-channel","channel":"Ia","rms":5000,"durationSamples":4000} ``` -The publisher retains a pending logical sample if a scheduler poll occurs before the next frame is due. This prevents a timer wake-up from consuming a waveform sample without transmission. Encode failure, physical TX failure, missing injector state, or logical sample/`smpCnt` divergence transitions the device to `FAULT` rather than silently continuing with inconsistent state. +At 4,000 sample instants/s, 4,000 samples is one logical second. The runtime program creates the transition and a final indefinite hold. RMS, DC, frequency, harmonic amplitude and clipping use deterministic integer interpolation. Phase target changes currently use state-boundary phase-offset behavior rather than a continuous phase-slew model. + +## Transactional state sequencer -## Windows P4 control client +Finite state programs are built as a transaction so a partially uploaded program can never become active. -A standalone Windows client is provided: +Protocol flow: ```text -ariec61850_sv_device_ctl +sequence-begin +sequence-state-begin [step|linear] +sequence-set-channel +sequence-state-commit +... repeat state construction ... +sequence-commit ``` -It is built from `tools/device_ctl` and uses the same JSON contract over the P4 USB Serial/JTAG COM port. +`sequence-abort` discards the draft. + +Each newly opened state inherits the previous committed draft state, so only changed channels need to be transmitted. The fixed-capacity builder validates each state before accepting it. `sequence-commit` is the only point that stages the prepared sequence into the active runtime source ring. + +A finite sequence ends in an indefinite hold of its final state. Repeat counts, external triggers and conditional branches remain higher-level orchestration features and are not silently implied by the bounded primitive. + +## Windows device-control client + +Executable: + +```text +ariec61850_sv_device_ctl +``` -Examples: +Lifecycle examples: ```powershell .\ariec61850_sv_device_ctl.exe --device COM7 status @@ -163,65 +205,125 @@ Examples: .\ariec61850_sv_device_ctl.exe --device COM7 start .\ariec61850_sv_device_ctl.exe --device COM7 stats .\ariec61850_sv_device_ctl.exe --device COM7 stop +``` -.\ariec61850_sv_device_ctl.exe --device COM7 configure protection-fault +Realtime examples: + +```powershell +.\ariec61850_sv_device_ctl.exe --device COM7 set-channel Ia rms 2500 +.\ariec61850_sv_device_ctl.exe --device COM7 set-channel Va phase 15000 +.\ariec61850_sv_device_ctl.exe --device COM7 ramp-channel Ia rms 5000 4000 ``` -The client removes the `ARCTRL ` prefix and writes the returned JSON object to stdout, making it suitable for PowerShell, automated tests, and a future GUI. +Sequence examples: -## Built-in scenarios +```powershell +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-begin +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-begin 2000 step +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-set-channel Ia rms 1000 +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-commit +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-begin 800 linear +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-set-channel Ia rms 4000 +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-commit +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-commit +``` + +`--emit-request` generates the exact JSON without opening a serial port. CI uses this path to test the public command contract on Windows and Linux. + +## Atomic grouped-edit foundation + +Interactive GUIs often need several phase/channel parameters to become active on the same sample boundary. The core therefore includes a revisioned full-profile transaction builder that can snapshot a profile, apply multiple channel edits and represent either an immediate or ramped commit target. + +This builder is covered by embedded regression tests. It is **not yet exposed as a device control command**, so grouped multi-channel edits should not be described as an active public device feature until the transactional wire commands are added. + +## Recorded-waveform replay + +Recorded waveform replay is kept separate from generated phasor sources. + +Current host pipeline: + +```text +recorded waveform + | + v +host parser + semantic channel map + | + v +ARSVRPL1 normalized replay bundle +8 x (INT32 + quality), 64 bytes/sample + | + +------> Windows live replay reference + | + v +bounded device replay-ring primitive +``` + +`ariec61850_sv_replay_prepare` parses COMTRADE input on the host, maps recognized analog channels into `Ia, Ib, Ic, In, Va, Vb, Vc, Vn`, converts supported engineering units to base A/V counts and emits an `ARSVRPL1` bundle. Missing mapped channels are zero-filled. Replay bundle v1 accepts an exact 4,000 Hz source; it does not silently resample. + +`ariec61850_sv_replay_live` validates the same bundle cross-platform and can transmit it through a selected Windows Npcap adapter. A finite run plays the bundle once; `--continuous` repeats it until interrupted. + +The bundle stores the exact 64-byte SV payload per logical sample. An independent Python oracle in CI verifies magic, header fields, expected byte count, channel order, first sample values and quality words without calling the C++ bundle decoder. + +The MCU side provides a fixed-capacity replay ring with explicit overflow and underrun counters. It is the intended boundary between bulk delivery/preload and the 4 kHz publisher task. + +At 4,000 samples/s and 64 payload bytes/sample, replay payload alone is 256,000 bytes/s before framing. The line-delimited JSON management channel is therefore **not** treated as a proven bulk replay data plane. Device replay requires a separately measured binary upload/preload transport before it can be labeled realtime-capable. + +## Built-in generated scenarios ### `normal` -Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. +Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. This is the editable manual/ramp/sequence source. ### `protection-fault` -A deterministic 4,800-sample sequence: +A deterministic 4,800-sample generated sequence: 1. prefault: 2,000 samples / 0.5 s 2. fault: 800 samples / 0.2 s, phase currents 4x and phase voltages 0.25x 3. recovery: 2,000 samples / 0.5 s -The sequence loops while the device remains running. +The built-in scenario loops while the device remains running. Runtime manual/sequence editing is intentionally kept on the normal source path in the first implementation. ## Physical P4 acceptance 1. flash the CI-produced P4 firmware -2. connect USB Serial/JTAG and Ethernet +2. connect USB management and Ethernet 3. query `status`; expect `CONFIGURED` 4. issue `arm`, then `start` -5. capture `eth.type == 0x88ba` in Process Bus Insight/Wireshark +5. capture `eth.type == 0x88ba` with an independent receiver 6. verify APPID, `svID`, dataset, 4I+4V waveform and `smpCnt` continuity -7. compare phase/value behavior against the Windows reference stream -8. request `stats` and retain timer/TX/lateness evidence -9. issue `stop` and verify the stream ceases -10. repeat with short, 10-minute, and one-hour runs +7. change manual values while running and verify sample-boundary behavior +8. run a sample-indexed ramp and verify transition duration +9. build and commit a finite state sequence and verify its state timing +10. request `stats` and retain timer/TX/lateness evidence +11. issue `stop` and verify the stream ceases +12. repeat short, 10-minute and one-hour soak runs ## Evidence levels Do not conflate these results: - `LOOPBACK/PASSED` — deterministic logical stream and software receive path accepted -- Windows live subscriber accepted — external host analyzer decoded the real Npcap stream +- Windows live subscriber accepted — an independent external analyzer decoded a real live-NIC stream - `SIMULATED/PASSED` — embedded-host behavior and independent wire oracle accepted +- `REPLAY-BUNDLE/PASSED` — normalized recorded-waveform bundle accepted by an independent oracle +- `REPLAY-LIVE/PASSED` / `REPLAY-LIVE/STOPPED` — host replay transmitted through the live Windows reference path - `ESP32P4/CROSS-COMPILED` — ESP-IDF toolchain compiled and linked the P4 firmware - `FLASHABLE/READY` — complete P4 firmware artifact accepted -- P4 USB control accepted — physical COM request/response observed on hardware +- P4 USB control accepted — physical request/response observed on hardware - `HARDWARE/PASSED` — physical P4 Ethernet/timing/subscriber evidence recorded -A deterministic logical waveform does not by itself make Windows, FreeRTOS scheduling, Ethernet transmission, USB transport, or clock synchronization deterministic. Those remain separate measurable properties. - -## Next source modes +A deterministic logical source does not by itself make Windows, FreeRTOS scheduling, Ethernet transmission, USB transport or clock synchronization deterministic. Those remain separate measurable properties. -After P4 control and physical SV acceptance: +## Remaining source/control slices -1. explicit per-channel manual phasors -2. deterministic ramp/state sequencer -3. harmonic/DC/clipping controls -4. COMTRADE-to-SV sample-index playback -5. quality-state scripting -6. controlled duplicate/corrupt/jitter scheduling faults -7. SCL-derived stream identity and payload mapping -8. multi-ASDU packing -9. GOOSE injector scenarios +- expose atomic grouped multi-channel edits through the control protocol +- explicit continuous phase-slew model +- interruptible ramp based on current effective state +- repeat counts, triggers and conditional sequencer orchestration +- proven high-throughput binary replay upload/preload +- quality-state scripting beyond direct field edits +- controlled duplicate/corrupt/jitter network fault modes +- SCL-derived dynamic stream identity and payload mapping +- multi-ASDU packing +- additional IEC 61850 test modes after SV hardware acceptance From 81cd059a5b6f7f13d16d20bba5dc1458afdd2b31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 06:59:34 +0700 Subject: [PATCH 099/188] Guard sequence commits with source revision --- .../sampled_values/injector_sequence_builder.hpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/sampled_values/injector_sequence_builder.hpp b/include/ariec61850/sampled_values/injector_sequence_builder.hpp index 2342bf21b..81bc522c2 100644 --- a/include/ariec61850/sampled_values/injector_sequence_builder.hpp +++ b/include/ariec61850/sampled_values/injector_sequence_builder.hpp @@ -16,11 +16,14 @@ class InjectorSequenceBuilder final { public: using ChannelProfiles = InjectorRuntimeProgram::ChannelProfiles; - void begin(const ChannelProfiles& base) noexcept { + void begin( + const ChannelProfiles& base, + const std::uint32_t source_revision) noexcept { base_ = base; working_ = base; + source_revision_ = source_revision; state_count_ = 0U; - active_ = true; + active_ = source_revision != 0U; state_open_ = false; pending_duration_samples_ = 0U; pending_transition_ = InjectorSegmentTransition::step; @@ -30,12 +33,20 @@ class InjectorSequenceBuilder final { active_ = false; state_open_ = false; state_count_ = 0U; + source_revision_ = 0U; pending_duration_samples_ = 0U; } [[nodiscard]] bool active() const noexcept { return active_; } [[nodiscard]] bool state_open() const noexcept { return state_open_; } [[nodiscard]] std::size_t state_count() const noexcept { return state_count_; } + [[nodiscard]] std::uint32_t source_revision() const noexcept { + return source_revision_; + } + [[nodiscard]] bool based_on_revision( + const std::uint32_t source_revision) const noexcept { + return active_ && source_revision_ == source_revision; + } [[nodiscard]] bool begin_state( const std::uint32_t duration_samples, @@ -85,6 +96,7 @@ class InjectorSequenceBuilder final { ChannelProfiles working_{}; std::array states_{}; std::size_t state_count_{}; + std::uint32_t source_revision_{}; std::uint32_t pending_duration_samples_{}; InjectorSegmentTransition pending_transition_{InjectorSegmentTransition::step}; bool active_{}; From 36eb234aca8360253c230c21c016af239259942b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 07:00:28 +0700 Subject: [PATCH 100/188] Preserve sequence builder caller compatibility --- .../sampled_values/injector_sequence_builder.hpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/include/ariec61850/sampled_values/injector_sequence_builder.hpp b/include/ariec61850/sampled_values/injector_sequence_builder.hpp index 81bc522c2..ecebae5d9 100644 --- a/include/ariec61850/sampled_values/injector_sequence_builder.hpp +++ b/include/ariec61850/sampled_values/injector_sequence_builder.hpp @@ -16,6 +16,13 @@ class InjectorSequenceBuilder final { public: using ChannelProfiles = InjectorRuntimeProgram::ChannelProfiles; + // Compatibility entry point for existing callers. New transactional + // control paths should pass the runtime source revision explicitly and + // reject a stale commit with based_on_revision(). + void begin(const ChannelProfiles& base) noexcept { + begin(base, 1U); + } + void begin( const ChannelProfiles& base, const std::uint32_t source_revision) noexcept { From 5ecb6635639626738a9329be89b058690452d205 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 10:05:45 +0700 Subject: [PATCH 101/188] Fix P4 control console for onboard UART bridge --- ports/esp_idf/sv_trial_app/sdkconfig.defaults | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/sdkconfig.defaults b/ports/esp_idf/sv_trial_app/sdkconfig.defaults index 7931a0904..9a9d4d7f8 100644 --- a/ports/esp_idf/sv_trial_app/sdkconfig.defaults +++ b/ports/esp_idf/sv_trial_app/sdkconfig.defaults @@ -4,6 +4,9 @@ CONFIG_COMPILER_CXX_EXCEPTIONS=y # First physical proof uses the ESP32-P4 internal EMAC/RMII path. CONFIG_ETH_USE_ESP32_EMAC=y -# PC-to-device injector control uses the ESP32-P4 built-in USB Serial/JTAG -# controller as the primary stdio transport. One JSON command is sent per line. -CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y +# Board management/control uses the onboard Type-C USB-to-UART bridge wired to +# ESP32-P4 UART0 (TX GPIO37 / RX GPIO38). Keep UART0 as primary stdio so the +# same COM port used for flashing/monitoring can also carry line-delimited JSON +# injector commands after boot. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 From 3b18d8dfd1d86d027b8483d69d32d8269cc473d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 10:06:23 +0700 Subject: [PATCH 102/188] Improve Windows serial control diagnostics --- tools/windows_serial_control.hpp | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tools/windows_serial_control.hpp b/tools/windows_serial_control.hpp index 1d1372e4d..d14dc6f8c 100644 --- a/tools/windows_serial_control.hpp +++ b/tools/windows_serial_control.hpp @@ -49,9 +49,10 @@ class WindowsSerialControlPort final { 0, nullptr); if (handle_ == INVALID_HANDLE_VALUE) { + const auto code = GetLastError(); handle_ = nullptr; error_ = "CreateFile failed for " + std::string{device} + - " (Windows error " + std::to_string(GetLastError()) + ")."; + " (" + describe_windows_error(code) + ")."; return false; } @@ -67,6 +68,13 @@ class WindowsSerialControlPort final { dcb.Parity = NOPARITY; dcb.StopBits = ONESTOPBIT; dcb.fBinary = TRUE; + dcb.fOutxCtsFlow = FALSE; + dcb.fOutxDsrFlow = FALSE; + dcb.fDtrControl = DTR_CONTROL_DISABLE; + dcb.fDsrSensitivity = FALSE; + dcb.fOutX = FALSE; + dcb.fInX = FALSE; + dcb.fRtsControl = RTS_CONTROL_DISABLE; if (SetCommState(handle_, &dcb) == 0) { set_last_error("SetCommState failed"); close(); @@ -210,7 +218,8 @@ class WindowsSerialControlPort final { } } - error_ = "Timed out waiting for ARCTRL response from " + device_ + '.'; + error_ = "Timed out waiting for ARCTRL response from " + device_ + + ". Verify that the injector firmware is running and that this COM port is the board UART control port."; return false; #else (void)timeout_ms; @@ -221,9 +230,21 @@ class WindowsSerialControlPort final { private: #ifdef _WIN32 + [[nodiscard]] static std::string describe_windows_error(const DWORD code) { + switch (code) { + case ERROR_FILE_NOT_FOUND: + return "Windows error 2: COM port not found; verify the current port number"; + case ERROR_ACCESS_DENIED: + return "Windows error 5: access denied; close any serial monitor or other process using the port"; + case ERROR_SEM_TIMEOUT: + return "Windows error 121: semaphore timeout; reconnect the USB-to-UART cable/device, verify the driver, and confirm the COM port still enumerates"; + default: + return "Windows error " + std::to_string(code); + } + } + void set_last_error(const std::string_view prefix) { - error_ = std::string{prefix} + " (Windows error " + - std::to_string(GetLastError()) + ")."; + error_ = std::string{prefix} + " (" + describe_windows_error(GetLastError()) + ")."; } HANDLE handle_{}; From dadb7a43ac26b1bf54167484fb99f8ccbd343c79 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 10:08:57 +0700 Subject: [PATCH 103/188] Document P4 UART0 control path --- ports/esp_idf/sv_trial_app/README.md | 52 +++++++++++++++++----------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/README.md b/ports/esp_idf/sv_trial_app/README.md index 4dd96ea08..d73b9de85 100644 --- a/ports/esp_idf/sv_trial_app/README.md +++ b/ports/esp_idf/sv_trial_app/README.md @@ -1,6 +1,6 @@ # ESP32-P4 Sampled Values injector firmware -This is the first flashable arstack61850 Sampled Values injector target for ESP32-P4. The firmware now uses the same fixed-point `DeterministicSvInjector` waveform engine and canonical 4I+4V profile as the Windows reference injector. +This is the first flashable arstack61850 Sampled Values injector target for ESP32-P4. The firmware uses the same fixed-point `DeterministicSvInjector` waveform engine and canonical 4I+4V profile as the Windows reference injector. ## Hardware profile @@ -10,6 +10,10 @@ This is the first flashable arstack61850 Sampled Values injector target for ESP3 - PHY: IP101-compatible IEEE 802.3 path - PHY address: 1 - PHY reset GPIO: 51 +- management/control: UART0 through the onboard Type-C USB-to-UART bridge +- UART0 TX: GPIO37 +- UART0 RX: GPIO38 +- control baud rate: 115200, 8 data bits, no parity, one stop bit - Sampled Values: 50 Hz / 80 samples per cycle / 4,000 sample instants per second - APPID: `0x4001` - destination MAC: `01:0C:CD:04:00:01` @@ -20,7 +24,7 @@ This is the first flashable arstack61850 Sampled Values injector target for ESP3 - channel order: `Ia, Ib, Ic, In, Va, Vb, Vc, Vn` - 8 x `INT32 + quality` pairs (64-byte `seqOfData`) -The first profile uses raw engineering counts shared with the Windows oracle. It is not yet a claim of IEC 61869-9 calibrated instrument-transformer scaling. +The first profile uses raw engineering counts shared with the Windows reference path. It is not yet a claim of IEC 61869-9 calibrated instrument-transformer scaling. ## Deterministic execution model @@ -28,9 +32,9 @@ The logical waveform advances by integer sample index through the shared fixed-p If a timer notification arrives before the publisher is due, the firmware retains the same pending sample rather than advancing the waveform. If encode or physical TX fails, the first control slice enters `FAULT` instead of silently allowing waveform sample index and `smpCnt` to diverge. -## USB Serial/JTAG control plane +## UART0 control plane -The built-in ESP32-P4 USB Serial/JTAG controller is the primary stdio/control transport. Connect the board's USB Serial/JTAG port to the PC. On Windows it appears as a `COMx` device. +The board's Type-C programming/debug connector is implemented through an onboard USB-to-UART bridge connected to ESP32-P4 UART0. The injector therefore keeps UART0 as primary standard input/output so the same `COMx` port used for flashing and monitoring can carry injector commands after boot. The protocol is line-delimited JSON, schema: @@ -38,9 +42,9 @@ The protocol is line-delimited JSON, schema: arstack-sv-injector-control-v1 ``` -Each request is one JSON object terminated by a newline. Responses are one-line JSON prefixed by `ARCTRL ` so a PC application can separate control responses from ESP-IDF log output. +Each request is one JSON object terminated by a newline. Responses are one-line JSON prefixed by `ARCTRL ` so a PC application can separate control responses from firmware log output. -Supported commands: +Supported lifecycle commands: ```json {"command":"capabilities"} @@ -53,6 +57,8 @@ Supported commands: {"command":"stats"} ``` +Realtime source commands additionally provide sparse channel edits, sample-indexed ramps, and transactional finite state-sequence construction. + Lifecycle: ```text @@ -100,13 +106,15 @@ GitHub Actions builds the same project with ESP-IDF v6.0.2. A successful workflo ## Flash -With the repository and ESP-IDF environment available: +Use the board Type-C programming/debug COM port: ```bash -idf.py -p flash monitor +idf.py -p flash ``` -A downloaded CI artifact can also be flashed with Espressif's flashing tools using the included `flash_args`/`flasher_args.json`. Keep the extracted directory structure intact so relative binary paths remain valid. +A downloaded CI artifact can also be flashed with compatible flashing tools using the included `flash_args`/`flasher_args.json`. Keep the extracted directory structure intact so relative binary paths remain valid. + +If a serial monitor is used after flashing, close it before starting a separate device-control client because the control client opens the COM port exclusively. ## Expected runtime behavior @@ -115,25 +123,27 @@ After boot the firmware: 1. initializes the ESP32-P4 EMAC and PHY; 2. initializes the deterministic 4I+4V control configuration; 3. starts the 250 us timer and the high-priority publisher task; -4. exposes the JSON control plane on primary USB Serial/JTAG stdio; +4. exposes the JSON control plane on primary UART0 stdio through the Type-C USB-to-UART bridge; 5. remains `CONFIGURED` until `arm` and `start` are received; 6. while `RUNNING`, emits at most one SV frame for each processed timer opportunity; 7. pauses transmission while Ethernet link is down without consuming logical waveform samples; 8. reports publisher/timer/heap telemetry and responds to `stats` on demand. -The `stats` response includes frames sent, encode/TX failures, late polls, maximum lateness, timer notifications, and coalesced notifications. Periodic ESP-IDF QA logs additionally include heap watermarks. +The `stats` response includes frames sent, encode/TX failures, late polls, maximum lateness, timer notifications, and coalesced notifications. Periodic firmware QA logs additionally include heap watermarks. ## First physical acceptance -1. flash the firmware; -2. connect USB Serial/JTAG and Ethernet; -3. verify `status` reports `CONFIGURED` and Ethernet link state; -4. send `arm`, then `start`; -5. capture `eth.type == 0x88ba` in Wireshark or Process Bus Insight; -6. confirm APPID `0x4001`, `svID`, dataset, 4I+4V waveform, 50 Hz, and `smpCnt` continuity; -7. compare values/phase sequence against the Windows reference injector; -8. request `stats` and retain the telemetry as evidence; -9. send `stop` and verify the stream ceases. +1. flash the firmware through the Type-C UART programming/debug port; +2. close any serial monitor that still owns the COM port; +3. connect Ethernet; +4. verify `status` reports `CONFIGURED` and Ethernet link state; +5. send `arm`, then `start`; +6. capture EtherType `0x88BA` with an independent receiver; +7. confirm APPID `0x4001`, `svID`, dataset, 4I+4V waveform, 50 Hz, and `smpCnt` continuity; +8. compare values/phase sequence against the Windows reference injector; +9. exercise realtime channel edits, ramps, and finite state sequences; +10. request `stats` and retain the telemetry as evidence; +11. send `stop` and verify the stream ceases. ## What CI proves without hardware @@ -145,4 +155,4 @@ A green repository can prove: - ESP32-P4 RISC-V cross-compilation and final link succeed under ESP-IDF v6.0.2; - a complete flashable firmware image set is produced. -CI cannot prove PHY electrical bring-up, USB cable/driver behavior, actual serial command exchange, real Ethernet transmission, DMA behavior under sustained 4 kHz traffic, scheduler jitter on silicon, or external subscriber interoperability. Those remain explicit hardware gates. +CI cannot prove USB-to-UART driver/cable behavior, physical UART command exchange, PHY electrical bring-up, real Ethernet transmission, DMA behavior under sustained 4 kHz traffic, scheduler jitter on silicon, or external subscriber interoperability. Those remain explicit hardware gates. From fabb224e80a915fa7a9606d7c2ee8e02030a5604 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:02:39 +0700 Subject: [PATCH 104/188] Add no-throw UTC time decode boundary --- include/ariec61850/mms/utc_time.hpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/include/ariec61850/mms/utc_time.hpp b/include/ariec61850/mms/utc_time.hpp index 6b62af416..78d00ca1e 100644 --- a/include/ariec61850/mms/utc_time.hpp +++ b/include/ariec61850/mms/utc_time.hpp @@ -13,14 +13,24 @@ struct Iec61850UtcTime final { std::chrono::system_clock::time_point value{}; std::uint8_t quality{}; + // No-throw decode primitive for constrained/embedded profiles. + [[nodiscard]] static bool try_from_bytes( + std::span bytes, + Iec61850UtcTime& result) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host convenience wrapper retained when C++ exceptions are available. [[nodiscard]] static Iec61850UtcTime from_bytes(std::span bytes); +#endif // Allocation-free wire path for embedded publishers. Returns false when // the timestamp cannot be represented by IEC 61850's 32-bit seconds field. [[nodiscard]] bool try_write_bytes(std::span destination) const noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) // Host convenience wrapper retained for source compatibility. [[nodiscard]] std::vector to_bytes() const; +#endif friend bool operator==(const Iec61850UtcTime&, const Iec61850UtcTime&) = default; }; From 30e0c1d899ae560f057df6292bab143c14bb9d6a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:03:00 +0700 Subject: [PATCH 105/188] Split no-throw UTC time primitives from host wrappers --- src/mms/utc_time.cpp | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/mms/utc_time.cpp b/src/mms/utc_time.cpp index 0abb34c5a..67439e4f7 100644 --- a/src/mms/utc_time.cpp +++ b/src/mms/utc_time.cpp @@ -7,13 +7,18 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include +#endif namespace ar::iec61850::mms { -Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span bytes) { +bool Iec61850UtcTime::try_from_bytes( + const std::span bytes, + Iec61850UtcTime& result) noexcept { if (bytes.size() != 8U) { - throw std::invalid_argument("IEC 61850 UTC time requires exactly 8 bytes."); + result = {}; + return false; } const auto seconds = @@ -30,8 +35,19 @@ Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span const auto timestamp = std::chrono::system_clock::time_point{std::chrono::seconds{seconds}} + std::chrono::duration_cast( fraction_duration{fraction}); - return {timestamp, bytes[7]}; + result = {timestamp, bytes[7]}; + return true; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) +Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span bytes) { + Iec61850UtcTime result{}; + if (!try_from_bytes(bytes, result)) { + throw std::invalid_argument("IEC 61850 UTC time requires exactly 8 bytes."); + } + return result; } +#endif bool Iec61850UtcTime::try_write_bytes( const std::span destination) const noexcept { @@ -64,6 +80,7 @@ bool Iec61850UtcTime::try_write_bytes( return true; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector Iec61850UtcTime::to_bytes() const { std::array bytes{}; if (!try_write_bytes(bytes)) { @@ -71,5 +88,6 @@ std::vector Iec61850UtcTime::to_bytes() const { } return {bytes.begin(), bytes.end()}; } +#endif } // namespace ar::iec61850::mms From 785d7ed89c156d49cc36df3522b439d082ada3fb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:03:13 +0700 Subject: [PATCH 106/188] Split hard-profile SV PDU codec surface --- include/ariec61850/sampled_values/pdu_codec.hpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/sampled_values/pdu_codec.hpp b/include/ariec61850/sampled_values/pdu_codec.hpp index 9fd31c8ce..15bde4be5 100644 --- a/include/ariec61850/sampled_values/pdu_codec.hpp +++ b/include/ariec61850/sampled_values/pdu_codec.hpp @@ -9,7 +9,9 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include +#endif namespace ar::iec61850::sampled_values { @@ -22,12 +24,16 @@ class SampledValuesPduCodec final { const SampledValuesPdu& pdu, std::span destination) noexcept; - // Host convenience wrapper. Embedded steady-state publishers should use - // encode_into with caller-owned storage to avoid per-frame allocation. +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host/soft-profile convenience wrapper. Hard embedded publishers use + // encode_into with caller-owned storage. [[nodiscard]] static std::vector encode(const SampledValuesPdu& pdu); + // The current dynamic decoder is retained outside the hard no-exception + // profile until its BER traversal/storage path is fully status-based. [[nodiscard]] static bool try_decode( std::span apdu, SampledValuesPdu& pdu) noexcept; +#endif }; } // namespace ar::iec61850::sampled_values From aa0d920eb6ed44c14f65480b6e6806ea1f38b270 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:04:16 +0700 Subject: [PATCH 107/188] Make bounded SV PDU encoder no-exception compatible --- src/sampled_values/pdu_codec.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp index 253b624b6..3cc037d29 100644 --- a/src/sampled_values/pdu_codec.cpp +++ b/src/sampled_values/pdu_codec.cpp @@ -2,7 +2,9 @@ #include "ariec61850/sampled_values/pdu_codec.hpp" +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include "ariec61850/asn1/ber.hpp" +#endif #include "ariec61850/mms/utc_time.hpp" #include @@ -12,10 +14,12 @@ #include #include #include -#include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) +#include #include #include +#endif namespace ar::iec61850::sampled_values { namespace { @@ -266,6 +270,7 @@ void write_unsigned( return true; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) bool read_unsigned_exact( const asn1::BerTlv& field, const std::uint64_t maximum, @@ -360,6 +365,7 @@ bool read_asdu_sequence( } return true; } +#endif } // namespace @@ -428,6 +434,7 @@ wire::EncodeResult SampledValuesPduCodec::encode_into( return {wire::EncodeStatus::ok, offset, *required}; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector SampledValuesPduCodec::encode( const SampledValuesPdu& pdu) { const auto required = encoded_size(pdu); @@ -495,5 +502,6 @@ bool SampledValuesPduCodec::try_decode( return false; } } +#endif } // namespace ar::iec61850::sampled_values From 897924cf010ee87dec681d08045d437ccd9f0abd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:04:29 +0700 Subject: [PATCH 108/188] Split hard-profile SV frame codec surface --- include/ariec61850/sampled_values/frame_codec.hpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/sampled_values/frame_codec.hpp b/include/ariec61850/sampled_values/frame_codec.hpp index ba9ddce0d..44bd774d0 100644 --- a/include/ariec61850/sampled_values/frame_codec.hpp +++ b/include/ariec61850/sampled_values/frame_codec.hpp @@ -9,7 +9,9 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include +#endif namespace ar::iec61850::sampled_values { @@ -22,12 +24,15 @@ class SampledValuesFrameCodec final { const SampledValuesFrame& frame, std::span destination) noexcept; - // Host convenience wrapper. Embedded SV publishers should use encode_into - // with a persistent Ethernet-sized buffer. +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host/soft-profile convenience wrapper. Hard embedded publishers should + // use encode_into with persistent caller-owned Ethernet storage. [[nodiscard]] static std::vector encode(const SampledValuesFrame& frame); + // Dynamic receive-side decode remains outside the first hard profile. [[nodiscard]] static bool try_decode( std::span bytes, SampledValuesFrame& frame) noexcept; +#endif }; } // namespace ar::iec61850::sampled_values From 232fd2f2555827f7ab9e929cfb446bc5dcc0a741 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:04:53 +0700 Subject: [PATCH 109/188] Make bounded SV frame encoder no-exception compatible --- src/sampled_values/frame_codec.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sampled_values/frame_codec.cpp b/src/sampled_values/frame_codec.cpp index 80f62e830..d5f70fe51 100644 --- a/src/sampled_values/frame_codec.cpp +++ b/src/sampled_values/frame_codec.cpp @@ -11,9 +11,11 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include #include #include +#endif namespace ar::iec61850::sampled_values { namespace { @@ -117,6 +119,7 @@ wire::EncodeResult SampledValuesFrameCodec::encode_into( return {wire::EncodeStatus::ok, offset, *required}; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector SampledValuesFrameCodec::encode( const SampledValuesFrame& frame) { const auto required = encoded_size(frame); @@ -163,5 +166,6 @@ bool SampledValuesFrameCodec::try_decode( frame.pdu = std::move(pdu); return true; } +#endif } // namespace ar::iec61850::sampled_values From 2ee8ecdd3f4e04673728945bcc27390f0b741752 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:05:36 +0700 Subject: [PATCH 110/188] Add no-exception SV hard-profile smoke --- embedded/sv_hard_profile_smoke.cpp | 124 +++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 embedded/sv_hard_profile_smoke.cpp diff --git a/embedded/sv_hard_profile_smoke.cpp b/embedded/sv_hard_profile_smoke.cpp new file mode 100644 index 000000000..c5f0481bf --- /dev/null +++ b/embedded/sv_hard_profile_smoke.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +struct FakeEthernet final { + std::array last_frame{}; + std::size_t last_size{}; + std::uint64_t sends{}; +}; + +embedded::IoResult capture_frame( + void* context, + const std::span frame) noexcept { + auto* fake = static_cast(context); + if (fake == nullptr || frame.size() > fake->last_frame.size()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + std::copy(frame.begin(), frame.end(), fake->last_frame.begin()); + fake->last_size = frame.size(); + ++fake->sends; + return {embedded::IoStatus::ok, frame.size()}; +} + +sampled_values::SampledValuesFrame make_frame() { + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + const std::array source{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_HARD_PROFILE"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{4'000U}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(64U, 0U); + + return { + ethernet::MacAddress{destination}, + ethernet::MacAddress{source}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +} // namespace + +int main() { + FakeEthernet fake; + embedded::RawEthernetPort port{&fake, &capture_frame}; + auto frame = make_frame(); + std::array frame_buffer{}; + + const auto asdu_capacity = frame.pdu.asdus.capacity(); + const auto payload_capacity = frame.pdu.asdus.front().sample_payload.capacity(); + const auto sv_id_capacity = frame.pdu.asdus.front().sv_id.capacity(); + const auto dataset_capacity = frame.pdu.asdus.front().data_set_reference.capacity(); + + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + 4'000U, + std::uint16_t{4'000U}, + 0U, + true}); + if (!publisher.valid()) { + return 1; + } + + constexpr std::uint64_t start_us = 1'000'000U; + constexpr std::uint64_t frame_count = 8'000U; + for (std::uint64_t index = 0U; index < frame_count; ++index) { + const auto result = publisher.poll(start_us + index * 250U); + const auto expected_count = static_cast(index % 4'000U); + if (!result.sent() || result.sample_count != expected_count || + fake.sends != index + 1U) { + return 2; + } + } + + const auto& stats = publisher.statistics(); + if (stats.frames_sent != frame_count || stats.encode_failures != 0U || + stats.transmit_failures != 0U || stats.late_polls != 0U || + stats.maximum_lateness_us != 0U || publisher.next_sample_count() != 0U) { + return 3; + } + + // The current hard-profile milestone permits setup-time dynamic storage in + // the legacy frame model, but the 4 kHz steady-state path must not grow it. + if (frame.pdu.asdus.capacity() != asdu_capacity || + frame.pdu.asdus.front().sample_payload.capacity() != payload_capacity || + frame.pdu.asdus.front().sv_id.capacity() != sv_id_capacity || + frame.pdu.asdus.front().data_set_reference.capacity() != dataset_capacity) { + return 4; + } + + if (fake.last_size < 22U || + fake.last_frame[12] != 0x88U || fake.last_frame[13] != 0xBAU || + fake.last_frame[14] != 0x40U || fake.last_frame[15] != 0x01U) { + return 5; + } + + return 0; +} From 317da5bcf22015b64c4832e1e85e8e2b798c2021 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:06:18 +0700 Subject: [PATCH 111/188] Add hard no-exception SV embedded profile --- embedded/hard_profile/CMakeLists.txt | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 embedded/hard_profile/CMakeLists.txt diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt new file mode 100644 index 000000000..fa4b1000b --- /dev/null +++ b/embedded/hard_profile/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850HardSvProfile + VERSION 0.1.0 + DESCRIPTION "No-exception/no-RTTI MCU profile for the bounded SV transmit path" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "The first hard embedded profile is validated with GCC/Clang -fno-exceptions.") +endif() + +set(ARIEC61850_HARD_SV_SOURCES + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp) + +add_executable(ariec61850_hard_sv_smoke + ${CMAKE_CURRENT_LIST_DIR}/../sv_hard_profile_smoke.cpp + ${ARIEC61850_HARD_SV_SOURCES}) + +target_compile_features(ariec61850_hard_sv_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_hard_sv_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_hard_sv_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) +target_compile_options(ariec61850_hard_sv_smoke PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) + +enable_testing() +add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) + +message(STATUS "arstack61850 hard SV profile: exceptions disabled=ON") +message(STATUS "arstack61850 hard SV profile: RTTI disabled=ON") +message(STATUS "arstack61850 hard SV profile: host services excluded") From 6b6fedf43510514ffb2b42e0be66f2def6f20f5f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:06:39 +0700 Subject: [PATCH 112/188] Gate no-exception hard SV embedded profile --- .../workflows/embedded-hard-profile-ci.yml | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/embedded-hard-profile-ci.yml diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml new file mode 100644 index 000000000..dc4f2a81a --- /dev/null +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -0,0 +1,76 @@ +name: Embedded Hard Profile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + hard-sv-profile: + name: Hard SV / ${{ 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 hard profile + run: cmake -S embedded/hard_profile -B build-hard-sv + + - name: Build hard profile + run: cmake --build build-hard-sv --parallel + + - name: Run hard-profile smoke + run: >- + ctest --test-dir build-hard-sv + --output-on-failure + --output-junit hard-sv-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + binary=build-hard-sv/ariec61850_hard_sv_smoke + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo 'exception runtime symbol found in hard-profile binary' >&2 + exit 1 + fi + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report hard-profile footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + echo "binary=ariec61850_hard_sv_smoke" + size build-hard-sv/ariec61850_hard_sv_smoke + } | tee build-hard-sv/hard-sv-footprint.txt + + - name: Upload hard-profile evidence + uses: actions/upload-artifact@v4 + with: + name: embedded-hard-sv-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-hard-sv/hard-sv-ctest.xml + build-hard-sv/hard-sv-footprint.txt From 39aa24ef508bffa3c9319e2ffe67a1602a85f32c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:07:46 +0700 Subject: [PATCH 113/188] Link sample counter into hard SV profile --- embedded/hard_profile/CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index fa4b1000b..8aac94fc0 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -14,7 +14,8 @@ endif() set(ARIEC61850_HARD_SV_SOURCES ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp - ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp) + ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp) add_executable(ariec61850_hard_sv_smoke ${CMAKE_CURRENT_LIST_DIR}/../sv_hard_profile_smoke.cpp From d9721a049ec9c84cf5c6bababbfe17812b7ab432 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:11:13 +0700 Subject: [PATCH 114/188] Add bounded no-throw Ethernet wire APIs --- include/ariec61850/ethernet/ethernet.hpp | 48 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/include/ariec61850/ethernet/ethernet.hpp b/include/ariec61850/ethernet/ethernet.hpp index 143611215..363191086 100644 --- a/include/ariec61850/ethernet/ethernet.hpp +++ b/include/ariec61850/ethernet/ethernet.hpp @@ -2,7 +2,10 @@ #pragma once +#include "ariec61850/wire/encode_result.hpp" + #include +#include #include #include #include @@ -25,15 +28,26 @@ class MacAddress final { explicit constexpr MacAddress(const std::array& bytes) noexcept : bytes_(bytes) {} - // Retained for host/source compatibility when the caller only has a span. + [[nodiscard]] static bool try_from_bytes( + std::span bytes, + MacAddress& address) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host/source compatibility wrapper when the caller only has a span. explicit MacAddress(std::span bytes); +#endif - [[nodiscard]] static MacAddress parse(const std::string& text); [[nodiscard]] static bool try_parse(const std::string& text, MacAddress& address) noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) + [[nodiscard]] static MacAddress parse(const std::string& text); +#endif [[nodiscard]] const std::array& bytes() const noexcept { return bytes_; } + [[nodiscard]] bool try_copy_to(std::span destination) const noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) void copy_to(std::span destination) const; [[nodiscard]] std::string to_string() const; +#endif friend bool operator==(const MacAddress&, const MacAddress&) = default; @@ -52,7 +66,10 @@ struct VlanTag final { VlanTag(std::uint8_t priority, bool drop, std::uint16_t vlan) : priority_code_point(priority), drop_eligible(drop), vlan_id(vlan) {} + [[nodiscard]] bool try_to_tag_control_information(std::uint16_t& tci) const noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] std::uint16_t to_tag_control_information() const; +#endif [[nodiscard]] static VlanTag from_tag_control_information(std::uint16_t tci) noexcept; friend bool operator==(const VlanTag&, const VlanTag&) = default; @@ -68,8 +85,18 @@ struct EthernetFrame final { class EthernetFrameCodec final { public: + [[nodiscard]] static std::optional encoded_size( + const EthernetFrame& frame) noexcept; + [[nodiscard]] static wire::EncodeResult encode_into( + const EthernetFrame& frame, + std::span destination) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] static std::vector encode(const EthernetFrame& frame); - [[nodiscard]] static bool try_decode(std::span bytes, EthernetFrame& frame) noexcept; + [[nodiscard]] static bool try_decode( + std::span bytes, + EthernetFrame& frame) noexcept; +#endif }; struct ProcessBusFrame final { @@ -85,6 +112,16 @@ class ProcessBusFrameCodec final { public: static constexpr std::size_t header_length = 8U; + [[nodiscard]] static std::optional encoded_payload_size( + std::span apdu) noexcept; + [[nodiscard]] static wire::EncodeResult encode_payload_into( + std::uint16_t app_id, + std::span apdu, + std::span destination, + std::uint16_t reserved1 = 0, + std::uint16_t reserved2 = 0) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] static std::vector encode_payload( std::uint16_t app_id, std::span apdu, @@ -101,7 +138,10 @@ class ProcessBusFrameCodec final { std::uint16_t reserved1 = 0, std::uint16_t reserved2 = 0); - [[nodiscard]] static bool try_decode(const EthernetFrame& ethernet, ProcessBusFrame& frame) noexcept; + [[nodiscard]] static bool try_decode( + const EthernetFrame& ethernet, + ProcessBusFrame& frame) noexcept; +#endif }; } // namespace ar::iec61850::ethernet From ff2d25cea4b64b0a08dfb07bd4c596697bdeb2b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:12:01 +0700 Subject: [PATCH 115/188] Implement bounded no-throw Ethernet framing --- src/ethernet/ethernet.cpp | 272 ++++++++++++++++++++++++++++---------- 1 file changed, 205 insertions(+), 67 deletions(-) diff --git a/src/ethernet/ethernet.cpp b/src/ethernet/ethernet.cpp index b0ae6db1b..5214a99cf 100644 --- a/src/ethernet/ethernet.cpp +++ b/src/ethernet/ethernet.cpp @@ -4,24 +4,35 @@ #include #include -#include +#include #include +#include +#include +#include +#if !defined(ARIEC61850_NO_EXCEPTIONS) +#include #include #include +#include +#endif namespace ar::iec61850::ethernet { namespace { -void write_u16_be(std::span destination, const std::size_t offset, - const std::uint16_t value) { +void write_u16_be( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { destination[offset] = static_cast((value >> 8U) & 0xFFU); destination[offset + 1U] = static_cast(value & 0xFFU); } -std::uint16_t read_u16_be(const std::span source, - const std::size_t offset) noexcept { - return static_cast((static_cast(source[offset]) << 8U) | - static_cast(source[offset + 1U])); +std::uint16_t read_u16_be( + const std::span source, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(source[offset]) << 8U) | + static_cast(source[offset + 1U])); } int hex_value(const char ch) noexcept { @@ -39,20 +50,24 @@ int hex_value(const char ch) noexcept { } // namespace -MacAddress::MacAddress(const std::span bytes) { - if (bytes.size() != bytes_.size()) { - throw std::invalid_argument("A MAC address must contain exactly 6 bytes."); +bool MacAddress::try_from_bytes( + const std::span bytes, + MacAddress& address) noexcept { + if (bytes.size() != address.bytes_.size()) { + address = {}; + return false; } - std::copy(bytes.begin(), bytes.end(), bytes_.begin()); + std::copy(bytes.begin(), bytes.end(), address.bytes_.begin()); + return true; } -MacAddress MacAddress::parse(const std::string& text) { - MacAddress address; - if (!try_parse(text, address)) { - throw std::invalid_argument("Invalid MAC address '" + text + "'."); +#if !defined(ARIEC61850_NO_EXCEPTIONS) +MacAddress::MacAddress(const std::span bytes) { + if (!try_from_bytes(bytes, *this)) { + throw std::invalid_argument("A MAC address must contain exactly 6 bytes."); } - return address; } +#endif bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcept { std::string normalized; @@ -67,6 +82,7 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep --end; } if (begin == end) { + address = {}; return false; } @@ -74,6 +90,7 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep normalized.push_back(*it == '-' ? ':' : *it); } if (normalized.size() != 17U) { + address = {}; return false; } @@ -81,11 +98,13 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep for (std::size_t index = 0; index < bytes.size(); ++index) { const auto offset = index * 3U; if (index != bytes.size() - 1U && normalized[offset + 2U] != ':') { + address = {}; return false; } const auto high = hex_value(normalized[offset]); const auto low = hex_value(normalized[offset + 1U]); if (high < 0 || low < 0) { + address = {}; return false; } bytes[index] = static_cast((high << 4) | low); @@ -95,11 +114,30 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep return true; } -void MacAddress::copy_to(const std::span destination) const { +#if !defined(ARIEC61850_NO_EXCEPTIONS) +MacAddress MacAddress::parse(const std::string& text) { + MacAddress address; + if (!try_parse(text, address)) { + throw std::invalid_argument("Invalid MAC address '" + text + "'."); + } + return address; +} +#endif + +bool MacAddress::try_copy_to( + const std::span destination) const noexcept { if (destination.size() < bytes_.size()) { - throw std::invalid_argument("Destination span must be at least 6 bytes."); + return false; } std::copy(bytes_.begin(), bytes_.end(), destination.begin()); + return true; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) +void MacAddress::copy_to(const std::span destination) const { + if (!try_copy_to(destination)) { + throw std::invalid_argument("Destination span must be at least 6 bytes."); + } } std::string MacAddress::to_string() const { @@ -113,20 +151,34 @@ std::string MacAddress::to_string() const { } return stream.str(); } +#endif -std::uint16_t VlanTag::to_tag_control_information() const { - if (priority_code_point > 7U) { - throw std::out_of_range("VLAN priority must be 0..7."); +bool VlanTag::try_to_tag_control_information(std::uint16_t& tci) const noexcept { + if (priority_code_point > 7U || vlan_id > 4094U) { + tci = 0U; + return false; } - if (vlan_id > 4094U) { + + const auto value = + (static_cast(priority_code_point) << 13U) | + (drop_eligible ? 0x1000U : 0U) | + static_cast(vlan_id); + tci = static_cast(value); + return true; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) +std::uint16_t VlanTag::to_tag_control_information() const { + std::uint16_t tci{}; + if (!try_to_tag_control_information(tci)) { + if (priority_code_point > 7U) { + throw std::out_of_range("VLAN priority must be 0..7."); + } throw std::out_of_range("VLAN ID must be 0..4094."); } - - const auto tci = (static_cast(priority_code_point) << 13U) | - (drop_eligible ? 0x1000U : 0U) | - static_cast(vlan_id); - return static_cast(tci); + return tci; } +#endif VlanTag VlanTag::from_tag_control_information(const std::uint16_t tci) noexcept { return { @@ -135,38 +187,81 @@ VlanTag VlanTag::from_tag_control_information(const std::uint16_t tci) noexcept static_cast(tci & 0x0FFFU)}; } -std::vector EthernetFrameCodec::encode(const EthernetFrame& frame) { - const std::size_t header_length = frame.vlan ? 18U : 14U; - std::vector bytes(header_length + frame.payload.size()); - const std::span span{bytes}; +std::optional EthernetFrameCodec::encoded_size( + const EthernetFrame& frame) noexcept { + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return std::nullopt; + } + } + + const std::size_t header_length = frame.vlan.has_value() ? 18U : 14U; + if (frame.payload.size() > std::numeric_limits::max() - header_length) { + return std::nullopt; + } + return header_length + frame.payload.size(); +} - frame.destination.copy_to(span.first(6U)); - frame.source.copy_to(span.subspan(6U, 6U)); +wire::EncodeResult EthernetFrameCodec::encode_into( + const EthernetFrame& frame, + const std::span destination) noexcept { + const auto required = encoded_size(frame); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + std::copy(frame.destination.bytes().begin(), frame.destination.bytes().end(), destination.begin()); + std::copy( + frame.source.bytes().begin(), + frame.source.bytes().end(), + destination.begin() + 6); std::size_t offset = 12U; - if (frame.vlan) { - write_u16_be(span, offset, vlan_tag_ethertype); - write_u16_be(span, offset + 2U, frame.vlan->to_tag_control_information()); + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + write_u16_be(destination, offset, vlan_tag_ethertype); + write_u16_be(destination, offset + 2U, tci); offset += 4U; } - write_u16_be(span, offset, frame.ether_type); + write_u16_be(destination, offset, frame.ether_type); offset += 2U; - std::copy(frame.payload.begin(), frame.payload.end(), bytes.begin() + static_cast(offset)); - return bytes; + std::copy( + frame.payload.begin(), + frame.payload.end(), + destination.begin() + static_cast(offset)); + offset += frame.payload.size(); + return {wire::EncodeStatus::ok, offset, *required}; } -bool EthernetFrameCodec::try_decode(const std::span bytes, - EthernetFrame& frame) noexcept { - frame = {}; - if (bytes.size() < 14U) { - return false; +#if !defined(ARIEC61850_NO_EXCEPTIONS) +std::vector EthernetFrameCodec::encode(const EthernetFrame& frame) { + const auto required = encoded_size(frame); + if (!required) { + throw std::out_of_range("Ethernet frame exceeds the supported wire range."); } + std::vector bytes(*required); + const auto result = encode_into(frame, bytes); + if (!result.success() || result.bytes_written != bytes.size()) { + throw std::runtime_error("Failed to encode Ethernet frame into sized buffer."); + } + return bytes; +} - try { - frame.destination = MacAddress(bytes.first(6U)); - frame.source = MacAddress(bytes.subspan(6U, 6U)); - } catch (...) { +bool EthernetFrameCodec::try_decode( + const std::span bytes, + EthernetFrame& frame) noexcept { + frame = {}; + if (bytes.size() < 14U || + !MacAddress::try_from_bytes(bytes.first(6U), frame.destination) || + !MacAddress::try_from_bytes(bytes.subspan(6U, 6U), frame.source)) { return false; } @@ -180,8 +275,6 @@ bool EthernetFrameCodec::try_decode(const std::span bytes, } const auto decoded_vlan = VlanTag::from_tag_control_information(read_u16_be(bytes, 14U)); - // IEEE 802.1Q reserves VID 4095. Keep try_decode closed under encode: - // every successfully decoded VlanTag must be valid for re-encoding. if (decoded_vlan.vlan_id > 4094U) { return false; } @@ -192,27 +285,65 @@ bool EthernetFrameCodec::try_decode(const std::span bytes, frame.ether_type = ether_type; frame.vlan = vlan; - frame.payload.assign(bytes.begin() + static_cast(payload_offset), bytes.end()); + frame.payload.assign( + bytes.begin() + static_cast(payload_offset), + bytes.end()); return true; } +#endif +std::optional ProcessBusFrameCodec::encoded_payload_size( + const std::span apdu) noexcept { + if (apdu.size() > + static_cast(std::numeric_limits::max()) - header_length) { + return std::nullopt; + } + return header_length + apdu.size(); +} + +wire::EncodeResult ProcessBusFrameCodec::encode_payload_into( + const std::uint16_t app_id, + const std::span apdu, + const std::span destination, + const std::uint16_t reserved1, + const std::uint16_t reserved2) noexcept { + const auto required = encoded_payload_size(apdu); + 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 declared_length = static_cast(*required); + write_u16_be(destination, 0U, app_id); + write_u16_be(destination, 2U, declared_length); + write_u16_be(destination, 4U, reserved1); + write_u16_be(destination, 6U, reserved2); + std::copy( + apdu.begin(), + apdu.end(), + destination.begin() + static_cast(header_length)); + return {wire::EncodeStatus::ok, *required, *required}; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector ProcessBusFrameCodec::encode_payload( const std::uint16_t app_id, const std::span apdu, const std::uint16_t reserved1, const std::uint16_t reserved2) { - if (apdu.size() > std::numeric_limits::max() - header_length) { + const auto required = encoded_payload_size(apdu); + if (!required) { throw std::out_of_range("Process-bus APDU exceeds the 16-bit declared length."); } - const auto declared_length = static_cast(header_length + apdu.size()); - std::vector bytes(declared_length); - const std::span span{bytes}; - write_u16_be(span, 0U, app_id); - write_u16_be(span, 2U, declared_length); - write_u16_be(span, 4U, reserved1); - write_u16_be(span, 6U, reserved2); - std::copy(apdu.begin(), apdu.end(), bytes.begin() + static_cast(header_length)); + std::vector bytes(*required); + const auto result = encode_payload_into( + app_id, apdu, bytes, reserved1, reserved2); + if (!result.success() || result.bytes_written != bytes.size()) { + throw std::runtime_error("Failed to encode process-bus payload into sized buffer."); + } return bytes; } @@ -225,12 +356,17 @@ EthernetFrame ProcessBusFrameCodec::encode_ethernet_frame( const std::optional vlan, const std::uint16_t reserved1, const std::uint16_t reserved2) { - return {destination, source, ether_type, vlan, - encode_payload(app_id, apdu, reserved1, reserved2)}; + return { + destination, + source, + ether_type, + vlan, + encode_payload(app_id, apdu, reserved1, reserved2)}; } -bool ProcessBusFrameCodec::try_decode(const EthernetFrame& ethernet, - ProcessBusFrame& frame) noexcept { +bool ProcessBusFrameCodec::try_decode( + const EthernetFrame& ethernet, + ProcessBusFrame& frame) noexcept { frame = {}; if (ethernet.payload.size() < header_length) { return false; @@ -256,9 +392,11 @@ bool ProcessBusFrameCodec::try_decode(const EthernetFrame& ethernet, frame.declared_length = declared_length; frame.reserved1 = reserved1; frame.reserved2 = reserved2; - frame.apdu.assign(payload.begin() + static_cast(header_length), - payload.begin() + static_cast(header_length + declared_apdu_length)); + frame.apdu.assign( + payload.begin() + static_cast(header_length), + payload.begin() + static_cast(header_length + declared_apdu_length)); return true; } +#endif -} // namespace ar::iec61850::ethernet \ No newline at end of file +} // namespace ar::iec61850::ethernet From 6f44b7fa8760eca6e19cafb350a015568c7a8671 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:13:05 +0700 Subject: [PATCH 116/188] Exercise bounded Ethernet APIs in hard profile --- embedded/sv_hard_profile_smoke.cpp | 73 ++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/embedded/sv_hard_profile_smoke.cpp b/embedded/sv_hard_profile_smoke.cpp index c5f0481bf..ba106b2f8 100644 --- a/embedded/sv_hard_profile_smoke.cpp +++ b/embedded/sv_hard_profile_smoke.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace { @@ -61,9 +62,71 @@ sampled_values::SampledValuesFrame make_frame() { sampled_values::SampledValuesPdu{{asdu}}}; } +bool bounded_ethernet_primitives_work() { + const std::array raw_mac{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + ethernet::MacAddress parsed{}; + if (!ethernet::MacAddress::try_from_bytes(raw_mac, parsed) || + parsed.bytes() != raw_mac) { + return false; + } + + std::array copied{}; + if (!parsed.try_copy_to(copied) || copied != raw_mac) { + return false; + } + + ethernet::VlanTag vlan{4U, true, 100U}; + std::uint16_t tci{}; + if (!vlan.try_to_tag_control_information(tci) || tci != 0x9064U) { + return false; + } + ethernet::VlanTag invalid_vlan{8U, false, 100U}; + if (invalid_vlan.try_to_tag_control_information(tci)) { + return false; + } + + const std::array apdu{0x61U, 0x01U, 0x00U}; + std::array process_bus{}; + const auto process_result = ethernet::ProcessBusFrameCodec::encode_payload_into( + 0x1001U, + apdu, + process_bus, + 0U, + 0U); + if (!process_result.success() || process_result.bytes_written != 11U || + process_bus[0] != 0x10U || process_bus[1] != 0x01U || + process_bus[2] != 0x00U || process_bus[3] != 0x0BU || + process_bus[8] != 0x61U) { + return false; + } + + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x01U, 0x00U, 0x01U}; + ethernet::EthernetFrame frame{ + ethernet::MacAddress{destination}, + parsed, + ethernet::goose_ethertype, + vlan, + std::vector{process_bus.begin(), process_bus.begin() + 11}}; + std::array wire{}; + const auto ethernet_result = ethernet::EthernetFrameCodec::encode_into(frame, wire); + if (!ethernet_result.success() || ethernet_result.bytes_written != 29U || + wire[12] != 0x81U || wire[13] != 0x00U || + wire[16] != 0x88U || wire[17] != 0xB8U) { + return false; + } + + return true; +} + } // namespace int main() { + if (!bounded_ethernet_primitives_work()) { + return 1; + } + FakeEthernet fake; embedded::RawEthernetPort port{&fake, &capture_frame}; auto frame = make_frame(); @@ -84,7 +147,7 @@ int main() { 0U, true}); if (!publisher.valid()) { - return 1; + return 2; } constexpr std::uint64_t start_us = 1'000'000U; @@ -94,7 +157,7 @@ int main() { const auto expected_count = static_cast(index % 4'000U); if (!result.sent() || result.sample_count != expected_count || fake.sends != index + 1U) { - return 2; + return 3; } } @@ -102,7 +165,7 @@ int main() { if (stats.frames_sent != frame_count || stats.encode_failures != 0U || stats.transmit_failures != 0U || stats.late_polls != 0U || stats.maximum_lateness_us != 0U || publisher.next_sample_count() != 0U) { - return 3; + return 4; } // The current hard-profile milestone permits setup-time dynamic storage in @@ -111,13 +174,13 @@ int main() { frame.pdu.asdus.front().sample_payload.capacity() != payload_capacity || frame.pdu.asdus.front().sv_id.capacity() != sv_id_capacity || frame.pdu.asdus.front().data_set_reference.capacity() != dataset_capacity) { - return 4; + return 5; } if (fake.last_size < 22U || fake.last_frame[12] != 0x88U || fake.last_frame[13] != 0xBAU || fake.last_frame[14] != 0x40U || fake.last_frame[15] != 0x01U) { - return 5; + return 6; } return 0; From f350587e91495012aa35def7c7b640aed618484d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:13:29 +0700 Subject: [PATCH 117/188] Expand hard profile through Ethernet framing --- embedded/hard_profile/CMakeLists.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 8aac94fc0..9adfd8074 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850HardSvProfile VERSION 0.1.0 - DESCRIPTION "No-exception/no-RTTI MCU profile for the bounded SV transmit path" + DESCRIPTION "No-exception/no-RTTI MCU profile for bounded process-bus and SV transmit paths" LANGUAGES CXX) set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") @@ -12,6 +12,7 @@ if(MSVC) endif() set(ARIEC61850_HARD_SV_SOURCES + ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp @@ -40,6 +41,6 @@ target_compile_options(ariec61850_hard_sv_smoke PRIVATE enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) -message(STATUS "arstack61850 hard SV profile: exceptions disabled=ON") -message(STATUS "arstack61850 hard SV profile: RTTI disabled=ON") -message(STATUS "arstack61850 hard SV profile: host services excluded") +message(STATUS "arstack61850 hard process-bus/SV profile: exceptions disabled=ON") +message(STATUS "arstack61850 hard process-bus/SV profile: RTTI disabled=ON") +message(STATUS "arstack61850 hard process-bus/SV profile: host services excluded") From 1ba7ff6216542b80542ef9b967ce3488361cfb21 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:15:38 +0700 Subject: [PATCH 118/188] Split lightweight BER tag types --- include/ariec61850/asn1/ber_types.hpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 include/ariec61850/asn1/ber_types.hpp diff --git a/include/ariec61850/asn1/ber_types.hpp b/include/ariec61850/asn1/ber_types.hpp new file mode 100644 index 000000000..b4b3a5c4a --- /dev/null +++ b/include/ariec61850/asn1/ber_types.hpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::asn1 { + +enum class BerClass : std::uint8_t { + universal = 0, + application = 1, + context_specific = 2, + private_class = 3 +}; + +} // namespace ar::iec61850::asn1 From cf8b37f349f3e74147705206a6eed18b34d87c85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:15:55 +0700 Subject: [PATCH 119/188] Use lightweight BER tag type header --- include/ariec61850/asn1/ber.hpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/include/ariec61850/asn1/ber.hpp b/include/ariec61850/asn1/ber.hpp index 9cc816fa2..a8fd07d56 100644 --- a/include/ariec61850/asn1/ber.hpp +++ b/include/ariec61850/asn1/ber.hpp @@ -2,6 +2,8 @@ #pragma once +#include "ariec61850/asn1/ber_types.hpp" + #include #include #include @@ -13,13 +15,6 @@ namespace ar::iec61850::asn1 { -enum class BerClass : std::uint8_t { - universal = 0, - application = 1, - context_specific = 2, - private_class = 3 -}; - class BerFormatError final : public std::runtime_error { public: using std::runtime_error::runtime_error; From d01b4699cacea702c1ef4a0658b2c6dd676db942 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:16:23 +0700 Subject: [PATCH 120/188] Add allocation-free BER span writer --- include/ariec61850/asn1/ber_span_writer.hpp | 193 ++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 include/ariec61850/asn1/ber_span_writer.hpp diff --git a/include/ariec61850/asn1/ber_span_writer.hpp b/include/ariec61850/asn1/ber_span_writer.hpp new file mode 100644 index 000000000..f169ea7e5 --- /dev/null +++ b/include/ariec61850/asn1/ber_span_writer.hpp @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_types.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +class BerSpanWriter final { +public: + explicit BerSpanWriter(std::span destination) noexcept + : destination_{destination} {} + + [[nodiscard]] static std::optional identifier_octets( + const std::int32_t tag_number) noexcept { + if (tag_number < 0) { + return std::nullopt; + } + if (tag_number <= 30) { + return 1U; + } + + auto remaining = static_cast(tag_number); + std::size_t groups = 0U; + do { + ++groups; + remaining >>= 7U; + } while (remaining != 0U); + return 1U + groups; + } + + [[nodiscard]] static std::optional length_octets( + const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + return 1U; + } + if (value_length > std::numeric_limits::max()) { + return std::nullopt; + } + + auto value = static_cast(value_length); + std::size_t bytes = 0U; + while (value != 0U) { + ++bytes; + value >>= 8U; + } + return 1U + bytes; + } + + [[nodiscard]] static std::optional tlv_size( + const std::int32_t tag_number, + const std::size_t value_length) noexcept { + const auto identifier = identifier_octets(tag_number); + const auto length = length_octets(value_length); + if (!identifier || !length) { + return std::nullopt; + } + if (*length > std::numeric_limits::max() - *identifier) { + return std::nullopt; + } + const auto header = *identifier + *length; + if (value_length > std::numeric_limits::max() - header) { + return std::nullopt; + } + return header + value_length; + } + + [[nodiscard]] bool write_identifier( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number) noexcept { + if (tag_number < 0) { + failed_ = true; + return false; + } + + const auto class_bits = static_cast( + static_cast(tag_class) << 6U); + const auto constructed_bit = constructed ? std::uint8_t{0x20U} : std::uint8_t{0U}; + if (tag_number <= 30) { + return write_byte(static_cast( + class_bits | constructed_bit | static_cast(tag_number))); + } + + if (!write_byte(static_cast(class_bits | constructed_bit | 0x1FU))) { + return false; + } + + std::array groups{}; + std::size_t count = 0U; + auto remaining = static_cast(tag_number); + do { + if (count >= groups.size()) { + failed_ = true; + return false; + } + groups[count++] = static_cast(remaining & 0x7FU); + remaining >>= 7U; + } while (remaining != 0U); + + for (std::size_t index = count; index-- > 0U;) { + auto byte = groups[index]; + if (index != 0U) { + byte = static_cast(byte | 0x80U); + } + if (!write_byte(byte)) { + return false; + } + } + return true; + } + + [[nodiscard]] bool write_length(const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + return write_byte(static_cast(value_length)); + } + if (value_length > std::numeric_limits::max()) { + failed_ = true; + return false; + } + + std::array bytes{}; + auto value = static_cast(value_length); + std::size_t count = 0U; + while (value != 0U) { + bytes[bytes.size() - 1U - count] = static_cast(value & 0xFFU); + value >>= 8U; + ++count; + } + if (!write_byte(static_cast(0x80U | count))) { + return false; + } + return write_bytes(std::span{bytes}.last(count)); + } + + [[nodiscard]] bool write_tlv_header( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number, + const std::size_t value_length) noexcept { + return write_identifier(tag_class, constructed, tag_number) && + write_length(value_length); + } + + [[nodiscard]] bool write_tlv( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number, + const std::span value) noexcept { + return write_tlv_header(tag_class, constructed, tag_number, value.size()) && + write_bytes(value); + } + + [[nodiscard]] bool write_bytes(const std::span value) noexcept { + if (failed_ || value.size() > remaining()) { + failed_ = true; + return false; + } + std::copy(value.begin(), value.end(), destination_.begin() + + static_cast(offset_)); + offset_ += value.size(); + return true; + } + + [[nodiscard]] bool write_byte(const std::uint8_t value) noexcept { + if (failed_ || offset_ >= destination_.size()) { + failed_ = true; + return false; + } + destination_[offset_++] = value; + return true; + } + + [[nodiscard]] std::size_t size() const noexcept { return offset_; } + [[nodiscard]] std::size_t remaining() const noexcept { + return offset_ <= destination_.size() ? destination_.size() - offset_ : 0U; + } + [[nodiscard]] bool good() const noexcept { return !failed_; } + +private: + std::span destination_; + std::size_t offset_{}; + bool failed_{}; +}; + +} // namespace ar::iec61850::asn1 From 80710d16bd6662b1d73f2a1b140346f84790c0e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:17:08 +0700 Subject: [PATCH 121/188] Add bounded no-throw MMS data encoder --- include/ariec61850/mms/data_span_codec.hpp | 340 +++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 include/ariec61850/mms/data_span_codec.hpp diff --git a/include/ariec61850/mms/data_span_codec.hpp b/include/ariec61850/mms/data_span_codec.hpp new file mode 100644 index 000000000..39c13690a --- /dev/null +++ b/include/ariec61850/mms/data_span_codec.hpp @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsDataSpanCodec final { +public: + static constexpr std::size_t maximum_nesting_depth = 16U; + + [[nodiscard]] static std::optional encoded_size( + const MmsDataValue& value) noexcept { + return encoded_size_impl(value, 0U); + } + + [[nodiscard]] static std::optional encoded_all_size( + const std::span values) noexcept { + return encoded_all_size_impl(values, 0U); + } + + [[nodiscard]] static wire::EncodeResult encode_into( + const MmsDataValue& value, + const std::span destination) noexcept { + const auto required = encoded_size(value); + 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 (!write_value(writer, value, 0U) || writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, writer.size(), *required}; + } + + [[nodiscard]] static wire::EncodeResult encode_all_into( + const std::span values, + const std::span destination) noexcept { + const auto required = encoded_all_size(values); + 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)}; + for (const auto& value : values) { + if (!write_value(writer, value, 0U)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, writer.size(), *required}; + } + +private: + [[nodiscard]] static std::optional tag_for( + const MmsDataValue& value) noexcept { + switch (value.kind()) { + case MmsDataKind::array: return 1; + case MmsDataKind::structure: return 2; + case MmsDataKind::boolean: return 3; + case MmsDataKind::bit_string: return 4; + case MmsDataKind::integer: return 5; + case MmsDataKind::unsigned_integer: return 6; + case MmsDataKind::floating_point: return 7; + case MmsDataKind::octet_string: return 9; + case MmsDataKind::visible_string: return 10; + case MmsDataKind::binary_time: return 12; + case MmsDataKind::mms_string: return 16; + case MmsDataKind::utc_time: return 17; + case MmsDataKind::unknown: + return value.unknown_tag_number(); + default: + return std::nullopt; + } + } + + [[nodiscard]] static std::size_t unsigned_integer_size( + std::uint64_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; + } + + [[nodiscard]] static std::size_t signed_integer_size( + const std::int64_t value) noexcept { + const auto raw = std::bit_cast(value); + std::array bytes{}; + for (std::size_t index = 0U; index < bytes.size(); ++index) { + const auto shift = static_cast((bytes.size() - 1U - index) * 8U); + bytes[index] = static_cast((raw >> shift) & 0xFFU); + } + + std::size_t first = 0U; + while (first < bytes.size() - 1U) { + const auto current = bytes[first]; + const auto next = bytes[first + 1U]; + const bool redundant_positive = current == 0x00U && (next & 0x80U) == 0U; + const bool redundant_negative = current == 0xFFU && (next & 0x80U) != 0U; + if (!redundant_positive && !redundant_negative) { + break; + } + ++first; + } + return bytes.size() - first; + } + + [[nodiscard]] static std::optional content_size( + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + + switch (value.kind()) { + case MmsDataKind::array: + case MmsDataKind::structure: + return encoded_all_size_impl(value.children(), depth + 1U); + case MmsDataKind::boolean: + return std::get_if(&value.value()) != nullptr + ? std::optional{1U} + : std::nullopt; + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + return value.raw_value().size(); + case MmsDataKind::integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr + ? std::optional{signed_integer_size(*scalar)} + : std::nullopt; + } + case MmsDataKind::unsigned_integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr + ? std::optional{unsigned_integer_size(*scalar)} + : std::nullopt; + } + case MmsDataKind::floating_point: + return (std::get_if(&value.value()) != nullptr || + std::get_if(&value.value()) != nullptr) + ? std::optional{5U} + : std::nullopt; + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: { + const auto* text = std::get_if(&value.value()); + return text != nullptr + ? std::optional{text->size()} + : std::nullopt; + } + case MmsDataKind::utc_time: { + const auto* utc = std::get_if(&value.value()); + if (utc == nullptr) { + return std::nullopt; + } + std::array bytes{}; + return utc->try_write_bytes(bytes) + ? std::optional{bytes.size()} + : std::nullopt; + } + case MmsDataKind::unknown: + return value.unknown_tag_number().has_value() + ? std::optional{value.raw_value().size()} + : std::nullopt; + default: + return std::nullopt; + } + } + + [[nodiscard]] static std::optional encoded_size_impl( + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + const auto tag = tag_for(value); + const auto content = content_size(value, depth); + if (!tag || !content) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(*tag, *content); + } + + [[nodiscard]] static std::optional encoded_all_size_impl( + const std::span values, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + std::size_t total = 0U; + for (const auto& value : values) { + const auto size = encoded_size_impl(value, depth); + if (!size || *size > std::numeric_limits::max() - total) { + return std::nullopt; + } + total += *size; + } + return total; + } + + [[nodiscard]] static bool write_unsigned( + asn1::BerSpanWriter& writer, + const std::uint64_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; + } + + [[nodiscard]] static bool write_signed( + asn1::BerSpanWriter& writer, + const std::int64_t value) noexcept { + const auto raw = std::bit_cast(value); + std::array bytes{}; + for (std::size_t index = 0U; index < bytes.size(); ++index) { + const auto shift = static_cast((bytes.size() - 1U - index) * 8U); + bytes[index] = static_cast((raw >> shift) & 0xFFU); + } + const auto size = signed_integer_size(value); + return writer.write_bytes(std::span{bytes}.last(size)); + } + + [[nodiscard]] static bool write_value( + asn1::BerSpanWriter& writer, + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return false; + } + const auto tag = tag_for(value); + const auto content = content_size(value, depth); + if (!tag || !content) { + return false; + } + const bool constructed = + value.kind() == MmsDataKind::array || value.kind() == MmsDataKind::structure; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, + constructed, + *tag, + *content)) { + return false; + } + + switch (value.kind()) { + case MmsDataKind::array: + case MmsDataKind::structure: + for (const auto& child : value.children()) { + if (!write_value(writer, child, depth + 1U)) { + return false; + } + } + return true; + case MmsDataKind::boolean: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && writer.write_byte(*scalar ? 0x01U : 0x00U); + } + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + case MmsDataKind::unknown: + return writer.write_bytes(value.raw_value()); + case MmsDataKind::integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && write_signed(writer, *scalar); + } + case MmsDataKind::unsigned_integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && write_unsigned(writer, *scalar); + } + case MmsDataKind::floating_point: { + float scalar = 0.0F; + if (const auto* single = std::get_if(&value.value())) { + scalar = *single; + } else if (const auto* double_value = std::get_if(&value.value())) { + scalar = static_cast(*double_value); + } else { + return false; + } + const auto raw = std::bit_cast(scalar); + return writer.write_byte(0x08U) && + writer.write_byte(static_cast((raw >> 24U) & 0xFFU)) && + writer.write_byte(static_cast((raw >> 16U) & 0xFFU)) && + writer.write_byte(static_cast((raw >> 8U) & 0xFFU)) && + writer.write_byte(static_cast(raw & 0xFFU)); + } + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: { + const auto* text = std::get_if(&value.value()); + if (text == nullptr) { + return false; + } + for (const auto character : *text) { + if (!writer.write_byte(static_cast(character))) { + return false; + } + } + return true; + } + case MmsDataKind::utc_time: { + const auto* utc = std::get_if(&value.value()); + if (utc == nullptr) { + return false; + } + std::array bytes{}; + return utc->try_write_bytes(bytes) && writer.write_bytes(bytes); + } + default: + return false; + } + } +}; + +} // namespace ar::iec61850::mms From 2c866611bcbfe6f1405ec224df21e8fba092a825 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:18:43 +0700 Subject: [PATCH 122/188] Add bounded GOOSE PDU encode API --- include/ariec61850/goose/pdu_codec.hpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/include/ariec61850/goose/pdu_codec.hpp b/include/ariec61850/goose/pdu_codec.hpp index 724bd38b5..80c18304d 100644 --- a/include/ariec61850/goose/pdu_codec.hpp +++ b/include/ariec61850/goose/pdu_codec.hpp @@ -3,8 +3,11 @@ #pragma once #include "ariec61850/goose/pdu.hpp" +#include "ariec61850/wire/encode_result.hpp" +#include #include +#include #include #include @@ -12,9 +15,19 @@ namespace ar::iec61850::goose { class GoosePduCodec final { public: + // Allocation-free transmit path for embedded/steady-state publishers. + [[nodiscard]] static std::optional encoded_size( + const GoosePdu& pdu) noexcept; + [[nodiscard]] static wire::EncodeResult encode_into( + const GoosePdu& pdu, + std::span destination) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host convenience/decode surface retained for compatibility. [[nodiscard]] static std::vector encode(const GoosePdu& pdu); [[nodiscard]] static bool try_decode( std::span apdu, GoosePdu& pdu) noexcept; +#endif }; } // namespace ar::iec61850::goose From a727b834f1ff09e8a3d28f15fdd85ccd8de19588 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:19:17 +0700 Subject: [PATCH 123/188] Add bounded no-throw GOOSE PDU encoder --- src/goose/pdu_encode.cpp | 212 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 src/goose/pdu_encode.cpp diff --git a/src/goose/pdu_encode.cpp b/src/goose/pdu_encode.cpp new file mode 100644 index 000000000..e005379b6 --- /dev/null +++ b/src/goose/pdu_encode.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/pdu_codec.hpp" + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/data_span_codec.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::goose { +namespace { + +constexpr std::int32_t kGooseApplicationTag = 1; + +[[nodiscard]] std::size_t unsigned_integer_size(std::uint64_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] std::optional add_size( + const std::size_t left, + const std::optional right) noexcept { + if (!right || *right > std::numeric_limits::max() - left) { + return std::nullopt; + } + return left + *right; +} + +[[nodiscard]] std::optional primitive_size( + const std::int32_t tag, + const std::size_t value_size) noexcept { + return asn1::BerSpanWriter::tlv_size(tag, value_size); +} + +[[nodiscard]] std::optional unsigned_field_size( + const std::int32_t tag, + const std::uint64_t value) noexcept { + return primitive_size(tag, unsigned_integer_size(value)); +} + +[[nodiscard]] bool write_unsigned_value( + asn1::BerSpanWriter& writer, + const std::uint64_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte( + static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool write_unsigned_field( + asn1::BerSpanWriter& writer, + const std::int32_t tag, + const std::uint64_t value) noexcept { + const auto size = unsigned_integer_size(value); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, tag, size) && + write_unsigned_value(writer, value); +} + +[[nodiscard]] bool write_string_field( + asn1::BerSpanWriter& writer, + const std::int32_t tag, + const std::string& value) noexcept { + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, tag, value.size())) { + return false; + } + for (const auto character : value) { + if (!writer.write_byte(static_cast(character))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional content_size( + const GoosePdu& pdu) noexcept { + std::array timestamp{}; + if (!pdu.timestamp.try_write_bytes(timestamp)) { + return std::nullopt; + } + + const auto all_data_size = mms::MmsDataSpanCodec::encoded_all_size(pdu.values); + if (!all_data_size || + pdu.values.size() > static_cast( + std::numeric_limits::max())) { + return std::nullopt; + } + + std::size_t total = 0U; + const auto add = [&total](const std::optional size) noexcept { + const auto next = add_size(total, size); + if (!next) { + return false; + } + total = *next; + return true; + }; + + return add(primitive_size(0, pdu.go_cb_ref.size())) && + add(unsigned_field_size(1, pdu.time_allowed_to_live_milliseconds)) && + add(primitive_size(2, pdu.data_set_reference.size())) && + add(primitive_size(3, pdu.go_id.size())) && + add(primitive_size(4, timestamp.size())) && + add(unsigned_field_size(5, pdu.state_number)) && + add(unsigned_field_size(6, pdu.sequence_number)) && + add(primitive_size(7, 1U)) && + add(unsigned_field_size(8, pdu.configuration_revision)) && + add(primitive_size(9, 1U)) && + add(unsigned_field_size( + 10, + static_cast(pdu.values.size()))) && + add(asn1::BerSpanWriter::tlv_size(11, *all_data_size)) + ? std::optional{total} + : std::nullopt; +} + +} // namespace + +std::optional GoosePduCodec::encoded_size( + const GoosePdu& pdu) noexcept { + const auto content = content_size(pdu); + return content + ? asn1::BerSpanWriter::tlv_size(kGooseApplicationTag, *content) + : std::nullopt; +} + +wire::EncodeResult GoosePduCodec::encode_into( + const GoosePdu& pdu, + const std::span destination) noexcept { + const auto required = encoded_size(pdu); + 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 content = content_size(pdu); + const auto all_data_size = mms::MmsDataSpanCodec::encoded_all_size(pdu.values); + std::array timestamp{}; + if (!content || !all_data_size || !pdu.timestamp.try_write_bytes(timestamp)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::application, + true, + kGooseApplicationTag, + *content) || + !write_string_field(writer, 0, pdu.go_cb_ref) || + !write_unsigned_field(writer, 1, pdu.time_allowed_to_live_milliseconds) || + !write_string_field(writer, 2, pdu.data_set_reference) || + !write_string_field(writer, 3, pdu.go_id) || + !writer.write_tlv( + asn1::BerClass::context_specific, + false, + 4, + timestamp) || + !write_unsigned_field(writer, 5, pdu.state_number) || + !write_unsigned_field(writer, 6, pdu.sequence_number) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 7, 1U) || + !writer.write_byte(pdu.test ? 0x01U : 0x00U) || + !write_unsigned_field(writer, 8, pdu.configuration_revision) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 9, 1U) || + !writer.write_byte(pdu.needs_commissioning ? 0x01U : 0x00U) || + !write_unsigned_field( + writer, + 10, + static_cast(pdu.values.size())) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, + true, + 11, + *all_data_size)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + const auto all_data_result = mms::MmsDataSpanCodec::encode_all_into( + pdu.values, + destination.first(*required).subspan(writer.size(), *all_data_size)); + if (!all_data_result.success() || + all_data_result.bytes_written != *all_data_size) { + return {all_data_result.status, 0U, *required}; + } + + const auto written = writer.size() + all_data_result.bytes_written; + if (written != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, written, *required}; +} + +} // namespace ar::iec61850::goose From 29cb1d9641fb4d7082e3ff37cc11f5682b0f9f80 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:19:40 +0700 Subject: [PATCH 124/188] Add bounded GOOSE frame encode API --- include/ariec61850/goose/frame_codec.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/include/ariec61850/goose/frame_codec.hpp b/include/ariec61850/goose/frame_codec.hpp index 473f3cfdb..158e86df5 100644 --- a/include/ariec61850/goose/frame_codec.hpp +++ b/include/ariec61850/goose/frame_codec.hpp @@ -3,8 +3,11 @@ #pragma once #include "ariec61850/goose/frame.hpp" +#include "ariec61850/wire/encode_result.hpp" +#include #include +#include #include #include @@ -12,9 +15,17 @@ namespace ar::iec61850::goose { class GooseFrameCodec final { public: + [[nodiscard]] static std::optional encoded_size( + const GooseFrame& frame) noexcept; + [[nodiscard]] static wire::EncodeResult encode_into( + const GooseFrame& frame, + std::span destination) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] static std::vector encode(const GooseFrame& frame); [[nodiscard]] static bool try_decode( std::span frame_bytes, GooseFrame& frame) noexcept; +#endif }; } // namespace ar::iec61850::goose From 3ce51a793bb10beda708d594dc8dd1cea4678dad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:20:01 +0700 Subject: [PATCH 125/188] Add bounded no-throw GOOSE frame encoder --- src/goose/frame_encode.cpp | 110 +++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/goose/frame_encode.cpp diff --git a/src/goose/frame_encode.cpp b/src/goose/frame_encode.cpp new file mode 100644 index 000000000..dd881ee3a --- /dev/null +++ b/src/goose/frame_encode.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/frame_codec.hpp" + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/pdu_codec.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::goose { +namespace { + +void write_u16_be( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +} // namespace + +std::optional GooseFrameCodec::encoded_size( + const GooseFrame& frame) noexcept { + std::uint16_t tci{}; + if (frame.vlan.has_value() && + !frame.vlan->try_to_tag_control_information(tci)) { + return std::nullopt; + } + + const auto apdu_size = GoosePduCodec::encoded_size(frame.pdu); + if (!apdu_size || + *apdu_size > + static_cast(std::numeric_limits::max()) - + ethernet::ProcessBusFrameCodec::header_length) { + return std::nullopt; + } + + const std::size_t ethernet_header = frame.vlan.has_value() ? 18U : 14U; + const std::size_t process_bus_size = + ethernet::ProcessBusFrameCodec::header_length + *apdu_size; + if (process_bus_size > std::numeric_limits::max() - ethernet_header) { + return std::nullopt; + } + return ethernet_header + process_bus_size; +} + +wire::EncodeResult GooseFrameCodec::encode_into( + const GooseFrame& frame, + const std::span destination) noexcept { + const auto required = encoded_size(frame); + 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 apdu_size = GoosePduCodec::encoded_size(frame.pdu); + if (!apdu_size) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::copy( + frame.destination.bytes().begin(), + frame.destination.bytes().end(), + destination.begin()); + std::copy( + frame.source.bytes().begin(), + frame.source.bytes().end(), + destination.begin() + 6); + + std::size_t offset = 12U; + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + write_u16_be(destination, offset, ethernet::vlan_tag_ethertype); + write_u16_be(destination, offset + 2U, tci); + offset += 4U; + } + + write_u16_be(destination, offset, ethernet::goose_ethertype); + offset += 2U; + + const auto declared_length = static_cast( + ethernet::ProcessBusFrameCodec::header_length + *apdu_size); + write_u16_be(destination, offset, frame.app_id); + write_u16_be(destination, offset + 2U, declared_length); + write_u16_be(destination, offset + 4U, frame.reserved1); + write_u16_be(destination, offset + 6U, frame.reserved2); + offset += ethernet::ProcessBusFrameCodec::header_length; + + const auto pdu_result = GoosePduCodec::encode_into( + frame.pdu, + destination.subspan(offset, *apdu_size)); + if (!pdu_result.success() || pdu_result.bytes_written != *apdu_size) { + return {pdu_result.status, 0U, *required}; + } + offset += pdu_result.bytes_written; + return {wire::EncodeStatus::ok, offset, *required}; +} + +} // namespace ar::iec61850::goose From 6acded6bf0fe450bf3119003e3be705dcdb9cff0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:20:53 +0700 Subject: [PATCH 126/188] Build bounded GOOSE encode path in core --- CMakeLists.txt | 133 +------------------------------------------------ 1 file changed, 2 insertions(+), 131 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 39c5e1a8b..f874ee00b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,7 +17,9 @@ add_library(ariec61850_core src/capture/pcap.cpp src/goose/retransmission_schedule.cpp src/goose/pdu_codec.cpp + src/goose/pdu_encode.cpp src/goose/frame_codec.cpp + src/goose/frame_encode.cpp src/goose/subscriber_supervisor.cpp src/goose/publisher_runtime.cpp src/mms/utc_time.cpp @@ -182,135 +184,4 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_warnings(ariec61850_scl_tests) ariec61850_apply_sanitizers(ariec61850_scl_tests) add_test(NAME ariec61850_scl_tests COMMAND ariec61850_scl_tests) - - add_executable(ariec61850_comtrade_tests tests/test_comtrade.cpp) - target_link_libraries(ariec61850_comtrade_tests PRIVATE ARIEC61850::core) - target_compile_definitions(ariec61850_comtrade_tests PRIVATE - ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - target_compile_features(ariec61850_comtrade_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_comtrade_tests) - ariec61850_apply_sanitizers(ariec61850_comtrade_tests) - add_test(NAME ariec61850_comtrade_tests COMMAND ariec61850_comtrade_tests) - - add_executable(ariec61850_osi_transport_tests tests/test_osi_transport.cpp) - target_link_libraries(ariec61850_osi_transport_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_osi_transport_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_osi_transport_tests) - ariec61850_apply_sanitizers(ariec61850_osi_transport_tests) - add_test(NAME ariec61850_osi_transport_tests COMMAND ariec61850_osi_transport_tests) - - add_executable(ariec61850_osi_association_tests tests/test_osi_association.cpp) - target_link_libraries(ariec61850_osi_association_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_osi_association_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_osi_association_tests) - ariec61850_apply_sanitizers(ariec61850_osi_association_tests) - add_test(NAME ariec61850_osi_association_tests COMMAND ariec61850_osi_association_tests) - - add_executable(ariec61850_mms_services_tests tests/test_mms_services.cpp) - target_link_libraries(ariec61850_mms_services_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_mms_services_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_mms_services_tests) - ariec61850_apply_sanitizers(ariec61850_mms_services_tests) - add_test(NAME ariec61850_mms_services_tests COMMAND ariec61850_mms_services_tests) - - add_executable(ariec61850_reporting_tests tests/test_reporting.cpp) - target_link_libraries(ariec61850_reporting_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_reporting_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_reporting_tests) - ariec61850_apply_sanitizers(ariec61850_reporting_tests) - add_test(NAME ariec61850_reporting_tests COMMAND ariec61850_reporting_tests) - - add_executable(ariec61850_mms_runtime_tests tests/test_mms_runtime.cpp) - target_link_libraries(ariec61850_mms_runtime_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_mms_runtime_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_mms_runtime_tests) - ariec61850_apply_sanitizers(ariec61850_mms_runtime_tests) - add_test(NAME ariec61850_mms_runtime_tests COMMAND ariec61850_mms_runtime_tests) - - add_executable(ariec61850_tcp_transport_tests tests/test_tcp_transport.cpp) - target_link_libraries(ariec61850_tcp_transport_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_tcp_transport_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_tcp_transport_tests) - ariec61850_apply_sanitizers(ariec61850_tcp_transport_tests) - add_test(NAME ariec61850_tcp_transport_tests COMMAND ariec61850_tcp_transport_tests) - - add_executable(ariec61850_live_discovery_tests tests/test_live_discovery.cpp) - target_link_libraries(ariec61850_live_discovery_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_live_discovery_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_live_discovery_tests) - ariec61850_apply_sanitizers(ariec61850_live_discovery_tests) - add_test(NAME ariec61850_live_discovery_tests COMMAND ariec61850_live_discovery_tests) - - add_executable(ariec61850_rcb_selection_tests tests/test_rcb_selection.cpp) - target_link_libraries(ariec61850_rcb_selection_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_rcb_selection_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_rcb_selection_tests) - ariec61850_apply_sanitizers(ariec61850_rcb_selection_tests) - add_test(NAME ariec61850_rcb_selection_tests COMMAND ariec61850_rcb_selection_tests) - - add_executable(ariec61850_control_block_read_tests tests/test_control_block_read.cpp) - target_link_libraries(ariec61850_control_block_read_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_control_block_read_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_control_block_read_tests) - ariec61850_apply_sanitizers(ariec61850_control_block_read_tests) - add_test(NAME ariec61850_control_block_read_tests COMMAND ariec61850_control_block_read_tests) - - add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) - target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) - target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/fuzz) - target_compile_definitions(ariec61850_fuzz_smoke_tests PRIVATE - ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - target_compile_features(ariec61850_fuzz_smoke_tests PRIVATE cxx_std_20) - ariec61850_apply_warnings(ariec61850_fuzz_smoke_tests) - ariec61850_apply_sanitizers(ariec61850_fuzz_smoke_tests) - add_test(NAME ariec61850_fuzz_smoke_tests COMMAND ariec61850_fuzz_smoke_tests) - - if(ARIEC61850_BUILD_TOOLS) - add_test( - NAME ariec61850_pcap_interop_smoke - COMMAND ariec61850_pcap_interop_check - ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/process_bus_csharp_vectors.pcap - --json) - add_test( - NAME ariec61850_comtrade_inspect_smoke - COMMAND ariec61850_comtrade_inspect - ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/comtrade/simple_fault.cfg - --json) - add_test( - NAME ariec61850_pagination_probe_help - COMMAND ariec61850_pagination_probe --help) - add_test( - NAME ariec61850_sv_injector_loopback_smoke - COMMAND ariec61850_sv_injector --frames 160 --json) - endif() endif() - -if(ARIEC61850_BUILD_FUZZERS) - if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") - message(FATAL_ERROR "ARIEC61850_BUILD_FUZZERS requires Clang/libFuzzer.") - endif() - - function(ariec61850_add_fuzzer name source) - add_executable(${name} ${source}) - target_link_libraries(${name} PRIVATE ARIEC61850::core) - target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/fuzz) - target_compile_features(${name} PRIVATE cxx_std_20) - target_compile_options(${name} PRIVATE - -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) - target_link_options(${name} PRIVATE - -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) - ariec61850_apply_warnings(${name}) - endfunction() - - ariec61850_add_fuzzer(ariec61850_fuzz_ber fuzz/fuzz_ber.cpp) - ariec61850_add_fuzzer(ariec61850_fuzz_goose fuzz/fuzz_goose.cpp) - ariec61850_add_fuzzer(ariec61850_fuzz_sampled_values fuzz/fuzz_sampled_values.cpp) - ariec61850_add_fuzzer(ariec61850_fuzz_pcap fuzz/fuzz_pcap.cpp) - ariec61850_add_fuzzer(ariec61850_fuzz_scl fuzz/fuzz_scl.cpp) - ariec61850_add_fuzzer(ariec61850_fuzz_comtrade fuzz/fuzz_comtrade.cpp) - 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_reporting fuzz/fuzz_reporting.cpp) -endif() \ No newline at end of file From 203cc2cf7d220458215702dc11f05aad2e715c2b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:22:23 +0700 Subject: [PATCH 127/188] Restore full root CMake regression matrix --- CMakeLists.txt | 133 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f874ee00b..39c5e1a8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,9 +17,7 @@ add_library(ariec61850_core src/capture/pcap.cpp src/goose/retransmission_schedule.cpp src/goose/pdu_codec.cpp - src/goose/pdu_encode.cpp src/goose/frame_codec.cpp - src/goose/frame_encode.cpp src/goose/subscriber_supervisor.cpp src/goose/publisher_runtime.cpp src/mms/utc_time.cpp @@ -184,4 +182,135 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_warnings(ariec61850_scl_tests) ariec61850_apply_sanitizers(ariec61850_scl_tests) add_test(NAME ariec61850_scl_tests COMMAND ariec61850_scl_tests) + + add_executable(ariec61850_comtrade_tests tests/test_comtrade.cpp) + target_link_libraries(ariec61850_comtrade_tests PRIVATE ARIEC61850::core) + target_compile_definitions(ariec61850_comtrade_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_comtrade_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_comtrade_tests) + ariec61850_apply_sanitizers(ariec61850_comtrade_tests) + add_test(NAME ariec61850_comtrade_tests COMMAND ariec61850_comtrade_tests) + + add_executable(ariec61850_osi_transport_tests tests/test_osi_transport.cpp) + target_link_libraries(ariec61850_osi_transport_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_osi_transport_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_osi_transport_tests) + ariec61850_apply_sanitizers(ariec61850_osi_transport_tests) + add_test(NAME ariec61850_osi_transport_tests COMMAND ariec61850_osi_transport_tests) + + add_executable(ariec61850_osi_association_tests tests/test_osi_association.cpp) + target_link_libraries(ariec61850_osi_association_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_osi_association_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_osi_association_tests) + ariec61850_apply_sanitizers(ariec61850_osi_association_tests) + add_test(NAME ariec61850_osi_association_tests COMMAND ariec61850_osi_association_tests) + + add_executable(ariec61850_mms_services_tests tests/test_mms_services.cpp) + target_link_libraries(ariec61850_mms_services_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_mms_services_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_services_tests) + ariec61850_apply_sanitizers(ariec61850_mms_services_tests) + add_test(NAME ariec61850_mms_services_tests COMMAND ariec61850_mms_services_tests) + + add_executable(ariec61850_reporting_tests tests/test_reporting.cpp) + target_link_libraries(ariec61850_reporting_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_reporting_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_reporting_tests) + ariec61850_apply_sanitizers(ariec61850_reporting_tests) + add_test(NAME ariec61850_reporting_tests COMMAND ariec61850_reporting_tests) + + add_executable(ariec61850_mms_runtime_tests tests/test_mms_runtime.cpp) + target_link_libraries(ariec61850_mms_runtime_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_mms_runtime_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_runtime_tests) + ariec61850_apply_sanitizers(ariec61850_mms_runtime_tests) + add_test(NAME ariec61850_mms_runtime_tests COMMAND ariec61850_mms_runtime_tests) + + add_executable(ariec61850_tcp_transport_tests tests/test_tcp_transport.cpp) + target_link_libraries(ariec61850_tcp_transport_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_tcp_transport_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_tcp_transport_tests) + ariec61850_apply_sanitizers(ariec61850_tcp_transport_tests) + add_test(NAME ariec61850_tcp_transport_tests COMMAND ariec61850_tcp_transport_tests) + + add_executable(ariec61850_live_discovery_tests tests/test_live_discovery.cpp) + target_link_libraries(ariec61850_live_discovery_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_live_discovery_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_live_discovery_tests) + ariec61850_apply_sanitizers(ariec61850_live_discovery_tests) + add_test(NAME ariec61850_live_discovery_tests COMMAND ariec61850_live_discovery_tests) + + add_executable(ariec61850_rcb_selection_tests tests/test_rcb_selection.cpp) + target_link_libraries(ariec61850_rcb_selection_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_rcb_selection_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_rcb_selection_tests) + ariec61850_apply_sanitizers(ariec61850_rcb_selection_tests) + add_test(NAME ariec61850_rcb_selection_tests COMMAND ariec61850_rcb_selection_tests) + + add_executable(ariec61850_control_block_read_tests tests/test_control_block_read.cpp) + target_link_libraries(ariec61850_control_block_read_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_control_block_read_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_control_block_read_tests) + ariec61850_apply_sanitizers(ariec61850_control_block_read_tests) + add_test(NAME ariec61850_control_block_read_tests COMMAND ariec61850_control_block_read_tests) + + add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) + target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) + target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/fuzz) + target_compile_definitions(ariec61850_fuzz_smoke_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_fuzz_smoke_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_fuzz_smoke_tests) + ariec61850_apply_sanitizers(ariec61850_fuzz_smoke_tests) + add_test(NAME ariec61850_fuzz_smoke_tests COMMAND ariec61850_fuzz_smoke_tests) + + if(ARIEC61850_BUILD_TOOLS) + add_test( + NAME ariec61850_pcap_interop_smoke + COMMAND ariec61850_pcap_interop_check + ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/process_bus_csharp_vectors.pcap + --json) + add_test( + NAME ariec61850_comtrade_inspect_smoke + COMMAND ariec61850_comtrade_inspect + ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/comtrade/simple_fault.cfg + --json) + add_test( + NAME ariec61850_pagination_probe_help + COMMAND ariec61850_pagination_probe --help) + add_test( + NAME ariec61850_sv_injector_loopback_smoke + COMMAND ariec61850_sv_injector --frames 160 --json) + endif() endif() + +if(ARIEC61850_BUILD_FUZZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR "ARIEC61850_BUILD_FUZZERS requires Clang/libFuzzer.") + endif() + + function(ariec61850_add_fuzzer name source) + add_executable(${name} ${source}) + target_link_libraries(${name} PRIVATE ARIEC61850::core) + target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/fuzz) + target_compile_features(${name} PRIVATE cxx_std_20) + target_compile_options(${name} PRIVATE + -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) + target_link_options(${name} PRIVATE + -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) + ariec61850_apply_warnings(${name}) + endfunction() + + ariec61850_add_fuzzer(ariec61850_fuzz_ber fuzz/fuzz_ber.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_goose fuzz/fuzz_goose.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_sampled_values fuzz/fuzz_sampled_values.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_pcap fuzz/fuzz_pcap.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_scl fuzz/fuzz_scl.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_comtrade fuzz/fuzz_comtrade.cpp) + 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_reporting fuzz/fuzz_reporting.cpp) +endif() \ No newline at end of file From 6f61daaabe3e4355d5fc7a30e7ab626e986133c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:24:01 +0700 Subject: [PATCH 128/188] Add bounded GOOSE hard-profile golden smoke --- embedded/goose_hard_profile_smoke.cpp | 159 ++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 embedded/goose_hard_profile_smoke.cpp diff --git a/embedded/goose_hard_profile_smoke.cpp b/embedded/goose_hard_profile_smoke.cpp new file mode 100644 index 000000000..ca54347e2 --- /dev/null +++ b/embedded/goose_hard_profile_smoke.cpp @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/goose/pdu_codec.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/mms/utc_time.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kExpectedPdu{ + 0x61U, 0x54U, 0x80U, 0x10U, 0x4CU, 0x44U, 0x30U, 0x2FU, 0x4CU, 0x4CU, + 0x4EU, 0x30U, 0x24U, 0x47U, 0x4FU, 0x24U, 0x67U, 0x63U, 0x62U, 0x31U, + 0x81U, 0x02U, 0x03U, 0xE8U, 0x82U, 0x0CU, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x44U, 0x53U, 0x31U, 0x83U, 0x06U, + 0x47U, 0x4FU, 0x4FU, 0x53U, 0x45U, 0x31U, 0x84U, 0x08U, 0x6AU, 0x2BU, + 0x77U, 0x25U, 0x40U, 0x00U, 0x00U, 0x0AU, 0x85U, 0x01U, 0x03U, 0x86U, + 0x01U, 0x09U, 0x87U, 0x01U, 0x00U, 0x88U, 0x01U, 0x02U, 0x89U, 0x01U, + 0x01U, 0x8AU, 0x01U, 0x03U, 0xABU, 0x0AU, 0x83U, 0x01U, 0x01U, 0x85U, + 0x01U, 0xFDU, 0x8AU, 0x02U, 0x4FU, 0x4BU}; + +constexpr std::array kExpectedFrame{ + 0x01U, 0x0CU, 0xCDU, 0x01U, 0x00U, 0x01U, 0x02U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x01U, 0x81U, 0x00U, 0x80U, 0x64U, 0x88U, 0xB8U, 0x10U, 0x01U, + 0x00U, 0x5EU, 0x00U, 0x00U, 0x00U, 0x00U, 0x61U, 0x54U, 0x80U, 0x10U, + 0x4CU, 0x44U, 0x30U, 0x2FU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x47U, + 0x4FU, 0x24U, 0x67U, 0x63U, 0x62U, 0x31U, 0x81U, 0x02U, 0x03U, 0xE8U, + 0x82U, 0x0CU, 0x4CU, 0x44U, 0x30U, 0x2FU, 0x4CU, 0x4CU, 0x4EU, 0x30U, + 0x24U, 0x44U, 0x53U, 0x31U, 0x83U, 0x06U, 0x47U, 0x4FU, 0x4FU, 0x53U, + 0x45U, 0x31U, 0x84U, 0x08U, 0x6AU, 0x2BU, 0x77U, 0x25U, 0x40U, 0x00U, + 0x00U, 0x0AU, 0x85U, 0x01U, 0x03U, 0x86U, 0x01U, 0x09U, 0x87U, 0x01U, + 0x00U, 0x88U, 0x01U, 0x02U, 0x89U, 0x01U, 0x01U, 0x8AU, 0x01U, 0x03U, + 0xABU, 0x0AU, 0x83U, 0x01U, 0x01U, 0x85U, 0x01U, 0xFDU, 0x8AU, 0x02U, + 0x4FU, 0x4BU}; + +[[nodiscard]] goose::GoosePdu make_pdu() { + goose::GoosePdu pdu; + pdu.go_cb_ref = "LD0/LLN0$GO$gcb1"; + pdu.time_allowed_to_live_milliseconds = 1'000U; + pdu.data_set_reference = "LD0/LLN0$DS1"; + pdu.go_id = "GOOSE1"; + pdu.timestamp = mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'781'233'445}} + + std::chrono::milliseconds{250}, + 0x0AU}; + pdu.state_number = 3U; + pdu.sequence_number = 9U; + pdu.test = false; + pdu.configuration_revision = 2U; + pdu.needs_commissioning = true; + pdu.values = { + mms::MmsDataValue::boolean(true), + mms::MmsDataValue::integer(-3), + mms::MmsDataValue::visible_string("OK")}; + return pdu; +} + +[[nodiscard]] goose::GooseFrame make_frame() { + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x01U, 0x00U, 0x01U}; + const std::array source{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + goose::GooseFrame frame; + frame.destination = ethernet::MacAddress{destination}; + frame.source = ethernet::MacAddress{source}; + frame.vlan = ethernet::VlanTag{4U, 100U}; + frame.app_id = 0x1001U; + frame.pdu = make_pdu(); + return frame; +} + +[[nodiscard]] bool equals( + const std::span left, + const std::span right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0U; index < left.size(); ++index) { + if (left[index] != right[index]) { + return false; + } + } + return true; +} + +} // namespace + +int main() { + auto pdu = make_pdu(); + const auto pdu_size = goose::GoosePduCodec::encoded_size(pdu); + if (!pdu_size || *pdu_size != kExpectedPdu.size()) { + return 1; + } + + std::array pdu_buffer{}; + const auto pdu_result = goose::GoosePduCodec::encode_into(pdu, pdu_buffer); + if (!pdu_result.success() || pdu_result.bytes_written != kExpectedPdu.size() || + !equals( + std::span{pdu_buffer}.first(pdu_result.bytes_written), + kExpectedPdu)) { + return 2; + } + + std::array small{}; + const auto small_result = goose::GoosePduCodec::encode_into(pdu, small); + if (small_result.status != wire::EncodeStatus::buffer_too_small || + small_result.required_bytes != kExpectedPdu.size()) { + return 3; + } + + auto frame = make_frame(); + const auto frame_size = goose::GooseFrameCodec::encoded_size(frame); + if (!frame_size || *frame_size != kExpectedFrame.size()) { + return 4; + } + + std::array frame_buffer{}; + const auto frame_result = goose::GooseFrameCodec::encode_into(frame, frame_buffer); + if (!frame_result.success() || frame_result.bytes_written != kExpectedFrame.size() || + !equals( + std::span{frame_buffer}.first(frame_result.bytes_written), + kExpectedFrame)) { + return 5; + } + + const auto values_capacity = frame.pdu.values.capacity(); + const auto go_cb_capacity = frame.pdu.go_cb_ref.capacity(); + const auto dataset_capacity = frame.pdu.data_set_reference.capacity(); + const auto go_id_capacity = frame.pdu.go_id.capacity(); + + // Simulate a sustained retransmission workload. Only scalar state/sequence + // fields change; the bounded encoder must not grow configured storage. + constexpr std::uint32_t retransmissions = 20'000U; + for (std::uint32_t index = 0U; index < retransmissions; ++index) { + frame.pdu.sequence_number = index; + const auto result = goose::GooseFrameCodec::encode_into(frame, frame_buffer); + if (!result.success() || result.bytes_written == 0U) { + return 6; + } + } + + if (frame.pdu.values.capacity() != values_capacity || + frame.pdu.go_cb_ref.capacity() != go_cb_capacity || + frame.pdu.data_set_reference.capacity() != dataset_capacity || + frame.pdu.go_id.capacity() != go_id_capacity) { + return 7; + } + + return 0; +} From 7ed9d3b4ac0b7238255a9b58fd227feea5f5ce15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:24:22 +0700 Subject: [PATCH 129/188] Add bounded GOOSE hard-profile target --- embedded/hard_profile/CMakeLists.txt | 63 ++++++++++++++++------------ 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 9adfd8074..697bb98c6 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -1,46 +1,57 @@ cmake_minimum_required(VERSION 3.20) -project(ARIEC61850HardSvProfile +project(ARIEC61850HardProcessBusProfile VERSION 0.1.0 - DESCRIPTION "No-exception/no-RTTI MCU profile for bounded process-bus and SV transmit paths" + DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus transmit paths" LANGUAGES CXX) set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") if(MSVC) - message(FATAL_ERROR "The first hard embedded profile is validated with GCC/Clang -fno-exceptions.") + message(FATAL_ERROR "Hard embedded profiles are validated with GCC/Clang -fno-exceptions.") endif() -set(ARIEC61850_HARD_SV_SOURCES +function(ariec61850_configure_hard_target target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + target_compile_definitions(${target} PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) + target_compile_options(${target} PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) +endfunction() + +add_executable(ariec61850_hard_sv_smoke + ${CMAKE_CURRENT_LIST_DIR}/../sv_hard_profile_smoke.cpp ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp) +ariec61850_configure_hard_target(ariec61850_hard_sv_smoke) -add_executable(ariec61850_hard_sv_smoke - ${CMAKE_CURRENT_LIST_DIR}/../sv_hard_profile_smoke.cpp - ${ARIEC61850_HARD_SV_SOURCES}) - -target_compile_features(ariec61850_hard_sv_smoke PRIVATE cxx_std_20) -target_include_directories(ariec61850_hard_sv_smoke PRIVATE ${ARIEC61850_ROOT}/include) -target_compile_definitions(ariec61850_hard_sv_smoke PRIVATE - ARIEC61850_EMBEDDED_PROFILE=1 - ARIEC61850_HOST_SERVICES=0 - ARIEC61850_NO_EXCEPTIONS=1) -target_compile_options(ariec61850_hard_sv_smoke PRIVATE - -Wall - -Wextra - -Wpedantic - -Wconversion - -Wsign-conversion - -Werror - -fno-exceptions - -fno-rtti) +add_executable(ariec61850_hard_goose_smoke + ${CMAKE_CURRENT_LIST_DIR}/../goose_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/goose/pdu_encode.cpp + ${ARIEC61850_ROOT}/src/goose/frame_encode.cpp) +ariec61850_configure_hard_target(ariec61850_hard_goose_smoke) enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) +add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) -message(STATUS "arstack61850 hard process-bus/SV profile: exceptions disabled=ON") -message(STATUS "arstack61850 hard process-bus/SV profile: RTTI disabled=ON") -message(STATUS "arstack61850 hard process-bus/SV profile: host services excluded") +message(STATUS "arstack61850 hard process-bus profile: exceptions disabled=ON") +message(STATUS "arstack61850 hard process-bus profile: RTTI disabled=ON") +message(STATUS "arstack61850 hard process-bus profile: host services excluded") +message(STATUS "arstack61850 hard process-bus profile: bounded SV + GOOSE transmit gates=ON") From dafbf981f2468e0d1b6a43eb154693641a0ac4a4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:24:45 +0700 Subject: [PATCH 130/188] Gate hard SV and GOOSE process-bus profiles --- .../workflows/embedded-hard-profile-ci.yml | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index dc4f2a81a..a23bdf26d 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -10,8 +10,8 @@ permissions: contents: read jobs: - hard-sv-profile: - name: Hard SV / ${{ matrix.compiler }} + hard-process-bus-profile: + name: Hard Process Bus / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -33,26 +33,29 @@ jobs: fi - name: Configure hard profile - run: cmake -S embedded/hard_profile -B build-hard-sv + run: cmake -S embedded/hard_profile -B build-hard-process-bus - name: Build hard profile - run: cmake --build build-hard-sv --parallel + run: cmake --build build-hard-process-bus --parallel - - name: Run hard-profile smoke + - name: Run hard-profile smokes run: >- - ctest --test-dir build-hard-sv + ctest --test-dir build-hard-process-bus --output-on-failure - --output-junit hard-sv-ctest.xml + --output-junit hard-process-bus-ctest.xml - name: Prove exception runtime is absent shell: bash run: | set -euo pipefail - binary=build-hard-sv/ariec61850_hard_sv_smoke - if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then - echo 'exception runtime symbol found in hard-profile binary' >&2 - exit 1 - fi + for binary in \ + build-hard-process-bus/ariec61850_hard_sv_smoke \ + build-hard-process-bus/ariec61850_hard_goose_smoke; do + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + done echo 'NO_EXCEPTION_RUNTIME=PASS' - name: Report hard-profile footprint @@ -61,16 +64,17 @@ jobs: set -euo pipefail { echo "compiler=${{ matrix.compiler }}" - echo "binary=ariec61850_hard_sv_smoke" - size build-hard-sv/ariec61850_hard_sv_smoke - } | tee build-hard-sv/hard-sv-footprint.txt + size \ + build-hard-process-bus/ariec61850_hard_sv_smoke \ + build-hard-process-bus/ariec61850_hard_goose_smoke + } | tee build-hard-process-bus/hard-process-bus-footprint.txt - name: Upload hard-profile evidence uses: actions/upload-artifact@v4 with: - name: embedded-hard-sv-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + name: embedded-hard-process-bus-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} if-no-files-found: error retention-days: 14 path: | - build-hard-sv/hard-sv-ctest.xml - build-hard-sv/hard-sv-footprint.txt + build-hard-process-bus/hard-process-bus-ctest.xml + build-hard-process-bus/hard-process-bus-footprint.txt From 4a5a7adb7a1f5320d0cb51dcb49ba76bf4e765b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:26:30 +0700 Subject: [PATCH 131/188] Make ESP-IDF SV component no-exception hard profile --- ports/esp_idf/CMakeLists.txt | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/ports/esp_idf/CMakeLists.txt b/ports/esp_idf/CMakeLists.txt index 14aa1db27..2e532aed0 100644 --- a/ports/esp_idf/CMakeLists.txt +++ b/ports/esp_idf/CMakeLists.txt @@ -1,18 +1,16 @@ # arstack61850 ESP-IDF component entry point. # # Add this directory to EXTRA_COMPONENT_DIRS from an ESP-IDF application. -# This first MCU component is deliberately narrower than the desktop/host core: -# it contains only the dependencies required to build and transmit Sampled +# The MCU component is deliberately narrower than the desktop/host core: it +# contains only the bounded dependencies required to build and transmit Sampled # Values Ethernet frames plus the ESP-IDF raw-Ethernet adapter. Host discovery, -# MMS-over-TCP runtime, reporting, GOOSE, SCL, PCAP, COMTRADE, filesystem and CLI -# sources remain outside the first hardware proof. +# dynamic BER decode, generic Ethernet object codecs, MMS-over-TCP runtime, +# reporting, GOOSE, SCL, PCAP, COMTRADE, filesystem and CLI sources stay outside +# this first target-specific link boundary. set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") set(ARIEC61850_ESP_IDF_SOURCES - ${ARIEC61850_ROOT}/src/asn1/ber.cpp - ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp - ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp @@ -31,19 +29,16 @@ idf_component_register( target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) target_compile_definitions(${COMPONENT_LIB} PUBLIC ARIEC61850_EMBEDDED_PROFILE=1 - ARIEC61850_HOST_SERVICES=0) + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) -# RTTI is not required by this process-bus transmit slice. -# -# IMPORTANT: the shared BER/Ethernet/SV source files still retain exception-based -# convenience/validation APIs used by the desktop stack. ESP-IDF disables C++ -# exceptions by default, so an application using this transitional component must -# enable CONFIG_COMPILER_CXX_EXCEPTIONS. The steady-state publisher API itself is -# noexcept and uses status-returning encode_into()/RawEthernetPort operations. -# Removing the remaining compile-time exception dependency is a separate embedded -# hardening gate; do not hide it by weakening the cross-compile CI. +# The physical SV transmit slice is now a hard embedded profile: no C++ +# exceptions and no RTTI. Host convenience/decode APIs are excluded at compile +# time; the linked wire path uses status-returning encode_into() operations and +# caller-owned frame storage. target_compile_options(${COMPONENT_LIB} PRIVATE - -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -fno-rtti) + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion + -fno-exceptions -fno-rtti) # Keep -Wpedantic on the portable protocol sources. The one ESP-IDF adapter must # include vendor C headers which intentionally use GNU extensions such as From d0712461220082ac20dee82a031d681a3a6942ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:26:47 +0700 Subject: [PATCH 132/188] Disable C++ exceptions in ESP32-P4 SV trial --- ports/esp_idf/sv_trial_app/sdkconfig.defaults | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ports/esp_idf/sv_trial_app/sdkconfig.defaults b/ports/esp_idf/sv_trial_app/sdkconfig.defaults index 9a9d4d7f8..9745c3cf7 100644 --- a/ports/esp_idf/sv_trial_app/sdkconfig.defaults +++ b/ports/esp_idf/sv_trial_app/sdkconfig.defaults @@ -1,5 +1,5 @@ -# Transitional setting: shared codecs still retain exception-based host APIs. -CONFIG_COMPILER_CXX_EXCEPTIONS=y +# The linked arstack61850 SV transmit component is a hard no-exception profile. +CONFIG_COMPILER_CXX_EXCEPTIONS=n # First physical proof uses the ESP32-P4 internal EMAC/RMII path. CONFIG_ETH_USE_ESP32_EMAC=y From 6e08d06bbcc51abeebd1ffe0cc42e14ca1df4a0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:27:09 +0700 Subject: [PATCH 133/188] Align ESP-IDF smoke with no-exception profile --- ports/esp_idf/smoke_app/sdkconfig.defaults | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ports/esp_idf/smoke_app/sdkconfig.defaults b/ports/esp_idf/smoke_app/sdkconfig.defaults index d4b863632..d7c3e6cb5 100644 --- a/ports/esp_idf/smoke_app/sdkconfig.defaults +++ b/ports/esp_idf/smoke_app/sdkconfig.defaults @@ -1,6 +1,2 @@ -# Transitional portability setting for the first ESP32-P4 SV transmit proof. -# The shared desktop codecs still contain exception-based convenience and -# validation APIs, while the publisher hot path uses noexcept/status-returning -# APIs. Removing this compile-time dependency is tracked as a later embedded -# hardening gate. -CONFIG_COMPILER_CXX_EXCEPTIONS=y +# The MCU component is compiled as a hard no-exception/no-RTTI SV transmit slice. +CONFIG_COMPILER_CXX_EXCEPTIONS=n From 2ab34e339fc9fdf1c02612fcd0b1f725babd241f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:28:17 +0700 Subject: [PATCH 134/188] Split bounded Ethernet encode implementation --- src/ethernet/ethernet_encode.cpp | 156 +++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/ethernet/ethernet_encode.cpp diff --git a/src/ethernet/ethernet_encode.cpp b/src/ethernet/ethernet_encode.cpp new file mode 100644 index 000000000..1b139c3f6 --- /dev/null +++ b/src/ethernet/ethernet_encode.cpp @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::ethernet { +namespace { + +void write_u16_be( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +} // namespace + +bool MacAddress::try_from_bytes( + const std::span bytes, + MacAddress& address) noexcept { + if (bytes.size() != address.bytes().size()) { + address = {}; + return false; + } + std::array copy{}; + std::copy(bytes.begin(), bytes.end(), copy.begin()); + address = MacAddress{copy}; + return true; +} + +bool MacAddress::try_copy_to( + const std::span destination) const noexcept { + if (destination.size() < bytes().size()) { + return false; + } + std::copy(bytes().begin(), bytes().end(), destination.begin()); + return true; +} + +bool VlanTag::try_to_tag_control_information(std::uint16_t& tci) const noexcept { + if (priority_code_point > 7U || vlan_id > 4094U) { + tci = 0U; + return false; + } + + const auto value = + (static_cast(priority_code_point) << 13U) | + (drop_eligible ? 0x1000U : 0U) | + static_cast(vlan_id); + tci = static_cast(value); + return true; +} + +VlanTag VlanTag::from_tag_control_information(const std::uint16_t tci) noexcept { + return { + static_cast((tci >> 13U) & 0x07U), + (tci & 0x1000U) != 0U, + static_cast(tci & 0x0FFFU)}; +} + +std::optional EthernetFrameCodec::encoded_size( + const EthernetFrame& frame) noexcept { + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return std::nullopt; + } + } + + const std::size_t header_length = frame.vlan.has_value() ? 18U : 14U; + if (frame.payload.size() > std::numeric_limits::max() - header_length) { + return std::nullopt; + } + return header_length + frame.payload.size(); +} + +wire::EncodeResult EthernetFrameCodec::encode_into( + const EthernetFrame& frame, + const std::span destination) noexcept { + const auto required = encoded_size(frame); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + std::copy(frame.destination.bytes().begin(), frame.destination.bytes().end(), destination.begin()); + std::copy( + frame.source.bytes().begin(), + frame.source.bytes().end(), + destination.begin() + 6); + + std::size_t offset = 12U; + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + write_u16_be(destination, offset, vlan_tag_ethertype); + write_u16_be(destination, offset + 2U, tci); + offset += 4U; + } + + write_u16_be(destination, offset, frame.ether_type); + offset += 2U; + std::copy( + frame.payload.begin(), + frame.payload.end(), + destination.begin() + static_cast(offset)); + offset += frame.payload.size(); + return {wire::EncodeStatus::ok, offset, *required}; +} + +std::optional ProcessBusFrameCodec::encoded_payload_size( + const std::span apdu) noexcept { + if (apdu.size() > + static_cast(std::numeric_limits::max()) - header_length) { + return std::nullopt; + } + return header_length + apdu.size(); +} + +wire::EncodeResult ProcessBusFrameCodec::encode_payload_into( + const std::uint16_t app_id, + const std::span apdu, + const std::span destination, + const std::uint16_t reserved1, + const std::uint16_t reserved2) noexcept { + const auto required = encoded_payload_size(apdu); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + write_u16_be(destination, 0U, app_id); + write_u16_be(destination, 2U, static_cast(*required)); + write_u16_be(destination, 4U, reserved1); + write_u16_be(destination, 6U, reserved2); + std::copy( + apdu.begin(), + apdu.end(), + destination.begin() + static_cast(header_length)); + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::ethernet From 4323081b35ba53d6c1a2611cc5050ff98c1f6cf3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:28:45 +0700 Subject: [PATCH 135/188] Use bounded-only Ethernet source in hard profile --- embedded/hard_profile/CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 697bb98c6..51a8041c2 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -31,7 +31,7 @@ endfunction() add_executable(ariec61850_hard_sv_smoke ${CMAKE_CURRENT_LIST_DIR}/../sv_hard_profile_smoke.cpp - ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp + ${ARIEC61850_ROOT}/src/ethernet/ethernet_encode.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp @@ -40,7 +40,7 @@ ariec61850_configure_hard_target(ariec61850_hard_sv_smoke) add_executable(ariec61850_hard_goose_smoke ${CMAKE_CURRENT_LIST_DIR}/../goose_hard_profile_smoke.cpp - ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp + ${ARIEC61850_ROOT}/src/ethernet/ethernet_encode.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/mms/data_value.cpp ${ARIEC61850_ROOT}/src/goose/pdu_encode.cpp From 07dfc5355821ef0f5ffb35d67609724c422e3d11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 12:55:18 +0700 Subject: [PATCH 136/188] Add bounded TPKT and COTP transport views --- .../workflows/embedded-hard-profile-ci.yml | 30 +- embedded/hard_profile/CMakeLists.txt | 19 +- embedded/osi_hard_profile_smoke.cpp | 234 ++++++++++ include/ariec61850/osi/cotp_span.hpp | 96 ++++ include/ariec61850/osi/tpkt_span.hpp | 58 +++ src/osi/cotp_span.cpp | 432 ++++++++++++++++++ src/osi/tpkt_span.cpp | 87 ++++ 7 files changed, 936 insertions(+), 20 deletions(-) create mode 100644 embedded/osi_hard_profile_smoke.cpp create mode 100644 include/ariec61850/osi/cotp_span.hpp create mode 100644 include/ariec61850/osi/tpkt_span.hpp create mode 100644 src/osi/cotp_span.cpp create mode 100644 src/osi/tpkt_span.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index a23bdf26d..3ef717a92 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -10,8 +10,8 @@ permissions: contents: read jobs: - hard-process-bus-profile: - name: Hard Process Bus / ${{ matrix.compiler }} + hard-embedded-profile: + name: Hard Embedded / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -33,24 +33,25 @@ jobs: fi - name: Configure hard profile - run: cmake -S embedded/hard_profile -B build-hard-process-bus + run: cmake -S embedded/hard_profile -B build-hard-embedded - name: Build hard profile - run: cmake --build build-hard-process-bus --parallel + run: cmake --build build-hard-embedded --parallel - name: Run hard-profile smokes run: >- - ctest --test-dir build-hard-process-bus + ctest --test-dir build-hard-embedded --output-on-failure - --output-junit hard-process-bus-ctest.xml + --output-junit hard-embedded-ctest.xml - name: Prove exception runtime is absent shell: bash run: | set -euo pipefail for binary in \ - build-hard-process-bus/ariec61850_hard_sv_smoke \ - build-hard-process-bus/ariec61850_hard_goose_smoke; do + build-hard-embedded/ariec61850_hard_sv_smoke \ + build-hard-embedded/ariec61850_hard_goose_smoke \ + build-hard-embedded/ariec61850_hard_osi_transport_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -65,16 +66,17 @@ jobs: { echo "compiler=${{ matrix.compiler }}" size \ - build-hard-process-bus/ariec61850_hard_sv_smoke \ - build-hard-process-bus/ariec61850_hard_goose_smoke - } | tee build-hard-process-bus/hard-process-bus-footprint.txt + build-hard-embedded/ariec61850_hard_sv_smoke \ + build-hard-embedded/ariec61850_hard_goose_smoke \ + build-hard-embedded/ariec61850_hard_osi_transport_smoke + } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence uses: actions/upload-artifact@v4 with: - name: embedded-hard-process-bus-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + name: embedded-hard-profile-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} if-no-files-found: error retention-days: 14 path: | - build-hard-process-bus/hard-process-bus-ctest.xml - build-hard-process-bus/hard-process-bus-footprint.txt + build-hard-embedded/hard-embedded-ctest.xml + build-hard-embedded/hard-embedded-footprint.txt diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 51a8041c2..d71f91699 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -1,8 +1,8 @@ cmake_minimum_required(VERSION 3.20) -project(ARIEC61850HardProcessBusProfile +project(ARIEC61850HardEmbeddedProfile VERSION 0.1.0 - DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus transmit paths" + DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus and OSI transport paths" LANGUAGES CXX) set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") @@ -47,11 +47,18 @@ add_executable(ariec61850_hard_goose_smoke ${ARIEC61850_ROOT}/src/goose/frame_encode.cpp) ariec61850_configure_hard_target(ariec61850_hard_goose_smoke) +add_executable(ariec61850_hard_osi_transport_smoke + ${CMAKE_CURRENT_LIST_DIR}/../osi_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_osi_transport_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) +add_test(NAME ariec61850_hard_osi_transport_smoke COMMAND ariec61850_hard_osi_transport_smoke) -message(STATUS "arstack61850 hard process-bus profile: exceptions disabled=ON") -message(STATUS "arstack61850 hard process-bus profile: RTTI disabled=ON") -message(STATUS "arstack61850 hard process-bus profile: host services excluded") -message(STATUS "arstack61850 hard process-bus profile: bounded SV + GOOSE transmit gates=ON") +message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") +message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") +message(STATUS "arstack61850 hard embedded profile: host services excluded") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + TPKT/COTP gates=ON") diff --git a/embedded/osi_hard_profile_smoke.cpp b/embedded/osi_hard_profile_smoke.cpp new file mode 100644 index 000000000..cb4ff0d17 --- /dev/null +++ b/embedded/osi_hard_profile_smoke.cpp @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::osi; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool parameter_matches( + const CotpTpduView& tpdu, + const std::uint8_t code, + const std::span expected) noexcept { + std::span value; + return tpdu.try_parameter(code, value) && + value.size() == expected.size() && + std::equal(value.begin(), value.end(), expected.begin()); +} + +} // namespace + +int main() { + // Existing desktop golden vector: TPKT wrapping a COTP Data TPDU prefix. + constexpr std::array tpkt_payload{ + 0x02U, 0xF0U, 0x80U, 0xA8U, 0x01U, 0x00U}; + constexpr std::array tpkt_golden{ + 0x03U, 0x00U, 0x00U, 0x0AU, + 0x02U, 0xF0U, 0x80U, 0xA8U, 0x01U, 0x00U}; + + std::array tpkt_buffer{}; + const auto tpkt_result = TpktSpanCodec::encode_into(tpkt_payload, tpkt_buffer); + if (!tpkt_result.success() || tpkt_result.bytes_written != tpkt_golden.size() || + !matches( + std::span{tpkt_buffer}.first(tpkt_result.bytes_written), + tpkt_golden)) { + return 1; + } + + TpktFrameView tpkt_view; + if (!TpktSpanCodec::try_decode_view(tpkt_golden, tpkt_view) || + tpkt_view.version != TpktSpanCodec::supported_version || + tpkt_view.declared_length != tpkt_golden.size() || + !matches(tpkt_view.payload, tpkt_payload)) { + return 2; + } + + if (TpktSpanCodec::peek_frame( + std::span{tpkt_golden}.first(2U)).status != + TpktPeekStatus::need_more) { + return 3; + } + + std::array coalesced{}; + std::copy(tpkt_golden.begin(), tpkt_golden.end(), coalesced.begin()); + coalesced.back() = 0xFFU; + const auto peek = TpktSpanCodec::peek_frame(coalesced); + if (!peek.ready() || peek.frame_bytes != tpkt_golden.size()) { + return 4; + } + + constexpr std::array invalid_tpkt{ + 0x02U, 0x00U, 0x00U, 0x04U}; + if (TpktSpanCodec::peek_frame(invalid_tpkt).status != TpktPeekStatus::invalid || + TpktSpanCodec::try_decode_view(invalid_tpkt, tpkt_view)) { + return 5; + } + + // Existing desktop golden vector: default COTP Connection Request. + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array default_parameters{ + CotpParameterView{CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + CotpParameterView{CotpSpanCodec::source_tsap_parameter, source_tsap}, + CotpParameterView{CotpSpanCodec::destination_tsap_parameter, destination_tsap}, + }; + constexpr std::array cr_golden{ + 0x11U, 0xE0U, 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, + 0xC0U, 0x01U, 0x0AU, + 0xC1U, 0x02U, 0x00U, 0x01U, + 0xC2U, 0x02U, 0x00U, 0x01U}; + + std::array cr_buffer{}; + const auto cr_result = CotpSpanCodec::encode_connection_request_into( + 0x0001U, default_parameters, cr_buffer); + if (!cr_result.success() || cr_result.bytes_written != cr_golden.size() || + !matches( + std::span{cr_buffer}.first(cr_result.bytes_written), + cr_golden)) { + return 6; + } + + CotpTpduView cr_view; + if (!CotpSpanCodec::try_decode_view(cr_golden, cr_view) || + cr_view.kind != CotpWireKind::connection_request || + cr_view.destination_reference != 0x0000U || + cr_view.source_reference != 0x0001U || + !parameter_matches(cr_view, CotpSpanCodec::tpdu_size_parameter, tpdu_size) || + !parameter_matches(cr_view, CotpSpanCodec::source_tsap_parameter, source_tsap) || + !parameter_matches( + cr_view, CotpSpanCodec::destination_tsap_parameter, destination_tsap)) { + return 7; + } + + // Server-side CC must negotiate TPDU size and mirror TSAP values without + // constructing an owning parameter list. + constexpr std::array offered_size{0x09U}; + constexpr std::array offered_source{0x11U, 0x22U}; + constexpr std::array offered_destination{0x33U, 0x44U, 0x55U}; + const std::array offered_parameters{ + CotpParameterView{CotpSpanCodec::tpdu_size_parameter, offered_size}, + CotpParameterView{CotpSpanCodec::source_tsap_parameter, offered_source}, + CotpParameterView{CotpSpanCodec::destination_tsap_parameter, offered_destination}, + }; + + std::array request_buffer{}; + const auto request_result = CotpSpanCodec::encode_connection_request_into( + 0x0001U, offered_parameters, request_buffer); + CotpTpduView request_view; + if (!request_result.success() || + !CotpSpanCodec::try_decode_view( + std::span{request_buffer}.first(request_result.bytes_written), + request_view)) { + return 8; + } + + constexpr std::array cc_golden{ + 0x12U, 0xD0U, 0x00U, 0x01U, 0x10U, 0x01U, 0x00U, + 0xC0U, 0x01U, 0x09U, + 0xC2U, 0x02U, 0x11U, 0x22U, + 0xC1U, 0x03U, 0x33U, 0x44U, 0x55U}; + std::array cc_buffer{}; + const auto cc_result = CotpSpanCodec::encode_connection_confirm_from_request_into( + request_view, 0x1001U, cc_buffer, 0x0AU); + if (!cc_result.success() || cc_result.bytes_written != cc_golden.size() || + !matches( + std::span{cc_buffer}.first(cc_result.bytes_written), + cc_golden)) { + return 9; + } + + CotpTpduView cc_view; + if (!CotpSpanCodec::try_decode_view(cc_golden, cc_view) || + cc_view.kind != CotpWireKind::connection_confirm || + cc_view.destination_reference != 0x0001U || + cc_view.source_reference != 0x1001U || + !parameter_matches(cc_view, CotpSpanCodec::tpdu_size_parameter, offered_size) || + !parameter_matches(cc_view, CotpSpanCodec::source_tsap_parameter, offered_destination) || + !parameter_matches(cc_view, CotpSpanCodec::destination_tsap_parameter, offered_source)) { + return 10; + } + + constexpr std::array cotp_user_data{ + 0x0DU, 0x01U, 0x02U, 0x03U}; + constexpr std::array cotp_data_golden{ + 0x02U, 0xF0U, 0x80U, 0x0DU, 0x01U, 0x02U, 0x03U}; + std::array cotp_data_buffer{}; + const auto data_result = CotpSpanCodec::encode_data_into( + cotp_user_data, cotp_data_buffer); + if (!data_result.success() || data_result.bytes_written != cotp_data_golden.size() || + !matches( + std::span{cotp_data_buffer}.first(data_result.bytes_written), + cotp_data_golden)) { + return 11; + } + + CotpTpduView data_view; + if (!CotpSpanCodec::try_decode_view(cotp_data_golden, data_view) || + data_view.kind != CotpWireKind::data || + !data_view.end_of_transmission || data_view.tpdu_number != 0U || + !matches(data_view.user_data, cotp_user_data)) { + return 12; + } + + // Complete zero-copy transport stack: COTP Data -> TPKT -> view decode -> + // COTP view decode, retaining the same caller-owned storage throughout. + std::array transport_buffer{}; + const auto transport_result = TpktSpanCodec::encode_into( + std::span{cotp_data_buffer}.first(data_result.bytes_written), + transport_buffer); + TpktFrameView transport_view; + CotpTpduView nested_data_view; + if (!transport_result.success() || + !TpktSpanCodec::try_decode_view( + std::span{transport_buffer}.first( + transport_result.bytes_written), + transport_view) || + !CotpSpanCodec::try_decode_view(transport_view.payload, nested_data_view) || + !matches(nested_data_view.user_data, cotp_user_data)) { + return 13; + } + + // Exercise the steady-state bounded path repeatedly. No owning container is + // constructed by either codec during these encode/decode iterations. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto encoded = CotpSpanCodec::encode_data_into( + cotp_user_data, cotp_data_buffer, true, + static_cast(iteration & 0x7FU)); + if (!encoded.success()) { + return 14; + } + CotpTpduView decoded; + if (!CotpSpanCodec::try_decode_view( + std::span{cotp_data_buffer}.first( + encoded.bytes_written), + decoded) || + decoded.tpdu_number != static_cast(iteration & 0x7FU)) { + return 15; + } + } + + constexpr std::array malformed_parameter{ + 0x09U, 0xE0U, 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, + 0xC0U, 0x02U, 0x0AU}; + if (CotpSpanCodec::try_decode_view(malformed_parameter, cr_view)) { + return 16; + } + + return 0; +} diff --git a/include/ariec61850/osi/cotp_span.hpp b/include/ariec61850/osi/cotp_span.hpp new file mode 100644 index 000000000..99a068250 --- /dev/null +++ b/include/ariec61850/osi/cotp_span.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class CotpWireKind : std::uint8_t { + unknown, + connection_request, + connection_confirm, + data, + disconnect_request, + error, +}; + +struct CotpParameterView final { + std::uint8_t code{}; + std::span value{}; +}; + +struct CotpTpduView final { + CotpWireKind kind{CotpWireKind::unknown}; + std::uint8_t code{}; + std::uint8_t length_indicator{}; + std::uint16_t destination_reference{}; + std::uint16_t source_reference{}; + std::uint8_t class_or_reason{}; + bool end_of_transmission{}; + std::uint8_t tpdu_number{}; + std::span parameter_bytes{}; + std::span user_data{}; + + [[nodiscard]] bool try_parameter( + std::uint8_t parameter_code, + std::span& value) const noexcept; +}; + +class CotpSpanCodec final { +public: + static constexpr std::uint8_t connection_request_code = 0xE0U; + static constexpr std::uint8_t connection_confirm_code = 0xD0U; + static constexpr std::uint8_t disconnect_request_code = 0x80U; + static constexpr std::uint8_t data_code = 0xF0U; + static constexpr std::uint8_t error_code = 0x70U; + + static constexpr std::uint8_t tpdu_size_parameter = 0xC0U; + static constexpr std::uint8_t source_tsap_parameter = 0xC1U; + static constexpr std::uint8_t destination_tsap_parameter = 0xC2U; + + [[nodiscard]] static CotpWireKind kind_from_code(std::uint8_t code) noexcept; + + // Zero-copy decode. parameter_bytes and user_data point into the supplied + // TPDU storage, which must outlive the returned view. + [[nodiscard]] static bool try_decode_view( + std::span bytes, + CotpTpduView& tpdu) noexcept; + + [[nodiscard]] static bool try_tpdu_size_bytes( + std::uint8_t tpdu_size_code, + std::size_t& bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_data_into( + std::span user_data, + std::span destination, + bool end_of_transmission = true, + std::uint8_t tpdu_number = 0U) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connection_request_into( + std::uint16_t source_reference, + std::span parameters, + std::span destination, + std::uint16_t destination_reference = 0U, + std::uint8_t transport_class = 0U) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connection_confirm_into( + std::uint16_t destination_reference, + std::uint16_t source_reference, + std::span destination, + std::uint8_t maximum_tpdu_size_code = 0x0AU) noexcept; + + // Build a server-side CC directly from a zero-copy CR view. The TPDU-size + // offer is negotiated and source/destination TSAP parameters are mirrored + // using the same wire behavior as the host codec, without heap storage. + [[nodiscard]] static wire::EncodeResult encode_connection_confirm_from_request_into( + const CotpTpduView& connection_request, + std::uint16_t source_reference, + std::span destination, + std::uint8_t maximum_tpdu_size_code = 0x0AU) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/tpkt_span.hpp b/include/ariec61850/osi/tpkt_span.hpp new file mode 100644 index 000000000..07327ebe8 --- /dev/null +++ b/include/ariec61850/osi/tpkt_span.hpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class TpktPeekStatus : std::uint8_t { + need_more, + ready, + invalid, +}; + +struct TpktPeekResult final { + TpktPeekStatus status{TpktPeekStatus::need_more}; + std::size_t frame_bytes{}; + + [[nodiscard]] constexpr bool ready() const noexcept { + return status == TpktPeekStatus::ready; + } +}; + +struct TpktFrameView final { + std::uint8_t version{}; + std::uint16_t declared_length{}; + std::span payload{}; +}; + +class TpktSpanCodec final { +public: + static constexpr std::uint8_t supported_version = 0x03U; + static constexpr std::size_t header_length = 4U; + static constexpr std::size_t maximum_frame_bytes = 65'535U; + static constexpr std::size_t maximum_payload_bytes = + maximum_frame_bytes - header_length; + + [[nodiscard]] static wire::EncodeResult encode_into( + std::span payload, + std::span destination) noexcept; + + // Inspect the first frame in a TCP receive window without copying it. + // `ready` may be returned even when bytes contains coalesced frames; + // frame_bytes identifies the exact prefix belonging to the first TPKT. + [[nodiscard]] static TpktPeekResult peek_frame( + std::span bytes) noexcept; + + // Decode one exact TPKT frame into non-owning spans. The supplied storage + // must outlive the returned view. + [[nodiscard]] static bool try_decode_view( + std::span bytes, + TpktFrameView& frame) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/src/osi/cotp_span.cpp b/src/osi/cotp_span.cpp new file mode 100644 index 000000000..54a4cdb65 --- /dev/null +++ b/src/osi/cotp_span.cpp @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/cotp_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +[[nodiscard]] std::uint16_t read_be_u16( + const std::span bytes, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(bytes[offset]) << 8U) | + static_cast(bytes[offset + 1U])); +} + +void write_be_u16( + const std::span bytes, + const std::size_t offset, + const std::uint16_t value) noexcept { + bytes[offset] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 1U] = static_cast(value & 0xFFU); +} + +[[nodiscard]] bool encoded_parameters_size( + const std::span parameters, + std::size_t& total) noexcept { + total = 0U; + for (const auto& parameter : parameters) { + if (parameter.value.size() > 248U) { + total = 0U; + return false; + } + const auto encoded = 2U + parameter.value.size(); + if (encoded > 248U - total) { + total = 0U; + return false; + } + total += encoded; + } + return true; +} + +[[nodiscard]] bool write_parameter( + const std::span destination, + std::size_t& offset, + const std::uint8_t code, + const std::span value) noexcept { + if (value.size() > 248U || destination.size() - offset < value.size() + 2U) { + return false; + } + destination[offset++] = code; + destination[offset++] = static_cast(value.size()); + std::copy( + value.begin(), + value.end(), + destination.begin() + static_cast(offset)); + offset += value.size(); + return true; +} + +[[nodiscard]] wire::EncodeResult encode_reference_into( + const std::uint8_t code, + const std::uint16_t destination_reference, + const std::uint16_t source_reference, + const std::uint8_t class_or_reason, + const std::span parameters, + const std::span destination) noexcept { + std::size_t parameter_bytes{}; + if (!encoded_parameters_size(parameters, parameter_bytes)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto length_indicator = 6U + parameter_bytes; + const auto required = length_indicator + 1U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = static_cast(length_indicator); + destination[1] = code; + write_be_u16(destination, 2U, destination_reference); + write_be_u16(destination, 4U, source_reference); + destination[6] = class_or_reason; + + std::size_t offset = 7U; + for (const auto& parameter : parameters) { + if (!write_parameter(destination, offset, parameter.code, parameter.value)) { + 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}; +} + +[[nodiscard]] bool validate_parameter_bytes( + const std::span bytes) noexcept { + std::size_t offset = 0U; + while (offset < bytes.size()) { + if (bytes.size() - offset < 2U) { + return false; + } + const auto length = static_cast(bytes[offset + 1U]); + offset += 2U; + if (length > bytes.size() - offset) { + return false; + } + offset += length; + } + return offset == bytes.size(); +} + +[[nodiscard]] bool selected_tpdu_size( + const CotpTpduView& request, + const std::uint8_t maximum_tpdu_size_code, + std::uint8_t& selected) noexcept { + std::size_t ignored{}; + if (!CotpSpanCodec::try_tpdu_size_bytes(maximum_tpdu_size_code, ignored)) { + return false; + } + + std::span offered; + if (!request.try_parameter(CotpSpanCodec::tpdu_size_parameter, offered) || + offered.size() != 1U) { + selected = maximum_tpdu_size_code; + return true; + } + + if (!CotpSpanCodec::try_tpdu_size_bytes(offered[0], ignored)) { + return false; + } + selected = std::min(offered[0], maximum_tpdu_size_code); + return true; +} + +[[nodiscard]] bool mirrored_parameter_size( + const CotpTpduView& request, + std::size_t& total) noexcept { + // The CC always starts with C0 / TPDU size (3 encoded bytes). + total = 3U; + std::size_t offset = 0U; + while (offset < request.parameter_bytes.size()) { + if (request.parameter_bytes.size() - offset < 2U) { + total = 0U; + return false; + } + const auto code = request.parameter_bytes[offset]; + const auto length = static_cast(request.parameter_bytes[offset + 1U]); + offset += 2U; + if (length > request.parameter_bytes.size() - offset) { + total = 0U; + return false; + } + + if ((code == CotpSpanCodec::source_tsap_parameter || + code == CotpSpanCodec::destination_tsap_parameter) && + length != 0U) { + const auto encoded = length + 2U; + if (length > 248U || encoded > 248U - total) { + total = 0U; + return false; + } + total += encoded; + } + offset += length; + } + return true; +} + +} // namespace + +bool CotpTpduView::try_parameter( + const std::uint8_t parameter_code, + std::span& value) const noexcept { + value = {}; + std::size_t offset = 0U; + while (offset < parameter_bytes.size()) { + if (parameter_bytes.size() - offset < 2U) { + return false; + } + const auto code = parameter_bytes[offset]; + const auto length = static_cast(parameter_bytes[offset + 1U]); + offset += 2U; + if (length > parameter_bytes.size() - offset) { + return false; + } + if (code == parameter_code) { + value = parameter_bytes.subspan(offset, length); + return true; + } + offset += length; + } + return false; +} + +CotpWireKind CotpSpanCodec::kind_from_code(const std::uint8_t code) noexcept { + switch (code) { + case connection_request_code: + return CotpWireKind::connection_request; + case connection_confirm_code: + return CotpWireKind::connection_confirm; + case data_code: + return CotpWireKind::data; + case disconnect_request_code: + return CotpWireKind::disconnect_request; + case error_code: + return CotpWireKind::error; + default: + return CotpWireKind::unknown; + } +} + +bool CotpSpanCodec::try_decode_view( + const std::span bytes, + CotpTpduView& tpdu) noexcept { + tpdu = {}; + if (bytes.size() < 2U) { + return false; + } + + const auto length_indicator = static_cast(bytes[0]); + const auto header_end = length_indicator + 1U; + if (length_indicator < 2U || header_end > bytes.size()) { + return false; + } + + const auto kind = kind_from_code(bytes[1]); + if (kind == CotpWireKind::unknown) { + return false; + } + + tpdu.code = bytes[1]; + tpdu.kind = kind; + tpdu.length_indicator = bytes[0]; + + if (kind == CotpWireKind::data) { + if (length_indicator != 2U || header_end != 3U) { + tpdu = {}; + return false; + } + tpdu.end_of_transmission = (bytes[2] & 0x80U) != 0U; + tpdu.tpdu_number = static_cast(bytes[2] & 0x7FU); + tpdu.user_data = bytes.subspan(3U); + return true; + } + + if (length_indicator < 6U || bytes.size() < 7U) { + tpdu = {}; + return false; + } + + tpdu.destination_reference = read_be_u16(bytes, 2U); + tpdu.source_reference = read_be_u16(bytes, 4U); + tpdu.class_or_reason = bytes[6]; + tpdu.end_of_transmission = true; + tpdu.parameter_bytes = bytes.subspan(7U, header_end - 7U); + tpdu.user_data = bytes.subspan(header_end); + + if (!validate_parameter_bytes(tpdu.parameter_bytes) || + ((kind == CotpWireKind::connection_request || + kind == CotpWireKind::connection_confirm) && + !tpdu.user_data.empty())) { + tpdu = {}; + return false; + } + return true; +} + +bool CotpSpanCodec::try_tpdu_size_bytes( + const std::uint8_t tpdu_size_code, + std::size_t& bytes) noexcept { + bytes = 0U; + if (tpdu_size_code < 0x07U || tpdu_size_code > 0x0FU) { + return false; + } + bytes = static_cast(1U) << tpdu_size_code; + return true; +} + +wire::EncodeResult CotpSpanCodec::encode_data_into( + const std::span user_data, + const std::span destination, + const bool end_of_transmission, + const std::uint8_t tpdu_number) noexcept { + if (tpdu_number > 0x7FU || + user_data.size() > std::numeric_limits::max() - 3U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto required = user_data.size() + 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = 0x02U; + destination[1] = data_code; + destination[2] = static_cast( + (end_of_transmission ? 0x80U : 0x00U) | tpdu_number); + std::copy( + user_data.begin(), + user_data.end(), + destination.begin() + 3); + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult CotpSpanCodec::encode_connection_request_into( + const std::uint16_t source_reference, + const std::span parameters, + const std::span destination, + const std::uint16_t destination_reference, + const std::uint8_t transport_class) noexcept { + return encode_reference_into( + connection_request_code, + destination_reference, + source_reference, + transport_class, + parameters, + destination); +} + +wire::EncodeResult CotpSpanCodec::encode_connection_confirm_into( + const std::uint16_t destination_reference, + const std::uint16_t source_reference, + const std::span destination, + const std::uint8_t maximum_tpdu_size_code) noexcept { + std::size_t ignored{}; + if (!try_tpdu_size_bytes(maximum_tpdu_size_code, ignored)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const std::uint8_t value = maximum_tpdu_size_code; + const CotpParameterView parameter{ + tpdu_size_parameter, + std::span{&value, 1U}}; + return encode_reference_into( + connection_confirm_code, + destination_reference, + source_reference, + 0U, + std::span{¶meter, 1U}, + destination); +} + +wire::EncodeResult CotpSpanCodec::encode_connection_confirm_from_request_into( + const CotpTpduView& connection_request, + const std::uint16_t source_reference, + const std::span destination, + const std::uint8_t maximum_tpdu_size_code) noexcept { + if (connection_request.kind != CotpWireKind::connection_request || + !connection_request.user_data.empty()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::uint8_t selected{}; + std::size_t parameter_bytes{}; + if (!selected_tpdu_size( + connection_request, maximum_tpdu_size_code, selected) || + !mirrored_parameter_size(connection_request, parameter_bytes)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto length_indicator = 6U + parameter_bytes; + const auto required = length_indicator + 1U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = static_cast(length_indicator); + destination[1] = connection_confirm_code; + write_be_u16(destination, 2U, connection_request.source_reference); + write_be_u16(destination, 4U, source_reference); + destination[6] = 0U; + + std::size_t output_offset = 7U; + const std::span selected_span{&selected, 1U}; + if (!write_parameter( + destination, + output_offset, + tpdu_size_parameter, + selected_span)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + + std::size_t input_offset = 0U; + while (input_offset < connection_request.parameter_bytes.size()) { + if (connection_request.parameter_bytes.size() - input_offset < 2U) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto code = connection_request.parameter_bytes[input_offset]; + const auto length = static_cast( + connection_request.parameter_bytes[input_offset + 1U]); + input_offset += 2U; + if (length > connection_request.parameter_bytes.size() - input_offset) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = connection_request.parameter_bytes.subspan( + input_offset, length); + + if (code == source_tsap_parameter && !value.empty()) { + if (!write_parameter( + destination, + output_offset, + destination_tsap_parameter, + value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } else if (code == destination_tsap_parameter && !value.empty()) { + if (!write_parameter( + destination, + output_offset, + source_tsap_parameter, + value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + input_offset += length; + } + + if (output_offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/tpkt_span.cpp b/src/osi/tpkt_span.cpp new file mode 100644 index 000000000..aefec4b99 --- /dev/null +++ b/src/osi/tpkt_span.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +[[nodiscard]] std::uint16_t read_be_u16( + const std::span bytes, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(bytes[offset]) << 8U) | + static_cast(bytes[offset + 1U])); +} + +void write_be_u16( + const std::span bytes, + const std::size_t offset, + const std::uint16_t value) noexcept { + bytes[offset] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 1U] = static_cast(value & 0xFFU); +} + +} // namespace + +wire::EncodeResult TpktSpanCodec::encode_into( + const std::span payload, + const std::span destination) noexcept { + if (payload.size() > maximum_payload_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto required = payload.size() + header_length; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = supported_version; + destination[1] = 0x00U; + write_be_u16(destination, 2U, static_cast(required)); + std::copy( + payload.begin(), + payload.end(), + destination.begin() + static_cast(header_length)); + return {wire::EncodeStatus::ok, required, required}; +} + +TpktPeekResult TpktSpanCodec::peek_frame( + const std::span bytes) noexcept { + if (bytes.size() < header_length) { + return {TpktPeekStatus::need_more, 0U}; + } + if (bytes[0] != supported_version || bytes[1] != 0x00U) { + return {TpktPeekStatus::invalid, 0U}; + } + + const auto declared = static_cast(read_be_u16(bytes, 2U)); + if (declared < header_length) { + return {TpktPeekStatus::invalid, 0U}; + } + if (bytes.size() < declared) { + return {TpktPeekStatus::need_more, declared}; + } + return {TpktPeekStatus::ready, declared}; +} + +bool TpktSpanCodec::try_decode_view( + const std::span bytes, + TpktFrameView& frame) noexcept { + frame = {}; + const auto peek = peek_frame(bytes); + if (!peek.ready() || peek.frame_bytes != bytes.size()) { + return false; + } + + frame.version = bytes[0]; + frame.declared_length = static_cast(peek.frame_bytes); + frame.payload = bytes.subspan(header_length); + return true; +} + +} // namespace ar::iec61850::osi From 81b4efc2ab0ecf70a1db1ef95470b7e7c8f10bf5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 13:05:31 +0700 Subject: [PATCH 137/188] Add bounded BER Session and Presentation paths --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 12 +- ...ession_presentation_hard_profile_smoke.cpp | 212 +++++++++++ include/ariec61850/asn1/ber_span_reader.hpp | 46 +++ include/ariec61850/osi/presentation_span.hpp | 46 +++ include/ariec61850/osi/session_span.hpp | 99 +++++ src/asn1/ber_span_reader.cpp | 142 +++++++ src/osi/presentation_span.cpp | 251 +++++++++++++ src/osi/session_span.cpp | 346 ++++++++++++++++++ 9 files changed, 1157 insertions(+), 3 deletions(-) create mode 100644 embedded/session_presentation_hard_profile_smoke.cpp create mode 100644 include/ariec61850/asn1/ber_span_reader.hpp create mode 100644 include/ariec61850/osi/presentation_span.hpp create mode 100644 include/ariec61850/osi/session_span.hpp create mode 100644 src/asn1/ber_span_reader.cpp create mode 100644 src/osi/presentation_span.cpp create mode 100644 src/osi/session_span.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index 3ef717a92..fa8b2c9b7 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -51,7 +51,8 @@ jobs: for binary in \ build-hard-embedded/ariec61850_hard_sv_smoke \ build-hard-embedded/ariec61850_hard_goose_smoke \ - build-hard-embedded/ariec61850_hard_osi_transport_smoke; do + build-hard-embedded/ariec61850_hard_osi_transport_smoke \ + build-hard-embedded/ariec61850_hard_session_presentation_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -68,7 +69,8 @@ jobs: size \ build-hard-embedded/ariec61850_hard_sv_smoke \ build-hard-embedded/ariec61850_hard_goose_smoke \ - build-hard-embedded/ariec61850_hard_osi_transport_smoke + build-hard-embedded/ariec61850_hard_osi_transport_smoke \ + build-hard-embedded/ariec61850_hard_session_presentation_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index d71f91699..020a515ae 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -53,12 +53,22 @@ add_executable(ariec61850_hard_osi_transport_smoke ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp) ariec61850_configure_hard_target(ariec61850_hard_osi_transport_smoke) +add_executable(ariec61850_hard_session_presentation_smoke + ${CMAKE_CURRENT_LIST_DIR}/../session_presentation_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_session_presentation_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) add_test(NAME ariec61850_hard_osi_transport_smoke COMMAND ariec61850_hard_osi_transport_smoke) +add_test( + NAME ariec61850_hard_session_presentation_smoke + COMMAND ariec61850_hard_session_presentation_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + TPKT/COTP gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + TPKT/COTP + Session/Presentation gates=ON") diff --git a/embedded/session_presentation_hard_profile_smoke.cpp b/embedded/session_presentation_hard_profile_smoke.cpp new file mode 100644 index 000000000..c43cde56e --- /dev/null +++ b/embedded/session_presentation_hard_profile_smoke.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool parameter_matches( + const osi::SessionSpduView& spdu, + const std::uint8_t code, + const std::span expected) noexcept { + std::span value; + return spdu.try_parameter(code, value) && + value.size() == expected.size() && + std::equal(value.begin(), value.end(), expected.begin()); +} + +} // namespace + +int main() { + constexpr std::array mms_payload{ + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + constexpr std::array presentation_golden{ + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + // BER reader stays zero-copy and rejects indefinite-length BER. + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(presentation_golden, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != 1 || !outer.constructed) { + return 1; + } + constexpr std::array indefinite{ + 0x61U, 0x80U, 0x00U, 0x00U}; + if (asn1::BerSpanReader::try_read_exact(indefinite, outer)) { + return 2; + } + + // Presentation fully-encoded-data must match the existing host encoding. + std::array presentation_buffer{}; + const auto presentation_result = + osi::PresentationSpanCodec::encode_fully_encoded_data_into( + 3U, mms_payload, presentation_buffer); + if (!presentation_result.success() || + presentation_result.bytes_written != presentation_golden.size() || + !matches( + std::span{presentation_buffer}.first( + presentation_result.bytes_written), + presentation_golden)) { + return 3; + } + + osi::PresentationPdvView pdv; + if (!osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + presentation_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 4; + } + + // Default Session parameters are byte-identical to the established host + // profile. The bounded encoder appends Presentation bytes as final C1 data. + constexpr std::array parameter05{ + 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U}; + constexpr std::array parameter14{0x00U, 0x02U}; + constexpr std::array parameter33{0x00U, 0x01U}; + constexpr std::array parameter34{0x00U, 0x01U}; + constexpr std::array session_connect_golden{ + 0x0DU, 0x29U, + 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, + 0x14U, 0x02U, 0x00U, 0x02U, + 0x33U, 0x02U, 0x00U, 0x01U, + 0x34U, 0x02U, 0x00U, 0x01U, + 0xC1U, 0x13U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + std::array session_buffer{}; + const auto connect_result = osi::SessionSpanCodec::encode_connect_into( + presentation_golden, session_buffer); + if (!connect_result.success() || + connect_result.bytes_written != session_connect_golden.size() || + !matches( + std::span{session_buffer}.first( + connect_result.bytes_written), + session_connect_golden)) { + return 5; + } + + osi::SessionSpduView connect_view; + if (!osi::SessionSpanCodec::try_decode_view( + session_connect_golden, connect_view) || + connect_view.kind != osi::SessionWireKind::connect || + !matches(connect_view.user_data, presentation_golden) || + !parameter_matches(connect_view, 0x05U, parameter05) || + !parameter_matches(connect_view, 0x14U, parameter14) || + !parameter_matches(connect_view, 0x33U, parameter33) || + !parameter_matches(connect_view, 0x34U, parameter34)) { + return 6; + } + + constexpr std::array session_accept_golden{ + 0x0EU, 0x29U, + 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, + 0x14U, 0x02U, 0x00U, 0x02U, + 0x33U, 0x02U, 0x00U, 0x01U, + 0x34U, 0x02U, 0x00U, 0x01U, + 0xC1U, 0x13U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + const auto accept_result = osi::SessionSpanCodec::encode_accept_mirroring_into( + connect_view, presentation_golden, session_buffer); + if (!accept_result.success() || + accept_result.bytes_written != session_accept_golden.size() || + !matches( + std::span{session_buffer}.first( + accept_result.bytes_written), + session_accept_golden)) { + return 7; + } + + // Existing Session data-transfer profile: optional Give-Tokens prefix plus + // the Data-Transfer SPDU, followed directly by Presentation bytes. + constexpr std::array p_data_golden{ + 0x01U, 0x00U, 0x01U, 0x00U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + mms_payload, presentation_buffer, 3U, true); + if (!p_data_result.success() || + p_data_result.bytes_written != p_data_golden.size() || + !matches( + std::span{presentation_buffer}.first( + p_data_result.bytes_written), + p_data_golden)) { + return 8; + } + + if (!osi::PresentationSpanCodec::try_decode_p_data_view(p_data_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 9; + } + + osi::SessionDataTransferView transfer; + if (!osi::SessionSpanCodec::try_decode_data_transfer_view( + p_data_golden, transfer) || + !transfer.has_give_tokens_prefix || + !matches(transfer.presentation_payload, presentation_golden)) { + return 10; + } + + // Direct Presentation decode remains accepted when the Session wrapper has + // already been removed by the transport state machine. + if (!osi::PresentationSpanCodec::try_decode_p_data_view( + presentation_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 11; + } + + // Steady-state encode/decode regression: no owning container is created by + // BER, Session, or Presentation bounded APIs. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto context_id = (iteration & 1U) == 0U ? 3U : 5U; + const auto encoded = osi::PresentationSpanCodec::encode_p_data_into( + mms_payload, presentation_buffer, context_id, true); + if (!encoded.success()) { + return 12; + } + osi::PresentationPdvView decoded; + if (!osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{presentation_buffer}.first( + encoded.bytes_written), + decoded) || + decoded.context_id != context_id || + !matches(decoded.single_asn1_type, mms_payload)) { + return 13; + } + } + + return 0; +} diff --git a/include/ariec61850/asn1/ber_span_reader.hpp b/include/ariec61850/asn1/ber_span_reader.hpp new file mode 100644 index 000000000..82b58965b --- /dev/null +++ b/include/ariec61850/asn1/ber_span_reader.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_types.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +struct BerTlvView final { + std::uint8_t encoded_tag{}; + BerClass tag_class{BerClass::universal}; + bool constructed{}; + std::int32_t tag_number{}; + std::size_t header_bytes{}; + std::size_t encoded_bytes{}; + std::span value{}; +}; + +class BerSpanReader final { +public: + // Read one definite-length BER TLV without allocating or copying its value. + // The input offset is advanced only on success. + [[nodiscard]] static bool try_read_tlv( + std::span source, + std::size_t& offset, + BerTlvView& tlv) noexcept; + + [[nodiscard]] static bool try_read_exact( + std::span source, + BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_unsigned_integer( + const BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_signed_integer( + const BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_uint32( + const BerTlvView& tlv) noexcept; +}; + +} // namespace ar::iec61850::asn1 diff --git a/include/ariec61850/osi/presentation_span.hpp b/include/ariec61850/osi/presentation_span.hpp new file mode 100644 index 000000000..d5d77d399 --- /dev/null +++ b/include/ariec61850/osi/presentation_span.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::osi { + +struct PresentationPdvView final { + std::uint32_t context_id{}; + std::span single_asn1_type{}; +}; + +class PresentationSpanCodec final { +public: + static constexpr std::size_t maximum_ppdu_bytes = 1U * 1024U * 1024U; + + [[nodiscard]] static std::optional fully_encoded_data_size( + std::uint32_t context_id, + std::size_t single_asn1_type_bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_fully_encoded_data_into( + std::uint32_t context_id, + std::span single_asn1_type, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_fully_encoded_data_view( + std::span bytes, + PresentationPdvView& pdv) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_p_data_into( + std::span abstract_syntax_payload, + std::span destination, + std::uint32_t presentation_context_id = 3U, + bool include_give_tokens_prefix = true) noexcept; + + [[nodiscard]] static bool try_decode_p_data_view( + std::span bytes, + PresentationPdvView& pdv) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/session_span.hpp b/include/ariec61850/osi/session_span.hpp new file mode 100644 index 000000000..cc0667c90 --- /dev/null +++ b/include/ariec61850/osi/session_span.hpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class SessionWireKind : std::uint8_t { + unknown, + connect, + accept, + reject, + refuse, + abort, + data_transfer, +}; + +struct SessionParameterView final { + std::uint8_t code{}; + std::span value{}; +}; + +struct SessionSpduView final { + SessionWireKind kind{SessionWireKind::unknown}; + std::uint8_t code{}; + std::uint8_t length_indicator{}; + std::span parameter_bytes{}; + std::span user_data{}; + + [[nodiscard]] bool try_parameter( + std::uint8_t parameter_code, + std::span& value) const noexcept; +}; + +struct SessionDataTransferView final { + bool has_give_tokens_prefix{}; + std::span presentation_payload{}; +}; + +class SessionSpanCodec final { +public: + static constexpr std::uint8_t connect_code = 0x0DU; + static constexpr std::uint8_t accept_code = 0x0EU; + static constexpr std::uint8_t reject_code = 0x0AU; + static constexpr std::uint8_t refuse_code = 0x0CU; + static constexpr std::uint8_t abort_code = 0x19U; + static constexpr std::uint8_t data_transfer_code = 0x01U; + static constexpr std::uint8_t user_data_parameter = 0xC1U; + static constexpr std::size_t maximum_spdu_bytes = 257U; + static constexpr std::size_t maximum_parameter_count = 64U; + + [[nodiscard]] static SessionWireKind kind_from_code(std::uint8_t code) noexcept; + [[nodiscard]] static std::span default_parameters() noexcept; + + [[nodiscard]] static bool try_decode_prefix_view( + std::span bytes, + SessionSpduView& spdu, + std::size_t& consumed) noexcept; + + [[nodiscard]] static bool try_decode_view( + std::span bytes, + SessionSpduView& spdu) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_into( + std::uint8_t code, + std::span parameters, + std::span user_data, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connect_into( + std::span presentation_payload, + std::span destination, + std::span parameters = {}) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_accept_into( + std::span presentation_payload, + std::span destination, + std::span parameters = {}) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_accept_mirroring_into( + const SessionSpduView& connect, + std::span presentation_payload, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_data_transfer_into( + std::span presentation_payload, + std::span destination, + bool include_give_tokens_prefix = true) noexcept; + + [[nodiscard]] static bool try_decode_data_transfer_view( + std::span bytes, + SessionDataTransferView& transfer) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/src/asn1/ber_span_reader.cpp b/src/asn1/ber_span_reader.cpp new file mode 100644 index 000000000..65b4e5aa6 --- /dev/null +++ b/src/asn1/ber_span_reader.cpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +bool BerSpanReader::try_read_tlv( + const std::span source, + std::size_t& offset, + BerTlvView& tlv) noexcept { + tlv = {}; + if (offset >= source.size()) { + return false; + } + + auto cursor = offset; + const auto start = cursor; + const auto encoded_tag = source[cursor++]; + std::int32_t tag_number = static_cast(encoded_tag & 0x1FU); + + if (tag_number == 0x1F) { + tag_number = 0; + bool read_any = false; + bool terminated = false; + while (cursor < source.size()) { + const auto byte = source[cursor++]; + read_any = true; + if (tag_number > (1'000'000 >> 7)) { + return false; + } + const auto next_tag = + (static_cast(tag_number) << 7U) | + static_cast(byte & 0x7FU); + tag_number = static_cast(next_tag); + if (tag_number > 1'000'000) { + return false; + } + if ((byte & 0x80U) == 0U) { + terminated = true; + break; + } + } + if (!read_any || !terminated) { + return false; + } + } + + if (cursor >= source.size()) { + return false; + } + + const auto length_byte = source[cursor++]; + std::size_t length = 0U; + if ((length_byte & 0x80U) == 0U) { + length = static_cast(length_byte); + } else { + const auto length_bytes = static_cast(length_byte & 0x7FU); + if (length_bytes == 0U || length_bytes > 4U || + length_bytes > source.size() - cursor) { + return false; + } + for (std::size_t index = 0U; index < length_bytes; ++index) { + length = (length << 8U) | + static_cast(source[cursor++]); + } + } + + if (length > source.size() - cursor) { + return false; + } + + const auto header_bytes = cursor - start; + const auto encoded_bytes = header_bytes + length; + tlv.encoded_tag = encoded_tag; + tlv.tag_class = static_cast((encoded_tag >> 6U) & 0x03U); + tlv.constructed = (encoded_tag & 0x20U) != 0U; + tlv.tag_number = tag_number; + tlv.header_bytes = header_bytes; + tlv.encoded_bytes = encoded_bytes; + tlv.value = source.subspan(cursor, length); + offset = cursor + length; + return true; +} + +bool BerSpanReader::try_read_exact( + const std::span source, + BerTlvView& tlv) noexcept { + std::size_t offset = 0U; + return try_read_tlv(source, offset, tlv) && offset == source.size(); +} + +std::optional BerSpanReader::read_unsigned_integer( + const BerTlvView& tlv) noexcept { + if (tlv.value.size() > sizeof(std::uint64_t)) { + return std::nullopt; + } + + std::uint64_t value = 0U; + for (const auto byte : tlv.value) { + value = (value << 8U) | static_cast(byte); + } + return value; +} + +std::optional BerSpanReader::read_signed_integer( + const BerTlvView& tlv) noexcept { + if (tlv.value.empty()) { + return std::int64_t{0}; + } + if (tlv.value.size() > sizeof(std::int64_t)) { + return std::nullopt; + } + + std::uint64_t raw = 0U; + for (const auto byte : tlv.value) { + raw = (raw << 8U) | static_cast(byte); + } + if ((tlv.value.front() & 0x80U) != 0U && + tlv.value.size() < sizeof(std::int64_t)) { + const auto used_bits = static_cast(tlv.value.size() * 8U); + raw |= (~std::uint64_t{0}) << used_bits; + } + return std::bit_cast(raw); +} + +std::optional BerSpanReader::read_uint32( + const BerTlvView& tlv) noexcept { + const auto value = read_unsigned_integer(tlv); + if (!value || *value > std::numeric_limits::max()) { + return std::nullopt; + } + return static_cast(*value); +} + +} // namespace ar::iec61850::asn1 diff --git a/src/osi/presentation_span.cpp b/src/osi/presentation_span.cpp new file mode 100644 index 000000000..629e3d216 --- /dev/null +++ b/src/osi/presentation_span.cpp @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/presentation_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +[[nodiscard]] std::size_t unsigned_integer_size(std::uint32_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] bool write_unsigned_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +struct FullyEncodedSizes final { + std::size_t id_value{}; + std::size_t id_tlv{}; + std::size_t payload_tlv{}; + std::size_t sequence_content{}; + std::size_t sequence_tlv{}; + std::size_t outer_tlv{}; +}; + +[[nodiscard]] std::optional calculate_sizes( + const std::uint32_t context_id, + const std::size_t payload_bytes) noexcept { + if (context_id == 0U || payload_bytes > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + FullyEncodedSizes sizes; + sizes.id_value = unsigned_integer_size(context_id); + const auto id_tlv = asn1::BerSpanWriter::tlv_size(2, sizes.id_value); + const auto payload_tlv = asn1::BerSpanWriter::tlv_size(0, payload_bytes); + const auto sequence_content = add_size(id_tlv, payload_tlv); + if (!id_tlv || !payload_tlv || !sequence_content) { + return std::nullopt; + } + const auto sequence_tlv = asn1::BerSpanWriter::tlv_size(16, *sequence_content); + if (!sequence_tlv) { + return std::nullopt; + } + const auto outer_tlv = asn1::BerSpanWriter::tlv_size(1, *sequence_tlv); + if (!outer_tlv || *outer_tlv > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + sizes.id_tlv = *id_tlv; + sizes.payload_tlv = *payload_tlv; + sizes.sequence_content = *sequence_content; + sizes.sequence_tlv = *sequence_tlv; + sizes.outer_tlv = *outer_tlv; + return sizes; +} + +} // namespace + +std::optional PresentationSpanCodec::fully_encoded_data_size( + const std::uint32_t context_id, + const std::size_t single_asn1_type_bytes) noexcept { + const auto sizes = calculate_sizes(context_id, single_asn1_type_bytes); + return sizes ? std::optional{sizes->outer_tlv} : std::nullopt; +} + +wire::EncodeResult PresentationSpanCodec::encode_fully_encoded_data_into( + const std::uint32_t context_id, + const std::span single_asn1_type, + const std::span destination) noexcept { + const auto sizes = calculate_sizes(context_id, single_asn1_type.size()); + if (!sizes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizes->outer_tlv) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizes->outer_tlv}; + } + + asn1::BerSpanWriter writer{destination.first(sizes->outer_tlv)}; + if (!writer.write_tlv_header( + asn1::BerClass::application, true, 1, sizes->sequence_tlv) || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, sizes->sequence_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizes->id_value) || + !write_unsigned_integer(writer, context_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, + true, + 0, + single_asn1_type.size()) || + !writer.write_bytes(single_asn1_type) || + writer.size() != sizes->outer_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + return {wire::EncodeStatus::ok, sizes->outer_tlv, sizes->outer_tlv}; +} + +bool PresentationSpanCodec::try_decode_fully_encoded_data_view( + const std::span bytes, + PresentationPdvView& pdv) noexcept { + pdv = {}; + if (bytes.empty() || bytes.size() > maximum_ppdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + 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_id = 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_id) { + pdv = {}; + return false; + } + const auto value = asn1::BerSpanReader::read_uint32(field); + if (!value || *value == 0U) { + pdv = {}; + return false; + } + pdv.context_id = *value; + saw_context_id = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_payload) { + pdv = {}; + return false; + } + pdv.single_asn1_type = field.value; + saw_payload = true; + } else { + pdv = {}; + return false; + } + } + + if (!saw_context_id || !saw_payload) { + pdv = {}; + return false; + } + return true; +} + +wire::EncodeResult PresentationSpanCodec::encode_p_data_into( + const std::span abstract_syntax_payload, + const std::span destination, + const std::uint32_t presentation_context_id, + const bool include_give_tokens_prefix) noexcept { + const auto fully_encoded = fully_encoded_data_size( + presentation_context_id, abstract_syntax_payload.size()); + if (!fully_encoded) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto prefix = include_give_tokens_prefix ? 4U : 2U; + if (*fully_encoded > std::numeric_limits::max() - prefix) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = prefix + *fully_encoded; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + std::size_t offset = 0U; + if (include_give_tokens_prefix) { + destination[offset++] = SessionSpanCodec::data_transfer_code; + destination[offset++] = 0x00U; + } + destination[offset++] = SessionSpanCodec::data_transfer_code; + destination[offset++] = 0x00U; + + const auto result = encode_fully_encoded_data_into( + presentation_context_id, + abstract_syntax_payload, + destination.subspan(offset, *fully_encoded)); + if (!result.success() || result.bytes_written != *fully_encoded) { + return {result.status, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +bool PresentationSpanCodec::try_decode_p_data_view( + const std::span bytes, + PresentationPdvView& pdv) noexcept { + pdv = {}; + std::span presentation = bytes; + SessionDataTransferView transfer; + if (!bytes.empty() && bytes.front() == SessionSpanCodec::data_transfer_code) { + if (!SessionSpanCodec::try_decode_data_transfer_view(bytes, transfer)) { + return false; + } + presentation = transfer.presentation_payload; + } + return try_decode_fully_encoded_data_view(presentation, pdv); +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/session_span.cpp b/src/osi/session_span.cpp new file mode 100644 index 000000000..be0dcc10e --- /dev/null +++ b/src/osi/session_span.cpp @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +constexpr std::array kParameter05{ + 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U}; +constexpr std::array kParameter14{0x00U, 0x02U}; +constexpr std::array kParameter33{0x00U, 0x01U}; +constexpr std::array kParameter34{0x00U, 0x01U}; + +const std::array kDefaultParameters{ + SessionParameterView{0x05U, kParameter05}, + SessionParameterView{0x14U, kParameter14}, + SessionParameterView{0x33U, kParameter33}, + SessionParameterView{0x34U, kParameter34}, +}; + +[[nodiscard]] bool encoded_body_size( + const std::span parameters, + const std::span user_data, + std::size_t& body_length) noexcept { + body_length = 0U; + if (parameters.size() > SessionSpanCodec::maximum_parameter_count || + user_data.size() > std::numeric_limits::max()) { + return false; + } + + if (!user_data.empty()) { + const auto encoded_user = user_data.size() + 2U; + if (encoded_user > std::numeric_limits::max()) { + return false; + } + body_length = encoded_user; + } + for (const auto& parameter : parameters) { + if (parameter.code == SessionSpanCodec::user_data_parameter || + parameter.value.size() > std::numeric_limits::max()) { + body_length = 0U; + return false; + } + const auto encoded = parameter.value.size() + 2U; + if (body_length > std::numeric_limits::max() || + encoded > std::numeric_limits::max() - body_length) { + body_length = 0U; + return false; + } + body_length += encoded; + } + return body_length <= std::numeric_limits::max(); +} + +[[nodiscard]] bool write_parameter( + const std::span destination, + std::size_t& offset, + const std::uint8_t code, + const std::span value) noexcept { + if (offset > destination.size() || + value.size() > std::numeric_limits::max() || + value.size() + 2U > destination.size() - offset) { + return false; + } + destination[offset++] = code; + destination[offset++] = static_cast(value.size()); + std::copy( + value.begin(), + value.end(), + destination.begin() + static_cast(offset)); + offset += value.size(); + return true; +} + +} // namespace + +bool SessionSpduView::try_parameter( + const std::uint8_t parameter_code, + std::span& value) const noexcept { + value = {}; + std::size_t offset = 0U; + while (offset < parameter_bytes.size()) { + if (parameter_bytes.size() - offset < 2U) { + return false; + } + const auto code = parameter_bytes[offset]; + const auto length = static_cast(parameter_bytes[offset + 1U]); + offset += 2U; + if (length > parameter_bytes.size() - offset) { + return false; + } + if (code == parameter_code) { + value = parameter_bytes.subspan(offset, length); + return true; + } + offset += length; + } + return false; +} + +SessionWireKind SessionSpanCodec::kind_from_code(const std::uint8_t code) noexcept { + switch (code) { + case connect_code: + return SessionWireKind::connect; + case accept_code: + return SessionWireKind::accept; + case reject_code: + return SessionWireKind::reject; + case refuse_code: + return SessionWireKind::refuse; + case abort_code: + return SessionWireKind::abort; + case data_transfer_code: + return SessionWireKind::data_transfer; + default: + return SessionWireKind::unknown; + } +} + +std::span SessionSpanCodec::default_parameters() noexcept { + return kDefaultParameters; +} + +bool SessionSpanCodec::try_decode_prefix_view( + const std::span bytes, + SessionSpduView& spdu, + std::size_t& consumed) noexcept { + spdu = {}; + consumed = 0U; + if (bytes.size() < 2U) { + return false; + } + + const auto kind = kind_from_code(bytes[0]); + if (kind == SessionWireKind::unknown) { + return false; + } + + const auto body_length = static_cast(bytes[1]); + const auto total_length = body_length + 2U; + if (total_length > maximum_spdu_bytes || total_length > bytes.size()) { + return false; + } + + std::size_t offset = 2U; + std::size_t parameter_end = total_length; + std::size_t parameter_count = 0U; + bool saw_user_data = false; + std::span user_data; + + while (offset < total_length) { + if (parameter_count >= maximum_parameter_count || + total_length - offset < 2U) { + return false; + } + ++parameter_count; + const auto parameter_start = offset; + const auto code = bytes[offset++]; + const auto length = static_cast(bytes[offset++]); + if (length > total_length - offset) { + return false; + } + + if (code == user_data_parameter) { + if (saw_user_data || offset + length != total_length) { + return false; + } + saw_user_data = true; + parameter_end = parameter_start; + user_data = bytes.subspan(offset, length); + } + offset += length; + } + + spdu.kind = kind; + spdu.code = bytes[0]; + spdu.length_indicator = bytes[1]; + spdu.parameter_bytes = bytes.subspan(2U, parameter_end - 2U); + spdu.user_data = user_data; + consumed = total_length; + return true; +} + +bool SessionSpanCodec::try_decode_view( + const std::span bytes, + SessionSpduView& spdu) noexcept { + std::size_t consumed = 0U; + return try_decode_prefix_view(bytes, spdu, consumed) && consumed == bytes.size(); +} + +wire::EncodeResult SessionSpanCodec::encode_into( + const std::uint8_t code, + const std::span parameters, + const std::span user_data, + const std::span destination) noexcept { + if (kind_from_code(code) == SessionWireKind::unknown) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t body_length{}; + if (!encoded_body_size(parameters, user_data, body_length)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = body_length + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = code; + destination[1] = static_cast(body_length); + std::size_t offset = 2U; + for (const auto& parameter : parameters) { + if (!write_parameter(destination, offset, parameter.code, parameter.value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + if (!user_data.empty() && + !write_parameter(destination, offset, user_data_parameter, user_data)) { + 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}; +} + +wire::EncodeResult SessionSpanCodec::encode_connect_into( + const std::span presentation_payload, + const std::span destination, + const std::span parameters) noexcept { + const auto effective = parameters.empty() ? default_parameters() : parameters; + return encode_into(connect_code, effective, presentation_payload, destination); +} + +wire::EncodeResult SessionSpanCodec::encode_accept_into( + const std::span presentation_payload, + const std::span destination, + const std::span parameters) noexcept { + const auto effective = parameters.empty() ? default_parameters() : parameters; + return encode_into(accept_code, effective, presentation_payload, destination); +} + +wire::EncodeResult SessionSpanCodec::encode_accept_mirroring_into( + const SessionSpduView& connect, + const std::span presentation_payload, + const std::span destination) noexcept { + if (connect.kind != SessionWireKind::connect || + presentation_payload.size() > std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto user_bytes = presentation_payload.empty() + ? 0U + : presentation_payload.size() + 2U; + if (user_bytes > std::numeric_limits::max() || + connect.parameter_bytes.size() > + std::numeric_limits::max() - user_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto body_length = connect.parameter_bytes.size() + user_bytes; + const auto required = body_length + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = accept_code; + destination[1] = static_cast(body_length); + std::copy( + connect.parameter_bytes.begin(), + connect.parameter_bytes.end(), + destination.begin() + 2); + std::size_t offset = 2U + connect.parameter_bytes.size(); + if (!presentation_payload.empty() && + !write_parameter( + destination, + offset, + user_data_parameter, + presentation_payload)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return offset == required + ? wire::EncodeResult{wire::EncodeStatus::ok, required, required} + : wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, required}; +} + +wire::EncodeResult SessionSpanCodec::encode_data_transfer_into( + const std::span presentation_payload, + const std::span destination, + const bool include_give_tokens_prefix) noexcept { + const auto prefix = include_give_tokens_prefix ? 4U : 2U; + if (presentation_payload.size() > + std::numeric_limits::max() - prefix) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = presentation_payload.size() + prefix; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + std::size_t offset = 0U; + if (include_give_tokens_prefix) { + destination[offset++] = data_transfer_code; + destination[offset++] = 0x00U; + } + destination[offset++] = data_transfer_code; + destination[offset++] = 0x00U; + std::copy( + presentation_payload.begin(), + presentation_payload.end(), + destination.begin() + static_cast(offset)); + return {wire::EncodeStatus::ok, required, required}; +} + +bool SessionSpanCodec::try_decode_data_transfer_view( + const std::span bytes, + SessionDataTransferView& transfer) noexcept { + transfer = {}; + if (bytes.size() < 2U) { + return false; + } + + std::size_t offset = 0U; + if (bytes.size() >= 4U && + bytes[0] == data_transfer_code && bytes[1] == 0x00U && + bytes[2] == data_transfer_code && bytes[3] == 0x00U) { + transfer.has_give_tokens_prefix = true; + offset = 4U; + } else if (bytes[0] == data_transfer_code && bytes[1] == 0x00U) { + transfer.has_give_tokens_prefix = false; + offset = 2U; + } else { + return false; + } + + transfer.presentation_payload = bytes.subspan(offset); + return true; +} + +} // namespace ar::iec61850::osi From e13e4bba04ced09a99bd66ada5e0f17a552ea1f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 13:15:26 +0700 Subject: [PATCH 138/188] Add bounded ACSE association responder --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/association_hard_profile_smoke.cpp | 166 ++++++ embedded/hard_profile/CMakeLists.txt | 15 +- include/ariec61850/acse/association_span.hpp | 73 +++ include/ariec61850/osi/presentation_span.hpp | 36 ++ src/acse/association_span.cpp | 417 +++++++++++++ src/osi/presentation_span.cpp | 559 ++++++++++++++++-- 7 files changed, 1227 insertions(+), 45 deletions(-) create mode 100644 embedded/association_hard_profile_smoke.cpp create mode 100644 include/ariec61850/acse/association_span.hpp create mode 100644 src/acse/association_span.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index fa8b2c9b7..bd539a4e6 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -52,7 +52,8 @@ jobs: build-hard-embedded/ariec61850_hard_sv_smoke \ build-hard-embedded/ariec61850_hard_goose_smoke \ build-hard-embedded/ariec61850_hard_osi_transport_smoke \ - build-hard-embedded/ariec61850_hard_session_presentation_smoke; do + build-hard-embedded/ariec61850_hard_session_presentation_smoke \ + build-hard-embedded/ariec61850_hard_association_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -70,7 +71,8 @@ jobs: build-hard-embedded/ariec61850_hard_sv_smoke \ build-hard-embedded/ariec61850_hard_goose_smoke \ build-hard-embedded/ariec61850_hard_osi_transport_smoke \ - build-hard-embedded/ariec61850_hard_session_presentation_smoke + build-hard-embedded/ariec61850_hard_session_presentation_smoke \ + build-hard-embedded/ariec61850_hard_association_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/association_hard_profile_smoke.cpp b/embedded/association_hard_profile_smoke.cpp new file mode 100644 index 000000000..558234eaf --- /dev/null +++ b/embedded/association_hard_profile_smoke.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kRequest{ + 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 kResponse{ + 0x0EU, 0x88U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0x72U, + 0x31U, 0x70U, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x69U, 0xA5U, 0x12U, 0x30U, + 0x07U, 0x80U, 0x01U, 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x30U, 0x07U, 0x80U, 0x01U, + 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x61U, 0x53U, 0x30U, 0x51U, 0x02U, 0x01U, 0x01U, + 0xA0U, 0x4CU, 0x61U, 0x4AU, 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, + 0x03U, 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, + 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}; + +constexpr std::array kMmsApplicationContext{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U}; +constexpr std::array kCalledApTitle{ + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U}; +constexpr std::array kCallingApTitle{ + 0x29U, 0x01U, 0x87U, 0x67U}; +constexpr std::array kBerSyntax{0x51U, 0x01U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +} // namespace + +int main() { + acse::AssociationRequestView request; + if (!acse::AcseSpanCodec::try_decode_association_request_view(kRequest, request)) { + return 1; + } + + if (request.session.kind != osi::SessionWireKind::connect || + request.presentation.mode_selector != 1U || + request.presentation.context_count != 2U || + request.acse_presentation_context_id != 1U || + request.mms_presentation_context_id != 3U || + !matches(request.aarq.application_context_name, kMmsApplicationContext) || + !matches(request.aarq.called_ap_title, kCalledApTitle) || + !request.aarq.called_ae_qualifier.has_value() || + *request.aarq.called_ae_qualifier != 12U || + !matches(request.aarq.calling_ap_title, kCallingApTitle) || + !request.aarq.calling_ae_qualifier.has_value() || + *request.aarq.calling_ae_qualifier != 12U || + !matches(request.aarq.user_information.direct_reference, kBerSyntax) || + request.aarq.user_information.indirect_reference != 3U || + !matches( + request.aarq.user_information.single_asn1_type, + std::array{ + 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})) { + return 2; + } + + std::uint32_t acse_context{}; + std::uint32_t mms_context{}; + if (!request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::acse_abstract_syntax_name(), acse_context) || + !request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::mms_abstract_syntax_name(), mms_context) || + acse_context != 1U || mms_context != 3U) { + return 3; + } + + std::array response{}; + const auto required = acse::AcseSpanCodec::accept_response_size(request); + const auto result = acse::AcseSpanCodec::build_accept_response_into(request, response); + if (!required || *required != kResponse.size() || + !result.success() || result.bytes_written != kResponse.size() || + !matches( + std::span{response}.first(result.bytes_written), + kResponse)) { + 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() || + aare_result.bytes_written != acse::AcseSpanCodec::default_accept_aare_size() || + !matches( + acse::AcseSpanCodec::default_mms_initiate_response(), + std::array{ + 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})) { + 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); + if (!repeated.success() || repeated.bytes_written != kResponse.size() || + response[0] != osi::SessionSpanCodec::accept_code || + response[1] != 0x88U) { + return 6; + } + } + + constexpr std::array malformed{ + 0x0DU, 0x02U, 0xC1U, 0x00U}; + acse::AssociationRequestView rejected; + if (acse::AcseSpanCodec::try_decode_association_request_view(malformed, rejected)) { + return 7; + } + + return 0; +} diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 020a515ae..885e3b42f 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850HardEmbeddedProfile VERSION 0.1.0 - DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus and OSI transport paths" + DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus and MMS association paths" LANGUAGES CXX) set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") @@ -60,6 +60,14 @@ add_executable(ariec61850_hard_session_presentation_smoke ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp) ariec61850_configure_hard_target(ariec61850_hard_session_presentation_smoke) +add_executable(ariec61850_hard_association_smoke + ${CMAKE_CURRENT_LIST_DIR}/../association_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_association_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -67,8 +75,11 @@ add_test(NAME ariec61850_hard_osi_transport_smoke COMMAND ariec61850_hard_osi_tr add_test( NAME ariec61850_hard_session_presentation_smoke COMMAND ariec61850_hard_session_presentation_smoke) +add_test( + NAME ariec61850_hard_association_smoke + COMMAND ariec61850_hard_association_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + TPKT/COTP + Session/Presentation gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + TPKT/COTP + Session/Presentation + ACSE association gates=ON") diff --git a/include/ariec61850/acse/association_span.hpp b/include/ariec61850/acse/association_span.hpp new file mode 100644 index 000000000..b18d1d7ea --- /dev/null +++ b/include/ariec61850/acse/association_span.hpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::acse { + +struct AcseExternalView final { + std::span direct_reference{}; + std::uint32_t indirect_reference{}; + std::span single_asn1_type{}; +}; + +struct AcseAarqView final { + std::span application_context_name{}; + std::span called_ap_title{}; + std::optional called_ae_qualifier{}; + std::span calling_ap_title{}; + std::optional calling_ae_qualifier{}; + AcseExternalView user_information{}; +}; + +struct AssociationRequestView final { + osi::SessionSpduView session{}; + osi::PresentationCpView presentation{}; + AcseAarqView aarq{}; + std::uint32_t acse_presentation_context_id{}; + std::uint32_t mms_presentation_context_id{}; +}; + +class AcseSpanCodec final { +public: + static constexpr std::size_t maximum_acse_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_oid_bytes = 64U; + + [[nodiscard]] static std::span + mms_application_context_name() noexcept; + [[nodiscard]] static std::span + default_mms_initiate_request() noexcept; + [[nodiscard]] static std::span + default_mms_initiate_response() noexcept; + + [[nodiscard]] static bool try_decode_aarq_view( + std::span bytes, + AcseAarqView& aarq) noexcept; + + [[nodiscard]] static bool try_decode_association_request_view( + std::span bytes, + AssociationRequestView& request) noexcept; + + [[nodiscard]] static constexpr std::size_t default_accept_aare_size() noexcept { + return 76U; + } + + [[nodiscard]] static wire::EncodeResult encode_default_accept_aare_into( + std::span destination) noexcept; + + [[nodiscard]] static std::optional accept_response_size( + const AssociationRequestView& request) noexcept; + + [[nodiscard]] static wire::EncodeResult build_accept_response_into( + const AssociationRequestView& request, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::acse diff --git a/include/ariec61850/osi/presentation_span.hpp b/include/ariec61850/osi/presentation_span.hpp index d5d77d399..7afb688e1 100644 --- a/include/ariec61850/osi/presentation_span.hpp +++ b/include/ariec61850/osi/presentation_span.hpp @@ -15,9 +15,32 @@ struct PresentationPdvView final { std::span single_asn1_type{}; }; +struct PresentationCpView final { + std::uint32_t mode_selector{1U}; + std::span calling_selector{}; + std::span called_selector{}; + std::span context_definition_list{}; + std::size_t context_count{}; + PresentationPdvView user_data{}; + + [[nodiscard]] bool try_context_id_for_abstract_syntax( + std::span object_identifier_value, + std::uint32_t& context_id) const noexcept; +}; + class PresentationSpanCodec final { public: static constexpr std::size_t maximum_ppdu_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_contexts = 64U; + static constexpr std::size_t maximum_oid_bytes = 64U; + static constexpr std::size_t maximum_selector_bytes = 64U; + + [[nodiscard]] static std::span + acse_abstract_syntax_name() noexcept; + [[nodiscard]] static std::span + mms_abstract_syntax_name() noexcept; + [[nodiscard]] static std::span + ber_transfer_syntax_name() noexcept; [[nodiscard]] static std::optional fully_encoded_data_size( std::uint32_t context_id, @@ -32,6 +55,19 @@ class PresentationSpanCodec final { std::span bytes, PresentationPdvView& pdv) noexcept; + [[nodiscard]] static bool try_decode_cp_view( + std::span bytes, + PresentationCpView& cp) noexcept; + + [[nodiscard]] static std::optional cpa_accepting_size( + const PresentationCpView& request, + std::size_t acse_aare_bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_cpa_accepting_into( + const PresentationCpView& request, + std::span acse_aare, + std::span destination) noexcept; + [[nodiscard]] static wire::EncodeResult encode_p_data_into( std::span abstract_syntax_payload, std::span destination, diff --git a/src/acse/association_span.cpp b/src/acse/association_span.cpp new file mode 100644 index 000000000..c7f1ea4d4 --- /dev/null +++ b/src/acse/association_span.cpp @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::acse { +namespace { + +constexpr std::array kMmsApplicationContext{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U}; + +constexpr std::array kDefaultMmsInitiateRequest{ + 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 kDefaultMmsInitiateResponse{ + 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}; + +// Byte-identical to AcseAssociationCodec::encode_aare(default_accept_aare()). +constexpr std::array kDefaultAcceptAare{ + 0x61U, 0x4AU, + 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, + 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, + 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, 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}; + +[[nodiscard]] bool valid_oid( + const std::span value, + const bool allow_empty = false) noexcept { + return (allow_empty || !value.empty()) && value.size() <= AcseSpanCodec::maximum_oid_bytes; +} + +[[nodiscard]] bool decode_explicit_oid( + const asn1::BerTlvView& field, + std::span& oid, + const bool allow_empty = false) noexcept { + oid = {}; + if (!field.constructed) { + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::universal || + inner.tag_number != 6 || inner.constructed || + !valid_oid(inner.value, allow_empty)) { + return false; + } + oid = inner.value; + return true; +} + +[[nodiscard]] bool decode_explicit_integer( + const asn1::BerTlvView& field, + std::uint32_t& value) noexcept { + value = 0U; + if (!field.constructed) { + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::universal || + inner.tag_number != 2 || inner.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(inner); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_external( + const asn1::BerTlvView& user_information, + AcseExternalView& external) noexcept { + external = {}; + if (!user_information.constructed) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(user_information.value, outer) || + outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 8 || !outer.constructed) { + return false; + } + + bool saw_direct = false; + bool saw_indirect = false; + bool saw_encoding = false; + 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) { + if (saw_direct || !valid_oid(field.value)) { + external = {}; + return false; + } + external.direct_reference = field.value; + saw_direct = 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) { + external = {}; + return false; + } + external.single_asn1_type = field.value; + saw_encoding = true; + } else { + external = {}; + return false; + } + } + return saw_direct && saw_indirect && saw_encoding; +} + +[[nodiscard]] bool request_is_valid_for_response( + 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 + +std::span AcseSpanCodec::mms_application_context_name() noexcept { + return kMmsApplicationContext; +} + +std::span AcseSpanCodec::default_mms_initiate_request() noexcept { + return kDefaultMmsInitiateRequest; +} + +std::span AcseSpanCodec::default_mms_initiate_response() noexcept { + return kDefaultMmsInitiateResponse; +} + +bool AcseSpanCodec::try_decode_aarq_view( + const std::span bytes, + AcseAarqView& aarq) noexcept { + aarq = {}; + if (bytes.empty() || bytes.size() > 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_application = false; + bool saw_called_ap = false; + bool saw_called_ae = false; + bool saw_calling_ap = false; + bool saw_calling_ae = 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) || + field.tag_class != asn1::BerClass::context_specific) { + aarq = {}; + return false; + } + + switch (field.tag_number) { + case 1: + if (saw_application || + !decode_explicit_oid(field, aarq.application_context_name)) { + aarq = {}; + return false; + } + saw_application = true; + break; + case 2: + if (saw_called_ap || + !decode_explicit_oid(field, aarq.called_ap_title, true)) { + aarq = {}; + return false; + } + saw_called_ap = true; + break; + case 3: { + if (saw_called_ae) { + aarq = {}; + return false; + } + std::uint32_t value{}; + if (!decode_explicit_integer(field, value)) { + aarq = {}; + return false; + } + aarq.called_ae_qualifier = value; + saw_called_ae = true; + break; + } + case 6: + if (saw_calling_ap || + !decode_explicit_oid(field, aarq.calling_ap_title, true)) { + aarq = {}; + return false; + } + saw_calling_ap = true; + break; + case 7: { + if (saw_calling_ae) { + aarq = {}; + return false; + } + std::uint32_t value{}; + if (!decode_explicit_integer(field, value)) { + aarq = {}; + return false; + } + aarq.calling_ae_qualifier = value; + saw_calling_ae = true; + break; + } + case 30: + if (saw_user_information || !decode_external(field, aarq.user_information)) { + aarq = {}; + return false; + } + saw_user_information = true; + break; + default: + aarq = {}; + return false; + } + } + + if (!saw_application || !saw_user_information) { + aarq = {}; + return false; + } + return true; +} + +bool AcseSpanCodec::try_decode_association_request_view( + const std::span bytes, + AssociationRequestView& request) noexcept { + request = {}; + if (!osi::SessionSpanCodec::try_decode_view(bytes, request.session) || + request.session.kind != osi::SessionWireKind::connect || + !osi::PresentationSpanCodec::try_decode_cp_view( + request.session.user_data, request.presentation) || + !try_decode_aarq_view( + 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) || + !request_is_valid_for_response(request)) { + request = {}; + return false; + } + return true; +} + +wire::EncodeResult AcseSpanCodec::encode_default_accept_aare_into( + const std::span destination) noexcept { + if (destination.size() < kDefaultAcceptAare.size()) { + return { + wire::EncodeStatus::buffer_too_small, + 0U, + kDefaultAcceptAare.size()}; + } + std::copy( + kDefaultAcceptAare.begin(), + kDefaultAcceptAare.end(), + destination.begin()); + return { + wire::EncodeStatus::ok, + kDefaultAcceptAare.size(), + kDefaultAcceptAare.size()}; +} + +std::optional AcseSpanCodec::accept_response_size( + const AssociationRequestView& request) noexcept { + if (!request_is_valid_for_response(request)) { + return std::nullopt; + } + + const auto cpa_size = osi::PresentationSpanCodec::cpa_accepting_size( + request.presentation, default_accept_aare_size()); + if (!cpa_size || *cpa_size > std::numeric_limits::max()) { + return std::nullopt; + } + + 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 std::nullopt; + } + const auto body_bytes = request.session.parameter_bytes.size() + user_parameter_bytes; + return body_bytes + 2U; +} + +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 {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + 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}; + } + + const auto body_bytes = *required - 2U; + destination[0] = osi::SessionSpanCodec::accept_code; + destination[1] = static_cast(body_bytes); + std::copy( + request.session.parameter_bytes.begin(), + request.session.parameter_bytes.end(), + destination.begin() + 2); + + std::size_t offset = 2U + request.session.parameter_bytes.size(); + destination[offset++] = osi::SessionSpanCodec::user_data_parameter; + destination[offset++] = static_cast(*cpa_size); + + const auto cpa_result = osi::PresentationSpanCodec::encode_cpa_accepting_into( + request.presentation, + 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}; + } + offset += cpa_result.bytes_written; + + if (offset != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::acse diff --git a/src/osi/presentation_span.cpp b/src/osi/presentation_span.cpp index 629e3d216..b0d535793 100644 --- a/src/osi/presentation_span.cpp +++ b/src/osi/presentation_span.cpp @@ -6,6 +6,8 @@ #include "ariec61850/asn1/ber_span_writer.hpp" #include "ariec61850/osi/session_span.hpp" +#include +#include #include #include #include @@ -15,6 +17,12 @@ namespace ar::iec61850::osi { namespace { +constexpr std::array kAcseAbstractSyntax{ + 0x52U, 0x01U, 0x00U, 0x01U}; +constexpr std::array kMmsAbstractSyntax{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U}; +constexpr std::array kBerTransferSyntax{0x51U, 0x01U}; + [[nodiscard]] std::size_t unsigned_integer_size(std::uint32_t value) noexcept { std::size_t size = 1U; while (value > 0xFFU) { @@ -47,6 +55,169 @@ namespace { return *left + *right; } +[[nodiscard]] bool valid_oid( + const std::span value) noexcept { + return !value.empty() && value.size() <= PresentationSpanCodec::maximum_oid_bytes; +} + +[[nodiscard]] bool read_positive_integer( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded || *decoded == 0U) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +struct ContextDefinitionView final { + std::uint32_t id{}; + std::span abstract_syntax{}; + std::span first_transfer_syntax{}; + std::size_t transfer_syntax_count{}; +}; + +[[nodiscard]] bool parse_context_definition( + const asn1::BerTlvView& definition, + ContextDefinitionView& context) noexcept { + context = {}; + if (definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + + bool saw_id = false; + bool saw_abstract = false; + bool saw_transfers = false; + std::size_t offset = 0U; + while (offset < definition.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(definition.value, offset, field)) { + return false; + } + + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_id || !read_positive_integer(field, context.id)) { + return false; + } + saw_id = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed) { + if (saw_abstract || !valid_oid(field.value)) { + return false; + } + context.abstract_syntax = field.value; + saw_abstract = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 16 && field.constructed) { + if (saw_transfers) { + return false; + } + std::size_t transfer_offset = 0U; + while (transfer_offset < field.value.size()) { + if (context.transfer_syntax_count >= + PresentationSpanCodec::maximum_contexts) { + return false; + } + asn1::BerTlvView syntax; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, transfer_offset, syntax) || + syntax.tag_class != asn1::BerClass::universal || + syntax.tag_number != 6 || syntax.constructed || + !valid_oid(syntax.value)) { + return false; + } + if (context.transfer_syntax_count == 0U) { + context.first_transfer_syntax = syntax.value; + } + ++context.transfer_syntax_count; + } + if (context.transfer_syntax_count == 0U) { + return false; + } + saw_transfers = true; + } else { + return false; + } + } + return saw_id && saw_abstract && saw_transfers; +} + +[[nodiscard]] bool validate_context_list( + const std::span list, + std::size_t& count) noexcept { + count = 0U; + std::array ids{}; + std::size_t offset = 0U; + while (offset < list.size()) { + if (count >= PresentationSpanCodec::maximum_contexts) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + count = 0U; + return false; + } + for (std::size_t index = 0U; index < count; ++index) { + if (ids[index] == context.id) { + count = 0U; + return false; + } + } + ids[count++] = context.id; + } + return count != 0U; +} + +[[nodiscard]] bool find_context_by_abstract_syntax( + const std::span list, + const std::span oid, + std::uint32_t& id) noexcept { + id = 0U; + std::size_t offset = 0U; + while (offset < list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + return false; + } + if (context.abstract_syntax.size() == oid.size() && + std::equal( + context.abstract_syntax.begin(), + context.abstract_syntax.end(), + oid.begin())) { + id = context.id; + return true; + } + } + return false; +} + +[[nodiscard]] bool context_id_exists( + const std::span list, + const std::uint32_t id) noexcept { + std::size_t offset = 0U; + while (offset < list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + return false; + } + if (context.id == id) { + return true; + } + } + return false; +} + struct FullyEncodedSizes final { std::size_t id_value{}; std::size_t id_tlv{}; @@ -56,7 +227,7 @@ struct FullyEncodedSizes final { std::size_t outer_tlv{}; }; -[[nodiscard]] std::optional calculate_sizes( +[[nodiscard]] std::optional calculate_fully_encoded_sizes( const std::uint32_t context_id, const std::size_t payload_bytes) noexcept { if (context_id == 0U || payload_bytes > PresentationSpanCodec::maximum_ppdu_bytes) { @@ -88,12 +259,187 @@ struct FullyEncodedSizes final { return sizes; } +[[nodiscard]] bool decode_fully_encoded_outer( + const asn1::BerTlvView& outer, + 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_id = 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_id || !read_positive_integer(field, pdv.context_id)) { + pdv = {}; + return false; + } + saw_context_id = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_payload) { + pdv = {}; + return false; + } + pdv.single_asn1_type = field.value; + saw_payload = true; + } else { + pdv = {}; + return false; + } + } + return saw_context_id && saw_payload; +} + +[[nodiscard]] bool decode_mode_selector( + const asn1::BerTlvView& item, + std::uint32_t& mode) noexcept { + if (item.tag_class != asn1::BerClass::context_specific || + item.tag_number != 0 || !item.constructed) { + return false; + } + asn1::BerTlvView value; + return asn1::BerSpanReader::try_read_exact(item.value, value) && + value.tag_class == asn1::BerClass::context_specific && + value.tag_number == 0 && !value.constructed && + read_positive_integer(value, mode); +} + +[[nodiscard]] std::optional context_results_content_size( + const PresentationCpView& request) noexcept { + std::size_t total = 0U; + std::size_t offset = 0U; + while (offset < request.context_definition_list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv( + request.context_definition_list, offset, definition) || + !parse_context_definition(definition, context)) { + return std::nullopt; + } + const auto result_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto syntax_tlv = asn1::BerSpanWriter::tlv_size( + 1, context.first_transfer_syntax.size()); + const auto sequence_content = add_size(result_tlv, syntax_tlv); + if (!sequence_content) { + return std::nullopt; + } + const auto sequence_tlv = asn1::BerSpanWriter::tlv_size( + 16, *sequence_content); + if (!sequence_tlv || *sequence_tlv > + std::numeric_limits::max() - total) { + return std::nullopt; + } + total += *sequence_tlv; + } + return total; +} + +struct CpaSizes final { + std::size_t mode_value{}; + std::size_t mode_inner_tlv{}; + std::size_t mode_outer_tlv{}; + std::size_t results_content{}; + std::size_t results_tlv{}; + std::size_t user_tlv{}; + std::size_t normal_content{}; + std::size_t normal_tlv{}; + std::size_t outer_content{}; + std::size_t outer_tlv{}; +}; + +[[nodiscard]] std::optional calculate_cpa_sizes( + const PresentationCpView& request, + const std::size_t acse_aare_bytes) noexcept { + if (request.mode_selector == 0U || request.context_count == 0U) { + return std::nullopt; + } + + CpaSizes sizes; + sizes.mode_value = unsigned_integer_size(request.mode_selector); + const auto mode_inner = asn1::BerSpanWriter::tlv_size(0, sizes.mode_value); + if (!mode_inner) { + return std::nullopt; + } + const auto mode_outer = asn1::BerSpanWriter::tlv_size(0, *mode_inner); + const auto results_content = context_results_content_size(request); + if (!mode_outer || !results_content) { + return std::nullopt; + } + const auto results_tlv = asn1::BerSpanWriter::tlv_size(5, *results_content); + const auto user_tlv = PresentationSpanCodec::fully_encoded_data_size( + request.user_data.context_id, acse_aare_bytes); + const auto normal_content = add_size(results_tlv, user_tlv); + if (!results_tlv || !user_tlv || !normal_content) { + return std::nullopt; + } + const auto normal_tlv = asn1::BerSpanWriter::tlv_size(2, *normal_content); + const auto outer_content = add_size(mode_outer, normal_tlv); + if (!normal_tlv || !outer_content) { + return std::nullopt; + } + const auto outer_tlv = asn1::BerSpanWriter::tlv_size(17, *outer_content); + if (!outer_tlv || *outer_tlv > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + sizes.mode_inner_tlv = *mode_inner; + sizes.mode_outer_tlv = *mode_outer; + sizes.results_content = *results_content; + sizes.results_tlv = *results_tlv; + sizes.user_tlv = *user_tlv; + sizes.normal_content = *normal_content; + sizes.normal_tlv = *normal_tlv; + sizes.outer_content = *outer_content; + sizes.outer_tlv = *outer_tlv; + return sizes; +} + } // namespace +bool PresentationCpView::try_context_id_for_abstract_syntax( + const std::span object_identifier_value, + std::uint32_t& context_id) const noexcept { + return find_context_by_abstract_syntax( + context_definition_list, object_identifier_value, context_id); +} + +std::span +PresentationSpanCodec::acse_abstract_syntax_name() noexcept { + return kAcseAbstractSyntax; +} + +std::span +PresentationSpanCodec::mms_abstract_syntax_name() noexcept { + return kMmsAbstractSyntax; +} + +std::span +PresentationSpanCodec::ber_transfer_syntax_name() noexcept { + return kBerTransferSyntax; +} + std::optional PresentationSpanCodec::fully_encoded_data_size( const std::uint32_t context_id, const std::size_t single_asn1_type_bytes) noexcept { - const auto sizes = calculate_sizes(context_id, single_asn1_type_bytes); + const auto sizes = calculate_fully_encoded_sizes(context_id, single_asn1_type_bytes); return sizes ? std::optional{sizes->outer_tlv} : std::nullopt; } @@ -101,7 +447,7 @@ wire::EncodeResult PresentationSpanCodec::encode_fully_encoded_data_into( const std::uint32_t context_id, const std::span single_asn1_type, const std::span destination) noexcept { - const auto sizes = calculate_sizes(context_id, single_asn1_type.size()); + const auto sizes = calculate_fully_encoded_sizes(context_id, single_asn1_type.size()); if (!sizes) { return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; } @@ -136,65 +482,196 @@ bool PresentationSpanCodec::try_decode_fully_encoded_data_view( if (bytes.empty() || bytes.size() > maximum_ppdu_bytes) { return false; } - asn1::BerTlvView outer; - if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || - outer.tag_class != asn1::BerClass::application || - outer.tag_number != 1 || !outer.constructed) { + return asn1::BerSpanReader::try_read_exact(bytes, outer) && + decode_fully_encoded_outer(outer, pdv); +} + +bool PresentationSpanCodec::try_decode_cp_view( + const std::span bytes, + PresentationCpView& cp) noexcept { + cp = {}; + if (bytes.empty() || bytes.size() > maximum_ppdu_bytes) { 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) { + 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_context_id = false; - bool saw_payload = false; + bool saw_mode = false; + bool saw_normal = 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 = {}; + while (offset < outer.value.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, item)) { + cp = {}; return false; } - if (field.tag_class == asn1::BerClass::universal && - field.tag_number == 2 && !field.constructed) { - if (saw_context_id) { - pdv = {}; + if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 0 && item.constructed) { + if (saw_mode || !decode_mode_selector(item, cp.mode_selector)) { + cp = {}; return false; } - const auto value = asn1::BerSpanReader::read_uint32(field); - if (!value || *value == 0U) { - pdv = {}; + saw_mode = true; + } else if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 2 && item.constructed) { + if (saw_normal) { + cp = {}; return false; } - pdv.context_id = *value; - saw_context_id = true; - } else if (field.tag_class == asn1::BerClass::context_specific && - field.tag_number == 0 && field.constructed) { - if (saw_payload) { - pdv = {}; + 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() > 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() > 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 || !validate_context_list( + 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_fully_encoded_outer( + normal, cp.user_data)) { + cp = {}; + return false; + } + saw_user_data = true; + } else { + cp = {}; + return false; + } + } + if (!saw_contexts || !saw_user_data || + !context_id_exists( + cp.context_definition_list, cp.user_data.context_id)) { + cp = {}; return false; } - pdv.single_asn1_type = field.value; - saw_payload = true; + saw_normal = true; } else { - pdv = {}; + cp = {}; return false; } } - if (!saw_context_id || !saw_payload) { - pdv = {}; + if (!saw_mode || !saw_normal) { + cp = {}; return false; } return true; } +std::optional PresentationSpanCodec::cpa_accepting_size( + const PresentationCpView& request, + const std::size_t acse_aare_bytes) noexcept { + const auto sizes = calculate_cpa_sizes(request, acse_aare_bytes); + return sizes ? std::optional{sizes->outer_tlv} : std::nullopt; +} + +wire::EncodeResult PresentationSpanCodec::encode_cpa_accepting_into( + const PresentationCpView& request, + const std::span acse_aare, + const std::span destination) noexcept { + const auto sizes = calculate_cpa_sizes(request, acse_aare.size()); + if (!sizes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizes->outer_tlv) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizes->outer_tlv}; + } + + asn1::BerSpanWriter writer{destination.first(sizes->outer_tlv)}; + if (!writer.write_tlv_header( + asn1::BerClass::universal, true, 17, sizes->outer_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, sizes->mode_inner_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, sizes->mode_value) || + !write_unsigned_integer(writer, request.mode_selector) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, sizes->normal_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 5, sizes->results_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + + std::size_t context_offset = 0U; + while (context_offset < request.context_definition_list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv( + request.context_definition_list, context_offset, definition) || + !parse_context_definition(definition, context)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + const auto result_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto syntax_tlv = asn1::BerSpanWriter::tlv_size( + 1, context.first_transfer_syntax.size()); + const auto sequence_content = add_size(result_tlv, syntax_tlv); + if (!sequence_content || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *sequence_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(0x00U) || + !writer.write_tlv( + asn1::BerClass::context_specific, + false, + 1, + context.first_transfer_syntax)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + } + + const auto user_result = encode_fully_encoded_data_into( + request.user_data.context_id, + acse_aare, + destination.first(sizes->outer_tlv).subspan(writer.size(), sizes->user_tlv)); + if (!user_result.success() || user_result.bytes_written != sizes->user_tlv) { + return {user_result.status, 0U, sizes->outer_tlv}; + } + const auto written = writer.size() + user_result.bytes_written; + if (written != sizes->outer_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + return {wire::EncodeStatus::ok, written, sizes->outer_tlv}; +} + wire::EncodeResult PresentationSpanCodec::encode_p_data_into( const std::span abstract_syntax_payload, const std::span destination, @@ -215,18 +692,18 @@ wire::EncodeResult PresentationSpanCodec::encode_p_data_into( return {wire::EncodeStatus::buffer_too_small, 0U, required}; } - std::size_t offset = 0U; + std::size_t output_offset = 0U; if (include_give_tokens_prefix) { - destination[offset++] = SessionSpanCodec::data_transfer_code; - destination[offset++] = 0x00U; + destination[output_offset++] = SessionSpanCodec::data_transfer_code; + destination[output_offset++] = 0x00U; } - destination[offset++] = SessionSpanCodec::data_transfer_code; - destination[offset++] = 0x00U; + destination[output_offset++] = SessionSpanCodec::data_transfer_code; + destination[output_offset++] = 0x00U; const auto result = encode_fully_encoded_data_into( presentation_context_id, abstract_syntax_payload, - destination.subspan(offset, *fully_encoded)); + destination.subspan(output_offset, *fully_encoded)); if (!result.success() || result.bytes_written != *fully_encoded) { return {result.status, 0U, required}; } From 626b3e6f4ebc421cdb7cbce3457a2ae6a5873118 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 13:21:43 +0700 Subject: [PATCH 139/188] Add bounded MMS PDU core --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 15 +- embedded/mms_pdu_hard_profile_smoke.cpp | 209 +++++++++ include/ariec61850/mms/pdu_span.hpp | 95 +++++ src/mms/pdu_span.cpp | 399 ++++++++++++++++++ 5 files changed, 720 insertions(+), 4 deletions(-) create mode 100644 embedded/mms_pdu_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/pdu_span.hpp create mode 100644 src/mms/pdu_span.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index bd539a4e6..11771880b 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -53,7 +53,8 @@ jobs: build-hard-embedded/ariec61850_hard_goose_smoke \ build-hard-embedded/ariec61850_hard_osi_transport_smoke \ build-hard-embedded/ariec61850_hard_session_presentation_smoke \ - build-hard-embedded/ariec61850_hard_association_smoke; do + build-hard-embedded/ariec61850_hard_association_smoke \ + build-hard-embedded/ariec61850_hard_mms_pdu_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -72,7 +73,8 @@ jobs: build-hard-embedded/ariec61850_hard_goose_smoke \ build-hard-embedded/ariec61850_hard_osi_transport_smoke \ build-hard-embedded/ariec61850_hard_session_presentation_smoke \ - build-hard-embedded/ariec61850_hard_association_smoke + build-hard-embedded/ariec61850_hard_association_smoke \ + build-hard-embedded/ariec61850_hard_mms_pdu_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 885e3b42f..16556d1d7 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850HardEmbeddedProfile VERSION 0.1.0 - DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus and MMS association paths" + DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus and MMS server paths" LANGUAGES CXX) set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") @@ -68,6 +68,14 @@ add_executable(ariec61850_hard_association_smoke ${ARIEC61850_ROOT}/src/acse/association_span.cpp) ariec61850_configure_hard_target(ariec61850_hard_association_smoke) +add_executable(ariec61850_hard_mms_pdu_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_pdu_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_pdu_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -78,8 +86,11 @@ add_test( add_test( NAME ariec61850_hard_association_smoke COMMAND ariec61850_hard_association_smoke) +add_test( + NAME ariec61850_hard_mms_pdu_smoke + COMMAND ariec61850_hard_mms_pdu_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + TPKT/COTP + Session/Presentation + ACSE association gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU gates=ON") diff --git a/embedded/mms_pdu_hard_profile_smoke.cpp b/embedded/mms_pdu_hard_profile_smoke.cpp new file mode 100644 index 000000000..e5dc63a72 --- /dev/null +++ b/embedded/mms_pdu_hard_profile_smoke.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kInitiateRequest{ + 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 kInitiateResponse{ + 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}; + +constexpr std::array kParameterSupport{ + 0x05U, 0xF1U, 0x00U}; +constexpr std::array kServicesSupported{ + 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool valid_initiate( + const mms::MmsInitiateView& value, + const mms::MmsWirePduKind expected_kind) noexcept { + return value.kind == expected_kind && + value.maximum_mms_pdu_size == 65'000U && + value.maximum_outstanding_calling == 10U && + value.maximum_outstanding_called == 10U && + 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); +} + +} // namespace + +int main() { + mms::MmsInitiateView initiate; + if (!mms::MmsPduSpanCodec::try_decode_initiate_request_view( + kInitiateRequest, initiate) || + !valid_initiate(initiate, mms::MmsWirePduKind::initiate_request)) { + return 1; + } + + if (!mms::MmsPduSpanCodec::try_decode_initiate_response_view( + kInitiateResponse, initiate) || + !valid_initiate(initiate, mms::MmsWirePduKind::initiate_response)) { + return 2; + } + + std::array buffer{}; + const auto initiate_result = + mms::MmsPduSpanCodec::encode_default_initiate_response_into(buffer); + if (!initiate_result.success() || + initiate_result.bytes_written != kInitiateResponse.size() || + !matches( + std::span{buffer}.first( + initiate_result.bytes_written), + kInitiateResponse)) { + return 3; + } + + constexpr std::array confirmed_request_golden{ + 0xA0U, 0x05U, 0x02U, 0x01U, 0x07U, 0xA4U, 0x00U}; + const auto confirmed_request_result = + mms::MmsPduSpanCodec::encode_confirmed_request_into( + 7U, 4, true, {}, buffer); + if (!confirmed_request_result.success() || + confirmed_request_result.bytes_written != confirmed_request_golden.size() || + !matches( + std::span{buffer}.first( + confirmed_request_result.bytes_written), + confirmed_request_golden)) { + return 4; + } + + mms::MmsConfirmedPduView confirmed; + if (!mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + confirmed_request_golden, confirmed) || + confirmed.kind != mms::MmsWirePduKind::confirmed_request || + confirmed.invoke_id != 7U || confirmed.service_tag != 4 || + confirmed.service() != mms::MmsWireConfirmedService::read || + !confirmed.service_constructed || !confirmed.service_value.empty()) { + return 5; + } + + constexpr std::array confirmed_response_golden{ + 0xA1U, 0x05U, 0x02U, 0x01U, 0x07U, 0xA4U, 0x00U}; + const auto confirmed_response_result = + mms::MmsPduSpanCodec::encode_confirmed_response_into( + 7U, 4, true, {}, buffer); + if (!confirmed_response_result.success() || + confirmed_response_result.bytes_written != confirmed_response_golden.size() || + !matches( + std::span{buffer}.first( + confirmed_response_result.bytes_written), + confirmed_response_golden) || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + confirmed_response_golden, confirmed) || + confirmed.kind != mms::MmsWirePduKind::confirmed_response || + confirmed.invoke_id != 7U || confirmed.service_tag != 4) { + 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 maximum_invoke_golden{ + 0xA0U, 0x08U, 0x02U, 0x04U, + 0x7FU, 0xFFU, 0xFFU, 0xFFU, + 0xA4U, 0x00U}; + const auto maximum_result = mms::MmsPduSpanCodec::encode_confirmed_request_into( + mms::MmsPduSpanCodec::maximum_invoke_id, 4, true, {}, buffer); + if (!maximum_result.success() || + maximum_result.bytes_written != maximum_invoke_golden.size() || + !matches( + std::span{buffer}.first(maximum_result.bytes_written), + maximum_invoke_golden) || + !mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + maximum_invoke_golden, confirmed) || + confirmed.invoke_id != mms::MmsPduSpanCodec::maximum_invoke_id) { + return 7; + } + + // 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( + 128U, 4, true, {}, buffer); + if (!invoke_128.success() || invoke_128.bytes_written != invoke_128_golden.size() || + !matches( + std::span{buffer}.first(invoke_128.bytes_written), + invoke_128_golden)) { + return 8; + } + + // 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); + osi::PresentationPdvView pdv; + if (!p_data_result.success() || + !osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{p_data}.first(p_data_result.bytes_written), + pdv) || + pdv.context_id != 3U || + !mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed) || + confirmed.invoke_id != 7U || + confirmed.service() != mms::MmsWireConfirmedService::read) { + return 9; + } + + // 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( + invoke, 4, true, {}, buffer); + mms::MmsConfirmedPduView decoded; + if (!encoded.success() || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.service_tag != 4) { + return 10; + } + } + + constexpr std::array malformed_initiate{ + 0xA8U, 0x03U, 0x80U, 0x01U, 0x01U}; + if (mms::MmsPduSpanCodec::try_decode_initiate_request_view( + malformed_initiate, initiate)) { + return 11; + } + + return 0; +} diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp new file mode 100644 index 000000000..5617c756a --- /dev/null +++ b/include/ariec61850/mms/pdu_span.hpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsWirePduKind : std::uint8_t { + unknown, + confirmed_request, + confirmed_response, + initiate_request, + initiate_response, +}; + +enum class MmsWireConfirmedService : std::int32_t { + unknown = -1, + get_name_list = 1, + identify = 2, + read = 4, + write = 5, + get_variable_access_attributes = 6, + get_named_variable_list_attributes = 12, +}; + +struct MmsInitiateDetailView final { + std::uint32_t version_number{}; + std::span parameter_support_options{}; + std::span services_supported_calling{}; +}; + +struct MmsInitiateView final { + MmsWirePduKind kind{MmsWirePduKind::unknown}; + 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{}; + MmsInitiateDetailView detail{}; +}; + +struct MmsConfirmedPduView final { + MmsWirePduKind kind{MmsWirePduKind::unknown}; + std::uint32_t invoke_id{}; + std::int32_t service_tag{-1}; + bool service_constructed{}; + std::span service_value{}; + + [[nodiscard]] MmsWireConfirmedService service() const noexcept; +}; + +class MmsPduSpanCodec final { +public: + static constexpr std::size_t maximum_pdu_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_bit_string_bytes = 256U; + static constexpr std::uint32_t maximum_invoke_id = 0x7FFF'FFFFU; + + [[nodiscard]] static bool try_decode_initiate_request_view( + std::span bytes, + MmsInitiateView& request) noexcept; + + [[nodiscard]] static bool try_decode_initiate_response_view( + std::span bytes, + MmsInitiateView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_default_initiate_response_into( + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_confirmed_request_view( + std::span bytes, + MmsConfirmedPduView& request) noexcept; + + [[nodiscard]] static bool try_decode_confirmed_response_view( + std::span bytes, + MmsConfirmedPduView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_request_into( + std::uint32_t invoke_id, + std::int32_t service_tag, + bool service_constructed, + std::span service_value, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_response_into( + std::uint32_t invoke_id, + std::int32_t service_tag, + bool service_constructed, + std::span service_value, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp new file mode 100644 index 000000000..e0236d03c --- /dev/null +++ b/src/mms/pdu_span.cpp @@ -0,0 +1,399 @@ +// 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 +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kDefaultInitiateResponse{ + 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}; + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_detail( + const asn1::BerTlvView& field, + MmsInitiateDetailView& detail) noexcept { + detail = {}; + if (field.tag_class != asn1::BerClass::context_specific || + field.tag_number != 4 || !field.constructed) { + return false; + } + + std::array seen{}; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < field.value.size()) { + if (count >= 3U) { + detail = {}; + return false; + } + ++count; + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(field.value, offset, child) || + child.tag_class != asn1::BerClass::context_specific || + child.constructed || child.tag_number < 0 || child.tag_number > 2) { + detail = {}; + return false; + } + const auto index = static_cast(child.tag_number); + if (seen[index]) { + detail = {}; + return false; + } + seen[index] = true; + + if (index == 0U) { + if (!read_u32(child, detail.version_number)) { + detail = {}; + return false; + } + } else if (index == 1U) { + if (child.value.empty() || child.value.size() > + MmsPduSpanCodec::maximum_bit_string_bytes) { + detail = {}; + return false; + } + detail.parameter_support_options = child.value; + } else { + if (child.value.empty() || child.value.size() > + MmsPduSpanCodec::maximum_bit_string_bytes) { + detail = {}; + return false; + } + detail.services_supported_calling = child.value; + } + } + return count == 3U && seen[0] && seen[1] && seen[2]; +} + +[[nodiscard]] bool decode_initiate( + const std::span bytes, + const std::int32_t expected_tag, + const MmsWirePduKind kind, + MmsInitiateView& result) noexcept { + result = {}; + if (bytes.empty() || bytes.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != expected_tag || !outer.constructed) { + return false; + } + + std::array seen{}; + std::array values{}; + MmsInitiateDetailView detail; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + if (count >= 5U) { + result = {}; + return false; + } + ++count; + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, child) || + child.tag_class != asn1::BerClass::context_specific || + child.tag_number < 0 || child.tag_number > 4) { + result = {}; + return false; + } + const auto index = static_cast(child.tag_number); + if (seen[index]) { + result = {}; + return false; + } + seen[index] = true; + + if (index < 4U) { + if (child.constructed || !read_u32(child, values[index])) { + result = {}; + return false; + } + } else if (!decode_detail(child, detail)) { + result = {}; + return false; + } + } + + 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) { + result = {}; + return false; + } + + result.kind = kind; + result.maximum_mms_pdu_size = values[0]; + result.maximum_outstanding_calling = values[1]; + result.maximum_outstanding_called = values[2]; + result.data_structure_nesting_level = values[3]; + result.detail = detail; + return true; +} + +[[nodiscard]] bool decode_invoke( + const asn1::BerTlvView& tlv, + std::uint32_t& invoke_id) noexcept { + const bool universal_integer = + tlv.tag_class == asn1::BerClass::universal && + tlv.tag_number == 2 && !tlv.constructed; + const bool implicit_invoke = + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 0 && !tlv.constructed; + if ((!universal_integer && !implicit_invoke) || !read_u32(tlv, invoke_id) || + invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + invoke_id = 0U; + return false; + } + return true; +} + +[[nodiscard]] bool decode_confirmed( + const std::span bytes, + const std::int32_t expected_outer_tag, + const MmsWirePduKind kind, + MmsConfirmedPduView& result) noexcept { + result = {}; + if (bytes.empty() || bytes.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != expected_outer_tag || !outer.constructed) { + return false; + } + + asn1::BerTlvView first; + asn1::BerTlvView last; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + if (count >= 4U) { + result = {}; + return false; + } + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, child)) { + result = {}; + return false; + } + if (count == 0U) { + first = child; + } + last = child; + ++count; + } + + if (count < 2U || !decode_invoke(first, result.invoke_id) || + last.tag_class != asn1::BerClass::context_specific || + last.tag_number < 0) { + result = {}; + return false; + } + + result.kind = kind; + result.service_tag = last.tag_number; + result.service_constructed = last.constructed; + result.service_value = last.value; + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] wire::EncodeResult encode_confirmed( + const std::int32_t outer_tag, + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + service_tag < 0 || service_tag > 1'000'000 || + service_value.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + 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(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size( + service_tag, service_value.size()); + if (!invoke_tlv || !service_tlv || + *service_tlv > std::numeric_limits::max() - *invoke_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content = *invoke_tlv + *service_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(outer_tag, 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, outer_tag, content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv( + asn1::BerClass::context_specific, + service_constructed, + service_tag, + service_value) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace + +MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { + switch (service_tag) { + case 1: return MmsWireConfirmedService::get_name_list; + case 2: return MmsWireConfirmedService::identify; + case 4: return MmsWireConfirmedService::read; + case 5: return MmsWireConfirmedService::write; + case 6: return MmsWireConfirmedService::get_variable_access_attributes; + case 12: return MmsWireConfirmedService::get_named_variable_list_attributes; + default: return MmsWireConfirmedService::unknown; + } +} + +bool MmsPduSpanCodec::try_decode_initiate_request_view( + const std::span bytes, + MmsInitiateView& request) noexcept { + return decode_initiate(bytes, 8, MmsWirePduKind::initiate_request, request); +} + +bool MmsPduSpanCodec::try_decode_initiate_response_view( + const std::span bytes, + MmsInitiateView& response) noexcept { + return decode_initiate(bytes, 9, MmsWirePduKind::initiate_response, response); +} + +wire::EncodeResult MmsPduSpanCodec::encode_default_initiate_response_into( + const std::span destination) noexcept { + if (destination.size() < kDefaultInitiateResponse.size()) { + return { + wire::EncodeStatus::buffer_too_small, + 0U, + kDefaultInitiateResponse.size()}; + } + std::copy( + kDefaultInitiateResponse.begin(), + kDefaultInitiateResponse.end(), + destination.begin()); + return { + wire::EncodeStatus::ok, + kDefaultInitiateResponse.size(), + kDefaultInitiateResponse.size()}; +} + +bool MmsPduSpanCodec::try_decode_confirmed_request_view( + const std::span bytes, + MmsConfirmedPduView& request) noexcept { + return decode_confirmed(bytes, 0, MmsWirePduKind::confirmed_request, request); +} + +bool MmsPduSpanCodec::try_decode_confirmed_response_view( + const std::span bytes, + MmsConfirmedPduView& response) noexcept { + return decode_confirmed(bytes, 1, MmsWirePduKind::confirmed_response, response); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_request_into( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + return encode_confirmed( + 0, invoke_id, service_tag, service_constructed, service_value, destination); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_response_into( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + return encode_confirmed( + 1, invoke_id, service_tag, service_constructed, service_value, destination); +} + +} // namespace ar::iec61850::mms From 37eba5fc15f37b3f715e5a9a09c1a8183d0eb095 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 13:31:02 +0700 Subject: [PATCH 140/188] Add bounded MMS GetNameList service --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 14 +- .../mms_get_name_list_hard_profile_smoke.cpp | 162 +++++++ include/ariec61850/mms/services_span.hpp | 73 ++++ src/mms/services_span.cpp | 411 ++++++++++++++++++ 5 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 embedded/mms_get_name_list_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/services_span.hpp create mode 100644 src/mms/services_span.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index 11771880b..e10d70897 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -54,7 +54,8 @@ jobs: build-hard-embedded/ariec61850_hard_osi_transport_smoke \ build-hard-embedded/ariec61850_hard_session_presentation_smoke \ build-hard-embedded/ariec61850_hard_association_smoke \ - build-hard-embedded/ariec61850_hard_mms_pdu_smoke; do + build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ + build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -74,7 +75,8 @@ jobs: build-hard-embedded/ariec61850_hard_osi_transport_smoke \ build-hard-embedded/ariec61850_hard_session_presentation_smoke \ build-hard-embedded/ariec61850_hard_association_smoke \ - build-hard-embedded/ariec61850_hard_mms_pdu_smoke + build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ + build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 16556d1d7..684b202ba 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -76,6 +76,15 @@ add_executable(ariec61850_hard_mms_pdu_smoke ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_pdu_smoke) +add_executable(ariec61850_hard_mms_get_name_list_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_get_name_list_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_get_name_list_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -89,8 +98,11 @@ add_test( add_test( NAME ariec61850_hard_mms_pdu_smoke COMMAND ariec61850_hard_mms_pdu_smoke) +add_test( + NAME ariec61850_hard_mms_get_name_list_smoke + COMMAND ariec61850_hard_mms_get_name_list_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU/GetNameList gates=ON") diff --git a/embedded/mms_get_name_list_hard_profile_smoke.cpp b/embedded/mms_get_name_list_hard_profile_smoke.cpp new file mode 100644 index 000000000..61c7c5e7d --- /dev/null +++ b/embedded/mms_get_name_list_hard_profile_smoke.cpp @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kDomainListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U}; + +constexpr std::array kDomainScopedRequest{ + 0xA0U, 0x14U, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0FU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x00U, + 0xA1U, 0x05U, 0x81U, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x82U, 0x01U, 0x58U}; + +constexpr std::array kResponse{ + 0xA1U, 0x14U, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x0FU, + 0xA0U, 0x0AU, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x31U, + 0x81U, 0x01U, 0x00U}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kLd1{0x4CU, 0x44U, 0x31U}; +constexpr std::array kContinuation{0x58U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +} // namespace + +int main() { + mms::MmsGetNameListRequestView request; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + kDomainListRequest, request) || + request.invoke_id != 7U || + request.object_class != mms::MmsNameListObjectClass::domain || + request.scope != mms::MmsNameScopeKind::vmd_specific || + !request.domain_id.empty() || !request.continue_after.empty()) { + return 1; + } + + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + kDomainScopedRequest, request) || + request.invoke_id != 8U || + request.object_class != mms::MmsNameListObjectClass::named_variable || + request.scope != mms::MmsNameScopeKind::domain_specific || + !matches(request.domain_id, kLd0) || + !matches(request.continue_after, kContinuation)) { + return 2; + } + + const std::array names{"LD0", "LD1"}; + std::array buffer{}; + const auto encoded = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 7U, names, false, buffer); + if (!encoded.success() || encoded.bytes_written != kResponse.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kResponse)) { + return 3; + } + + mms::MmsGetNameListResponseView response; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + kResponse, response) || + response.invoke_id != 7U || response.identifier_count != 2U || + response.more_follows) { + return 4; + } + + std::span identifier; + if (!response.try_identifier(0U, identifier) || !matches(identifier, kLd0) || + !response.try_identifier(1U, identifier) || !matches(identifier, kLd1) || + response.try_identifier(2U, identifier)) { + return 5; + } + + // Post-association application flow: response PDU -> Presentation P-DATA -> + // zero-copy Presentation view -> zero-copy GetNameList response view. + std::array p_data{}; + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + kResponse, p_data, 3U, true); + osi::PresentationPdvView pdv; + if (!p_data_result.success() || + !osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{p_data}.first(p_data_result.bytes_written), + pdv) || + pdv.context_id != 3U || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + pdv.single_asn1_type, response) || + response.identifier_count != 2U) { + return 6; + } + + // Empty lists are valid MMS responses; pagination is expressed separately + // by moreFollows. + const std::span empty_names; + const auto empty = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 9U, empty_names, true, buffer); + if (!empty.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(empty.bytes_written), + response) || + response.identifier_count != 0U || !response.more_follows) { + return 7; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto repeated = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + invoke, names, (iteration & 1U) != 0U, buffer); + mms::MmsGetNameListResponseView decoded; + if (!repeated.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(repeated.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.identifier_count != 2U || + decoded.more_follows != ((iteration & 1U) != 0U)) { + return 8; + } + } + + constexpr std::array malformed_scope{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x01U}; + if (mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + malformed_scope, request)) { + return 9; + } + + const std::array invalid_name{ + std::string_view{"A\0B", 3U}}; + if (mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 1U, invalid_name, false, buffer).success()) { + return 10; + } + + return 0; +} diff --git a/include/ariec61850/mms/services_span.hpp b/include/ariec61850/mms/services_span.hpp new file mode 100644 index 000000000..a8e656519 --- /dev/null +++ b/include/ariec61850/mms/services_span.hpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsNameListObjectClass : std::uint32_t { + named_variable = 0U, + named_variable_list = 2U, + domain = 9U, +}; + +enum class MmsNameScopeKind : std::uint8_t { + vmd_specific, + domain_specific, + aa_specific, +}; + +struct MmsGetNameListRequestView final { + std::uint32_t invoke_id{}; + MmsNameListObjectClass object_class{MmsNameListObjectClass::domain}; + MmsNameScopeKind scope{MmsNameScopeKind::vmd_specific}; + std::span domain_id{}; + std::span continue_after{}; +}; + +struct MmsGetNameListResponseView final { + std::uint32_t invoke_id{}; + std::span identifier_list{}; + std::size_t identifier_count{}; + bool more_follows{}; + + [[nodiscard]] bool try_identifier( + std::size_t index, + std::span& identifier) const noexcept; +}; + +class MmsServiceSpanCodec final { +public: + static constexpr std::size_t maximum_identifier_bytes = 1'024U; + static constexpr std::size_t maximum_identifiers = 128U; + + [[nodiscard]] static bool try_decode_get_name_list_request( + const MmsConfirmedPduView& confirmed, + MmsGetNameListRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_name_list_request( + std::span mms_pdu, + MmsGetNameListRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_name_list_response( + const MmsConfirmedPduView& confirmed, + MmsGetNameListResponseView& response) noexcept; + + [[nodiscard]] static bool try_decode_get_name_list_response( + std::span mms_pdu, + MmsGetNameListResponseView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_get_name_list_response_into( + std::uint32_t invoke_id, + std::span names, + bool more_follows, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/services_span.cpp b/src/mms/services_span.cpp new file mode 100644 index 000000000..6a430ab18 --- /dev/null +++ b/src/mms/services_span.cpp @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.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 { + +[[nodiscard]] bool visible_ascii( + const std::span value, + const bool allow_empty = false) noexcept { + if ((!allow_empty && value.empty()) || + value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto byte : value) { + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool visible_ascii( + const std::string_view value, + const bool allow_empty = false) noexcept { + if ((!allow_empty && value.empty()) || + value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto character : value) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool valid_identifier_tlv( + const asn1::BerTlvView& tlv) noexcept { + return !tlv.constructed && + tlv.tag_class == asn1::BerClass::universal && + (tlv.tag_number == 22 || tlv.tag_number == 26) && + visible_ascii(tlv.value); +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +} // namespace + +bool MmsGetNameListResponseView::try_identifier( + const std::size_t index, + std::span& identifier) const noexcept { + identifier = {}; + if (index >= identifier_count) { + return false; + } + + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < identifier_list.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(identifier_list, offset, item) || + !valid_identifier_tlv(item)) { + return false; + } + if (current == index) { + identifier = item.value; + return true; + } + ++current; + } + return false; +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_request( + const MmsConfirmedPduView& confirmed, + MmsGetNameListRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 1 || !confirmed.service_constructed) { + return false; + } + + bool have_class = false; + bool have_scope = false; + bool have_continuation = false; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + if (count >= 3U) { + request = {}; + return false; + } + ++count; + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + request = {}; + return false; + } + + if (field.tag_number == 0 && field.constructed) { + if (have_class) { + request = {}; + return false; + } + asn1::BerTlvView inner; + std::uint32_t value{}; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::context_specific || + inner.tag_number != 0 || inner.constructed || + !read_u32(inner, value) || + (value != 0U && value != 2U && value != 9U)) { + request = {}; + return false; + } + request.object_class = static_cast(value); + have_class = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_scope) { + request = {}; + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::context_specific || + inner.constructed) { + request = {}; + return false; + } + switch (inner.tag_number) { + case 0: + if (!inner.value.empty()) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::vmd_specific; + break; + case 1: + if (!visible_ascii(inner.value)) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::domain_specific; + request.domain_id = inner.value; + break; + case 2: + if (!inner.value.empty()) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::aa_specific; + break; + default: + request = {}; + return false; + } + have_scope = true; + } else if (field.tag_number == 2 && !field.constructed) { + if (have_continuation || !visible_ascii(field.value)) { + request = {}; + return false; + } + request.continue_after = field.value; + have_continuation = true; + } else { + request = {}; + return false; + } + } + + if (!have_class || !have_scope) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_request( + const std::span mms_pdu, + MmsGetNameListRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_get_name_list_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_response( + const MmsConfirmedPduView& confirmed, + MmsGetNameListResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 1 || !confirmed.service_constructed) { + return false; + } + + bool have_list = false; + bool have_more = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + + if (field.tag_number == 0 && field.constructed) { + if (have_list) { + response = {}; + return false; + } + std::size_t name_offset = 0U; + std::size_t name_count = 0U; + while (name_offset < field.value.size()) { + if (name_count >= maximum_identifiers) { + response = {}; + return false; + } + asn1::BerTlvView name; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, name_offset, name) || + !valid_identifier_tlv(name)) { + response = {}; + return false; + } + ++name_count; + } + response.identifier_list = field.value; + response.identifier_count = name_count; + have_list = true; + } else if (field.tag_number == 1 && !field.constructed) { + if (have_more || field.value.size() != 1U) { + response = {}; + return false; + } + response.more_follows = field.value[0] != 0U; + have_more = true; + } else { + response = {}; + return false; + } + } + + if (!have_list) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_response( + const std::span mms_pdu, + MmsGetNameListResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_get_name_list_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_get_name_list_response_into( + const std::uint32_t invoke_id, + const std::span names, + const bool more_follows, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + names.size() > maximum_identifiers) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t names_content = 0U; + for (const auto name : names) { + if (!visible_ascii(name)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto encoded = asn1::BerSpanWriter::tlv_size(26, name.size()); + if (!encoded || *encoded > std::numeric_limits::max() - names_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + names_content += *encoded; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(0, names_content); + const auto more_tlv = asn1::BerSpanWriter::tlv_size(1, 1U); + const auto service_content = add_size(list_tlv, more_tlv); + if (!service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto service_tlv = asn1::BerSpanWriter::tlv_size(1, *service_content); + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_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, 1, *outer_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, names_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + for (const auto name : names) { + if (!writer.write_tlv_header( + asn1::BerClass::universal, false, 26, name.size())) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + for (const auto character : name) { + if (!writer.write_byte(static_cast( + static_cast(character)))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 1U) || + !writer.write_byte(more_follows ? 0xFFU : 0x00U) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::mms From 2eadc466258df6c33c6591bd5ed4d34c14d73dc3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 14:54:44 +0700 Subject: [PATCH 141/188] Add bounded MMS static object read model --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 13 +- .../mms_static_model_hard_profile_smoke.cpp | 253 ++++++++ include/ariec61850/mms/services_span.hpp | 105 ++++ .../ariec61850/mms/static_object_table.hpp | 53 ++ src/mms/services_span.cpp | 562 +++++++++++++++++- src/mms/static_object_table.cpp | 111 ++++ 7 files changed, 1099 insertions(+), 4 deletions(-) create mode 100644 embedded/mms_static_model_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/static_object_table.hpp create mode 100644 src/mms/static_object_table.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index e10d70897..b48b101ec 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -55,7 +55,8 @@ jobs: build-hard-embedded/ariec61850_hard_session_presentation_smoke \ build-hard-embedded/ariec61850_hard_association_smoke \ build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ - build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke; do + build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ + build-hard-embedded/ariec61850_hard_mms_static_model_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -76,7 +77,8 @@ jobs: build-hard-embedded/ariec61850_hard_session_presentation_smoke \ build-hard-embedded/ariec61850_hard_association_smoke \ build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ - build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke + build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ + build-hard-embedded/ariec61850_hard_mms_static_model_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 684b202ba..25a30960d 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -85,6 +85,14 @@ add_executable(ariec61850_hard_mms_get_name_list_smoke ${ARIEC61850_ROOT}/src/mms/services_span.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_get_name_list_smoke) +add_executable(ariec61850_hard_mms_static_model_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_static_model_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/static_object_table.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_static_model_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -101,8 +109,11 @@ add_test( add_test( NAME ariec61850_hard_mms_get_name_list_smoke COMMAND ariec61850_hard_mms_get_name_list_smoke) +add_test( + NAME ariec61850_hard_mms_static_model_smoke + COMMAND ariec61850_hard_mms_static_model_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU/GetNameList gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU/GetNameList/GetVariableAccessAttributes/Read gates=ON") diff --git a/embedded/mms_static_model_hard_profile_smoke.cpp b/embedded/mms_static_model_hard_profile_smoke.cpp new file mode 100644 index 000000000..f5ccac663 --- /dev/null +++ b/embedded/mms_static_model_hard_profile_smoke.cpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +constexpr std::array kAttributesResponse{ + 0xA1U, 0x0CU, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x07U, 0x80U, 0x01U, 0x00U, + 0xA2U, 0x02U, 0x83U, 0x00U}; + +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}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kBooleanData{0x83U, 0x01U, 0xFFU}; +constexpr std::array kIntData{0x85U, 0x01U, 0x2AU}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[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}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + 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}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + const bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) { + return 1; + } + + mms::MmsVariableAccessAttributesRequestView attributes_request; + if (!mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + kAttributesRequest, attributes_request) || + attributes_request.invoke_id != 11U || + attributes_request.name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(attributes_request.name.domain, kLd0) || + !matches(attributes_request.name.item, kR1)) { + return 2; + } + const auto* relay = table.find(attributes_request.name); + if (relay == nullptr || relay->type_specification.data() != kBooleanType.data()) { + return 3; + } + + std::array buffer{}; + const auto attributes_encoded = + mms::MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + attributes_request.invoke_id, + relay->mms_deletable, + relay->type_specification, + buffer); + if (!attributes_encoded.success() || + attributes_encoded.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(attributes_encoded.bytes_written), + kAttributesResponse)) { + return 4; + } + + mms::MmsVariableAccessAttributesResponseView attributes_response; + if (!mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + kAttributesResponse, attributes_response) || + attributes_response.invoke_id != 11U || attributes_response.mms_deletable || + !matches(attributes_response.type_specification, kBooleanType)) { + return 5; + } + + mms::MmsReadRequestView read_request; + if (!mms::MmsServiceSpanCodec::try_decode_read_request(kReadRequest, read_request) || + read_request.invoke_id != 12U || read_request.specification_with_result || + read_request.variable_count != 2U) { + return 6; + } + + std::array resolved{}; + std::size_t resolved_count = 0U; + if (!table.try_resolve_read_request(read_request, resolved, resolved_count) || + resolved_count != 2U || resolved[0] != &objects[0] || resolved[1] != &objects[1]) { + return 7; + } + + mms::MmsObjectNameView variable; + if (!read_request.try_variable(0U, variable) || !matches(variable.item, kR1) || + !read_request.try_variable(1U, variable) || !matches(variable.item, kM1) || + read_request.try_variable(2U, variable)) { + return 8; + } + + std::array relay_data{}; + std::array meter_data{}; + const auto relay_read = resolved[0]->read(resolved[0]->context, relay_data); + const auto meter_read = resolved[1]->read(resolved[1]->context, meter_data); + if (!relay_read.success() || !meter_read.success() || + !matches( + std::span{relay_data}.first(relay_read.bytes_written), + kBooleanData) || + !matches( + std::span{meter_data}.first(meter_read.bytes_written), + kIntData)) { + return 9; + } + + const std::array results{ + mms::MmsReadAccessResultInput{ + true, + std::span{relay_data}.first(relay_read.bytes_written), + 0U}, + mms::MmsReadAccessResultInput{ + true, + std::span{meter_data}.first(meter_read.bytes_written), + 0U}}; + const auto read_encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + read_request.invoke_id, results, buffer); + if (!read_encoded.success() || read_encoded.bytes_written != kReadResponse.size() || + !matches( + std::span{buffer}.first(read_encoded.bytes_written), + kReadResponse)) { + return 10; + } + + mms::MmsReadResponseView read_response; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + kReadResponse, read_response) || + read_response.invoke_id != 12U || read_response.result_count != 2U) { + return 11; + } + mms::MmsReadAccessResultView access; + if (!read_response.try_result(0U, access) || !access.success || + !matches(access.encoded_data, kBooleanData) || + !read_response.try_result(1U, access) || !access.success || + !matches(access.encoded_data, kIntData) || + read_response.try_result(2U, access)) { + return 12; + } + + // Failure results remain bounded and explicit; no exception or owning object + // graph is required to return an MMS DataAccessError. + const std::array failed{ + mms::MmsReadAccessResultInput{false, {}, 10U}}; + const auto failed_encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + 13U, failed, buffer); + if (!failed_encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{buffer}.first(failed_encoded.bytes_written), + read_response) || + read_response.result_count != 1U || + !read_response.try_result(0U, access) || access.success || access.failure_code != 10U) { + return 13; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + invoke, results, buffer); + mms::MmsReadResponseView decoded; + if (!encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.result_count != 2U) { + return 14; + } + } + + const std::array duplicates{ + objects[0], objects[0]}; + if (mms::MmsStaticObjectTable{duplicates}.valid()) { + return 15; + } + + return 0; +} diff --git a/include/ariec61850/mms/services_span.hpp b/include/ariec61850/mms/services_span.hpp index a8e656519..0899534fc 100644 --- a/include/ariec61850/mms/services_span.hpp +++ b/include/ariec61850/mms/services_span.hpp @@ -23,6 +23,18 @@ enum class MmsNameScopeKind : std::uint8_t { aa_specific, }; +enum class MmsObjectNameViewKind : std::uint8_t { + vmd_specific, + domain_specific, + aa_specific, +}; + +struct MmsObjectNameView final { + MmsObjectNameViewKind kind{MmsObjectNameViewKind::domain_specific}; + std::span domain{}; + std::span item{}; +}; + struct MmsGetNameListRequestView final { std::uint32_t invoke_id{}; MmsNameListObjectClass object_class{MmsNameListObjectClass::domain}; @@ -42,10 +54,59 @@ struct MmsGetNameListResponseView final { std::span& identifier) const noexcept; }; +struct MmsVariableAccessAttributesRequestView final { + std::uint32_t invoke_id{}; + MmsObjectNameView name{}; +}; + +struct MmsVariableAccessAttributesResponseView final { + std::uint32_t invoke_id{}; + bool mms_deletable{}; + std::span type_specification{}; +}; + +struct MmsReadRequestView final { + std::uint32_t invoke_id{}; + bool specification_with_result{}; + std::span variable_list{}; + std::size_t variable_count{}; + + [[nodiscard]] bool try_variable( + std::size_t index, + MmsObjectNameView& name) const noexcept; +}; + +struct MmsReadAccessResultView final { + bool success{}; + std::span encoded_data{}; + std::uint32_t failure_code{}; +}; + +struct MmsReadResponseView final { + std::uint32_t invoke_id{}; + std::span access_result_list{}; + std::size_t result_count{}; + + [[nodiscard]] bool try_result( + std::size_t index, + MmsReadAccessResultView& result) const noexcept; +}; + +struct MmsReadAccessResultInput final { + bool success{}; + std::span encoded_data{}; + std::uint32_t failure_code{}; +}; + class MmsServiceSpanCodec final { public: static constexpr std::size_t maximum_identifier_bytes = 1'024U; static constexpr std::size_t maximum_identifiers = 128U; + static constexpr std::size_t maximum_variables = 64U; + + [[nodiscard]] static bool try_decode_object_name_view( + std::span encoded_object_name, + MmsObjectNameView& name) noexcept; [[nodiscard]] static bool try_decode_get_name_list_request( const MmsConfirmedPduView& confirmed, @@ -68,6 +129,50 @@ class MmsServiceSpanCodec final { std::span names, bool more_follows, std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_variable_access_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_variable_access_attributes_request( + std::span mms_pdu, + MmsVariableAccessAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_variable_access_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesResponseView& response) noexcept; + + [[nodiscard]] static bool try_decode_variable_access_attributes_response( + std::span mms_pdu, + MmsVariableAccessAttributesResponseView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult + encode_variable_access_attributes_response_into( + std::uint32_t invoke_id, + bool mms_deletable, + std::span encoded_type_specification, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_read_request( + const MmsConfirmedPduView& confirmed, + MmsReadRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_read_request( + std::span mms_pdu, + MmsReadRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_read_response( + const MmsConfirmedPduView& confirmed, + MmsReadResponseView& response) noexcept; + + [[nodiscard]] static bool try_decode_read_response( + std::span mms_pdu, + MmsReadResponseView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_read_response_into( + std::uint32_t invoke_id, + std::span results, + std::span destination) noexcept; }; } // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_object_table.hpp b/include/ariec61850/mms/static_object_table.hpp new file mode 100644 index 000000000..6c4d50151 --- /dev/null +++ b/include/ariec61850/mms/static_object_table.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +using MmsStaticReadCallback = wire::EncodeResult (*)( + const void* context, + std::span destination) noexcept; + +struct MmsStaticObjectEntry final { + std::string_view domain; + std::string_view item; + std::span type_specification; + MmsStaticReadCallback read{}; + const void* context{}; + bool mms_deletable{}; +}; + +class MmsStaticObjectTable final { +public: + static constexpr std::size_t maximum_objects = 128U; + + explicit constexpr MmsStaticObjectTable( + const std::span objects) noexcept + : objects_{objects} {} + + [[nodiscard]] bool valid() const noexcept; + + [[nodiscard]] const MmsStaticObjectEntry* find( + const MmsObjectNameView& name) const noexcept; + + [[nodiscard]] bool try_resolve_read_request( + const MmsReadRequestView& request, + std::span resolved, + std::size_t& resolved_count) const noexcept; + + [[nodiscard]] constexpr std::span objects() const noexcept { + return objects_; + } + +private: + std::span objects_; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/services_span.cpp b/src/mms/services_span.cpp index 6a430ab18..e68152280 100644 --- a/src/mms/services_span.cpp +++ b/src/mms/services_span.cpp @@ -67,6 +67,17 @@ namespace { visible_ascii(tlv.value); } +[[nodiscard]] bool valid_mms_data_tlv( + const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + [[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { std::size_t bytes = 1U; while (value > 0xFFU) { @@ -114,8 +125,163 @@ namespace { return *left + *right; } +struct ConfirmedResponseSizing final { + std::size_t invoke_value{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional confirmed_response_sizing( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const std::size_t service_content) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + service_tag < 0 || service_tag > 1'000'000) { + return std::nullopt; + } + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(service_tag, service_content); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + return ConfirmedResponseSizing{invoke_value, *outer_content, *required}; +} + +[[nodiscard]] bool write_confirmed_response_prefix( + asn1::BerSpanWriter& writer, + const ConfirmedResponseSizing& sizing, + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const std::size_t service_content) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing.outer_content) && + writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing.invoke_value) && + write_positive_integer(writer, invoke_id) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, service_tag, service_content); +} + +[[nodiscard]] bool try_read_variable_definition( + const std::span variable_list, + const std::size_t wanted, + MmsObjectNameView& name) noexcept { + name = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(definition.value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed) { + return false; + } + MmsObjectNameView decoded; + if (!MmsServiceSpanCodec::try_decode_object_name_view(wrapper.value, decoded)) { + return false; + } + if (current == wanted) { + name = decoded; + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_access_result( + const std::span list, + const std::size_t wanted, + MmsReadAccessResultView& result) noexcept { + result = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < list.size()) { + const auto start = offset; + asn1::BerTlvView access; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, access)) { + return false; + } + MmsReadAccessResultView decoded; + if (access.tag_class == asn1::BerClass::context_specific && + access.tag_number == 0 && !access.constructed) { + if (!read_u32(access, decoded.failure_code)) { + return false; + } + decoded.success = false; + } else if (valid_mms_data_tlv(access)) { + decoded.success = true; + decoded.encoded_data = list.subspan(start, offset - start); + } else { + return false; + } + if (current == wanted) { + result = decoded; + return true; + } + ++current; + } + return false; +} + } // namespace +bool MmsServiceSpanCodec::try_decode_object_name_view( + const std::span encoded_object_name, + MmsObjectNameView& name) noexcept { + name = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(encoded_object_name, outer) || + outer.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (outer.tag_number == 0 && !outer.constructed) { + if (!visible_ascii(outer.value)) { + return false; + } + name.kind = MmsObjectNameViewKind::vmd_specific; + name.item = outer.value; + return true; + } + if (outer.tag_number == 2 && !outer.constructed) { + if (!visible_ascii(outer.value)) { + return false; + } + name.kind = MmsObjectNameViewKind::aa_specific; + name.item = outer.value; + return true; + } + if (outer.tag_number != 1 || !outer.constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView domain; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, domain) || + !asn1::BerSpanReader::try_read_tlv(outer.value, offset, item) || + offset != outer.value.size() || + !valid_identifier_tlv(domain) || !valid_identifier_tlv(item)) { + return false; + } + name.kind = MmsObjectNameViewKind::domain_specific; + name.domain = domain.value; + name.item = item.value; + return true; +} + bool MmsGetNameListResponseView::try_identifier( const std::size_t index, std::span& identifier) const noexcept { @@ -123,7 +289,6 @@ bool MmsGetNameListResponseView::try_identifier( if (index >= identifier_count) { return false; } - std::size_t offset = 0U; std::size_t current = 0U; while (offset < identifier_list.size()) { @@ -141,6 +306,26 @@ bool MmsGetNameListResponseView::try_identifier( return false; } +bool MmsReadRequestView::try_variable( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + if (index >= variable_count) { + name = {}; + return false; + } + return try_read_variable_definition(variable_list, index, name); +} + +bool MmsReadResponseView::try_result( + const std::size_t index, + MmsReadAccessResultView& result) const noexcept { + if (index >= result_count) { + result = {}; + return false; + } + return try_read_access_result(access_result_list, index, result); +} + bool MmsServiceSpanCodec::try_decode_get_name_list_request( const MmsConfirmedPduView& confirmed, MmsGetNameListRequestView& request) noexcept { @@ -408,4 +593,379 @@ wire::EncodeResult MmsServiceSpanCodec::encode_get_name_list_response_into( return {wire::EncodeStatus::ok, *required, *required}; } +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 6 || !confirmed.service_constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(confirmed.service_value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed || + !try_decode_object_name_view(wrapper.value, request.name)) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + const std::span mms_pdu, + MmsVariableAccessAttributesRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_variable_access_attributes_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 6 || !confirmed.service_constructed) { + return false; + } + bool have_deletable = false; + bool have_type = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + if (field.tag_number == 0 && !field.constructed) { + if (have_deletable || field.value.size() != 1U) { + response = {}; + return false; + } + response.mms_deletable = field.value[0] != 0U; + have_deletable = true; + } else if (field.tag_number == 2 && field.constructed) { + if (have_type) { + response = {}; + return false; + } + asn1::BerTlvView type; + if (!asn1::BerSpanReader::try_read_exact(field.value, type) || + type.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + response.type_specification = field.value; + have_type = true; + } else { + response = {}; + return false; + } + } + if (!have_type) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + const std::span mms_pdu, + MmsVariableAccessAttributesResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_variable_access_attributes_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + const std::uint32_t invoke_id, + const bool mms_deletable, + const std::span encoded_type_specification, + const std::span destination) noexcept { + asn1::BerTlvView type; + if (!asn1::BerSpanReader::try_read_exact(encoded_type_specification, type) || + type.tag_class != asn1::BerClass::context_specific) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto deletable_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto type_wrapper = asn1::BerSpanWriter::tlv_size( + 2, encoded_type_specification.size()); + const auto service_content = add_size(deletable_tlv, type_wrapper); + if (!service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto sizing = confirmed_response_sizing(invoke_id, 6, *service_content); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, 6, *service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(mms_deletable ? 0xFFU : 0x00U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, + encoded_type_specification.size()) || + !writer.write_bytes(encoded_type_specification) || + writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +bool MmsServiceSpanCodec::try_decode_read_request( + const MmsConfirmedPduView& confirmed, + MmsReadRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 4 || !confirmed.service_constructed) { + return false; + } + + bool have_flag = false; + bool have_specification = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + request = {}; + return false; + } + if (field.tag_number == 0 && !field.constructed) { + if (have_flag || field.value.size() != 1U) { + request = {}; + return false; + } + request.specification_with_result = field.value[0] != 0U; + have_flag = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_specification) { + request = {}; + return false; + } + asn1::BerTlvView list; + if (!asn1::BerSpanReader::try_read_exact(field.value, list) || + list.tag_class != asn1::BerClass::context_specific || + list.tag_number != 0 || !list.constructed) { + request = {}; + return false; + } + std::size_t variable_offset = 0U; + std::size_t count = 0U; + while (variable_offset < list.value.size()) { + if (count >= maximum_variables) { + request = {}; + return false; + } + const auto before = variable_offset; + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv( + list.value, variable_offset, definition)) { + request = {}; + return false; + } + MmsObjectNameView ignored; + if (!try_read_variable_definition( + list.value.subspan(before, variable_offset - before), 0U, ignored)) { + request = {}; + return false; + } + ++count; + } + if (count == 0U) { + request = {}; + return false; + } + request.variable_list = list.value; + request.variable_count = count; + have_specification = true; + } else { + request = {}; + return false; + } + } + if (!have_specification) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_read_request( + const std::span mms_pdu, + MmsReadRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_read_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_read_response( + const MmsConfirmedPduView& confirmed, + MmsReadResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 4 || !confirmed.service_constructed) { + return false; + } + bool have_list = false; + bool have_optional_specification = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + if (field.tag_number == 0 && field.constructed) { + if (have_optional_specification) { + response = {}; + return false; + } + have_optional_specification = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_list) { + response = {}; + return false; + } + std::size_t result_offset = 0U; + std::size_t count = 0U; + while (result_offset < field.value.size()) { + if (count >= maximum_variables) { + response = {}; + return false; + } + const auto start = result_offset; + asn1::BerTlvView result; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, result_offset, result)) { + response = {}; + return false; + } + if (result.tag_class == asn1::BerClass::context_specific && + result.tag_number == 0 && !result.constructed) { + std::uint32_t ignored{}; + if (!read_u32(result, ignored)) { + response = {}; + return false; + } + } else if (!valid_mms_data_tlv(result)) { + response = {}; + return false; + } + if (result_offset <= start) { + response = {}; + return false; + } + ++count; + } + if (count == 0U) { + response = {}; + return false; + } + response.access_result_list = field.value; + response.result_count = count; + have_list = true; + } else { + response = {}; + return false; + } + } + if (!have_list) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_read_response( + const std::span mms_pdu, + MmsReadResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_read_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_read_response_into( + const std::uint32_t invoke_id, + const std::span results, + const std::span destination) noexcept { + if (results.empty() || results.size() > maximum_variables) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t list_content = 0U; + for (const auto& result : results) { + std::size_t encoded_size = 0U; + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + encoded_size = result.encoded_data.size(); + } else { + const auto failure_value = positive_integer_size(result.failure_code); + const auto failure_tlv = asn1::BerSpanWriter::tlv_size(0, failure_value); + if (!failure_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + encoded_size = *failure_tlv; + } + if (encoded_size > std::numeric_limits::max() - list_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + list_content += encoded_size; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(1, list_content); + if (!list_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto sizing = confirmed_response_sizing(invoke_id, 4, *list_tlv); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, 4, *list_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, list_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + for (const auto& result : results) { + if (result.success) { + if (!writer.write_bytes(result.encoded_data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } else { + const auto failure_value = positive_integer_size(result.failure_code); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, failure_value) || + !write_positive_integer(writer, result.failure_code)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + } + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + } // namespace ar::iec61850::mms diff --git a/src/mms/static_object_table.cpp b/src/mms/static_object_table.cpp new file mode 100644 index 000000000..8bff516b0 --- /dev/null +++ b/src/mms/static_object_table.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_object_table.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool visible_ascii(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto character : value) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_type_specification( + const std::span encoded) noexcept { + asn1::BerTlvView type; + return !encoded.empty() && + asn1::BerSpanReader::try_read_exact(encoded, type) && + type.tag_class == asn1::BerClass::context_specific; +} + +} // namespace + +bool MmsStaticObjectTable::valid() const noexcept { + if (objects_.empty() || objects_.size() > maximum_objects) { + return false; + } + for (std::size_t index = 0U; index < objects_.size(); ++index) { + const auto& object = objects_[index]; + if (!visible_ascii(object.domain) || !visible_ascii(object.item) || + !valid_type_specification(object.type_specification) || + object.read == nullptr) { + return false; + } + for (std::size_t other = index + 1U; other < objects_.size(); ++other) { + if (objects_[other].domain == object.domain && + objects_[other].item == object.item) { + return false; + } + } + } + return true; +} + +const MmsStaticObjectEntry* MmsStaticObjectTable::find( + const MmsObjectNameView& name) const noexcept { + if (name.kind != MmsObjectNameViewKind::domain_specific || + name.domain.empty() || name.item.empty()) { + return nullptr; + } + for (const auto& object : objects_) { + if (span_equals(name.domain, object.domain) && + span_equals(name.item, object.item)) { + return &object; + } + } + return nullptr; +} + +bool MmsStaticObjectTable::try_resolve_read_request( + const MmsReadRequestView& request, + const std::span resolved, + std::size_t& resolved_count) const noexcept { + resolved_count = 0U; + if (request.variable_count == 0U || + request.variable_count > resolved.size() || + request.variable_count > MmsServiceSpanCodec::maximum_variables) { + return false; + } + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + resolved_count = 0U; + return false; + } + resolved[index] = find(name); + ++resolved_count; + } + return true; +} + +} // namespace ar::iec61850::mms From ab220c743f59bba8751309138669ebd4f7a4e0bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 15:01:16 +0700 Subject: [PATCH 142/188] Add bounded MMS write control path --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 15 +- embedded/mms_write_hard_profile_smoke.cpp | 224 +++++++++ include/ariec61850/mms/services_span.hpp | 77 ++- .../ariec61850/mms/static_object_table.hpp | 20 + src/mms/static_write.cpp | 33 ++ src/mms/write_span.cpp | 458 ++++++++++++++++++ 7 files changed, 812 insertions(+), 21 deletions(-) create mode 100644 embedded/mms_write_hard_profile_smoke.cpp create mode 100644 src/mms/static_write.cpp create mode 100644 src/mms/write_span.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index b48b101ec..b020abeb7 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -56,7 +56,8 @@ jobs: build-hard-embedded/ariec61850_hard_association_smoke \ build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ - build-hard-embedded/ariec61850_hard_mms_static_model_smoke; do + build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ + build-hard-embedded/ariec61850_hard_mms_write_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -78,7 +79,8 @@ jobs: build-hard-embedded/ariec61850_hard_association_smoke \ build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ - build-hard-embedded/ariec61850_hard_mms_static_model_smoke + build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ + build-hard-embedded/ariec61850_hard_mms_write_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 25a30960d..ec092e5e5 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -93,6 +93,16 @@ add_executable(ariec61850_hard_mms_static_model_smoke ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_static_model_smoke) +add_executable(ariec61850_hard_mms_write_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_write_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/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_write_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -112,8 +122,11 @@ add_test( add_test( NAME ariec61850_hard_mms_static_model_smoke COMMAND ariec61850_hard_mms_static_model_smoke) +add_test( + NAME ariec61850_hard_mms_write_smoke + COMMAND ariec61850_hard_mms_write_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU/GetNameList/GetVariableAccessAttributes/Read gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU/GetNameList/GetVariableAccessAttributes/Read/Write gates=ON") diff --git a/embedded/mms_write_hard_profile_smoke.cpp b/embedded/mms_write_hard_profile_smoke.cpp new file mode 100644 index 000000000..c56b27374 --- /dev/null +++ b/embedded/mms_write_hard_profile_smoke.cpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteSuccessResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +constexpr std::array kWriteFailureResponse{ + 0xA1U, 0x08U, 0x02U, 0x01U, 0x0FU, + 0xA5U, 0x03U, 0x80U, 0x01U, 0x03U}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kFalseData{0x83U, 0x01U, 0x00U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[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}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + 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}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + 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 || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid() || !objects[0].writable() || objects[1].writable()) { + return 1; + } + + mms::MmsWriteRequestView request; + if (!mms::MmsServiceSpanCodec::try_decode_write_request(kWriteRequest, request) || + request.invoke_id != 14U || request.variable_count != 1U || request.data_count != 1U) { + return 2; + } + mms::MmsObjectNameView name; + std::span value; + if (!request.try_variable(0U, name) || + name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(name.domain, kLd0) || !matches(name.item, kR1) || + !request.try_value(0U, value) || !matches(value, kFalseData) || + request.try_variable(1U, name) || request.try_value(1U, value)) { + return 3; + } + + std::array resolved{}; + std::size_t resolved_count = 0U; + if (!table.try_resolve_write_request(request, resolved, resolved_count) || + resolved_count != 1U || resolved[0] != &objects[0] || !resolved[0]->writable()) { + return 4; + } + if (!request.try_value(0U, value)) { + return 5; + } + const auto write_result = resolved[0]->write(resolved[0]->write_context, value); + if (!write_result.success || relay_state) { + return 6; + } + + std::array buffer{}; + const std::array success{ + mms::MmsWriteAccessResultInput{true, 0U}}; + const auto success_encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + request.invoke_id, success, buffer); + if (!success_encoded.success() || + success_encoded.bytes_written != kWriteSuccessResponse.size() || + !matches( + std::span{buffer}.first(success_encoded.bytes_written), + kWriteSuccessResponse)) { + return 7; + } + + mms::MmsWriteResponseView response; + mms::MmsWriteAccessResultView access; + if (!mms::MmsServiceSpanCodec::try_decode_write_response( + kWriteSuccessResponse, response) || + response.invoke_id != 14U || response.result_count != 1U || + !response.try_result(0U, access) || !access.success || + response.try_result(1U, access)) { + return 8; + } + + auto read_only_request = kWriteRequest; + read_only_request[4] = 0x0FU; + read_only_request[22] = 0x4DU; + if (!mms::MmsServiceSpanCodec::try_decode_write_request( + read_only_request, request) || + !table.try_resolve_write_request(request, resolved, resolved_count) || + resolved_count != 1U || resolved[0] != &objects[1] || resolved[0]->writable()) { + return 9; + } + + const std::array denied{ + mms::MmsWriteAccessResultInput{false, 3U}}; + const auto denied_encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + request.invoke_id, denied, buffer); + if (!denied_encoded.success() || + denied_encoded.bytes_written != kWriteFailureResponse.size() || + !matches( + std::span{buffer}.first(denied_encoded.bytes_written), + kWriteFailureResponse) || + !mms::MmsServiceSpanCodec::try_decode_write_response( + kWriteFailureResponse, response) || + !response.try_result(0U, access) || access.success || access.failure_code != 3U) { + return 10; + } + + auto bad_type_request = kWriteRequest; + bad_type_request[26] = 0x85U; + if (!mms::MmsServiceSpanCodec::try_decode_write_request( + bad_type_request, request) || + !table.try_resolve_write_request(request, resolved, resolved_count) || + resolved[0] == nullptr || !resolved[0]->writable() || + !request.try_value(0U, value)) { + return 11; + } + const auto rejected = resolved[0]->write(resolved[0]->write_context, value); + if (rejected.success || rejected.failure_code != 7U || relay_state) { + return 12; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + invoke, (iteration & 1U) == 0U ? success : denied, buffer); + mms::MmsWriteResponseView decoded; + if (!encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.result_count != 1U || + !decoded.try_result(0U, access) || + access.success != ((iteration & 1U) == 0U)) { + return 13; + } + } + + return 0; +} diff --git a/include/ariec61850/mms/services_span.hpp b/include/ariec61850/mms/services_span.hpp index 0899534fc..2f6d172da 100644 --- a/include/ariec61850/mms/services_span.hpp +++ b/include/ariec61850/mms/services_span.hpp @@ -98,6 +98,43 @@ struct MmsReadAccessResultInput final { std::uint32_t failure_code{}; }; +struct MmsWriteRequestView final { + std::uint32_t invoke_id{}; + std::span variable_list{}; + std::size_t variable_count{}; + std::span data_list{}; + std::size_t data_count{}; + + [[nodiscard]] bool try_variable( + std::size_t index, + MmsObjectNameView& name) const noexcept; + + [[nodiscard]] bool try_value( + std::size_t index, + std::span& encoded_data) const noexcept; +}; + +struct MmsWriteAccessResultView final { + bool success{}; + std::uint32_t failure_code{}; +}; + +struct MmsWriteResponseView final { + std::uint32_t invoke_id{}; + std::span result_list{}; + std::size_t result_count{}; + bool implicit_single_success{}; + + [[nodiscard]] bool try_result( + std::size_t index, + MmsWriteAccessResultView& result) const noexcept; +}; + +struct MmsWriteAccessResultInput final { + bool success{}; + std::uint32_t failure_code{}; +}; + class MmsServiceSpanCodec final { public: static constexpr std::size_t maximum_identifier_bytes = 1'024U; @@ -111,19 +148,15 @@ class MmsServiceSpanCodec final { [[nodiscard]] static bool try_decode_get_name_list_request( const MmsConfirmedPduView& confirmed, MmsGetNameListRequestView& request) noexcept; - [[nodiscard]] static bool try_decode_get_name_list_request( std::span mms_pdu, MmsGetNameListRequestView& request) noexcept; - [[nodiscard]] static bool try_decode_get_name_list_response( const MmsConfirmedPduView& confirmed, MmsGetNameListResponseView& response) noexcept; - [[nodiscard]] static bool try_decode_get_name_list_response( std::span mms_pdu, MmsGetNameListResponseView& response) noexcept; - [[nodiscard]] static wire::EncodeResult encode_get_name_list_response_into( std::uint32_t invoke_id, std::span names, @@ -133,46 +166,54 @@ class MmsServiceSpanCodec final { [[nodiscard]] static bool try_decode_variable_access_attributes_request( const MmsConfirmedPduView& confirmed, MmsVariableAccessAttributesRequestView& request) noexcept; - [[nodiscard]] static bool try_decode_variable_access_attributes_request( std::span mms_pdu, MmsVariableAccessAttributesRequestView& request) noexcept; - [[nodiscard]] static bool try_decode_variable_access_attributes_response( const MmsConfirmedPduView& confirmed, MmsVariableAccessAttributesResponseView& response) noexcept; - [[nodiscard]] static bool try_decode_variable_access_attributes_response( std::span mms_pdu, MmsVariableAccessAttributesResponseView& response) noexcept; - - [[nodiscard]] static wire::EncodeResult - encode_variable_access_attributes_response_into( - std::uint32_t invoke_id, - bool mms_deletable, - std::span encoded_type_specification, - std::span destination) noexcept; + [[nodiscard]] static wire::EncodeResult encode_variable_access_attributes_response_into( + std::uint32_t invoke_id, + bool mms_deletable, + std::span encoded_type_specification, + std::span destination) noexcept; [[nodiscard]] static bool try_decode_read_request( const MmsConfirmedPduView& confirmed, MmsReadRequestView& request) noexcept; - [[nodiscard]] static bool try_decode_read_request( std::span mms_pdu, MmsReadRequestView& request) noexcept; - [[nodiscard]] static bool try_decode_read_response( const MmsConfirmedPduView& confirmed, MmsReadResponseView& response) noexcept; - [[nodiscard]] static bool try_decode_read_response( std::span mms_pdu, MmsReadResponseView& response) noexcept; - [[nodiscard]] static wire::EncodeResult encode_read_response_into( std::uint32_t invoke_id, std::span results, std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_write_request( + const MmsConfirmedPduView& confirmed, + MmsWriteRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_write_request( + std::span mms_pdu, + MmsWriteRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_write_response( + const MmsConfirmedPduView& confirmed, + MmsWriteResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_write_response( + std::span mms_pdu, + MmsWriteResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_write_response_into( + std::uint32_t invoke_id, + std::span results, + std::span destination) noexcept; }; } // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_object_table.hpp b/include/ariec61850/mms/static_object_table.hpp index 6c4d50151..f533cdabe 100644 --- a/include/ariec61850/mms/static_object_table.hpp +++ b/include/ariec61850/mms/static_object_table.hpp @@ -15,6 +15,15 @@ using MmsStaticReadCallback = wire::EncodeResult (*)( const void* context, std::span destination) noexcept; +struct MmsStaticWriteResult final { + bool success{}; + std::uint32_t failure_code{}; +}; + +using MmsStaticWriteCallback = MmsStaticWriteResult (*)( + void* context, + std::span encoded_data) noexcept; + struct MmsStaticObjectEntry final { std::string_view domain; std::string_view item; @@ -22,6 +31,12 @@ struct MmsStaticObjectEntry final { MmsStaticReadCallback read{}; const void* context{}; bool mms_deletable{}; + MmsStaticWriteCallback write{}; + void* write_context{}; + + [[nodiscard]] constexpr bool writable() const noexcept { + return write != nullptr; + } }; class MmsStaticObjectTable final { @@ -42,6 +57,11 @@ class MmsStaticObjectTable final { std::span resolved, std::size_t& resolved_count) const noexcept; + [[nodiscard]] bool try_resolve_write_request( + const MmsWriteRequestView& request, + std::span resolved, + std::size_t& resolved_count) const noexcept; + [[nodiscard]] constexpr std::span objects() const noexcept { return objects_; } diff --git a/src/mms/static_write.cpp b/src/mms/static_write.cpp new file mode 100644 index 000000000..5ffa21614 --- /dev/null +++ b/src/mms/static_write.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include + +namespace ar::iec61850::mms { + +bool MmsStaticObjectTable::try_resolve_write_request( + const MmsWriteRequestView& request, + const std::span resolved, + std::size_t& resolved_count) const noexcept { + resolved_count = 0U; + if (request.variable_count == 0U || + request.variable_count != request.data_count || + request.variable_count > resolved.size() || + request.variable_count > MmsServiceSpanCodec::maximum_variables) { + return false; + } + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + resolved_count = 0U; + return false; + } + resolved[index] = find(name); + ++resolved_count; + } + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/write_span.cpp b/src/mms/write_span.cpp new file mode 100644 index 000000000..1871f0852 --- /dev/null +++ b/src/mms/write_span.cpp @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool valid_mms_data_tlv( + const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +struct ConfirmedResponseSizing final { + std::size_t invoke_value{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional confirmed_response_sizing( + const std::uint32_t invoke_id, + const std::size_t service_content) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + return std::nullopt; + } + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(5, service_content); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + return ConfirmedResponseSizing{invoke_value, *outer_content, *required}; +} + +[[nodiscard]] bool write_confirmed_response_prefix( + asn1::BerSpanWriter& writer, + const ConfirmedResponseSizing& sizing, + const std::uint32_t invoke_id, + const std::size_t service_content) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing.outer_content) && + writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing.invoke_value) && + write_positive_integer(writer, invoke_id) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, 5, service_content); +} + +[[nodiscard]] bool try_read_variable_definition( + const std::span variable_list, + const std::size_t wanted, + MmsObjectNameView& name) noexcept { + name = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(definition.value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed) { + return false; + } + MmsObjectNameView decoded; + if (!MmsServiceSpanCodec::try_decode_object_name_view(wrapper.value, decoded)) { + return false; + } + if (current == wanted) { + name = decoded; + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_data_value( + const std::span data_list, + const std::size_t wanted, + std::span& encoded_data) noexcept { + encoded_data = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < data_list.size()) { + const auto start = offset; + asn1::BerTlvView value; + if (!asn1::BerSpanReader::try_read_tlv(data_list, offset, value) || + !valid_mms_data_tlv(value)) { + return false; + } + if (current == wanted) { + encoded_data = data_list.subspan(start, offset - start); + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_write_result( + const std::span result_list, + const std::size_t wanted, + MmsWriteAccessResultView& result) noexcept { + result = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < result_list.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(result_list, offset, field) || + field.tag_class != asn1::BerClass::context_specific || field.constructed) { + return false; + } + MmsWriteAccessResultView decoded; + if (field.tag_number == 1) { + if (!field.value.empty()) { + return false; + } + decoded.success = true; + } else if (field.tag_number == 0) { + if (!read_u32(field, decoded.failure_code)) { + return false; + } + decoded.success = false; + } else { + return false; + } + if (current == wanted) { + result = decoded; + return true; + } + ++current; + } + return false; +} + +} // namespace + +bool MmsWriteRequestView::try_variable( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + if (index >= variable_count) { + name = {}; + return false; + } + return try_read_variable_definition(variable_list, index, name); +} + +bool MmsWriteRequestView::try_value( + const std::size_t index, + std::span& encoded_data) const noexcept { + if (index >= data_count) { + encoded_data = {}; + return false; + } + return try_read_data_value(data_list, index, encoded_data); +} + +bool MmsWriteResponseView::try_result( + const std::size_t index, + MmsWriteAccessResultView& result) const noexcept { + if (implicit_single_success) { + if (index != 0U) { + result = {}; + return false; + } + result = MmsWriteAccessResultView{true, 0U}; + return true; + } + if (index >= result_count) { + result = {}; + return false; + } + return try_read_write_result(result_list, index, result); +} + +bool MmsServiceSpanCodec::try_decode_write_request( + const MmsConfirmedPduView& confirmed, + MmsWriteRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 5 || !confirmed.service_constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView variables; + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, variables) || + !asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, data) || + offset != confirmed.service_value.size() || + variables.tag_class != asn1::BerClass::context_specific || + variables.tag_number != 0 || !variables.constructed || + data.tag_class != asn1::BerClass::context_specific || + data.tag_number != 0 || !data.constructed) { + return false; + } + + std::size_t variable_offset = 0U; + std::size_t variable_count = 0U; + while (variable_offset < variables.value.size()) { + if (variable_count >= maximum_variables) { + return false; + } + const auto before = variable_offset; + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv( + variables.value, variable_offset, definition)) { + return false; + } + MmsObjectNameView ignored; + if (!try_read_variable_definition( + variables.value.subspan(before, variable_offset - before), 0U, ignored)) { + return false; + } + ++variable_count; + } + + std::size_t data_offset = 0U; + std::size_t data_count = 0U; + while (data_offset < data.value.size()) { + if (data_count >= maximum_variables) { + return false; + } + asn1::BerTlvView value; + if (!asn1::BerSpanReader::try_read_tlv(data.value, data_offset, value) || + !valid_mms_data_tlv(value)) { + return false; + } + ++data_count; + } + + if (variable_count == 0U || variable_count != data_count) { + return false; + } + request.invoke_id = confirmed.invoke_id; + request.variable_list = variables.value; + request.variable_count = variable_count; + request.data_list = data.value; + request.data_count = data_count; + return true; +} + +bool MmsServiceSpanCodec::try_decode_write_request( + const std::span mms_pdu, + MmsWriteRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_write_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_write_response( + const MmsConfirmedPduView& confirmed, + MmsWriteResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 5) { + return false; + } + response.invoke_id = confirmed.invoke_id; + if (!confirmed.service_constructed) { + if (!confirmed.service_value.empty()) { + response = {}; + return false; + } + response.implicit_single_success = true; + response.result_count = 1U; + return true; + } + + std::size_t offset = 0U; + std::size_t count = 0U; + while (offset < confirmed.service_value.size()) { + if (count >= maximum_variables) { + response = {}; + return false; + } + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific || field.constructed) { + response = {}; + return false; + } + if (field.tag_number == 1) { + if (!field.value.empty()) { + response = {}; + return false; + } + } else if (field.tag_number == 0) { + std::uint32_t ignored{}; + if (!read_u32(field, ignored)) { + response = {}; + return false; + } + } else { + response = {}; + return false; + } + ++count; + } + if (count == 0U) { + response = {}; + return false; + } + response.result_list = confirmed.service_value; + response.result_count = count; + return true; +} + +bool MmsServiceSpanCodec::try_decode_write_response( + const std::span mms_pdu, + MmsWriteResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_write_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_write_response_into( + const std::uint32_t invoke_id, + const std::span results, + const std::span destination) noexcept { + if (results.empty() || results.size() > maximum_variables) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t service_content = 0U; + for (const auto& result : results) { + std::optional encoded; + if (result.success) { + encoded = asn1::BerSpanWriter::tlv_size(1, 0U); + } else { + encoded = asn1::BerSpanWriter::tlv_size( + 0, positive_integer_size(result.failure_code)); + } + if (!encoded || *encoded > std::numeric_limits::max() - service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + service_content += *encoded; + } + + const auto sizing = confirmed_response_sizing(invoke_id, service_content); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, service_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + for (const auto& result : results) { + if (result.success) { + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 0U)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } else { + const auto failure_value = positive_integer_size(result.failure_code); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, failure_value) || + !write_positive_integer(writer, result.failure_code)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + } + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms From 39f8f2ebad5bf5d554d477e6f77f0b4feb2baeee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 15:02:57 +0700 Subject: [PATCH 143/188] Fix hard Write permission assertion --- embedded/mms_write_hard_profile_smoke.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/mms_write_hard_profile_smoke.cpp b/embedded/mms_write_hard_profile_smoke.cpp index c56b27374..7be9e886a 100644 --- a/embedded/mms_write_hard_profile_smoke.cpp +++ b/embedded/mms_write_hard_profile_smoke.cpp @@ -170,6 +170,7 @@ int main() { read_only_request[22] = 0x4DU; if (!mms::MmsServiceSpanCodec::try_decode_write_request( read_only_request, request) || + !request.try_variable(0U, name) || !matches(name.item, kM1) || !table.try_resolve_write_request(request, resolved, resolved_count) || resolved_count != 1U || resolved[0] != &objects[1] || resolved[0]->writable()) { return 9; From 6e9d5f3185f09105690a1d516f7e3c13e10e8cf1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 15:13:53 +0700 Subject: [PATCH 144/188] Add bounded MMS static application dispatcher --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 16 +- .../mms_dispatcher_hard_profile_smoke.cpp | 320 ++++++++++++++++ include/ariec61850/mms/static_dispatcher.hpp | 70 ++++ src/mms/static_dispatcher.cpp | 351 ++++++++++++++++++ 5 files changed, 760 insertions(+), 3 deletions(-) create mode 100644 embedded/mms_dispatcher_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/static_dispatcher.hpp create mode 100644 src/mms/static_dispatcher.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index b020abeb7..548b3fd80 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -57,7 +57,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ - build-hard-embedded/ariec61850_hard_mms_write_smoke; do + build-hard-embedded/ariec61850_hard_mms_write_smoke \ + build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -80,7 +81,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ - build-hard-embedded/ariec61850_hard_mms_write_smoke + build-hard-embedded/ariec61850_hard_mms_write_smoke \ + build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index ec092e5e5..3fce6e530 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -103,6 +103,17 @@ add_executable(ariec61850_hard_mms_write_smoke ${ARIEC61850_ROOT}/src/mms/static_write.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_write_smoke) +add_executable(ariec61850_hard_mms_dispatcher_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_dispatcher_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/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_dispatcher_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -125,8 +136,11 @@ add_test( add_test( NAME ariec61850_hard_mms_write_smoke COMMAND ariec61850_hard_mms_write_smoke) +add_test( + NAME ariec61850_hard_mms_dispatcher_smoke + COMMAND ariec61850_hard_mms_dispatcher_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS PDU/GetNameList/GetVariableAccessAttributes/Read/Write gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher gates=ON") diff --git a/embedded/mms_dispatcher_hard_profile_smoke.cpp b/embedded/mms_dispatcher_hard_profile_smoke.cpp new file mode 100644 index 000000000..37b9eef63 --- /dev/null +++ b/embedded/mms_dispatcher_hard_profile_smoke.cpp @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kDomainListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U}; + +constexpr std::array kDomainContinueRequest{ + 0xA0U, 0x13U, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0EU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U, + 0x82U, 0x03U, 0x4CU, 0x44U, 0x30U}; + +constexpr std::array kDomainFirstResponse{ + 0xA1U, 0x0FU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x0AU, 0xA0U, 0x05U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x81U, 0x01U, 0xFFU}; + +constexpr std::array kDomainSecondResponse{ + 0xA1U, 0x0FU, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0AU, 0xA0U, 0x05U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x31U, + 0x81U, 0x01U, 0x00U}; + +constexpr std::array kAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +constexpr std::array kAttributesResponse{ + 0xA1U, 0x0CU, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x07U, 0x80U, 0x01U, 0x00U, + 0xA2U, 0x02U, 0x83U, 0x00U}; + +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}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteSuccessResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +constexpr std::array kWriteDeniedResponse{ + 0xA1U, 0x08U, 0x02U, 0x01U, 0x0FU, + 0xA5U, 0x03U, 0x80U, 0x01U, 0x03U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[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}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr || destination.size() < required) { + return context == nullptr + ? wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, required} + : wire::EncodeResult{wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + 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 || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const bool remote_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}, + mms::MmsStaticObjectEntry{ + "LD1", "X1", kBooleanType, + read_boolean, &remote_state, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) { + return 1; + } + + const mms::MmsStaticDispatchPolicy policy{ + 1U, + 1U, + 10U, + 3U, + 10U}; + const mms::MmsStaticApplicationDispatcher dispatcher{table, policy}; + std::array response{}; + std::array workspace{}; + + auto dispatched = dispatcher.dispatch(kDomainListRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + dispatched.invoke_id != 7U || + dispatched.bytes_written != kDomainFirstResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kDomainFirstResponse)) { + return 2; + } + + dispatched = dispatcher.dispatch(kDomainContinueRequest, response, workspace); + if (!dispatched.success() || dispatched.invoke_id != 8U || + dispatched.bytes_written != kDomainSecondResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kDomainSecondResponse)) { + return 3; + } + + mms::MmsConfirmedPduView attributes_view; + if (!mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + kAttributesRequest, attributes_view)) { + return 4; + } + dispatched = dispatcher.dispatch(attributes_view, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_variable_access_attributes || + dispatched.bytes_written != kAttributesResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kAttributesResponse)) { + return 5; + } + + dispatched = dispatcher.dispatch(kReadRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.bytes_written != kReadResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kReadResponse)) { + return 6; + } + + std::array tiny_workspace{}; + dispatched = dispatcher.dispatch(kReadRequest, response, tiny_workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::workspace_too_small || + dispatched.required_bytes < 3U) { + return 7; + } + + auto missing_read = kReadRequest; + missing_read[39] = 0x5AU; + dispatched = dispatcher.dispatch(missing_read, response, workspace); + if (!dispatched.success()) { + return 8; + } + mms::MmsReadResponseView read_response; + mms::MmsReadAccessResultView read_result; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + read_response) || + read_response.result_count != 2U || + !read_response.try_result(0U, read_result) || !read_result.success || + !read_response.try_result(1U, read_result) || read_result.success || + read_result.failure_code != 10U) { + return 9; + } + + dispatched = dispatcher.dispatch(kWriteRequest, response, workspace); + if (!dispatched.success() || relay_state || + dispatched.service != mms::MmsWireConfirmedService::write || + dispatched.bytes_written != kWriteSuccessResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kWriteSuccessResponse)) { + return 10; + } + + auto read_only_write = kWriteRequest; + read_only_write[4] = 0x0FU; + read_only_write[22] = 0x4DU; + dispatched = dispatcher.dispatch(read_only_write, response, workspace); + if (!dispatched.success() || + dispatched.bytes_written != kWriteDeniedResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kWriteDeniedResponse) || + meter_value != 42U) { + return 11; + } + + std::array tiny_response{}; + dispatched = dispatcher.dispatch(kDomainListRequest, tiny_response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::response_buffer_too_small || + dispatched.required_bytes != kDomainFirstResponse.size()) { + return 12; + } + + auto unknown_continuation = kDomainContinueRequest; + unknown_continuation[18] = 0x5AU; + unknown_continuation[19] = 0x5AU; + unknown_continuation[20] = 0x5AU; + dispatched = dispatcher.dispatch(unknown_continuation, response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::object_not_found) { + return 13; + } + + std::array identify_request{}; + const auto identify_encoded = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 21U, + static_cast(mms::MmsWireConfirmedService::identify), + false, + {}, + identify_request); + if (!identify_encoded.success()) { + return 14; + } + dispatched = dispatcher.dispatch( + std::span{identify_request}.first(identify_encoded.bytes_written), + response, + workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::unsupported_service || + dispatched.invoke_id != 21U) { + return 15; + } + + constexpr std::array malformed{0x00U}; + dispatched = dispatcher.dispatch(malformed, response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::malformed_request || + dispatched.service != mms::MmsWireConfirmedService::unknown) { + return 16; + } + + 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 0; +} diff --git a/include/ariec61850/mms/static_dispatcher.hpp b/include/ariec61850/mms/static_dispatcher.hpp new file mode 100644 index 000000000..0f0c4d540 --- /dev/null +++ b/include/ariec61850/mms/static_dispatcher.hpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticDispatchStatus : std::uint8_t { + response_ready, + malformed_request, + unsupported_service, + unsupported_request, + object_not_found, + invalid_object_table, + workspace_too_small, + response_buffer_too_small, + backend_failure, +}; + +struct MmsStaticDispatchPolicy final { + std::size_t maximum_names_per_response{32U}; + std::size_t maximum_write_variables{1U}; + std::uint32_t missing_object_failure_code{10U}; + std::uint32_t access_denied_failure_code{3U}; + std::uint32_t backend_failure_code{10U}; +}; + +struct MmsStaticDispatchResult final { + MmsStaticDispatchStatus status{MmsStaticDispatchStatus::malformed_request}; + MmsWireConfirmedService service{MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticDispatchStatus::response_ready; + } +}; + +class MmsStaticApplicationDispatcher final { +public: + explicit constexpr MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, policy_{policy} {} + + [[nodiscard]] MmsStaticDispatchResult dispatch( + std::span mms_request, + std::span response, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticDispatchResult dispatch( + const MmsConfirmedPduView& request, + std::span response, + std::span workspace) const noexcept; + + [[nodiscard]] constexpr const MmsStaticDispatchPolicy& policy() const noexcept { + return policy_; + } + +private: + const MmsStaticObjectTable& objects_; + MmsStaticDispatchPolicy policy_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp new file mode 100644 index 000000000..089a3f292 --- /dev/null +++ b/src/mms/static_dispatcher.cpp @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[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]] MmsStaticDispatchResult make_status( + const MmsStaticDispatchStatus status, + const MmsConfirmedPduView& request, + const std::size_t required_bytes = 0U) noexcept { + return MmsStaticDispatchResult{ + status, + request.service(), + request.invoke_id, + 0U, + required_bytes}; +} + +[[nodiscard]] MmsStaticDispatchResult make_encoded( + const MmsConfirmedPduView& request, + const wire::EncodeResult encoded) noexcept { + if (encoded.success()) { + return MmsStaticDispatchResult{ + MmsStaticDispatchStatus::response_ready, + request.service(), + request.invoke_id, + encoded.bytes_written, + encoded.required_bytes}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticDispatchStatus::response_buffer_too_small, + request, + encoded.required_bytes); + } + return make_status(MmsStaticDispatchStatus::backend_failure, request); +} + +[[nodiscard]] bool policy_valid(const MmsStaticDispatchPolicy& policy) noexcept { + return policy.maximum_names_per_response > 0U && + policy.maximum_names_per_response <= MmsStaticObjectTable::maximum_objects && + policy.maximum_write_variables > 0U && + policy.maximum_write_variables <= MmsServiceSpanCodec::maximum_variables; +} + +[[nodiscard]] std::size_t collect_names( + const MmsStaticObjectTable& objects, + const MmsGetNameListRequestView& request, + std::array& names) noexcept { + std::size_t count = 0U; + if (request.object_class == MmsNameListObjectClass::domain && + request.scope == MmsNameScopeKind::vmd_specific) { + for (const auto& object : objects.objects()) { + bool duplicate = false; + for (std::size_t index = 0U; index < count; ++index) { + if (names[index] == object.domain) { + duplicate = true; + break; + } + } + if (!duplicate) { + names[count] = object.domain; + ++count; + } + } + return count; + } + + 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)) { + names[count] = object.item; + ++count; + } + } + return count; + } + return MmsStaticObjectTable::maximum_objects + 1U; +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_get_name_list( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsGetNameListRequestView request; + if (!MmsServiceSpanCodec::try_decode_get_name_list_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + std::array names{}; + const auto name_count = collect_names(objects, request, names); + if (name_count > MmsStaticObjectTable::maximum_objects) { + return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + } + + std::size_t start = 0U; + if (!request.continue_after.empty()) { + bool found = false; + for (std::size_t index = 0U; index < name_count; ++index) { + if (span_equals(request.continue_after, names[index])) { + start = index + 1U; + found = true; + break; + } + } + if (!found) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + } + + const auto available = name_count - start; + auto page_count = std::min(available, policy.maximum_names_per_response); + if (available == 0U) { + const std::span empty; + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, empty, false, response)); + } + + while (page_count > 0U) { + const auto more_follows = start + page_count < name_count; + const auto encoded = MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, + std::span{names}.subspan(start, page_count), + more_follows, + response); + if (encoded.success()) { + return make_encoded(confirmed, encoded); + } + if (encoded.status != wire::EncodeStatus::buffer_too_small || page_count == 1U) { + return make_encoded(confirmed, encoded); + } + --page_count; + } + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_attributes( + const MmsStaticObjectTable& objects, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsVariableAccessAttributesRequestView request; + if (!MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* object = objects.find(request.name); + if (object == nullptr) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + object->mms_deletable, + object->type_specification, + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_read( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response, + const std::span workspace) noexcept { + MmsReadRequestView request; + if (!MmsServiceSpanCodec::try_decode_read_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + std::array results{}; + std::size_t workspace_offset = 0U; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* object = objects.find(name); + if (object == nullptr) { + results[index] = MmsReadAccessResultInput{ + false, {}, policy.missing_object_failure_code}; + continue; + } + + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticDispatchStatus::workspace_too_small, + confirmed, + workspace_offset + read.required_bytes); + } + if (!read.success()) { + results[index] = MmsReadAccessResultInput{ + false, {}, policy.backend_failure_code}; + continue; + } + if (read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); + } + results[index] = MmsReadAccessResultInput{ + true, + remaining.first(read.bytes_written), + 0U}; + workspace_offset += read.bytes_written; + } + + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_read_response_into( + confirmed.invoke_id, + std::span{results}.first(request.variable_count), + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_write( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsWriteRequestView request; + if (!MmsServiceSpanCodec::try_decode_write_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + if (request.variable_count > policy.maximum_write_variables) { + return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + } + + std::array resolved{}; + std::array, MmsServiceSpanCodec::maximum_variables> values{}; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name) || !request.try_value(index, values[index])) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + resolved[index] = objects.find(name); + } + + std::array results{}; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + const auto* object = resolved[index]; + if (object == nullptr) { + results[index] = MmsWriteAccessResultInput{ + false, policy.missing_object_failure_code}; + continue; + } + if (!object->writable()) { + results[index] = MmsWriteAccessResultInput{ + false, policy.access_denied_failure_code}; + continue; + } + const auto applied = object->write(object->write_context, values[index]); + results[index] = MmsWriteAccessResultInput{ + applied.success, + applied.success ? 0U : applied.failure_code}; + } + + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_write_response_into( + confirmed.invoke_id, + std::span{results}.first(request.variable_count), + response)); +} + +} // namespace + +MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( + const std::span mms_request, + const std::span response, + const std::span workspace) const noexcept { + MmsConfirmedPduView request; + if (!MmsPduSpanCodec::try_decode_confirmed_request_view(mms_request, request)) { + return MmsStaticDispatchResult{ + MmsStaticDispatchStatus::malformed_request, + MmsWireConfirmedService::unknown, + 0U, + 0U, + 0U}; + } + return dispatch(request, response, workspace); +} + +MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( + const MmsConfirmedPduView& request, + const std::span response, + const std::span workspace) const noexcept { + if (!objects_.valid() || !policy_valid(policy_)) { + return make_status(MmsStaticDispatchStatus::invalid_object_table, request); + } + if (request.kind != MmsWirePduKind::confirmed_request) { + return make_status(MmsStaticDispatchStatus::malformed_request, request); + } + + switch (request.service()) { + case MmsWireConfirmedService::get_name_list: + return dispatch_get_name_list(objects_, policy_, request, response); + case MmsWireConfirmedService::get_variable_access_attributes: + return dispatch_attributes(objects_, request, response); + case MmsWireConfirmedService::read: + return dispatch_read(objects_, policy_, request, response, workspace); + case MmsWireConfirmedService::write: + return dispatch_write(objects_, policy_, request, response); + default: + return make_status(MmsStaticDispatchStatus::unsupported_service, request); + } +} + +} // namespace ar::iec61850::mms From dda04e019eec1140eaa97aaac2f4f60b0a7ba84b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 15:43:06 +0700 Subject: [PATCH 145/188] Add bounded MMS connection runtime --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 22 +- ..._connection_runtime_hard_profile_smoke.cpp | 418 ++++++++++++++++++ include/ariec61850/mms/connection_runtime.hpp | 91 ++++ src/mms/connection_runtime.cpp | 408 +++++++++++++++++ 5 files changed, 942 insertions(+), 3 deletions(-) create mode 100644 embedded/mms_connection_runtime_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/connection_runtime.hpp create mode 100644 src/mms/connection_runtime.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index 548b3fd80..477e48b42 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -58,7 +58,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ build-hard-embedded/ariec61850_hard_mms_write_smoke \ - build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke; do + build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ + build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -82,7 +83,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ build-hard-embedded/ariec61850_hard_mms_write_smoke \ - build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke + build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ + build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 3fce6e530..7eff63361 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -114,6 +114,23 @@ add_executable(ariec61850_hard_mms_dispatcher_smoke ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_dispatcher_smoke) +add_executable(ariec61850_hard_mms_connection_runtime_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_connection_runtime_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/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_connection_runtime_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -139,8 +156,11 @@ add_test( add_test( NAME ariec61850_hard_mms_dispatcher_smoke COMMAND ariec61850_hard_mms_dispatcher_smoke) +add_test( + NAME ariec61850_hard_mms_connection_runtime_smoke + COMMAND ariec61850_hard_mms_connection_runtime_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime gates=ON") diff --git a/embedded/mms_connection_runtime_hard_profile_smoke.cpp b/embedded/mms_connection_runtime_hard_profile_smoke.cpp new file mode 100644 index 000000000..e4202ce0d --- /dev/null +++ b/embedded/mms_connection_runtime_hard_profile_smoke.cpp @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/services_span.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 +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +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 kAssociationResponse{ + 0x0EU, 0x88U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0x72U, + 0x31U, 0x70U, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x69U, 0xA5U, 0x12U, 0x30U, + 0x07U, 0x80U, 0x01U, 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x30U, 0x07U, 0x80U, 0x01U, + 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x61U, 0x53U, 0x30U, 0x51U, 0x02U, 0x01U, 0x01U, + 0xA0U, 0x4CU, 0x61U, 0x4AU, 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, + 0x03U, 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, + 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}; + +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}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[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}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + 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 value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + 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 || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +template +[[nodiscard]] wire::EncodeResult build_data_tpkt( + const std::span session_payload, + std::array& output, + std::array& scratch, + const bool eot = true) noexcept { + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_payload, scratch, eot, 0U); + 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, + const bool eot = true) 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, + eot); +} + +[[nodiscard]] bool extract_cotp( + const std::span frame, + osi::CotpTpduView& cotp) noexcept { + osi::TpktFrameView tpkt; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp); +} + +[[nodiscard]] bool extract_mms( + const std::span frame, + osi::PresentationPdvView& pdv) noexcept { + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + return extract_cotp(frame, cotp) && + cotp.kind == osi::CotpWireKind::data && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv); +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + if (!table.valid() || + runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect) { + return 1; + } + + 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}}; + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + std::array presentation{}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + const auto cr_tpkt = cr.success() + ? osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cr.bytes_written), request) + : wire::EncodeResult{}; + if (!cr.success() || !cr_tpkt.success()) { + return 2; + } + + auto result = runtime.process_tcp_window( + std::span{request}.first(3U), response, workspace); + if (result.status != mms::MmsStaticConnectionStatus::need_more || + result.consumed_bytes != 0U || + runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect) { + return 3; + } + + result = runtime.process_tcp_window( + std::span{request}.first(cr_tpkt.bytes_written), + response, + workspace); + osi::CotpTpduView cc; + if (!result.response_ready() || result.consumed_bytes != cr_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::awaiting_association || + !extract_cotp( + std::span{response}.first(result.bytes_written), cc) || + cc.kind != osi::CotpWireKind::connection_confirm || + cc.destination_reference != 0x0001U || cc.source_reference != 0x1001U) { + return 4; + } + + const auto association_tpkt = build_data_tpkt( + kAssociationRequest, request, scratch); + result = runtime.process_tcp_window( + std::span{request}.first(association_tpkt.bytes_written), + response, + workspace); + osi::CotpTpduView association_response; + if (!association_tpkt.success() || !result.response_ready() || + runtime.state() != mms::MmsStaticConnectionState::established || + runtime.mms_presentation_context_id() != 3U || + !extract_cotp( + std::span{response}.first(result.bytes_written), + association_response) || + !matches(association_response.user_data, kAssociationResponse)) { + return 5; + } + + const auto read_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + if (!read_tpkt.success()) { + return 6; + } + + std::array coalesced{}; + std::copy_n(request.begin(), read_tpkt.bytes_written, coalesced.begin()); + std::copy_n( + request.begin(), + read_tpkt.bytes_written, + coalesced.begin() + static_cast(read_tpkt.bytes_written)); + const auto coalesced_bytes = read_tpkt.bytes_written * 2U; + + result = runtime.process_tcp_window( + std::span{coalesced}.first(coalesced_bytes), + response, + workspace); + osi::PresentationPdvView pdv; + if (!result.response_ready() || result.consumed_bytes != read_tpkt.bytes_written || + result.application_service != mms::MmsWireConfirmedService::read || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, kReadResponse)) { + return 7; + } + + const auto second_window = std::span{coalesced} + .subspan(result.consumed_bytes, read_tpkt.bytes_written); + result = runtime.process_tcp_window(second_window, response, workspace); + if (!result.response_ready() || result.consumed_bytes != read_tpkt.bytes_written || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kReadResponse)) { + return 8; + } + + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + result = runtime.process_tcp_window( + std::span{request}.first(read_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || + result.application_service != mms::MmsWireConfirmedService::read || + result.invoke_id != 12U) { + return 9; + } + } + + std::array identify_mms{}; + const auto identify = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 21U, + static_cast(mms::MmsWireConfirmedService::identify), + false, + {}, + identify_mms); + const auto identify_tpkt = identify.success() + ? build_mms_tpkt( + std::span{identify_mms}.first(identify.bytes_written), + request, + presentation, + scratch) + : wire::EncodeResult{}; + if (!identify.success() || !identify_tpkt.success()) { + return 10; + } + result = runtime.process_tcp_window( + std::span{request}.first(identify_tpkt.bytes_written), + response, + workspace); + if (result.status != mms::MmsStaticConnectionStatus::application_rejected || + 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) { + return 11; + } + + const auto write_tpkt = build_mms_tpkt( + kWriteRequest, request, presentation, scratch); + if (!write_tpkt.success() || !relay_state) { + return 12; + } + std::array small_response{}; + result = runtime.process_tcp_window( + std::span{request}.first(write_tpkt.bytes_written), + small_response, + workspace); + if (result.status != mms::MmsStaticConnectionStatus::response_buffer_too_small || + result.consumed_bytes != 0U || result.required_response_bytes <= small_response.size() || + !relay_state || runtime.state() != mms::MmsStaticConnectionState::established) { + return 13; + } + + result = runtime.process_tcp_window( + std::span{request}.first(write_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || relay_state || + result.application_service != mms::MmsWireConfirmedService::write || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kWriteResponse)) { + return 14; + } + + const auto segmented_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch, false); + result = runtime.process_tcp_window( + std::span{request}.first(segmented_tpkt.bytes_written), + response, + workspace); + if (!segmented_tpkt.success() || + result.status != mms::MmsStaticConnectionStatus::protocol_violation || + runtime.state() != mms::MmsStaticConnectionState::fault) { + return 15; + } + + runtime.reset(); + if (runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect || + runtime.mms_presentation_context_id() != 0U) { + return 16; + } + + mms::MmsStaticConnectionRuntime wrong_order{dispatcher}; + const auto out_of_order = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + result = wrong_order.process_tcp_window( + std::span{request}.first(out_of_order.bytes_written), + response, + workspace); + if (!out_of_order.success() || + result.status != mms::MmsStaticConnectionStatus::protocol_violation || + wrong_order.state() != mms::MmsStaticConnectionState::fault) { + return 17; + } + + return 0; +} diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp new file mode 100644 index 000000000..8cb2ddbc8 --- /dev/null +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticConnectionState : std::uint8_t { + awaiting_cotp_connect, + awaiting_association, + established, + closed, + fault, +}; + +enum class MmsStaticConnectionStatus : std::uint8_t { + need_more, + response_ready, + consumed_no_response, + malformed_transport, + protocol_violation, + application_rejected, + response_buffer_too_small, + workspace_too_small, + backend_failure, + closed, +}; + +struct MmsStaticConnectionPolicy final { + std::uint16_t cotp_source_reference{0x1001U}; + std::uint8_t maximum_tpdu_size_code{0x0AU}; + bool require_end_of_transmission{true}; +}; + +struct MmsStaticConnectionResult final { + MmsStaticConnectionStatus status{MmsStaticConnectionStatus::need_more}; + MmsStaticConnectionState state{MmsStaticConnectionState::awaiting_cotp_connect}; + std::size_t consumed_bytes{}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + MmsStaticDispatchStatus application_status{MmsStaticDispatchStatus::malformed_request}; + MmsWireConfirmedService application_service{MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticConnectionStatus::response_ready; + } +}; + +class MmsStaticConnectionRuntime final { +public: + explicit constexpr MmsStaticConnectionRuntime( + const MmsStaticApplicationDispatcher& dispatcher, + const MmsStaticConnectionPolicy policy = {}) noexcept + : dispatcher_{dispatcher}, policy_{policy} {} + + // Process at most the first complete TPKT frame in a TCP receive window. + // `consumed_bytes` identifies the prefix the transport adapter may remove. + // On need_more or buffer-capacity failures no input is consumed, allowing + // the same frame to be retried once more bytes/capacity are available. + [[nodiscard]] MmsStaticConnectionResult process_tcp_window( + std::span tcp_bytes, + std::span response, + std::span workspace) noexcept; + + constexpr void reset() noexcept { + state_ = MmsStaticConnectionState::awaiting_cotp_connect; + mms_presentation_context_id_ = 0U; + } + + [[nodiscard]] constexpr MmsStaticConnectionState state() const noexcept { + return state_; + } + + [[nodiscard]] constexpr std::uint32_t mms_presentation_context_id() const noexcept { + return mms_presentation_context_id_; + } + +private: + const MmsStaticApplicationDispatcher& dispatcher_; + MmsStaticConnectionPolicy policy_{}; + MmsStaticConnectionState state_{MmsStaticConnectionState::awaiting_cotp_connect}; + std::uint32_t mms_presentation_context_id_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp new file mode 100644 index 000000000..88e18abfe --- /dev/null +++ b/src/mms/connection_runtime.cpp @@ -0,0 +1,408 @@ +// 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 { + +[[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]] MmsStaticConnectionResult wrap_cotp_data_response( + const std::span session_or_presentation, + const std::size_t consumed, + const MmsStaticConnectionState state, + 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 required{}; + if (!add_overhead(session_or_presentation.size(), 7U, required)) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + if (response.size() < required) { + return make_response_capacity(state, required); + } + if (workspace.size() < required) { + return make_workspace_capacity(state, required); + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_or_presentation, + response.first(required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + if (cotp.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity( + state, cotp.required_bytes + osi::TpktSpanCodec::header_length); + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), workspace.first(required)); + if (!tpkt.success() || tpkt.bytes_written != required) { + if (tpkt.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state, tpkt.required_bytes); + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + return make_result( + MmsStaticConnectionStatus::response_ready, + state, + consumed, + 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]] bool write_outer_capacity( + const MmsConfirmedPduView& confirmed, + const MmsStaticApplicationDispatcher& dispatcher, + const std::uint32_t presentation_context_id, + 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); + return fully_encoded && add_overhead(*fully_encoded, 11U, required); +} + +} // namespace + +MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( + const std::span tcp_bytes, + const std::span response, + const std::span workspace) noexcept { + 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; + 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; + return make_result( + MmsStaticConnectionStatus::malformed_transport, state_, peek.frame_bytes); + } + + if (cotp.kind == osi::CotpWireKind::disconnect_request) { + state_ = MmsStaticConnectionState::closed; + 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; + 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_); + } + + state_ = MmsStaticConnectionState::awaiting_association; + return make_result( + MmsStaticConnectionStatus::response_ready, + state_, + peek.frame_bytes, + encoded.bytes_written); + } + + if (cotp.kind != osi::CotpWireKind::data || + (policy_.require_end_of_transmission && !cotp.end_of_transmission)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + if (state_ == MmsStaticConnectionState::awaiting_association) { + acse::AssociationRequestView association; + if (!acse::AcseSpanCodec::try_decode_association_request_view( + cotp.user_data, association)) { + state_ = MmsStaticConnectionState::fault; + 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; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + 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; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + const auto wrapped = wrap_cotp_data_response( + workspace.first(accept.bytes_written), + peek.frame_bytes, + MmsStaticConnectionState::established, + response, + workspace); + if (!wrapped.response_ready()) { + return wrapped; + } + mms_presentation_context_id_ = association.mms_presentation_context_id; + state_ = MmsStaticConnectionState::established; + auto result = wrapped; + result.state = state_; + return result; + } + + if (state_ != MmsStaticConnectionState::established) { + state_ = MmsStaticConnectionState::fault; + 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; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + MmsConfirmedPduView confirmed; + if (MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed)) { + std::size_t write_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); + } + if (workspace.size() < write_required) { + return make_workspace_capacity(state_, write_required); + } + } + } + + const auto application = dispatcher_.dispatch( + pdv.single_asn1_type, response, workspace); + if (!application.success()) { + if (application.status == MmsStaticDispatchStatus::response_buffer_too_small) { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + mms_presentation_context_id_, application.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 = application.invoke_id; + return result; + } + state_ = MmsStaticConnectionState::fault; + 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; + 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; + } + return application_rejected(state_, peek.frame_bytes, application); + } + + 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) { + std::size_t required{}; + if (add_overhead(p_data.required_bytes, 7U, required)) { + return make_workspace_capacity(state_, required); + } + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + auto wrapped = wrap_cotp_data_response( + workspace.first(p_data.bytes_written), + peek.frame_bytes, + state_, + response, + workspace); + if (wrapped.response_ready()) { + wrapped.application_status = application.status; + wrapped.application_service = application.service; + wrapped.invoke_id = application.invoke_id; + } + return wrapped; +} + +} // namespace ar::iec61850::mms From 724a0a87ad65e397e46907922592afa342742270 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 16:04:21 +0700 Subject: [PATCH 146/188] Port bounded MMS static DataSet directory --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 22 +- embedded/mms_data_set_hard_profile_smoke.cpp | 214 +++++++++++ include/ariec61850/mms/data_set_span.hpp | 63 ++++ .../ariec61850/mms/static_data_set_table.hpp | 51 +++ include/ariec61850/mms/static_dispatcher.hpp | 12 + src/mms/data_set_span.cpp | 355 ++++++++++++++++++ src/mms/static_data_set_table.cpp | 116 ++++++ src/mms/static_dispatcher.cpp | 101 ++++- 9 files changed, 918 insertions(+), 22 deletions(-) create mode 100644 embedded/mms_data_set_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/data_set_span.hpp create mode 100644 include/ariec61850/mms/static_data_set_table.hpp create mode 100644 src/mms/data_set_span.cpp create mode 100644 src/mms/static_data_set_table.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index 477e48b42..669009017 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -59,7 +59,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ build-hard-embedded/ariec61850_hard_mms_write_smoke \ build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ - build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke; do + build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_data_set_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -84,7 +85,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ build-hard-embedded/ariec61850_hard_mms_write_smoke \ build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ - build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke + build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_data_set_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 7eff63361..c4ae15eac 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -109,8 +109,10 @@ add_executable(ariec61850_hard_mms_dispatcher_smoke ${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/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_configure_hard_target(ariec61850_hard_mms_dispatcher_smoke) @@ -125,12 +127,27 @@ add_executable(ariec61850_hard_mms_connection_runtime_smoke ${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/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_configure_hard_target(ariec61850_hard_mms_connection_runtime_smoke) +add_executable(ariec61850_hard_mms_data_set_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_data_set_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/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_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_configure_hard_target(ariec61850_hard_mms_data_set_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -159,8 +176,11 @@ add_test( add_test( NAME ariec61850_hard_mms_connection_runtime_smoke COMMAND ariec61850_hard_mms_connection_runtime_smoke) +add_test( + NAME ariec61850_hard_mms_data_set_smoke + COMMAND ariec61850_hard_mms_data_set_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet gates=ON") diff --git a/embedded/mms_data_set_hard_profile_smoke.cpp b/embedded/mms_data_set_hard_profile_smoke.cpp new file mode 100644 index 000000000..958e67910 --- /dev/null +++ b/embedded/mms_data_set_hard_profile_smoke.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_set_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kAttributesRequest{ + 0xA0U, 0x19U, 0x02U, 0x01U, 0x01U, + 0xACU, 0x14U, 0xA1U, 0x12U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x0BU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; +constexpr std::array kAttributesResponse{ + 0xA1U, 0x28U, 0x02U, 0x01U, 0x01U, + 0xACU, 0x23U, 0x80U, 0x01U, 0x00U, + 0xA1U, 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 kDataSetNameListRequest{ + 0xA0U, 0x11U, 0x02U, 0x01U, 0x02U, + 0xA1U, 0x0CU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x02U, + 0xA1U, 0x05U, 0x81U, 0x03U, 0x4CU, 0x44U, 0x30U}; +constexpr std::array kDataSetNameListResponse{ + 0xA1U, 0x17U, 0x02U, 0x01U, 0x02U, + 0xA1U, 0x12U, 0xA0U, 0x0DU, + 0x1AU, 0x0BU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U, + 0x81U, 0x01U, 0x00U}; +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kEvents{ + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[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}; +} + +} // namespace + +int main() { + mms::MmsNamedVariableListAttributesRequestView request; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + kAttributesRequest, request) || + request.invoke_id != 1U || + request.name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(request.name.domain, kLd0) || !matches(request.name.item, kEvents)) { + return 1; + } + + const std::array encoded_members{ + mms::MmsNamedVariableListMemberInput{"LD0", "R1"}, + mms::MmsNamedVariableListMemberInput{"LD0", "M1"}}; + std::array buffer{}; + const auto encoded = + mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + 1U, false, encoded_members, buffer); + if (!encoded.success() || encoded.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kAttributesResponse)) { + return 2; + } + + mms::MmsNamedVariableListAttributesResponseView response; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + kAttributesResponse, response) || + response.invoke_id != 1U || response.mms_deletable || response.member_count != 2U) { + return 3; + } + mms::MmsObjectNameView member; + if (!response.try_member(0U, member) || + !matches(member.domain, kLd0) || !matches(member.item, kR1) || + !response.try_member(1U, member) || + !matches(member.domain, kLd0) || !matches(member.item, kM1) || + response.try_member(2U, member)) { + return 4; + } + + const bool relay_state = true; + const bool meter_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kBooleanType, read_boolean, &meter_state, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array event_members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "M1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid() || !data_set_table.valid_against(object_table)) { + return 5; + } + + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; + std::array workspace{}; + auto dispatched = dispatcher.dispatch(kAttributesRequest, buffer, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_named_variable_list_attributes || + dispatched.invoke_id != 1U || dispatched.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(dispatched.bytes_written), + kAttributesResponse)) { + return 6; + } + + dispatched = dispatcher.dispatch(kDataSetNameListRequest, buffer, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + dispatched.bytes_written != kDataSetNameListResponse.size() || + !matches( + std::span{buffer}.first(dispatched.bytes_written), + kDataSetNameListResponse)) { + return 7; + } + + mms::MmsGetNameListResponseView names; + std::span identifier; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(dispatched.bytes_written), names) || + names.identifier_count != 1U || names.more_follows || + !names.try_identifier(0U, identifier) || !matches(identifier, kEvents)) { + return 8; + } + + std::array tiny{}; + dispatched = dispatcher.dispatch(kAttributesRequest, tiny, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::response_buffer_too_small || + dispatched.required_bytes != kAttributesResponse.size()) { + return 9; + } + + auto missing_request = kAttributesRequest; + missing_request[26] = 0x58U; + dispatched = dispatcher.dispatch(missing_request, buffer, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::object_not_found) { + return 10; + } + + const std::array invalid_members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + const std::array invalid_data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Broken", invalid_members, false}}; + const mms::MmsStaticDataSetTable invalid_table{invalid_data_sets}; + if (!invalid_table.valid() || invalid_table.valid_against(object_table)) { + return 11; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto repeated = + mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + invoke, (iteration & 1U) != 0U, encoded_members, buffer); + mms::MmsNamedVariableListAttributesResponseView decoded; + if (!repeated.success() || + !mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + std::span{buffer}.first(repeated.bytes_written), decoded) || + decoded.invoke_id != invoke || decoded.member_count != 2U || + decoded.mms_deletable != ((iteration & 1U) != 0U)) { + return 12; + } + } + + const std::array invalid_name{ + mms::MmsNamedVariableListMemberInput{"LD0", std::string_view{"A\0B", 3U}}}; + if (mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + 1U, false, invalid_name, buffer).success()) { + return 13; + } + + return 0; +} diff --git a/include/ariec61850/mms/data_set_span.hpp b/include/ariec61850/mms/data_set_span.hpp new file mode 100644 index 000000000..fd40adbf9 --- /dev/null +++ b/include/ariec61850/mms/data_set_span.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsNamedVariableListAttributesRequestView final { + std::uint32_t invoke_id{}; + MmsObjectNameView name{}; +}; + +struct MmsNamedVariableListAttributesResponseView final { + std::uint32_t invoke_id{}; + bool mms_deletable{}; + std::span variable_list{}; + std::size_t member_count{}; + + [[nodiscard]] bool try_member( + std::size_t index, + MmsObjectNameView& name) const noexcept; +}; + +struct MmsNamedVariableListMemberInput final { + std::string_view domain; + std::string_view item; +}; + +class MmsDataSetSpanCodec final { +public: + static constexpr std::size_t maximum_members = MmsServiceSpanCodec::maximum_variables; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_request( + std::span mms_pdu, + MmsNamedVariableListAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesResponseView& response) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_response( + std::span mms_pdu, + MmsNamedVariableListAttributesResponseView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult + encode_get_named_variable_list_attributes_response_into( + std::uint32_t invoke_id, + bool mms_deletable, + std::span members, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_data_set_table.hpp b/include/ariec61850/mms/static_data_set_table.hpp new file mode 100644 index 000000000..e7fc7a59d --- /dev/null +++ b/include/ariec61850/mms/static_data_set_table.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/data_set_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsStaticDataSetMember final { + std::string_view domain; + std::string_view item; +}; + +struct MmsStaticDataSetEntry final { + std::string_view domain; + std::string_view item; + std::span members; + bool mms_deletable{}; +}; + +class MmsStaticDataSetTable final { +public: + static constexpr std::size_t maximum_data_sets = 64U; + + constexpr MmsStaticDataSetTable() noexcept = default; + + explicit constexpr MmsStaticDataSetTable( + const std::span data_sets) noexcept + : data_sets_{data_sets} {} + + [[nodiscard]] bool valid() const noexcept; + + [[nodiscard]] bool valid_against( + const MmsStaticObjectTable& objects) const noexcept; + + [[nodiscard]] const MmsStaticDataSetEntry* find( + const MmsObjectNameView& name) const noexcept; + + [[nodiscard]] constexpr std::span data_sets() const noexcept { + return data_sets_; + } + +private: + std::span data_sets_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_dispatcher.hpp b/include/ariec61850/mms/static_dispatcher.hpp index 0f0c4d540..87c65d580 100644 --- a/include/ariec61850/mms/static_dispatcher.hpp +++ b/include/ariec61850/mms/static_dispatcher.hpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include "ariec61850/mms/static_data_set_table.hpp" #include "ariec61850/mms/static_object_table.hpp" #include @@ -48,6 +49,12 @@ class MmsStaticApplicationDispatcher final { const MmsStaticDispatchPolicy policy = {}) noexcept : objects_{objects}, policy_{policy} {} + constexpr MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, data_sets_{data_sets}, policy_{policy} {} + [[nodiscard]] MmsStaticDispatchResult dispatch( std::span mms_request, std::span response, @@ -62,8 +69,13 @@ class MmsStaticApplicationDispatcher final { return policy_; } + [[nodiscard]] constexpr const MmsStaticDataSetTable& data_sets() const noexcept { + return data_sets_; + } + private: const MmsStaticObjectTable& objects_; + MmsStaticDataSetTable data_sets_{}; MmsStaticDispatchPolicy policy_{}; }; diff --git a/src/mms/data_set_span.cpp b/src/mms/data_set_span.cpp new file mode 100644 index 000000000..4deb74bc9 --- /dev/null +++ b/src/mms/data_set_span.cpp @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_set_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 { + +[[nodiscard]] bool valid_identifier(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const char ch : value) { + const auto byte = static_cast(ch); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional checked_add( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] std::optional member_encoded_size( + const MmsNamedVariableListMemberInput& member) noexcept { + if (!valid_identifier(member.domain) || !valid_identifier(member.item)) { + return std::nullopt; + } + const auto domain = asn1::BerSpanWriter::tlv_size(26, member.domain.size()); + const auto item = asn1::BerSpanWriter::tlv_size(26, member.item.size()); + const auto object_content = checked_add(domain, item); + if (!object_content) { + return std::nullopt; + } + const auto object_name = asn1::BerSpanWriter::tlv_size(1, *object_content); + if (!object_name) { + return std::nullopt; + } + const auto specification = asn1::BerSpanWriter::tlv_size(0, *object_name); + if (!specification) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(16, *specification); +} + +struct ResponseSizing final { + std::size_t invoke_value{}; + std::size_t list_content{}; + std::size_t service_content{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional response_sizing( + const std::uint32_t invoke_id, + const std::span members) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + members.size() > MmsDataSetSpanCodec::maximum_members) { + return std::nullopt; + } + + std::size_t list_content = 0U; + for (const auto& member : members) { + const auto encoded = member_encoded_size(member); + if (!encoded || *encoded > std::numeric_limits::max() - list_content) { + return std::nullopt; + } + list_content += *encoded; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(1, list_content); + const auto deletable_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto service_content = checked_add(deletable_tlv, list_tlv); + if (!list_tlv || !deletable_tlv || !service_content) { + return std::nullopt; + } + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(12, *service_content); + const auto outer_content = checked_add(invoke_tlv, service_tlv); + if (!invoke_tlv || !service_tlv || !outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + + return ResponseSizing{ + invoke_value, + list_content, + *service_content, + *outer_content, + *required}; +} + +[[nodiscard]] bool decode_member_definition( + const asn1::BerTlvView& definition, + MmsObjectNameView& name) noexcept { + name = {}; + if (definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView specification; + if (!asn1::BerSpanReader::try_read_exact(definition.value, specification) || + specification.tag_class != asn1::BerClass::context_specific || + specification.tag_number != 0 || !specification.constructed) { + return false; + } + if (!MmsServiceSpanCodec::try_decode_object_name_view(specification.value, name)) { + return false; + } + return name.kind == MmsObjectNameViewKind::domain_specific && + !name.domain.empty() && !name.item.empty(); +} + +[[nodiscard]] bool validate_member_list( + const std::span bytes, + std::size_t& count) noexcept { + count = 0U; + std::size_t offset = 0U; + while (offset < bytes.size()) { + if (count >= MmsDataSetSpanCodec::maximum_members) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + MmsObjectNameView name; + if (!asn1::BerSpanReader::try_read_tlv(bytes, offset, definition) || + !decode_member_definition(definition, name)) { + count = 0U; + return false; + } + ++count; + } + return true; +} + +} // namespace + +bool MmsNamedVariableListAttributesResponseView::try_member( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + name = {}; + if (index >= member_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition)) { + return false; + } + if (current == index) { + return decode_member_definition(definition, name); + } + ++current; + } + return false; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 12 || !confirmed.service_constructed) { + return false; + } + MmsObjectNameView name; + if (!MmsServiceSpanCodec::try_decode_object_name_view(confirmed.service_value, name) || + name.kind != MmsObjectNameViewKind::domain_specific) { + return false; + } + request.invoke_id = confirmed.invoke_id; + request.name = name; + return true; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + const std::span mms_pdu, + MmsNamedVariableListAttributesRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_get_named_variable_list_attributes_request(confirmed, request); +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 12 || !confirmed.service_constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView deletable; + asn1::BerTlvView list; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, deletable) || + !asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, list) || + offset != confirmed.service_value.size() || + deletable.tag_class != asn1::BerClass::context_specific || + deletable.tag_number != 0 || deletable.constructed || deletable.value.size() != 1U || + list.tag_class != asn1::BerClass::context_specific || + list.tag_number != 1 || !list.constructed) { + return false; + } + + std::size_t member_count = 0U; + if (!validate_member_list(list.value, member_count)) { + return false; + } + response.invoke_id = confirmed.invoke_id; + response.mms_deletable = deletable.value.front() != 0U; + response.variable_list = list.value; + response.member_count = member_count; + return true; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + const std::span mms_pdu, + MmsNamedVariableListAttributesResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_get_named_variable_list_attributes_response(confirmed, response); +} + +wire::EncodeResult MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + const std::uint32_t invoke_id, + const bool mms_deletable, + const std::span members, + const std::span destination) noexcept { + const auto sizing = response_sizing(invoke_id, members); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing->outer_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing->invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 12, sizing->service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(mms_deletable ? 0xFFU : 0x00U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing->list_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + + for (const auto& member : members) { + const auto domain_tlv = asn1::BerSpanWriter::tlv_size(26, member.domain.size()); + const auto item_tlv = asn1::BerSpanWriter::tlv_size(26, member.item.size()); + const auto object_content = checked_add(domain_tlv, item_tlv); + const auto object_name = object_content + ? asn1::BerSpanWriter::tlv_size(1, *object_content) + : std::nullopt; + const auto specification = object_name + ? asn1::BerSpanWriter::tlv_size(0, *object_name) + : std::nullopt; + if (!domain_tlv || !item_tlv || !object_content || !object_name || !specification || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *specification) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *object_name) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *object_content) || + !writer.write_tlv( + asn1::BerClass::universal, + false, + 26, + std::span{ + reinterpret_cast(member.domain.data()), + member.domain.size()}) || + !writer.write_tlv( + asn1::BerClass::universal, + false, + 26, + std::span{ + reinterpret_cast(member.item.data()), + member.item.size()})) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_data_set_table.cpp b/src/mms/static_data_set_table.cpp new file mode 100644 index 000000000..3fa764748 --- /dev/null +++ b/src/mms/static_data_set_table.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_data_set_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool valid_name(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const char ch : value) { + const auto byte = static_cast(ch); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool same_member( + const MmsStaticDataSetMember& left, + const MmsStaticDataSetMember& right) noexcept { + return left.domain == right.domain && left.item == right.item; +} + +} // namespace + +bool MmsStaticDataSetTable::valid() const noexcept { + if (data_sets_.size() > maximum_data_sets) { + return false; + } + for (std::size_t index = 0U; index < data_sets_.size(); ++index) { + const auto& data_set = data_sets_[index]; + if (!valid_name(data_set.domain) || !valid_name(data_set.item) || + data_set.members.empty() || + data_set.members.size() > MmsDataSetSpanCodec::maximum_members) { + return false; + } + for (std::size_t earlier = 0U; earlier < index; ++earlier) { + if (data_sets_[earlier].domain == data_set.domain && + data_sets_[earlier].item == data_set.item) { + return false; + } + } + for (std::size_t member = 0U; member < data_set.members.size(); ++member) { + const auto& current = data_set.members[member]; + if (!valid_name(current.domain) || !valid_name(current.item)) { + return false; + } + for (std::size_t earlier = 0U; earlier < member; ++earlier) { + if (same_member(data_set.members[earlier], current)) { + return false; + } + } + } + } + return true; +} + +bool MmsStaticDataSetTable::valid_against( + const MmsStaticObjectTable& objects) const noexcept { + if (!valid() || !objects.valid()) { + return false; + } + for (const auto& data_set : data_sets_) { + for (const auto& member : data_set.members) { + const MmsObjectNameView name{ + MmsObjectNameViewKind::domain_specific, + std::span{ + reinterpret_cast(member.domain.data()), + member.domain.size()}, + std::span{ + reinterpret_cast(member.item.data()), + member.item.size()}}; + if (objects.find(name) == nullptr) { + return false; + } + } + } + return true; +} + +const MmsStaticDataSetEntry* MmsStaticDataSetTable::find( + const MmsObjectNameView& name) const noexcept { + if (name.kind != MmsObjectNameViewKind::domain_specific) { + return nullptr; + } + for (const auto& data_set : data_sets_) { + if (equals(name.domain, data_set.domain) && equals(name.item, data_set.item)) { + return &data_set; + } + } + return nullptr; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp index 089a3f292..ebc8ace4d 100644 --- a/src/mms/static_dispatcher.cpp +++ b/src/mms/static_dispatcher.cpp @@ -76,29 +76,43 @@ namespace { [[nodiscard]] bool policy_valid(const MmsStaticDispatchPolicy& policy) noexcept { return policy.maximum_names_per_response > 0U && - policy.maximum_names_per_response <= MmsStaticObjectTable::maximum_objects && + policy.maximum_names_per_response <= MmsServiceSpanCodec::maximum_identifiers && policy.maximum_write_variables > 0U && policy.maximum_write_variables <= MmsServiceSpanCodec::maximum_variables; } +[[nodiscard]] bool append_unique( + std::array& names, + std::size_t& count, + const std::string_view value) noexcept { + for (std::size_t index = 0U; index < count; ++index) { + if (names[index] == value) { + return true; + } + } + if (count >= names.size()) { + return false; + } + names[count++] = value; + return true; +} + [[nodiscard]] std::size_t collect_names( const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, const MmsGetNameListRequestView& request, - std::array& names) noexcept { + std::array& names) noexcept { std::size_t count = 0U; if (request.object_class == MmsNameListObjectClass::domain && request.scope == MmsNameScopeKind::vmd_specific) { for (const auto& object : objects.objects()) { - bool duplicate = false; - for (std::size_t index = 0U; index < count; ++index) { - if (names[index] == object.domain) { - duplicate = true; - break; - } + if (!append_unique(names, count, object.domain)) { + return names.size() + 1U; } - if (!duplicate) { - names[count] = object.domain; - ++count; + } + for (const auto& data_set : data_sets.data_sets()) { + if (!append_unique(names, count, data_set.domain)) { + return names.size() + 1U; } } return count; @@ -108,17 +122,34 @@ namespace { request.scope == MmsNameScopeKind::domain_specific) { for (const auto& object : objects.objects()) { if (span_equals(request.domain_id, object.domain)) { - names[count] = object.item; - ++count; + if (count >= names.size()) { + return names.size() + 1U; + } + names[count++] = object.item; + } + } + return count; + } + + if (request.object_class == MmsNameListObjectClass::named_variable_list && + request.scope == MmsNameScopeKind::domain_specific) { + for (const auto& data_set : data_sets.data_sets()) { + if (span_equals(request.domain_id, data_set.domain)) { + if (count >= names.size()) { + return names.size() + 1U; + } + names[count++] = data_set.item; } } return count; } - return MmsStaticObjectTable::maximum_objects + 1U; + + return names.size() + 1U; } [[nodiscard]] MmsStaticDispatchResult dispatch_get_name_list( const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, const MmsStaticDispatchPolicy& policy, const MmsConfirmedPduView& confirmed, const std::span response) noexcept { @@ -127,9 +158,9 @@ namespace { return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); } - std::array names{}; - const auto name_count = collect_names(objects, request, names); - if (name_count > MmsStaticObjectTable::maximum_objects) { + std::array names{}; + const auto name_count = collect_names(objects, data_sets, request, names); + if (name_count > names.size()) { return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); } @@ -198,6 +229,36 @@ namespace { response)); } +[[nodiscard]] MmsStaticDispatchResult dispatch_data_set_attributes( + const MmsStaticDataSetTable& data_sets, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsNamedVariableListAttributesRequestView request; + if (!MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* data_set = data_sets.find(request.name); + if (data_set == nullptr) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + + std::array members{}; + for (std::size_t index = 0U; index < data_set->members.size(); ++index) { + members[index] = MmsNamedVariableListMemberInput{ + data_set->members[index].domain, + data_set->members[index].item}; + } + return make_encoded( + confirmed, + MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + confirmed.invoke_id, + data_set->mms_deletable, + std::span{members}.first( + data_set->members.size()), + response)); +} + [[nodiscard]] MmsStaticDispatchResult dispatch_read( const MmsStaticObjectTable& objects, const MmsStaticDispatchPolicy& policy, @@ -327,7 +388,7 @@ MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( const MmsConfirmedPduView& request, const std::span response, const std::span workspace) const noexcept { - if (!objects_.valid() || !policy_valid(policy_)) { + if (!objects_.valid() || !data_sets_.valid_against(objects_) || !policy_valid(policy_)) { return make_status(MmsStaticDispatchStatus::invalid_object_table, request); } if (request.kind != MmsWirePduKind::confirmed_request) { @@ -336,9 +397,11 @@ MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( switch (request.service()) { case MmsWireConfirmedService::get_name_list: - return dispatch_get_name_list(objects_, policy_, request, response); + return dispatch_get_name_list(objects_, data_sets_, policy_, request, response); case MmsWireConfirmedService::get_variable_access_attributes: return dispatch_attributes(objects_, request, response); + case MmsWireConfirmedService::get_named_variable_list_attributes: + return dispatch_data_set_attributes(data_sets_, request, response); case MmsWireConfirmedService::read: return dispatch_read(objects_, policy_, request, response, workspace); case MmsWireConfirmedService::write: From 9f1fe3e18ce6c7f6fd6095eb099551a5a86e515c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 16:22:15 +0700 Subject: [PATCH 147/188] Port bounded MMS InformationReport snapshot encoder --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 17 +- ..._information_report_hard_profile_smoke.cpp | 276 ++++++++ .../mms/information_report_span.hpp | 64 ++ .../mms/static_information_report.hpp | 63 ++ src/mms/information_report_span.cpp | 606 ++++++++++++++++++ src/mms/static_information_report.cpp | 163 +++++ 7 files changed, 1192 insertions(+), 3 deletions(-) create mode 100644 embedded/mms_information_report_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/information_report_span.hpp create mode 100644 include/ariec61850/mms/static_information_report.hpp create mode 100644 src/mms/information_report_span.cpp create mode 100644 src/mms/static_information_report.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index 669009017..efca28fd6 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -60,7 +60,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_write_smoke \ build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ - build-hard-embedded/ariec61850_hard_mms_data_set_smoke; do + build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ + build-hard-embedded/ariec61850_hard_mms_information_report_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -86,7 +87,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_write_smoke \ build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ - build-hard-embedded/ariec61850_hard_mms_data_set_smoke + build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ + build-hard-embedded/ariec61850_hard_mms_information_report_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index c4ae15eac..e07920b1b 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -148,6 +148,18 @@ add_executable(ariec61850_hard_mms_data_set_smoke ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_data_set_smoke) +add_executable(ariec61850_hard_mms_information_report_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_information_report_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/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_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_information_report_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -179,8 +191,11 @@ add_test( add_test( NAME ariec61850_hard_mms_data_set_smoke COMMAND ariec61850_hard_mms_data_set_smoke) +add_test( + NAME ariec61850_hard_mms_information_report_smoke + COMMAND ariec61850_hard_mms_information_report_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport gates=ON") diff --git a/embedded/mms_information_report_hard_profile_smoke.cpp b/embedded/mms_information_report_hard_profile_smoke.cpp new file mode 100644 index 000000000..7a44e5a53 --- /dev/null +++ b/embedded/mms_information_report_hard_profile_smoke.cpp @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_information_report.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; +constexpr std::array kBooleanTrue{0x83U, 0x01U, 0x01U}; +constexpr std::array kBooleanFalse{0x83U, 0x01U, 0x00U}; +constexpr std::array kIntData{0x85U, 0x01U, 0x2AU}; +constexpr std::array kFirstReference{ + 0x8AU, 0x06U, 0x4CU, 0x44U, 0x30U, 0x2FU, 0x52U, 0x31U}; +constexpr std::array kReason{ + 0x84U, 0x02U, 0x02U, 0x08U}; +constexpr std::array kOptionalFields{0x7CU, 0x80U}; +constexpr std::array kBadSegmentationFields{0x7CU, 0xC0U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, 0x01U}; +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kEvents{ + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; +constexpr std::array kRpt{0x52U, 0x50U, 0x54U}; + +constexpr std::array kExpectedReport{ + 0xA3U, 0x56U, 0xA0U, 0x54U, 0xA1U, 0x05U, 0x80U, 0x03U, + 0x52U, 0x50U, 0x54U, 0xA0U, 0x4BU, 0x8AU, 0x03U, 0x72U, + 0x70U, 0x74U, 0x84U, 0x03U, 0x06U, 0x7CU, 0x80U, 0x86U, + 0x01U, 0x01U, 0x8CU, 0x06U, 0x00U, 0x00U, 0x00U, 0x01U, + 0x00U, 0x01U, 0x8AU, 0x0FU, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x45U, 0x76U, 0x65U, + 0x6EU, 0x74U, 0x73U, 0x86U, 0x01U, 0x01U, 0x84U, 0x02U, + 0x06U, 0xC0U, 0x8AU, 0x06U, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x52U, 0x31U, 0x8AU, 0x06U, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x4DU, 0x31U, 0x83U, 0x01U, 0x01U, 0x85U, 0x01U, 0x2AU, + 0x84U, 0x02U, 0x02U, 0x08U, 0x84U, 0x02U, 0x02U, 0x08U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[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) ? 0x01U : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + 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}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + const std::array references{ + mms::MmsInformationReportReferenceInput{"LD0", "R1"}, + mms::MmsInformationReportReferenceInput{"LD0", "M1"}}; + const std::array member_results{ + mms::MmsReadAccessResultInput{true, kBooleanTrue, 0U}, + mms::MmsReadAccessResultInput{true, kIntData, 0U}}; + + mms::MmsInformationReportSnapshotInput report; + report.report_id = "rpt"; + report.optional_fields = kOptionalFields; + report.sequence_number = 1U; + report.report_time = kReportTime; + report.data_set_reference = {"LD0", "LLN0$Events"}; + report.buffered = false; + report.conf_revision = 1U; + report.member_references = references; + report.member_results = member_results; + report.reason_for_inclusion = 0x08U; + + std::array buffer{}; + const auto encoded = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + report, buffer); + if (!encoded.success() || encoded.bytes_written != kExpectedReport.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kExpectedReport)) { + return 1; + } + + mms::MmsInformationReportView decoded; + if (!mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.variable_list_name.kind != mms::MmsObjectNameViewKind::vmd_specific || + !matches(decoded.variable_list_name.item, kRpt) || decoded.item_count != 13U) { + return 2; + } + + mms::MmsReadAccessResultView item; + if (!decoded.try_item(7U, item) || !item.success || + !matches(item.encoded_data, kFirstReference) || + !decoded.try_item(9U, item) || !item.success || + !matches(item.encoded_data, kBooleanTrue) || + !decoded.try_item(10U, item) || !item.success || + !matches(item.encoded_data, kIntData) || + !decoded.try_item(11U, item) || !item.success || + !matches(item.encoded_data, kReason) || decoded.try_item(13U, item)) { + return 3; + } + + std::array small_response{}; + const auto too_small = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + report, small_response); + if (too_small.status != wire::EncodeStatus::buffer_too_small || + too_small.required_bytes != kExpectedReport.size()) { + return 4; + } + + auto invalid_report = report; + invalid_report.optional_fields = kBadSegmentationFields; + if (mms::MmsInformationReportSpanCodec::encode_snapshot_into( + invalid_report, buffer).status != wire::EncodeStatus::value_out_of_range) { + return 5; + } + + std::array failure_results{ + member_results[0], + mms::MmsReadAccessResultInput{false, {}, 10U}}; + auto failure_report = report; + failure_report.member_results = failure_results; + const auto failure_encoded = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + failure_report, buffer); + if (!failure_encoded.success() || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(failure_encoded.bytes_written), + decoded) || + !decoded.try_item(10U, item) || item.success || item.failure_code != 10U) { + return 6; + } + + bool relay_state = true; + std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array event_members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "M1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!object_table.valid() || !data_set_table.valid() || + !data_set_table.valid_against(object_table)) { + return 7; + } + + const mms::MmsObjectNameView data_set_name{ + mms::MmsObjectNameViewKind::domain_specific, + kLd0, + kEvents}; + mms::MmsStaticInformationReportInput static_report; + static_report.report_id = "rpt"; + static_report.optional_fields = kOptionalFields; + static_report.sequence_number = 1U; + static_report.report_time = kReportTime; + static_report.buffered = false; + static_report.conf_revision = 1U; + static_report.reason_for_inclusion = 0x08U; + + std::array workspace{}; + const auto static_encoded = + mms::MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + object_table, + data_set_table, + data_set_name, + static_report, + buffer, + workspace); + if (!static_encoded.success() || static_encoded.member_count != 2U || + static_encoded.bytes_written != kExpectedReport.size() || + !matches( + std::span{buffer}.first(static_encoded.bytes_written), + kExpectedReport)) { + return 8; + } + + // The report has no shadow value store: changing the callback-owned source is + // visible in the next DataSet snapshot without rebuilding the DataSet model. + relay_state = false; + const auto changed = + mms::MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + object_table, + data_set_table, + data_set_name, + static_report, + buffer, + workspace); + if (!changed.success() || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(changed.bytes_written), + decoded) || + !decoded.try_item(9U, item) || !item.success || + !matches(item.encoded_data, kBooleanFalse)) { + return 9; + } + + std::array tiny_workspace{}; + const auto workspace_shortage = + mms::MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + object_table, + data_set_table, + data_set_name, + static_report, + buffer, + tiny_workspace); + if (workspace_shortage.status != + mms::MmsStaticInformationReportStatus::workspace_too_small || + workspace_shortage.required_bytes != 3U) { + return 10; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + report.sequence_number = iteration; + const auto repeated = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + report, buffer); + mms::MmsInformationReportView repeated_decoded; + if (!repeated.success() || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(repeated.bytes_written), + repeated_decoded) || + repeated_decoded.item_count != 13U) { + return 11; + } + } + + return 0; +} diff --git a/include/ariec61850/mms/information_report_span.hpp b/include/ariec61850/mms/information_report_span.hpp new file mode 100644 index 000000000..c1d1ee1f6 --- /dev/null +++ b/include/ariec61850/mms/information_report_span.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsInformationReportReferenceInput final { + std::string_view domain; + std::string_view item; +}; + +struct MmsInformationReportSnapshotInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + MmsInformationReportReferenceInput data_set_reference{}; + bool buffered{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + std::span member_references; + std::span member_results; + std::uint8_t reason_for_inclusion{}; +}; + +struct MmsInformationReportView final { + MmsObjectNameView variable_list_name{}; + std::span access_result_list{}; + std::size_t item_count{}; + + [[nodiscard]] bool try_item( + std::size_t index, + MmsReadAccessResultView& result) const noexcept; +}; + +class MmsInformationReportSpanCodec final { +public: + static constexpr std::size_t maximum_members = MmsServiceSpanCodec::maximum_variables; + static constexpr std::size_t maximum_reference_bytes = 129U; + static constexpr std::size_t maximum_report_id_bytes = 129U; + static constexpr std::size_t optional_field_bytes = 2U; + static constexpr std::size_t binary_time_bytes = 6U; + static constexpr std::size_t entry_id_bytes = 8U; + static constexpr std::size_t maximum_access_results = + (3U * maximum_members) + 16U; + + [[nodiscard]] static bool try_decode_information_report( + std::span mms_pdu, + MmsInformationReportView& report) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_snapshot_into( + const MmsInformationReportSnapshotInput& report, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms 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/src/mms/information_report_span.cpp b/src/mms/information_report_span.cpp new file mode 100644 index 000000000..85afbf644 --- /dev/null +++ b/src/mms/information_report_span.cpp @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/information_report_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/pdu_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kOptSequenceNumber = 0x40U; +constexpr std::uint8_t kOptReportTimeStamp = 0x20U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; +constexpr std::uint8_t kOptDataSetName = 0x08U; +constexpr std::uint8_t kOptDataReference = 0x04U; +constexpr std::uint8_t kOptBufferOverflow = 0x02U; +constexpr std::uint8_t kOptEntryId = 0x01U; +constexpr std::uint8_t kOptConfRevision = 0x80U; +constexpr std::uint8_t kOptSegmentation = 0x40U; + +[[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 span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] bool add_to( + std::size_t& total, + const std::optional value) noexcept { + if (!value || *value > std::numeric_limits::max() - total) { + return false; + } + total += *value; + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_unsigned_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + for (std::size_t index = bytes; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool write_positive_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + if (needs_positive_prefix(value, bytes) && !writer.write_byte(0x00U)) { + return false; + } + return write_unsigned_content(writer, value); +} + +[[nodiscard]] bool valid_mms_data_tlv(const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] bool decode_access_result( + const std::span encoded, + MmsReadAccessResultView& result) noexcept { + result = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv)) { + return false; + } + if (tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 0 && !tlv.constructed) { + const auto failure = asn1::BerSpanReader::read_uint32(tlv); + if (!failure) { + return false; + } + result.success = false; + result.failure_code = *failure; + return true; + } + if (!valid_mms_data_tlv(tlv)) { + return false; + } + result.success = true; + result.encoded_data = encoded; + return true; +} + +[[nodiscard]] std::optional access_result_size( + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return std::nullopt; + } + return result.encoded_data.size(); + } + return asn1::BerSpanWriter::tlv_size(0, positive_integer_size(result.failure_code)); +} + +[[nodiscard]] bool write_access_result( + asn1::BerSpanWriter& writer, + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + return writer.write_bytes(result.encoded_data); + } + const auto content = positive_integer_size(result.failure_code); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, content) && + write_positive_content(writer, result.failure_code); +} + +[[nodiscard]] std::optional reference_content_size( + const MmsInformationReportReferenceInput& reference) noexcept { + if (!visible_ascii(reference.domain, MmsServiceSpanCodec::maximum_identifier_bytes) || + !visible_ascii(reference.item, MmsServiceSpanCodec::maximum_identifier_bytes)) { + return std::nullopt; + } + if (reference.item.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U || + reference.domain.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U - reference.item.size()) { + return std::nullopt; + } + return reference.domain.size() + 1U + reference.item.size(); +} + +[[nodiscard]] bool write_reference_data( + asn1::BerSpanWriter& writer, + const MmsInformationReportReferenceInput& reference) noexcept { + const auto content = reference_content_size(reference); + if (!content || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, *content) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.domain.data()), + reference.domain.size()}) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.item.data()), + reference.item.size()})) { + return false; + } + return true; +} + +[[nodiscard]] bool write_visible_data( + asn1::BerSpanWriter& writer, + const std::string_view text) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) && + writer.write_bytes(std::span{ + reinterpret_cast(text.data()), text.size()}); +} + +[[nodiscard]] bool write_bit_string_data( + asn1::BerSpanWriter& writer, + const std::uint8_t unused_bits, + const std::span bytes) noexcept { + return unused_bits <= 7U && + writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes.size()) && + writer.write_byte(unused_bits) && writer.write_bytes(bytes); +} + +[[nodiscard]] bool write_unsigned_data( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto content = minimal_unsigned_size(value); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content) && + write_unsigned_content(writer, value); +} + +[[nodiscard]] bool write_boolean_data( + asn1::BerSpanWriter& writer, + const bool value) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 3, 1U) && + writer.write_byte(value ? 0x01U : 0x00U); +} + +[[nodiscard]] bool optional_selected( + const MmsInformationReportSnapshotInput& report, + const std::size_t byte_index, + const std::uint8_t mask) noexcept { + return report.optional_fields.size() > byte_index && + (report.optional_fields[byte_index] & mask) != 0U; +} + +[[nodiscard]] bool validate_report( + const MmsInformationReportSnapshotInput& report) noexcept { + if (!visible_ascii( + report.report_id, + MmsInformationReportSpanCodec::maximum_report_id_bytes) || + report.optional_fields.size() != + MmsInformationReportSpanCodec::optional_field_bytes || + report.member_results.empty() || + report.member_results.size() > MmsInformationReportSpanCodec::maximum_members || + report.member_references.size() != report.member_results.size()) { + return false; + } + if ((report.optional_fields[0] & 0x80U) != 0U || + (report.optional_fields[1] & kOptSegmentation) != 0U || + (report.optional_fields[1] & 0x3FU) != 0U) { + return false; + } + if (!reference_content_size(report.data_set_reference)) { + return false; + } + for (const auto& reference : report.member_references) { + if (!reference_content_size(reference)) { + return false; + } + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + report.report_time.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return false; + } + if ((optional_selected(report, 0U, kOptBufferOverflow) || + optional_selected(report, 0U, kOptEntryId)) && !report.buffered) { + return false; + } + if (optional_selected(report, 0U, kOptEntryId) && + report.entry_id.size() != MmsInformationReportSpanCodec::entry_id_bytes) { + return false; + } + if (optional_selected(report, 0U, kOptReasonForInclusion) && + (report.reason_for_inclusion & 0x03U) != 0U) { + return false; + } + for (const auto& result : report.member_results) { + if (!access_result_size(result)) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional access_list_content_size( + const MmsInformationReportSnapshotInput& report) noexcept { + std::size_t total = 0U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(10, report.report_id.size())) || + !add_to(total, asn1::BerSpanWriter::tlv_size( + 4, 1U + report.optional_fields.size()))) { + return std::nullopt; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.sequence_number)))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + !add_to(total, asn1::BerSpanWriter::tlv_size(12, report.report_time.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptDataSetName)) { + const auto content = reference_content_size(report.data_set_reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + if (optional_selected(report, 0U, kOptBufferOverflow) && + !add_to(total, asn1::BerSpanWriter::tlv_size(3, 1U))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptEntryId) && + !add_to(total, asn1::BerSpanWriter::tlv_size(9, report.entry_id.size()))) { + return std::nullopt; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.conf_revision)))) { + return std::nullopt; + } + + const auto inclusion_bytes = (report.member_results.size() + 7U) / 8U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + inclusion_bytes))) { + return std::nullopt; + } + + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.member_references) { + const auto content = reference_content_size(reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + } + + for (const auto& result : report.member_results) { + if (!add_to(total, access_result_size(result))) { + return std::nullopt; + } + } + + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(4, 2U); + for (std::size_t index = 0U; index < report.member_results.size(); ++index) { + if (!add_to(total, reason_tlv)) { + return std::nullopt; + } + } + } + return total; +} + +[[nodiscard]] bool write_inclusion( + asn1::BerSpanWriter& writer, + const std::size_t member_count) noexcept { + const auto bytes = (member_count + 7U) / 8U; + const auto unused = static_cast((bytes * 8U) - member_count); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes) || + !writer.write_byte(unused)) { + return false; + } + for (std::size_t index = 0U; index < bytes; ++index) { + const auto first_member = index * 8U; + const auto remaining = member_count - first_member; + const auto used = remaining >= 8U ? 8U : remaining; + const auto value = used == 8U + ? std::uint8_t{0xFFU} + : static_cast(0xFFU << (8U - used)); + if (!writer.write_byte(value)) { + return false; + } + } + return true; +} + +} // namespace + +bool MmsInformationReportView::try_item( + const std::size_t index, + MmsReadAccessResultView& result) const noexcept { + result = {}; + if (index >= item_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < access_result_list.size()) { + const auto start = offset; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(access_result_list, offset, item)) { + return false; + } + if (current == index) { + return decode_access_result( + access_result_list.subspan(start, offset - start), result); + } + ++current; + } + return false; +} + +bool MmsInformationReportSpanCodec::try_decode_information_report( + const std::span mms_pdu, + MmsInformationReportView& report) noexcept { + report = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(mms_pdu, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != 3 || !outer.constructed) { + return false; + } + + asn1::BerTlvView service; + if (!asn1::BerSpanReader::try_read_exact(outer.value, service) || + service.tag_class != asn1::BerClass::context_specific || + service.tag_number != 0 || !service.constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView variable_access; + asn1::BerTlvView access_results; + if (!asn1::BerSpanReader::try_read_tlv(service.value, offset, variable_access) || + !asn1::BerSpanReader::try_read_tlv(service.value, offset, access_results) || + offset != service.value.size() || + variable_access.tag_class != asn1::BerClass::context_specific || + variable_access.tag_number != 1 || !variable_access.constructed || + access_results.tag_class != asn1::BerClass::context_specific || + access_results.tag_number != 0 || !access_results.constructed) { + return false; + } + + MmsObjectNameView list_name; + if (!MmsServiceSpanCodec::try_decode_object_name_view( + variable_access.value, list_name) || + list_name.kind != MmsObjectNameViewKind::vmd_specific || + !span_equals(list_name.item, "RPT")) { + return false; + } + + std::size_t result_offset = 0U; + std::size_t count = 0U; + while (result_offset < access_results.value.size()) { + if (count >= maximum_access_results) { + return false; + } + const auto start = result_offset; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv( + access_results.value, result_offset, item)) { + return false; + } + MmsReadAccessResultView decoded; + if (!decode_access_result( + access_results.value.subspan(start, result_offset - start), decoded)) { + return false; + } + ++count; + } + if (count == 0U) { + return false; + } + + report.variable_list_name = list_name; + report.access_result_list = access_results.value; + report.item_count = count; + return true; +} + +wire::EncodeResult MmsInformationReportSpanCodec::encode_snapshot_into( + const MmsInformationReportSnapshotInput& report, + const std::span destination) noexcept { + if (!validate_report(report)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto access_content = access_list_content_size(report); + const auto access_tlv = access_content + ? asn1::BerSpanWriter::tlv_size(0, *access_content) + : std::nullopt; + const auto vmd_name_tlv = asn1::BerSpanWriter::tlv_size(0, 3U); + const auto variable_access_tlv = vmd_name_tlv + ? asn1::BerSpanWriter::tlv_size(1, *vmd_name_tlv) + : std::nullopt; + const auto information_content = add_size(variable_access_tlv, access_tlv); + const auto service_tlv = information_content + ? asn1::BerSpanWriter::tlv_size(0, *information_content) + : std::nullopt; + const auto required = service_tlv + ? asn1::BerSpanWriter::tlv_size(3, *service_tlv) + : std::nullopt; + if (!access_content || !access_tlv || !vmd_name_tlv || + !variable_access_tlv || !information_content || !service_tlv || !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, 3, *service_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *information_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *vmd_name_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 3U) || + !writer.write_bytes(std::span{ + reinterpret_cast("RPT"), 3U}) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *access_content) || + !write_visible_data(writer, report.report_id) || + !write_bit_string_data(writer, 6U, report.optional_fields)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !write_unsigned_data(writer, report.sequence_number)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 12, report.report_time.size()) || + !writer.write_bytes(report.report_time))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptDataSetName) && + !write_reference_data(writer, report.data_set_reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptBufferOverflow) && + !write_boolean_data(writer, report.buffer_overflow)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptEntryId) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 9, report.entry_id.size()) || + !writer.write_bytes(report.entry_id))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !write_unsigned_data(writer, report.conf_revision)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (!write_inclusion(writer, report.member_results.size())) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.member_references) { + if (!write_reference_data(writer, reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + for (const auto& result : report.member_results) { + if (!write_access_result(writer, result)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const std::uint8_t reason = report.reason_for_inclusion; + for (std::size_t index = 0U; index < report.member_results.size(); ++index) { + if (!write_bit_string_data( + writer, 2U, std::span{&reason, 1U})) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::mms 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 From 1cfa374b525f40c9ca91625897707b71b0fc3831 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 16:39:57 +0700 Subject: [PATCH 148/188] Port bounded MMS URCB runtime --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 18 +- .../mms_urcb_runtime_hard_profile_smoke.cpp | 317 +++++++++ .../ariec61850/mms/static_urcb_runtime.hpp | 192 ++++++ src/mms/static_urcb_runtime.cpp | 612 ++++++++++++++++++ 5 files changed, 1142 insertions(+), 3 deletions(-) create mode 100644 embedded/mms_urcb_runtime_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/static_urcb_runtime.hpp create mode 100644 src/mms/static_urcb_runtime.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index efca28fd6..40fbe39dd 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -61,7 +61,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ - build-hard-embedded/ariec61850_hard_mms_information_report_smoke; do + build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -88,7 +89,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ - build-hard-embedded/ariec61850_hard_mms_information_report_smoke + build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index e07920b1b..a5db888ba 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -160,6 +160,19 @@ add_executable(ariec61850_hard_mms_information_report_smoke ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_information_report_smoke) +add_executable(ariec61850_hard_mms_urcb_runtime_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_runtime_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/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_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_urcb_runtime_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -194,8 +207,11 @@ add_test( add_test( NAME ariec61850_hard_mms_information_report_smoke COMMAND ariec61850_hard_mms_information_report_smoke) +add_test( + NAME ariec61850_hard_mms_urcb_runtime_smoke + COMMAND ariec61850_hard_mms_urcb_runtime_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport/URCB gates=ON") diff --git a/embedded/mms_urcb_runtime_hard_profile_smoke.cpp b/embedded/mms_urcb_runtime_hard_profile_smoke.cpp new file mode 100644 index 000000000..425ab239f --- /dev/null +++ b/embedded/mms_urcb_runtime_hard_profile_smoke.cpp @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#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}; + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[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}; +} + +[[nodiscard]] bool decode_unsigned( + const mms::MmsReadAccessResultView& result, + std::uint32_t& value) noexcept { + value = 0U; + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(result.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; +} + +[[nodiscard]] bool decode_boolean( + const mms::MmsReadAccessResultView& result, + bool& value) noexcept { + value = false; + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, 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; +} + +} // 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_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 7U, + {0x7CU, 0x80U}, + 0U, + 0x0CU, + 1'000U}}; + std::array states{}; + mms::MmsStaticUrcbRuntime runtime{ + definitions, states, object_table, data_set_table}; + if (!runtime.initialize() || !runtime.valid() || runtime.size() != 1U) { + return 1; + } + + const auto* initial = runtime.state(0U); + if (initial == nullptr || initial->enabled || initial->sequence_number != 0U || + initial->report_id() != "LD0/LLN0$RP$Events" || + initial->data_set_domain() != "LD0" || + initial->data_set_item() != "LLN0$Events") { + return 2; + } + + std::size_t found = 99U; + const mms::MmsObjectNameView rcb_name{ + mms::MmsObjectNameViewKind::domain_specific, + as_bytes("LD0"), + as_bytes("LLN0$RP$Events")}; + if (!runtime.find_index(rcb_name, found) || found != 0U) { + return 3; + } + + mms::MmsStaticUrcbEmissionPlan plan; + if (runtime.next_due(0U, plan) || + runtime.request_general_interrogation(0U) != + mms::MmsStaticUrcbStatus::temporarily_unavailable) { + return 4; + } + + if (runtime.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok) { + return 5; + } + const auto* enabled = runtime.state(0U); + if (enabled == nullptr || !enabled->enabled || !enabled->integrity_armed || + enabled->next_integrity_due_ms != 1'100U || enabled->sequence_number != 0U) { + return 6; + } + if (runtime.set_report_id(0U, "blocked") != + mms::MmsStaticUrcbStatus::object_access_denied || + runtime.set_data_set(0U, "LD0", "LLN0$Events") != + mms::MmsStaticUrcbStatus::object_access_denied || + runtime.set_integrity_period_ms(0U, 500U) != + mms::MmsStaticUrcbStatus::object_access_denied) { + return 7; + } + + if (runtime.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok || + !runtime.next_due(100U, plan) || plan.index != 0U || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + plan.sequence_number != 1U) { + return 8; + } + + std::array tiny{}; + std::array output{}; + std::array workspace{}; + auto encoded = runtime.encode(plan, kReportTime, tiny, workspace); + if (encoded.status != mms::MmsStaticUrcbStatus::response_buffer_too_small || + encoded.required_bytes <= tiny.size()) { + return 9; + } + const auto* before_commit = runtime.state(0U); + if (before_commit == nullptr || before_commit->sequence_number != 0U || + !before_commit->general_interrogation_pending) { + return 10; + } + + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.member_count != 2U) { + return 11; + } + mms::MmsInformationReportView report; + if (!mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{output}.first(encoded.bytes_written), report) || + report.item_count != 13U) { + return 12; + } + mms::MmsReadAccessResultView item; + std::uint32_t sequence = 0U; + if (!report.try_item(2U, item) || !decode_unsigned(item, sequence) || sequence != 1U) { + return 13; + } + bool relay_value = false; + bool alarm_value = true; + if (!report.try_item(9U, item) || !decode_boolean(item, relay_value) || !relay_value || + !report.try_item(10U, item) || !decode_boolean(item, alarm_value) || alarm_value) { + return 14; + } + + if (runtime.commit(plan, 100U) != mms::MmsStaticUrcbStatus::ok) { + return 15; + } + const auto* after_gi = runtime.state(0U); + if (after_gi == nullptr || after_gi->sequence_number != 1U || + after_gi->general_interrogation_pending || + after_gi->next_integrity_due_ms != 1'100U || + runtime.commit(plan, 100U) != mms::MmsStaticUrcbStatus::stale_plan) { + return 16; + } + + if (runtime.next_due(1'099U, plan) || !runtime.next_due(1'100U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::integrity || + plan.sequence_number != 2U) { + return 17; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || runtime.commit(plan, 1'100U) != mms::MmsStaticUrcbStatus::ok) { + return 18; + } + const auto* after_integrity = runtime.state(0U); + if (after_integrity == nullptr || after_integrity->sequence_number != 2U || + after_integrity->next_integrity_due_ms != 2'100U) { + return 19; + } + + if (!runtime.next_due(5'500U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::integrity || + runtime.commit(plan, 5'500U) != mms::MmsStaticUrcbStatus::ok) { + return 20; + } + const auto* no_catch_up = runtime.state(0U); + if (no_catch_up == nullptr || no_catch_up->sequence_number != 3U || + no_catch_up->next_integrity_due_ms != 6'500U) { + return 21; + } + + if (runtime.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok || + !runtime.next_due(5'500U, plan) || + runtime.set_reserved(0U, true) != mms::MmsStaticUrcbStatus::ok) { + return 22; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (encoded.status != mms::MmsStaticUrcbStatus::stale_plan || + !runtime.next_due(5'500U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation) { + return 23; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || runtime.commit(plan, 5'500U) != mms::MmsStaticUrcbStatus::ok) { + return 24; + } + + if (runtime.set_enabled(0U, false, 6'000U) != mms::MmsStaticUrcbStatus::ok || + runtime.request_general_interrogation(0U) != + mms::MmsStaticUrcbStatus::temporarily_unavailable) { + return 25; + } + const std::array invalid_optional{0x7CU, 0x40U}; + const std::array compact_optional{0x5CU, 0x80U}; + if (runtime.set_report_id(0U, "LD0/LLN0$RP$Runtime") != + mms::MmsStaticUrcbStatus::ok || + runtime.set_data_set(0U, "LD0", "Missing") != + mms::MmsStaticUrcbStatus::data_set_not_found || + runtime.set_optional_fields(0U, invalid_optional) != + mms::MmsStaticUrcbStatus::invalid_value || + runtime.set_optional_fields(0U, compact_optional) != + mms::MmsStaticUrcbStatus::ok || + runtime.set_trigger_options(0U, 0x08U) != mms::MmsStaticUrcbStatus::ok || + runtime.set_integrity_period_ms(0U, 50U) != mms::MmsStaticUrcbStatus::ok || + runtime.set_buffer_time_ms(0U, 10U) != mms::MmsStaticUrcbStatus::ok) { + return 26; + } + + if (runtime.set_enabled(0U, true, 7'000U) != mms::MmsStaticUrcbStatus::ok) { + return 27; + } + const auto* clamped = runtime.state(0U); + if (clamped == nullptr || clamped->sequence_number != 0U || + clamped->next_integrity_due_ms != 7'100U || + runtime.next_due(7'099U, plan) || !runtime.next_due(7'100U, plan)) { + return 28; + } + + // Keep time below the integrity deadline and exercise repeated GI generation. + // The two-phase plan must preserve every report across buffer retries and wrap SqNum at 8 bits. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + if (runtime.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok || + !runtime.next_due(7'000U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation) { + return 29; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || + runtime.commit(plan, 7'000U) != mms::MmsStaticUrcbStatus::ok) { + return 30; + } + } + const auto* wrapped = runtime.state(0U); + if (wrapped == nullptr || wrapped->sequence_number != 80U || + wrapped->next_integrity_due_ms != 7'100U) { + return 31; + } + + const std::array duplicates{ + definitions[0], definitions[0]}; + std::array duplicate_states{}; + mms::MmsStaticUrcbRuntime duplicate_runtime{ + duplicates, duplicate_states, object_table, data_set_table}; + if (duplicate_runtime.initialize()) { + return 32; + } + + return 0; +} 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/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 From d7f6afe45245ccee1805d1981972016ffc5bb5d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 17:12:20 +0700 Subject: [PATCH 149/188] Expose bounded URCB attributes through MMS --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 22 +- .../mms_urcb_objects_hard_profile_smoke.cpp | 430 ++++++++++++ .../ariec61850/mms/static_urcb_objects.hpp | 89 +++ src/mms/static_urcb_objects.cpp | 630 ++++++++++++++++++ 5 files changed, 1174 insertions(+), 3 deletions(-) create mode 100644 embedded/mms_urcb_objects_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/static_urcb_objects.hpp create mode 100644 src/mms/static_urcb_objects.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index 40fbe39dd..c98c22ad8 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -62,7 +62,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ - build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke; do + build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -90,7 +91,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ - build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke + build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index a5db888ba..1db5e7f86 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -173,6 +173,23 @@ add_executable(ariec61850_hard_mms_urcb_runtime_smoke ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_urcb_runtime_smoke) +add_executable(ariec61850_hard_mms_urcb_objects_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_objects_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/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/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_objects.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_urcb_objects_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -210,8 +227,11 @@ add_test( add_test( NAME ariec61850_hard_mms_urcb_runtime_smoke COMMAND ariec61850_hard_mms_urcb_runtime_smoke) +add_test( + NAME ariec61850_hard_mms_urcb_objects_smoke + COMMAND ariec61850_hard_mms_urcb_objects_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport/URCB gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport/URCB/RCB-object gates=ON") diff --git a/embedded/mms_urcb_objects_hard_profile_smoke.cpp b/embedded/mms_urcb_objects_hard_profile_smoke.cpp new file mode 100644 index 000000000..45077e391 --- /dev/null +++ b/embedded/mms_urcb_objects_hard_profile_smoke.cpp @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/static_urcb_objects.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kTrueData{0x83U, 0x01U, 0xFFU}; +constexpr std::array kFalseData{0x83U, 0x01U, 0x00U}; +constexpr std::array kCustomReportId{ + 0x8AU, 0x08U, + 'C', 'U', 'S', 'T', 'O', 'M', '0', '1'}; + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] mms::MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + mms::MmsObjectNameViewKind::domain_specific, + as_bytes(domain), + as_bytes(item)}; +} + +[[nodiscard]] std::uint64_t fake_now_ms(const void* context) noexcept { + return context == nullptr + ? std::uint64_t{0U} + : *static_cast(context); +} + +[[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}; +} + +[[nodiscard]] std::optional definition_size( + const std::string_view domain, + const std::string_view item) noexcept { + const auto domain_tlv = asn1::BerSpanWriter::tlv_size(26, domain.size()); + const auto item_tlv = asn1::BerSpanWriter::tlv_size(26, item.size()); + if (!domain_tlv || !item_tlv || + *item_tlv > std::numeric_limits::max() - *domain_tlv) { + return std::nullopt; + } + const auto object_content = *domain_tlv + *item_tlv; + const auto object = asn1::BerSpanWriter::tlv_size(1, object_content); + if (!object) { + return std::nullopt; + } + const auto name_wrapper = asn1::BerSpanWriter::tlv_size(0, *object); + if (!name_wrapper) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(16, *name_wrapper); +} + +[[nodiscard]] bool write_definition( + asn1::BerSpanWriter& writer, + const std::string_view domain, + const std::string_view item) noexcept { + const auto domain_tlv = asn1::BerSpanWriter::tlv_size(26, domain.size()); + const auto item_tlv = asn1::BerSpanWriter::tlv_size(26, item.size()); + if (!domain_tlv || !item_tlv) { + return false; + } + const auto object_content = *domain_tlv + *item_tlv; + const auto object = asn1::BerSpanWriter::tlv_size(1, object_content); + const auto name_wrapper = object + ? asn1::BerSpanWriter::tlv_size(0, *object) + : std::optional{}; + if (!object || !name_wrapper) { + return false; + } + return writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *name_wrapper) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *object) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, object_content) && + writer.write_tlv( + asn1::BerClass::universal, false, 26, as_bytes(domain)) && + writer.write_tlv( + asn1::BerClass::universal, false, 26, as_bytes(item)); +} + +[[nodiscard]] wire::EncodeResult encode_single_read_request( + const std::uint32_t invoke_id, + const std::string_view domain, + const std::string_view item, + const std::span destination) noexcept { + const auto definition = definition_size(domain, item); + const auto list = definition + ? asn1::BerSpanWriter::tlv_size(0, *definition) + : std::optional{}; + const auto specification = list + ? asn1::BerSpanWriter::tlv_size(1, *list) + : std::optional{}; + if (!definition || !list || !specification || *specification > 256U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::array service{}; + asn1::BerSpanWriter writer{service}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *list) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *definition) || + !write_definition(writer, domain, item) || writer.size() != *specification) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + return mms::MmsPduSpanCodec::encode_confirmed_request_into( + invoke_id, + static_cast(mms::MmsWireConfirmedService::read), + true, + std::span{service}.first(writer.size()), + destination); +} + +[[nodiscard]] wire::EncodeResult encode_single_write_request( + const std::uint32_t invoke_id, + const std::string_view domain, + const std::string_view item, + const std::span encoded_data, + const std::span destination) noexcept { + const auto definition = definition_size(domain, item); + const auto list = definition + ? asn1::BerSpanWriter::tlv_size(0, *definition) + : std::optional{}; + const auto data_list = asn1::BerSpanWriter::tlv_size(0, encoded_data.size()); + if (!definition || !list || !data_list || + *data_list > std::numeric_limits::max() - *list || + *list + *data_list > 256U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::array service{}; + asn1::BerSpanWriter writer{service}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *definition) || + !write_definition(writer, domain, item) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, encoded_data.size()) || + !writer.write_bytes(encoded_data) || writer.size() != *list + *data_list) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + return mms::MmsPduSpanCodec::encode_confirmed_request_into( + invoke_id, + static_cast(mms::MmsWireConfirmedService::write), + true, + std::span{service}.first(writer.size()), + destination); +} + +} // namespace + +int main() { + bool relay_state = true; + bool alarm_state = false; + const std::array base_objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "A1", kBooleanType, read_boolean, &alarm_state, false}}; + const mms::MmsStaticObjectTable base_table{base_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 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 runtime{ + definitions, states, base_table, data_sets}; + if (!runtime.initialize()) { + return 1; + } + + std::uint64_t now_ms = 100U; + std::array object_storage{}; + std::array context_storage{}; + std::array name_storage{}; + mms::MmsStaticUrcbObjectBank bank{ + runtime, + base_objects, + object_storage, + context_storage, + name_storage, + fake_now_ms, + &now_ms}; + if (bank.required_object_capacity() != object_storage.size() || + bank.required_context_capacity() != context_storage.size() || + bank.required_name_bytes() == 0U || + bank.required_name_bytes() > name_storage.size() || + !bank.initialize() || !bank.valid() || + bank.object_count() != object_storage.size() || !bank.table().valid()) { + return 2; + } + + const auto rpt_ena_name = object_name("LD0", "LLN0$RP$Events$RptEna"); + const auto rpt_id_name = object_name("LD0", "LLN0$RP$Events$RptID"); + const auto gi_name = object_name("LD0", "LLN0$RP$Events$GI"); + const auto sq_num_name = object_name("LD0", "LLN0$RP$Events$SqNum"); + const auto* rpt_ena = bank.table().find(rpt_ena_name); + const auto* rpt_id = bank.table().find(rpt_id_name); + const auto* gi = bank.table().find(gi_name); + const auto* sq_num = bank.table().find(sq_num_name); + if (rpt_ena == nullptr || rpt_id == nullptr || gi == nullptr || sq_num == nullptr || + !rpt_ena->writable() || !rpt_id->writable() || !gi->writable() || + sq_num->writable()) { + return 3; + } + + std::array read_buffer{}; + auto read = rpt_ena->read(rpt_ena->context, read_buffer); + if (!read.success() || read.bytes_written != kFalseData.size() || + !std::equal( + read_buffer.begin(), + read_buffer.begin() + static_cast(read.bytes_written), + kFalseData.begin())) { + return 4; + } + std::array tiny{}; + read = rpt_ena->read(rpt_ena->context, tiny); + if (read.status != wire::EncodeStatus::buffer_too_small || read.required_bytes != 3U) { + return 5; + } + + const auto report_id_write = rpt_id->write(rpt_id->write_context, kCustomReportId); + const auto* state = runtime.state(0U); + if (!report_id_write.success || state == nullptr || state->report_id() != "CUSTOM01") { + return 6; + } + + mms::MmsStaticApplicationDispatcher dispatcher{bank.table(), data_sets}; + std::array request{}; + std::array response{}; + std::array workspace{}; + + const auto enable_request = encode_single_write_request( + 21U, + "LD0", + "LLN0$RP$Events$RptEna", + kTrueData, + request); + if (!enable_request.success()) { + return 7; + } + auto dispatched = dispatcher.dispatch( + std::span{request}.first(enable_request.bytes_written), + response, + workspace); + mms::MmsWriteResponseView write_response; + mms::MmsWriteAccessResultView write_result; + state = runtime.state(0U); + if (!dispatched.success() || dispatched.service != mms::MmsWireConfirmedService::write || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{response}.first(dispatched.bytes_written), + write_response) || + !write_response.try_result(0U, write_result) || !write_result.success || + state == nullptr || !state->enabled || !state->integrity_armed || + state->next_integrity_due_ms != 1'100U) { + return 8; + } + + const auto read_request = encode_single_read_request( + 22U, + "LD0", + "LLN0$RP$Events$RptEna", + request); + if (!read_request.success()) { + return 9; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(read_request.bytes_written), + response, + workspace); + mms::MmsReadResponseView read_response; + mms::MmsReadAccessResultView access; + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + read_response) || + !read_response.try_result(0U, access) || !access.success || + access.encoded_data.size() != kTrueData.size() || + !std::equal(access.encoded_data.begin(), access.encoded_data.end(), kTrueData.begin())) { + return 10; + } + + const auto denied_request = encode_single_write_request( + 23U, + "LD0", + "LLN0$RP$Events$RptID", + kCustomReportId, + request); + if (!denied_request.success()) { + return 11; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(denied_request.bytes_written), + response, + workspace); + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{response}.first(dispatched.bytes_written), + write_response) || + !write_response.try_result(0U, write_result) || write_result.success || + write_result.failure_code != 3U) { + return 12; + } + + const auto gi_request = encode_single_write_request( + 24U, + "LD0", + "LLN0$RP$Events$GI", + kTrueData, + request); + if (!gi_request.success()) { + return 13; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(gi_request.bytes_written), + response, + workspace); + state = runtime.state(0U); + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{response}.first(dispatched.bytes_written), + write_response) || + !write_response.try_result(0U, write_result) || !write_result.success || + state == nullptr || !state->general_interrogation_pending) { + return 14; + } + + mms::MmsStaticUrcbEmissionPlan plan; + if (!runtime.next_due(now_ms, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + plan.sequence_number != 1U) { + return 15; + } + + now_ms = 200U; + const auto disable_request = encode_single_write_request( + 25U, + "LD0", + "LLN0$RP$Events$RptEna", + kFalseData, + request); + if (!disable_request.success()) { + return 16; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(disable_request.bytes_written), + response, + workspace); + state = runtime.state(0U); + if (!dispatched.success() || state == nullptr || state->enabled || + state->general_interrogation_pending || state->integrity_armed) { + return 17; + } + + const auto gi_while_disabled = gi->write(gi->write_context, kTrueData); + if (gi_while_disabled.success || gi_while_disabled.failure_code != 2U) { + return 18; + } + + read = sq_num->read(sq_num->context, read_buffer); + if (!read.success() || read.bytes_written != 3U || + read_buffer[0] != 0x86U || read_buffer[1] != 0x01U || read_buffer[2] != 0x00U) { + return 19; + } + + // Storage shortages fail closed without publishing a partial object table. + std::array short_objects{}; + mms::MmsStaticUrcbObjectBank short_bank{ + runtime, + base_objects, + short_objects, + context_storage, + name_storage, + fake_now_ms, + &now_ms}; + if (short_bank.initialize() || short_bank.valid()) { + return 20; + } + + return 0; +} 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/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 From e2a7dc423c4d2e05504c825eb370047aa8a45327 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 17:19:05 +0700 Subject: [PATCH 150/188] Add retry-safe MMS report transport --- .../workflows/embedded-hard-profile-ci.yml | 6 +- embedded/hard_profile/CMakeLists.txt | 74 ++--- ...s_report_connection_hard_profile_smoke.cpp | 309 ++++++++++++++++++ .../mms/static_report_connection.hpp | 52 +++ src/mms/static_report_connection.cpp | 215 ++++++++++++ 5 files changed, 617 insertions(+), 39 deletions(-) create mode 100644 embedded/mms_report_connection_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/static_report_connection.hpp create mode 100644 src/mms/static_report_connection.cpp diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml index c98c22ad8..9169db5d0 100644 --- a/.github/workflows/embedded-hard-profile-ci.yml +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -63,7 +63,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke \ - build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke; do + build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke \ + build-hard-embedded/ariec61850_hard_mms_report_connection_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -92,7 +93,8 @@ jobs: build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke \ - build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke + build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke \ + build-hard-embedded/ariec61850_hard_mms_report_connection_smoke } | tee build-hard-embedded/hard-embedded-footprint.txt - name: Upload hard-profile evidence diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 1db5e7f86..6dfc824c2 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -190,48 +190,48 @@ add_executable(ariec61850_hard_mms_urcb_objects_smoke ${ARIEC61850_ROOT}/src/mms/static_urcb_objects.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_urcb_objects_smoke) +add_executable(ariec61850_hard_mms_report_connection_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_report_connection_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/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_report_connection.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_report_connection_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) add_test(NAME ariec61850_hard_osi_transport_smoke COMMAND ariec61850_hard_osi_transport_smoke) -add_test( - NAME ariec61850_hard_session_presentation_smoke - COMMAND ariec61850_hard_session_presentation_smoke) -add_test( - NAME ariec61850_hard_association_smoke - COMMAND ariec61850_hard_association_smoke) -add_test( - NAME ariec61850_hard_mms_pdu_smoke - COMMAND ariec61850_hard_mms_pdu_smoke) -add_test( - NAME ariec61850_hard_mms_get_name_list_smoke - COMMAND ariec61850_hard_mms_get_name_list_smoke) -add_test( - NAME ariec61850_hard_mms_static_model_smoke - COMMAND ariec61850_hard_mms_static_model_smoke) -add_test( - NAME ariec61850_hard_mms_write_smoke - COMMAND ariec61850_hard_mms_write_smoke) -add_test( - NAME ariec61850_hard_mms_dispatcher_smoke - COMMAND ariec61850_hard_mms_dispatcher_smoke) -add_test( - NAME ariec61850_hard_mms_connection_runtime_smoke - COMMAND ariec61850_hard_mms_connection_runtime_smoke) -add_test( - NAME ariec61850_hard_mms_data_set_smoke - COMMAND ariec61850_hard_mms_data_set_smoke) -add_test( - NAME ariec61850_hard_mms_information_report_smoke - COMMAND ariec61850_hard_mms_information_report_smoke) -add_test( - NAME ariec61850_hard_mms_urcb_runtime_smoke - COMMAND ariec61850_hard_mms_urcb_runtime_smoke) -add_test( - NAME ariec61850_hard_mms_urcb_objects_smoke - COMMAND ariec61850_hard_mms_urcb_objects_smoke) +add_test(NAME ariec61850_hard_session_presentation_smoke COMMAND ariec61850_hard_session_presentation_smoke) +add_test(NAME ariec61850_hard_association_smoke COMMAND ariec61850_hard_association_smoke) +add_test(NAME ariec61850_hard_mms_pdu_smoke COMMAND ariec61850_hard_mms_pdu_smoke) +add_test(NAME ariec61850_hard_mms_get_name_list_smoke COMMAND ariec61850_hard_mms_get_name_list_smoke) +add_test(NAME ariec61850_hard_mms_static_model_smoke COMMAND ariec61850_hard_mms_static_model_smoke) +add_test(NAME ariec61850_hard_mms_write_smoke COMMAND ariec61850_hard_mms_write_smoke) +add_test(NAME ariec61850_hard_mms_dispatcher_smoke COMMAND ariec61850_hard_mms_dispatcher_smoke) +add_test(NAME ariec61850_hard_mms_connection_runtime_smoke COMMAND ariec61850_hard_mms_connection_runtime_smoke) +add_test(NAME ariec61850_hard_mms_data_set_smoke COMMAND ariec61850_hard_mms_data_set_smoke) +add_test(NAME ariec61850_hard_mms_information_report_smoke COMMAND ariec61850_hard_mms_information_report_smoke) +add_test(NAME ariec61850_hard_mms_urcb_runtime_smoke COMMAND ariec61850_hard_mms_urcb_runtime_smoke) +add_test(NAME ariec61850_hard_mms_urcb_objects_smoke COMMAND ariec61850_hard_mms_urcb_objects_smoke) +add_test(NAME ariec61850_hard_mms_report_connection_smoke COMMAND ariec61850_hard_mms_report_connection_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") message(STATUS "arstack61850 hard embedded profile: host services excluded") -message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport/URCB/RCB-object gates=ON") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport/URCB/RCB-object/report-transport gates=ON") diff --git a/embedded/mms_report_connection_hard_profile_smoke.cpp b/embedded/mms_report_connection_hard_profile_smoke.cpp new file mode 100644 index 000000000..e8941198a --- /dev/null +++ b/embedded/mms_report_connection_hard_profile_smoke.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/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/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 From 4584aa6b935591f50a128eaf7ccd7523000648d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 17:28:14 +0700 Subject: [PATCH 151/188] Add event-driven bounded URCB reporting --- .../workflows/urcb-event-hard-profile-ci.yml | 75 +++ .../mms_urcb_event_hard_profile_smoke.cpp | 256 ++++++++++ .../urcb_event_hard_profile/CMakeLists.txt | 50 ++ .../mms/selective_information_report_span.hpp | 39 ++ .../mms/static_urcb_event_runtime.hpp | 114 +++++ src/mms/selective_information_report_span.cpp | 446 ++++++++++++++++++ src/mms/static_urcb_event_runtime.cpp | 391 +++++++++++++++ 7 files changed, 1371 insertions(+) create mode 100644 .github/workflows/urcb-event-hard-profile-ci.yml create mode 100644 embedded/mms_urcb_event_hard_profile_smoke.cpp create mode 100644 embedded/urcb_event_hard_profile/CMakeLists.txt create mode 100644 include/ariec61850/mms/selective_information_report_span.hpp create mode 100644 include/ariec61850/mms/static_urcb_event_runtime.hpp create mode 100644 src/mms/selective_information_report_span.cpp create mode 100644 src/mms/static_urcb_event_runtime.cpp diff --git a/.github/workflows/urcb-event-hard-profile-ci.yml b/.github/workflows/urcb-event-hard-profile-ci.yml new file mode 100644 index 000000000..e6b1871c0 --- /dev/null +++ b/.github/workflows/urcb-event-hard-profile-ci.yml @@ -0,0 +1,75 @@ +name: URCB Event Hard Profile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + urcb-event-hard-profile: + name: URCB Event Hard / ${{ 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 URCB event hard profile + run: cmake -S embedded/urcb_event_hard_profile -B build-urcb-event-hard + + - name: Build URCB event hard profile + run: cmake --build build-urcb-event-hard --parallel + + - name: Run URCB event smoke + run: >- + ctest --test-dir build-urcb-event-hard + --output-on-failure + --output-junit urcb-event-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + binary=build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + size build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke + } | tee build-urcb-event-hard/urcb-event-footprint.txt + + - name: Upload URCB event evidence + uses: actions/upload-artifact@v4 + with: + name: urcb-event-hard-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-urcb-event-hard/urcb-event-ctest.xml + build-urcb-event-hard/urcb-event-footprint.txt diff --git a/embedded/mms_urcb_event_hard_profile_smoke.cpp b/embedded/mms_urcb_event_hard_profile_smoke.cpp new file mode 100644 index 000000000..8d13a3d5a --- /dev/null +++ b/embedded/mms_urcb_event_hard_profile_smoke.cpp @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_urcb_event_runtime.hpp" + +#include +#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}; + +[[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}; +} + +[[nodiscard]] bool decode_bit_string( + const mms::MmsReadAccessResultView& result, + const std::uint8_t expected_unused, + const std::uint8_t expected_value) noexcept { + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 4 && !tlv.constructed && tlv.value.size() == 2U && + tlv.value[0] == expected_unused && tlv.value[1] == expected_value; +} + +[[nodiscard]] bool decode_boolean( + const mms::MmsReadAccessResultView& result, + const bool expected) noexcept { + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 3 && !tlv.constructed && tlv.value.size() == 1U && + (tlv.value[0] != 0U) == expected; +} + +} // namespace + +int main() { + bool first = true; + bool second = false; + bool third = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &first, false}, + mms::MmsStaticObjectEntry{"LD0", "X2", kBooleanType, read_boolean, &second, false}, + mms::MmsStaticObjectEntry{"LD0", "X3", kBooleanType, read_boolean, &third, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}, + mms::MmsStaticDataSetMember{"LD0", "X2"}, + mms::MmsStaticDataSetMember{"LD0", "X3"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 9U, + {0x7CU, 0x80U}, + 20U, + 0x70U, + 0U}}; + std::array urcb_states{}; + mms::MmsStaticUrcbRuntime urcb{ + definitions, urcb_states, object_table, data_set_table}; + if (!urcb.initialize()) { + return 1; + } + + std::array event_states{}; + mms::MmsStaticUrcbEventRuntime events{ + urcb, event_states, object_table, data_set_table}; + if (!events.initialize() || !events.valid()) { + return 2; + } + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 100U) != + mms::MmsStaticUrcbEventStatus::temporarily_unavailable) { + return 3; + } + if (urcb.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok) { + return 4; + } + + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 110U) != + mms::MmsStaticUrcbEventStatus::ok || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::quality_change, 115U) != + mms::MmsStaticUrcbEventStatus::ok || + events.notify(0U, 2U, mms::MmsStaticUrcbEventReason::data_update, 119U) != + mms::MmsStaticUrcbEventStatus::ok) { + return 5; + } + const auto* pending = events.state(0U); + if (pending == nullptr || !pending->pending || pending->pending_member_count != 2U || + pending->due_ms != 130U || pending->member_reason_masks[0] != 0xC0U || + pending->member_reason_masks[1] != 0U || pending->member_reason_masks[2] != 0x20U) { + return 6; + } + + mms::MmsStaticUrcbEventPlan plan; + if (events.next_due(129U, plan) || !events.next_due(130U, plan) || + plan.index != 0U || plan.sequence_number != 1U) { + return 7; + } + + std::array tiny{}; + std::array output{}; + std::array workspace{}; + auto encoded = events.encode(plan, kReportTime, tiny, workspace); + if (encoded.status != mms::MmsStaticUrcbEventStatus::response_buffer_too_small || + encoded.required_bytes <= tiny.size()) { + return 8; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.data_set_member_count != 3U || + encoded.included_member_count != 2U) { + return 9; + } + + mms::MmsInformationReportView report; + if (!mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{output}.first(encoded.bytes_written), report) || + report.item_count != 13U) { + return 10; + } + mms::MmsReadAccessResultView item; + // Full DataSet cardinality is three; only members 0 and 2 are included. + if (!report.try_item(6U, item) || !decode_bit_string(item, 5U, 0xA0U)) { + return 11; + } + if (!report.try_item(9U, item) || !decode_boolean(item, true) || + !report.try_item(10U, item) || !decode_boolean(item, true)) { + return 12; + } + // Member 0 coalesced dchg + qchg; member 2 is dupd. + if (!report.try_item(11U, item) || !decode_bit_string(item, 2U, 0xC0U) || + !report.try_item(12U, item) || !decode_bit_string(item, 2U, 0x20U)) { + return 13; + } + + if (events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::stale_plan) { + return 14; + } + const auto* control = urcb.state(0U); + pending = events.state(0U); + if (control == nullptr || control->sequence_number != 1U || + pending == nullptr || pending->pending || pending->pending_member_count != 0U) { + return 15; + } + + // A new event after planning invalidates the old event revision. + if (events.notify(0U, 1U, mms::MmsStaticUrcbEventReason::data_change, 200U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(220U, plan) || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::quality_change, 220U) != + mms::MmsStaticUrcbEventStatus::ok || + events.encode(plan, kReportTime, output, workspace).status != + mms::MmsStaticUrcbEventStatus::stale_plan || + !events.next_due(220U, plan)) { + return 16; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.included_member_count != 2U || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 17; + } + + // Reconfiguration invalidates pending source revision rather than leaking + // a pre-disable event into a later enable interval. + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 300U) != + mms::MmsStaticUrcbEventStatus::ok || + urcb.set_enabled(0U, false, 301U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_enabled(0U, true, 302U) != mms::MmsStaticUrcbStatus::ok || + events.next_due(400U, plan) || + events.notify(0U, 2U, mms::MmsStaticUrcbEventReason::data_update, 400U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(420U, plan)) { + return 18; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.included_member_count != 1U || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 19; + } + + // Secure trigger selection: a qchg event cannot bypass TrgOps. + if (urcb.set_enabled(0U, false, 500U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_trigger_options(0U, 0x40U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_buffer_time_ms(0U, 0U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_enabled(0U, true, 500U) != mms::MmsStaticUrcbStatus::ok || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::quality_change, 500U) != + mms::MmsStaticUrcbEventStatus::trigger_not_selected || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 500U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(500U, plan)) { + return 20; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 21; + } + + // Repeated immediate events prove deterministic bounded replay and 8-bit + // SqNum wrapping without heap-backed pending queues. + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 600U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(600U, plan)) { + return 22; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 23; + } + } + + if (events.notify(0U, 3U, mms::MmsStaticUrcbEventReason::data_change, 700U) != + mms::MmsStaticUrcbEventStatus::member_out_of_range) { + return 24; + } + + return 0; +} diff --git a/embedded/urcb_event_hard_profile/CMakeLists.txt b/embedded/urcb_event_hard_profile/CMakeLists.txt new file mode 100644 index 000000000..202c3b55b --- /dev/null +++ b/embedded/urcb_event_hard_profile/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850UrcbEventHardProfile + VERSION 0.1.0 + DESCRIPTION "No-exception bounded IEC 61850 URCB event-report profile" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "URCB event hard profile is validated with GCC/Clang -fno-exceptions.") +endif() + +add_executable(ariec61850_hard_mms_urcb_event_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_event_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/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_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_event_runtime.cpp) + +target_compile_features(ariec61850_hard_mms_urcb_event_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_hard_mms_urcb_event_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_hard_mms_urcb_event_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) +target_compile_options(ariec61850_hard_mms_urcb_event_smoke PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) + +enable_testing() +add_test( + NAME ariec61850_hard_mms_urcb_event_smoke + COMMAND ariec61850_hard_mms_urcb_event_smoke) + +message(STATUS "arstack61850 URCB event hard profile: dchg/qchg/dupd + BufTm=ON") +message(STATUS "arstack61850 URCB event hard profile: exceptions/RTTI disabled=ON") diff --git a/include/ariec61850/mms/selective_information_report_span.hpp b/include/ariec61850/mms/selective_information_report_span.hpp new file mode 100644 index 000000000..f6f516b4d --- /dev/null +++ b/include/ariec61850/mms/selective_information_report_span.hpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +// Event-report form of InformationReport. The inclusion bit-string keeps the +// original DataSet cardinality while only the selected member values are +// encoded. This is the shape required by change-driven IEC 61850 reports. +struct MmsSelectiveInformationReportSnapshotInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + MmsInformationReportReferenceInput data_set_reference{}; + std::uint32_t conf_revision{}; + + std::size_t data_set_member_count{}; + std::span included_member_indices; + std::span included_member_references; + std::span included_member_results; + std::span included_reason_for_inclusion; +}; + +class MmsSelectiveInformationReportSpanCodec final { +public: + [[nodiscard]] static wire::EncodeResult encode_snapshot_into( + const MmsSelectiveInformationReportSnapshotInput& report, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_event_runtime.hpp b/include/ariec61850/mms/static_urcb_event_runtime.hpp new file mode 100644 index 000000000..a81651975 --- /dev/null +++ b/include/ariec61850/mms/static_urcb_event_runtime.hpp @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/selective_information_report_span.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticUrcbEventStatus : std::uint8_t { + ok, + invalid_runtime, + index_out_of_range, + member_out_of_range, + temporarily_unavailable, + trigger_not_selected, + data_set_not_found, + stale_plan, + workspace_too_small, + object_not_found, + backend_failure, + response_buffer_too_small, + report_encode_failed, +}; + +enum class MmsStaticUrcbEventReason : std::uint8_t { + data_change, + quality_change, + data_update, +}; + +struct MmsStaticUrcbEventState final { + std::array + member_reason_masks{}; + std::uint64_t due_ms{}; + std::uint32_t source_revision{}; + std::uint32_t revision{}; + std::size_t pending_member_count{}; + bool pending{}; +}; + +struct MmsStaticUrcbEventPlan final { + static constexpr std::size_t invalid_index = + std::numeric_limits::max(); + + std::size_t index{invalid_index}; + std::uint32_t source_revision{}; + std::uint32_t event_revision{}; + std::uint8_t sequence_number{}; +}; + +struct MmsStaticUrcbEventEncodeResult final { + MmsStaticUrcbEventStatus status{MmsStaticUrcbEventStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t data_set_member_count{}; + std::size_t included_member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticUrcbEventStatus::ok; + } +}; + +// Change-trigger extension for the bounded URCB runtime. GI/integrity scheduling +// remains in MmsStaticUrcbRuntime; this class owns only dchg/qchg/dupd pending +// state and BufTm coalescing. +class MmsStaticUrcbEventRuntime final { +public: + MmsStaticUrcbEventRuntime( + MmsStaticUrcbRuntime& urcb, + std::span states, + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets) noexcept + : urcb_{&urcb}, states_{states}, objects_{&objects}, data_sets_{&data_sets} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + + [[nodiscard]] MmsStaticUrcbEventStatus notify( + std::size_t control_block_index, + std::size_t data_set_member_index, + MmsStaticUrcbEventReason reason, + std::uint64_t now_ms) noexcept; + + [[nodiscard]] bool next_due( + std::uint64_t now_ms, + MmsStaticUrcbEventPlan& plan) const noexcept; + + [[nodiscard]] MmsStaticUrcbEventEncodeResult encode( + const MmsStaticUrcbEventPlan& plan, + std::span report_time, + std::span destination, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticUrcbEventStatus commit( + const MmsStaticUrcbEventPlan& plan) noexcept; + + [[nodiscard]] const MmsStaticUrcbEventState* state( + std::size_t index) const noexcept; + +private: + MmsStaticUrcbRuntime* urcb_{}; + std::span states_{}; + const MmsStaticObjectTable* objects_{}; + const MmsStaticDataSetTable* data_sets_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/selective_information_report_span.cpp b/src/mms/selective_information_report_span.cpp new file mode 100644 index 000000000..97de3b51d --- /dev/null +++ b/src/mms/selective_information_report_span.cpp @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/selective_information_report_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/pdu_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kOptSequenceNumber = 0x40U; +constexpr std::uint8_t kOptReportTimeStamp = 0x20U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; +constexpr std::uint8_t kOptDataSetName = 0x08U; +constexpr std::uint8_t kOptDataReference = 0x04U; +constexpr std::uint8_t kOptConfRevision = 0x80U; + +[[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 optional_selected( + const MmsSelectiveInformationReportSnapshotInput& report, + const std::size_t byte_index, + const std::uint8_t mask) noexcept { + return report.optional_fields.size() > byte_index && + (report.optional_fields[byte_index] & mask) != 0U; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_unsigned_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + for (std::size_t index = bytes; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool write_positive_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + if (needs_positive_prefix(value, bytes) && !writer.write_byte(0x00U)) { + return false; + } + return write_unsigned_content(writer, value); +} + +[[nodiscard]] bool valid_mms_data_tlv(const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] std::optional access_result_size( + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return std::nullopt; + } + return result.encoded_data.size(); + } + return asn1::BerSpanWriter::tlv_size( + 0, positive_integer_size(result.failure_code)); +} + +[[nodiscard]] bool write_access_result( + asn1::BerSpanWriter& writer, + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + return writer.write_bytes(result.encoded_data); + } + const auto content = positive_integer_size(result.failure_code); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, content) && + write_positive_content(writer, result.failure_code); +} + +[[nodiscard]] std::optional reference_content_size( + const MmsInformationReportReferenceInput& reference) noexcept { + if (!visible_ascii(reference.domain, MmsServiceSpanCodec::maximum_identifier_bytes) || + !visible_ascii(reference.item, MmsServiceSpanCodec::maximum_identifier_bytes)) { + return std::nullopt; + } + if (reference.item.size() > MmsInformationReportSpanCodec::maximum_reference_bytes - 1U || + reference.domain.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U - + reference.item.size()) { + return std::nullopt; + } + return reference.domain.size() + 1U + reference.item.size(); +} + +[[nodiscard]] bool write_reference_data( + asn1::BerSpanWriter& writer, + const MmsInformationReportReferenceInput& reference) noexcept { + const auto content = reference_content_size(reference); + if (!content || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, *content) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.domain.data()), + reference.domain.size()}) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.item.data()), + reference.item.size()})) { + return false; + } + return true; +} + +[[nodiscard]] bool write_visible_data( + asn1::BerSpanWriter& writer, + const std::string_view text) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) && + writer.write_bytes(std::span{ + reinterpret_cast(text.data()), text.size()}); +} + +[[nodiscard]] bool write_bit_string_data( + asn1::BerSpanWriter& writer, + const std::uint8_t unused_bits, + const std::span bytes) noexcept { + return unused_bits <= 7U && + writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes.size()) && + writer.write_byte(unused_bits) && writer.write_bytes(bytes); +} + +[[nodiscard]] bool write_unsigned_data( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto content = minimal_unsigned_size(value); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content) && + write_unsigned_content(writer, value); +} + +[[nodiscard]] bool validate_report( + const MmsSelectiveInformationReportSnapshotInput& report) noexcept { + if (!visible_ascii( + report.report_id, + MmsInformationReportSpanCodec::maximum_report_id_bytes) || + report.optional_fields.size() != MmsInformationReportSpanCodec::optional_field_bytes || + (report.optional_fields[0] & 0x83U) != 0U || + (report.optional_fields[1] & 0x7FU) != 0U || + report.data_set_member_count == 0U || + report.data_set_member_count > MmsInformationReportSpanCodec::maximum_members || + report.included_member_indices.empty() || + report.included_member_indices.size() > report.data_set_member_count || + report.included_member_references.size() != report.included_member_indices.size() || + report.included_member_results.size() != report.included_member_indices.size() || + !reference_content_size(report.data_set_reference)) { + return false; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + report.report_time.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return false; + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + if (report.included_reason_for_inclusion.size() != + report.included_member_indices.size()) { + return false; + } + for (const auto reason : report.included_reason_for_inclusion) { + if (reason == 0U || (reason & 0x03U) != 0U) { + return false; + } + } + } else if (!report.included_reason_for_inclusion.empty()) { + return false; + } + + std::size_t previous = 0U; + bool first = true; + for (const auto index : report.included_member_indices) { + if (index >= report.data_set_member_count || (!first && index <= previous)) { + return false; + } + previous = index; + first = false; + } + for (const auto& reference : report.included_member_references) { + if (!reference_content_size(reference)) { + return false; + } + } + for (const auto& result : report.included_member_results) { + if (!access_result_size(result)) { + return false; + } + } + return true; +} + +[[nodiscard]] bool add_to( + std::size_t& total, + const std::optional value) noexcept { + if (!value || *value > std::numeric_limits::max() - total) { + return false; + } + total += *value; + return true; +} + +[[nodiscard]] std::optional access_content_size( + const MmsSelectiveInformationReportSnapshotInput& report) noexcept { + std::size_t total = 0U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(10, report.report_id.size())) || + !add_to(total, asn1::BerSpanWriter::tlv_size( + 4, 1U + report.optional_fields.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptSequenceNumber) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.sequence_number)))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + !add_to(total, asn1::BerSpanWriter::tlv_size(12, report.report_time.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptDataSetName)) { + const auto content = reference_content_size(report.data_set_reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + if (optional_selected(report, 1U, kOptConfRevision) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.conf_revision)))) { + return std::nullopt; + } + + const auto inclusion_bytes = (report.data_set_member_count + 7U) / 8U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + inclusion_bytes))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.included_member_references) { + const auto content = reference_content_size(reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + } + for (const auto& result : report.included_member_results) { + if (!add_to(total, access_result_size(result))) { + return std::nullopt; + } + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(4, 2U); + for (std::size_t index = 0U; index < report.included_member_indices.size(); ++index) { + if (!add_to(total, reason_tlv)) { + return std::nullopt; + } + } + } + return total; +} + +[[nodiscard]] bool write_inclusion( + asn1::BerSpanWriter& writer, + const std::size_t member_count, + const std::span included) noexcept { + const auto byte_count = (member_count + 7U) / 8U; + const auto unused = static_cast((byte_count * 8U) - member_count); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + byte_count) || + !writer.write_byte(unused)) { + return false; + } + + std::size_t included_offset = 0U; + for (std::size_t byte_index = 0U; byte_index < byte_count; ++byte_index) { + std::uint8_t value = 0U; + while (included_offset < included.size() && + included[included_offset] / 8U == byte_index) { + const auto bit = included[included_offset] % 8U; + value = static_cast( + value | static_cast(0x80U >> bit)); + ++included_offset; + } + if (!writer.write_byte(value)) { + return false; + } + } + return included_offset == included.size(); +} + +} // namespace + +wire::EncodeResult MmsSelectiveInformationReportSpanCodec::encode_snapshot_into( + const MmsSelectiveInformationReportSnapshotInput& report, + const std::span destination) noexcept { + if (!validate_report(report)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto access_content = access_content_size(report); + const auto access_tlv = access_content + ? asn1::BerSpanWriter::tlv_size(0, *access_content) + : std::nullopt; + const auto vmd_name_tlv = asn1::BerSpanWriter::tlv_size(0, 3U); + const auto variable_access_tlv = vmd_name_tlv + ? asn1::BerSpanWriter::tlv_size(1, *vmd_name_tlv) + : std::nullopt; + std::optional information_content; + if (access_tlv && variable_access_tlv && + *access_tlv <= std::numeric_limits::max() - *variable_access_tlv) { + information_content = *variable_access_tlv + *access_tlv; + } + const auto service_tlv = information_content + ? asn1::BerSpanWriter::tlv_size(0, *information_content) + : std::nullopt; + const auto required = service_tlv + ? asn1::BerSpanWriter::tlv_size(3, *service_tlv) + : std::nullopt; + if (!access_content || !access_tlv || !vmd_name_tlv || !variable_access_tlv || + !information_content || !service_tlv || !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, 3, *service_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *information_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *vmd_name_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 3U) || + !writer.write_bytes(std::span{ + reinterpret_cast("RPT"), 3U}) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *access_content) || + !write_visible_data(writer, report.report_id) || + !write_bit_string_data(writer, 6U, report.optional_fields)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !write_unsigned_data(writer, report.sequence_number)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 12, report.report_time.size()) || + !writer.write_bytes(report.report_time))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptDataSetName) && + !write_reference_data(writer, report.data_set_reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !write_unsigned_data(writer, report.conf_revision)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (!write_inclusion( + writer, report.data_set_member_count, report.included_member_indices)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.included_member_references) { + if (!write_reference_data(writer, reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + for (const auto& result : report.included_member_results) { + if (!write_access_result(writer, result)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + for (const auto reason : report.included_reason_for_inclusion) { + if (!write_bit_string_data( + writer, 2U, std::span{&reason, 1U})) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_event_runtime.cpp b/src/mms/static_urcb_event_runtime.cpp new file mode 100644 index 000000000..0a8c7396b --- /dev/null +++ b/src/mms/static_urcb_event_runtime.cpp @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_event_runtime.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kTriggerDataChange = 0x40U; +constexpr std::uint8_t kTriggerQualityChange = 0x20U; +constexpr std::uint8_t kTriggerDataUpdate = 0x10U; + +constexpr std::uint8_t kReasonDataChange = 0x80U; +constexpr std::uint8_t kReasonQualityChange = 0x40U; +constexpr std::uint8_t kReasonDataUpdate = 0x20U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; + +[[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; +} + +void bump_revision(std::uint32_t& revision) noexcept { + revision = revision == std::numeric_limits::max() + ? 1U + : revision + 1U; +} + +[[nodiscard]] std::uint8_t next_sequence_number( + const std::uint8_t current) noexcept { + return static_cast( + (static_cast(current) + 1U) & 0xFFU); +} + +[[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)}; +} + +void clear_pending(MmsStaticUrcbEventState& state) noexcept { + std::fill(state.member_reason_masks.begin(), state.member_reason_masks.end(), 0U); + state.due_ms = 0U; + state.source_revision = 0U; + state.pending_member_count = 0U; + state.pending = false; +} + +[[nodiscard]] bool event_mapping( + const MmsStaticUrcbEventReason reason, + std::uint8_t& trigger_mask, + std::uint8_t& report_reason) noexcept { + switch (reason) { + case MmsStaticUrcbEventReason::data_change: + trigger_mask = kTriggerDataChange; + report_reason = kReasonDataChange; + return true; + case MmsStaticUrcbEventReason::quality_change: + trigger_mask = kTriggerQualityChange; + report_reason = kReasonQualityChange; + return true; + case MmsStaticUrcbEventReason::data_update: + trigger_mask = kTriggerDataUpdate; + report_reason = kReasonDataUpdate; + return true; + } + return false; +} + +[[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]] bool plan_matches( + const MmsStaticUrcbEventPlan& plan, + const MmsStaticUrcbState& control, + const MmsStaticUrcbEventState& event) noexcept { + return control.enabled && event.pending && + event.source_revision == control.revision && + plan.source_revision == control.revision && + plan.event_revision == event.revision && + plan.sequence_number == next_sequence_number(control.sequence_number); +} + +} // namespace + +bool MmsStaticUrcbEventRuntime::initialize() noexcept { + initialized_ = false; + if (urcb_ == nullptr || objects_ == nullptr || data_sets_ == nullptr || + !urcb_->valid() || !objects_->valid() || !data_sets_->valid() || + !data_sets_->valid_against(*objects_) || states_.size() < urcb_->size()) { + return false; + } + for (std::size_t index = 0U; index < urcb_->size(); ++index) { + states_[index] = {}; + states_[index].revision = 1U; + } + initialized_ = true; + return true; +} + +const MmsStaticUrcbEventState* MmsStaticUrcbEventRuntime::state( + const std::size_t index) const noexcept { + return initialized_ && urcb_ != nullptr && index < urcb_->size() + ? &states_[index] + : nullptr; +} + +MmsStaticUrcbEventStatus MmsStaticUrcbEventRuntime::notify( + const std::size_t control_block_index, + const std::size_t data_set_member_index, + const MmsStaticUrcbEventReason reason, + const std::uint64_t now_ms) noexcept { + if (!initialized_ || urcb_ == nullptr || data_sets_ == nullptr) { + return MmsStaticUrcbEventStatus::invalid_runtime; + } + auto* control = urcb_->state(control_block_index); + if (control == nullptr || control_block_index >= states_.size()) { + return MmsStaticUrcbEventStatus::index_out_of_range; + } + if (!control->enabled) { + return MmsStaticUrcbEventStatus::temporarily_unavailable; + } + + std::uint8_t trigger_mask = 0U; + std::uint8_t report_reason = 0U; + if (!event_mapping(reason, trigger_mask, report_reason)) { + return MmsStaticUrcbEventStatus::trigger_not_selected; + } + if ((control->trigger_options & trigger_mask) == 0U) { + return MmsStaticUrcbEventStatus::trigger_not_selected; + } + + const auto* data_set = data_sets_->find(object_name( + control->data_set_domain(), control->data_set_item())); + if (data_set == nullptr) { + return MmsStaticUrcbEventStatus::data_set_not_found; + } + if (data_set_member_index >= data_set->members.size() || + data_set_member_index >= MmsInformationReportSpanCodec::maximum_members) { + return MmsStaticUrcbEventStatus::member_out_of_range; + } + + auto& event = states_[control_block_index]; + if (event.pending && event.source_revision != control->revision) { + clear_pending(event); + bump_revision(event.revision); + } + if (!event.pending) { + event.pending = true; + event.source_revision = control->revision; + event.due_ms = saturating_add(now_ms, control->buffer_time_ms); + } + if (event.member_reason_masks[data_set_member_index] == 0U) { + ++event.pending_member_count; + } + event.member_reason_masks[data_set_member_index] = static_cast( + event.member_reason_masks[data_set_member_index] | report_reason); + bump_revision(event.revision); + return MmsStaticUrcbEventStatus::ok; +} + +bool MmsStaticUrcbEventRuntime::next_due( + const std::uint64_t now_ms, + MmsStaticUrcbEventPlan& plan) const noexcept { + plan = {}; + if (!initialized_ || urcb_ == nullptr) { + return false; + } + for (std::size_t index = 0U; index < urcb_->size(); ++index) { + const auto* control = urcb_->state(index); + const auto& event = states_[index]; + if (control == nullptr || !control->enabled || !event.pending || + event.pending_member_count == 0U || + event.source_revision != control->revision || now_ms < event.due_ms) { + continue; + } + plan.index = index; + plan.source_revision = control->revision; + plan.event_revision = event.revision; + plan.sequence_number = next_sequence_number(control->sequence_number); + return true; + } + return false; +} + +MmsStaticUrcbEventEncodeResult MmsStaticUrcbEventRuntime::encode( + const MmsStaticUrcbEventPlan& plan, + const std::span report_time, + const std::span destination, + const std::span workspace) const noexcept { + if (!initialized_ || urcb_ == nullptr || objects_ == nullptr || data_sets_ == nullptr) { + return {MmsStaticUrcbEventStatus::invalid_runtime, 0U, 0U, 0U, 0U}; + } + if (plan.index >= urcb_->size() || plan.index >= states_.size()) { + return {MmsStaticUrcbEventStatus::index_out_of_range, 0U, 0U, 0U, 0U}; + } + const auto* control = urcb_->state(plan.index); + const auto& event = states_[plan.index]; + if (control == nullptr || !plan_matches(plan, *control, event)) { + return {MmsStaticUrcbEventStatus::stale_plan, 0U, 0U, 0U, 0U}; + } + + const auto* data_set = data_sets_->find(object_name( + control->data_set_domain(), control->data_set_item())); + if (data_set == nullptr) { + return {MmsStaticUrcbEventStatus::data_set_not_found, 0U, 0U, 0U, 0U}; + } + if (data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return { + MmsStaticUrcbEventStatus::report_encode_failed, + 0U, + 0U, + data_set->members.size(), + 0U}; + } + + std::array indices{}; + std::array references{}; + std::array results{}; + std::array reasons{}; + + std::size_t included = 0U; + std::size_t workspace_offset = 0U; + for (std::size_t member_index = 0U; + member_index < data_set->members.size(); + ++member_index) { + const auto reason = event.member_reason_masks[member_index]; + if (reason == 0U) { + continue; + } + const auto& member = data_set->members[member_index]; + const auto* object = objects_->find(object_name(member.domain, member.item)); + if (object == nullptr) { + return { + MmsStaticUrcbEventStatus::object_not_found, + 0U, + 0U, + data_set->members.size(), + included}; + } + 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 { + MmsStaticUrcbEventStatus::backend_failure, + 0U, + 0U, + data_set->members.size(), + included}; + } + return { + MmsStaticUrcbEventStatus::workspace_too_small, + 0U, + workspace_offset + read.required_bytes, + data_set->members.size(), + included}; + } + if (!read.success() || read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return { + MmsStaticUrcbEventStatus::backend_failure, + 0U, + 0U, + data_set->members.size(), + included}; + } + + indices[included] = member_index; + references[included] = {member.domain, member.item}; + results[included] = { + true, + remaining.first(read.bytes_written), + 0U}; + reasons[included] = reason; + workspace_offset += read.bytes_written; + ++included; + } + if (included == 0U || included != event.pending_member_count) { + return { + MmsStaticUrcbEventStatus::stale_plan, + 0U, + 0U, + data_set->members.size(), + included}; + } + + MmsSelectiveInformationReportSnapshotInput report; + report.report_id = control->report_id(); + report.optional_fields = control->optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.data_set_reference = { + control->data_set_domain(), control->data_set_item()}; + report.conf_revision = control->conf_revision; + report.data_set_member_count = data_set->members.size(); + report.included_member_indices = + std::span{indices}.first(included); + report.included_member_references = + std::span{references}.first(included); + report.included_member_results = + std::span{results}.first(included); + if (control->optional_fields.size() > 0U && + (control->optional_fields[0] & kOptReasonForInclusion) != 0U) { + report.included_reason_for_inclusion = + std::span{reasons}.first(included); + } + + const auto encoded = MmsSelectiveInformationReportSpanCodec::encode_snapshot_into( + report, destination); + if (encoded.success()) { + return { + MmsStaticUrcbEventStatus::ok, + encoded.bytes_written, + encoded.required_bytes, + data_set->members.size(), + included}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return { + MmsStaticUrcbEventStatus::response_buffer_too_small, + 0U, + encoded.required_bytes, + data_set->members.size(), + included}; + } + return { + MmsStaticUrcbEventStatus::report_encode_failed, + 0U, + encoded.required_bytes, + data_set->members.size(), + included}; +} + +MmsStaticUrcbEventStatus MmsStaticUrcbEventRuntime::commit( + const MmsStaticUrcbEventPlan& plan) noexcept { + if (!initialized_ || urcb_ == nullptr) { + return MmsStaticUrcbEventStatus::invalid_runtime; + } + if (plan.index >= urcb_->size() || plan.index >= states_.size()) { + return MmsStaticUrcbEventStatus::index_out_of_range; + } + auto* control = urcb_->state(plan.index); + auto& event = states_[plan.index]; + if (control == nullptr || !plan_matches(plan, *control, event)) { + return MmsStaticUrcbEventStatus::stale_plan; + } + + control->sequence_number = plan.sequence_number; + bump_revision(control->revision); + clear_pending(event); + bump_revision(event.revision); + return MmsStaticUrcbEventStatus::ok; +} + +} // namespace ar::iec61850::mms From f9ec8e45aa9dac8c5591e16ce0e38a1910295742 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 17:35:12 +0700 Subject: [PATCH 152/188] Route bounded URCB events over MMS connection --- .../workflows/urcb-event-hard-profile-ci.yml | 19 +- ...cb_event_connection_hard_profile_smoke.cpp | 245 ++++++++++++++++++ .../urcb_event_hard_profile/CMakeLists.txt | 66 +++-- .../mms/static_urcb_event_connection.hpp | 53 ++++ src/mms/static_urcb_event_connection.cpp | 212 +++++++++++++++ 5 files changed, 571 insertions(+), 24 deletions(-) create mode 100644 embedded/mms_urcb_event_connection_hard_profile_smoke.cpp create mode 100644 include/ariec61850/mms/static_urcb_event_connection.hpp create mode 100644 src/mms/static_urcb_event_connection.cpp diff --git a/.github/workflows/urcb-event-hard-profile-ci.yml b/.github/workflows/urcb-event-hard-profile-ci.yml index e6b1871c0..ea12e7b03 100644 --- a/.github/workflows/urcb-event-hard-profile-ci.yml +++ b/.github/workflows/urcb-event-hard-profile-ci.yml @@ -38,7 +38,7 @@ jobs: - name: Build URCB event hard profile run: cmake --build build-urcb-event-hard --parallel - - name: Run URCB event smoke + - name: Run URCB event smokes run: >- ctest --test-dir build-urcb-event-hard --output-on-failure @@ -48,11 +48,14 @@ jobs: shell: bash run: | set -euo pipefail - binary=build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke - if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then - echo "exception runtime symbol found in $binary" >&2 - exit 1 - fi + for binary in \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_connection_smoke; do + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + done echo 'NO_EXCEPTION_RUNTIME=PASS' - name: Report footprint @@ -61,7 +64,9 @@ jobs: set -euo pipefail { echo "compiler=${{ matrix.compiler }}" - size build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke + size \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_connection_smoke } | tee build-urcb-event-hard/urcb-event-footprint.txt - name: Upload URCB event evidence diff --git a/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp b/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp new file mode 100644 index 000000000..b3d7aff4c --- /dev/null +++ b/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_urcb_event_connection.hpp" +#include "ariec61850/mms/static_dispatcher.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 + +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}; +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + 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 = connection.process_tcp_window( + request.first(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( + scratch.first(cotp.bytes_written), request); + if (!association.success()) return false; + result = connection.process_tcp_window( + request.first(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_bit_string( + const mms::MmsReadAccessResultView& result, + const std::uint8_t unused, + const std::uint8_t value) noexcept { + if (!result.success) return false; + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 4 && !tlv.constructed && tlv.value.size() == 2U && + tlv.value[0] == unused && tlv.value[1] == value; +} + +} // namespace + +int main() { + bool first = true; + bool second = false; + bool third = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0","X1",kBooleanType,read_boolean,&first,false}, + mms::MmsStaticObjectEntry{"LD0","X2",kBooleanType,read_boolean,&second,false}, + mms::MmsStaticObjectEntry{"LD0","X3",kBooleanType,read_boolean,&third,false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0","X1"}, + mms::MmsStaticDataSetMember{"LD0","X2"}, + mms::MmsStaticDataSetMember{"LD0","X3"}}; + 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::MmsStaticConnectionRuntime connection{dispatcher}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0","LLN0$RP$Events","LD0/LLN0$RP$Events", + "LD0","LLN0$Events",9U,{0x7CU,0x80U},20U,0x70U,0U}}; + std::array urcb_states{}; + mms::MmsStaticUrcbRuntime urcb{definitions,urcb_states,object_table,data_set_table}; + if (!urcb.initialize()) return 1; + std::array event_states{}; + mms::MmsStaticUrcbEventRuntime events{urcb,event_states,object_table,data_set_table}; + if (!events.initialize() || urcb.set_enabled(0U,true,100U) != mms::MmsStaticUrcbStatus::ok) { + return 2; + } + if (events.notify(0U,0U,mms::MmsStaticUrcbEventReason::data_change,100U) != + mms::MmsStaticUrcbEventStatus::ok || + events.notify(0U,2U,mms::MmsStaticUrcbEventReason::quality_change,105U) != + mms::MmsStaticUrcbEventStatus::ok) { + return 3; + } + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + auto poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,workspace); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::not_established) return 4; + const auto* pending = events.state(0U); + if (pending == nullptr || !pending->pending || pending->pending_member_count != 2U) return 5; + + if (!establish(connection,request,response,workspace,scratch)) return 6; + + std::array tiny_response{}; + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,tiny_response,workspace); + pending = events.state(0U); + const auto* control = urcb.state(0U); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::response_buffer_too_small || + poll.required_response_bytes <= tiny_response.size() || + poll.sequence_number != 1U || pending == nullptr || !pending->pending || + control == nullptr || control->sequence_number != 0U) { + return 7; + } + + std::array tiny_workspace{}; + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,tiny_workspace); + pending = events.state(0U); + control = urcb.state(0U); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::workspace_too_small || + poll.required_workspace_bytes <= tiny_workspace.size() || + pending == nullptr || !pending->pending || control == nullptr || + control->sequence_number != 0U) { + return 8; + } + + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,workspace); + pending = events.state(0U); + control = urcb.state(0U); + if (!poll.response_ready() || poll.bytes_written == 0U || poll.sequence_number != 1U || + pending == nullptr || pending->pending || control == nullptr || + control->sequence_number != 1U) { + return 9; + } + + mms::MmsInformationReportView report; + if (!decode_report_frame(response.first(poll.bytes_written), report) || + report.item_count != 13U) { + return 10; + } + mms::MmsReadAccessResultView item; + if (!report.try_item(6U,item) || !decode_bit_string(item,5U,0xA0U) || + !report.try_item(11U,item) || !decode_bit_string(item,2U,0x80U) || + !report.try_item(12U,item) || !decode_bit_string(item,2U,0x40U)) { + return 11; + } + if (mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,workspace).status != + mms::MmsStaticUrcbEventConnectionStatus::no_report_due) { + return 12; + } + + connection.reset(); + if (events.notify(0U,1U,mms::MmsStaticUrcbEventReason::data_update,200U) != + mms::MmsStaticUrcbEventStatus::ok) { + return 13; + } + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,220U,kReportTime,response,workspace); + pending = events.state(0U); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::not_established || + pending == nullptr || !pending->pending || pending->pending_member_count != 1U) { + return 14; + } + + return 0; +} diff --git a/embedded/urcb_event_hard_profile/CMakeLists.txt b/embedded/urcb_event_hard_profile/CMakeLists.txt index 202c3b55b..32dbe7d50 100644 --- a/embedded/urcb_event_hard_profile/CMakeLists.txt +++ b/embedded/urcb_event_hard_profile/CMakeLists.txt @@ -1,8 +1,8 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850UrcbEventHardProfile - VERSION 0.1.0 - DESCRIPTION "No-exception bounded IEC 61850 URCB event-report profile" + VERSION 0.2.0 + DESCRIPTION "No-exception bounded IEC 61850 URCB event-report and connection profile" LANGUAGES CXX) set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") @@ -11,6 +11,24 @@ if(MSVC) message(FATAL_ERROR "URCB event hard profile is validated with GCC/Clang -fno-exceptions.") endif() +function(ariec61850_configure_urcb_event_target target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + target_compile_definitions(${target} PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) + target_compile_options(${target} PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) +endfunction() + add_executable(ariec61850_hard_mms_urcb_event_smoke ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_event_hard_profile_smoke.cpp ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp @@ -24,27 +42,41 @@ add_executable(ariec61850_hard_mms_urcb_event_smoke ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp ${ARIEC61850_ROOT}/src/mms/selective_information_report_span.cpp ${ARIEC61850_ROOT}/src/mms/static_urcb_event_runtime.cpp) +ariec61850_configure_urcb_event_target(ariec61850_hard_mms_urcb_event_smoke) -target_compile_features(ariec61850_hard_mms_urcb_event_smoke PRIVATE cxx_std_20) -target_include_directories(ariec61850_hard_mms_urcb_event_smoke PRIVATE ${ARIEC61850_ROOT}/include) -target_compile_definitions(ariec61850_hard_mms_urcb_event_smoke PRIVATE - ARIEC61850_EMBEDDED_PROFILE=1 - ARIEC61850_HOST_SERVICES=0 - ARIEC61850_NO_EXCEPTIONS=1) -target_compile_options(ariec61850_hard_mms_urcb_event_smoke PRIVATE - -Wall - -Wextra - -Wpedantic - -Wconversion - -Wsign-conversion - -Werror - -fno-exceptions - -fno-rtti) +add_executable(ariec61850_hard_mms_urcb_event_connection_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_event_connection_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/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_event_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_event_connection.cpp) +ariec61850_configure_urcb_event_target(ariec61850_hard_mms_urcb_event_connection_smoke) enable_testing() add_test( NAME ariec61850_hard_mms_urcb_event_smoke COMMAND ariec61850_hard_mms_urcb_event_smoke) +add_test( + NAME ariec61850_hard_mms_urcb_event_connection_smoke + COMMAND ariec61850_hard_mms_urcb_event_connection_smoke) message(STATUS "arstack61850 URCB event hard profile: dchg/qchg/dupd + BufTm=ON") +message(STATUS "arstack61850 URCB event hard profile: retry-safe connection transport=ON") message(STATUS "arstack61850 URCB event hard profile: exceptions/RTTI disabled=ON") diff --git a/include/ariec61850/mms/static_urcb_event_connection.hpp b/include/ariec61850/mms/static_urcb_event_connection.hpp new file mode 100644 index 000000000..dfec1c6a8 --- /dev/null +++ b/include/ariec61850/mms/static_urcb_event_connection.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_urcb_event_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticUrcbEventConnectionStatus : std::uint8_t { + no_report_due, + response_ready, + not_established, + response_buffer_too_small, + workspace_too_small, + report_encode_failed, + stale_plan, +}; + +struct MmsStaticUrcbEventConnectionResult final { + MmsStaticUrcbEventConnectionStatus status{ + MmsStaticUrcbEventConnectionStatus::no_report_due}; + MmsStaticUrcbEventStatus event_status{MmsStaticUrcbEventStatus::ok}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + std::size_t control_block_index{MmsStaticUrcbEventPlan::invalid_index}; + std::uint8_t sequence_number{}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticUrcbEventConnectionStatus::response_ready; + } +}; + +class MmsStaticUrcbEventConnection final { +public: + // Poll exactly one due event-driven URCB report on an established MMS + // association. The event plan is committed only after the complete TPKT + // frame exists, preserving pending membership/reasons and SqNum across + // response/workspace capacity retries. + [[nodiscard]] static MmsStaticUrcbEventConnectionResult poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbEventRuntime& events, + std::uint64_t now_ms, + std::span report_time, + std::span response, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_event_connection.cpp b/src/mms/static_urcb_event_connection.cpp new file mode 100644 index 000000000..0900f0754 --- /dev/null +++ b/src/mms/static_urcb_event_connection.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_event_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]] MmsStaticUrcbEventConnectionResult make_result( + const MmsStaticUrcbEventConnectionStatus status, + const MmsStaticUrcbEventStatus event_status, + const MmsStaticUrcbEventPlan& plan = {}) noexcept { + MmsStaticUrcbEventConnectionResult result; + result.status = status; + result.event_status = event_status; + result.control_block_index = plan.index; + result.sequence_number = plan.sequence_number; + 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]] MmsStaticUrcbEventConnectionResult response_capacity( + const MmsStaticUrcbEventPlan& plan, + const MmsStaticUrcbEventStatus event_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticUrcbEventConnectionStatus::response_buffer_too_small, + event_status, + plan); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticUrcbEventConnectionResult workspace_capacity( + const MmsStaticUrcbEventPlan& plan, + const MmsStaticUrcbEventStatus event_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticUrcbEventConnectionStatus::workspace_too_small, + event_status, + plan); + result.required_workspace_bytes = required; + return result; +} + +} // namespace + +MmsStaticUrcbEventConnectionResult MmsStaticUrcbEventConnection::poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbEventRuntime& events, + 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( + MmsStaticUrcbEventConnectionStatus::not_established, + MmsStaticUrcbEventStatus::temporarily_unavailable); + } + + MmsStaticUrcbEventPlan plan; + if (!events.next_due(now_ms, plan)) { + return make_result( + MmsStaticUrcbEventConnectionStatus::no_report_due, + events.valid() + ? MmsStaticUrcbEventStatus::ok + : MmsStaticUrcbEventStatus::invalid_runtime); + } + + const auto encoded = events.encode(plan, report_time, response, workspace); + if (!encoded.success()) { + if (encoded.status == MmsStaticUrcbEventStatus::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( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + return response_capacity(plan, encoded.status, final_required); + } + if (encoded.status == MmsStaticUrcbEventStatus::workspace_too_small) { + return workspace_capacity(plan, encoded.status, encoded.required_bytes); + } + if (encoded.status == MmsStaticUrcbEventStatus::stale_plan) { + return make_result( + MmsStaticUrcbEventConnectionStatus::stale_plan, + encoded.status, + plan); + } + return make_result( + MmsStaticUrcbEventConnectionStatus::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( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + auto result = workspace_capacity( + plan, MmsStaticUrcbEventStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + + std::size_t final_required{}; + if (!add_size(p_data.bytes_written, 7U, final_required)) { + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + if (response.size() < final_required) { + return response_capacity( + plan, MmsStaticUrcbEventStatus::response_buffer_too_small, final_required); + } + if (workspace.size() < final_required) { + auto result = workspace_capacity( + plan, MmsStaticUrcbEventStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + + 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( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::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( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + + const auto committed = events.commit(plan); + if (committed != MmsStaticUrcbEventStatus::ok) { + return make_result( + MmsStaticUrcbEventConnectionStatus::stale_plan, + committed, + plan); + } + + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + auto result = make_result( + MmsStaticUrcbEventConnectionStatus::response_ready, + MmsStaticUrcbEventStatus::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 From 98b5158b6ee1ad2e14ec2e80a8b5a3c087e9b486 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:02:00 +0700 Subject: [PATCH 153/188] Fix URCB event connection smoke span view --- embedded/mms_urcb_event_connection_hard_profile_smoke.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp b/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp index b3d7aff4c..38ab6919e 100644 --- a/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp +++ b/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp @@ -212,7 +212,8 @@ int main() { } mms::MmsInformationReportView report; - if (!decode_report_frame(response.first(poll.bytes_written), report) || + if (!decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || report.item_count != 13U) { return 10; } @@ -242,4 +243,4 @@ int main() { } return 0; -} +} \ No newline at end of file From 38a490225fb5242cf9766c7f35bc4f50078f8d88 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:04:36 +0700 Subject: [PATCH 154/188] Add buffered selective MMS report codec API --- ...ered_selective_information_report_span.hpp | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 include/ariec61850/mms/buffered_selective_information_report_span.hpp diff --git a/include/ariec61850/mms/buffered_selective_information_report_span.hpp b/include/ariec61850/mms/buffered_selective_information_report_span.hpp new file mode 100644 index 000000000..cbbdcfbfd --- /dev/null +++ b/include/ariec61850/mms/buffered_selective_information_report_span.hpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +// Selective InformationReport form for buffered reports. EntryID and BufOvfl +// are encoded only when selected by OptFlds. The DataSet inclusion bit-string +// keeps the original DataSet cardinality while values/reasons are emitted only +// for selected members. +struct MmsBufferedSelectiveInformationReportSnapshotInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + MmsInformationReportReferenceInput data_set_reference{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + + std::size_t data_set_member_count{}; + std::span included_member_indices; + std::span included_member_references; + std::span included_member_results; + std::span included_reason_for_inclusion; +}; + +class MmsBufferedSelectiveInformationReportSpanCodec final { +public: + [[nodiscard]] static wire::EncodeResult encode_snapshot_into( + const MmsBufferedSelectiveInformationReportSnapshotInput& report, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms From c3ae0a3ca193f350c4023b3b0d204a36c612c9af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:05:48 +0700 Subject: [PATCH 155/188] Add buffered report wire helper placeholder --- src/mms/buffered_report_wire_helpers.cpp | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/mms/buffered_report_wire_helpers.cpp diff --git a/src/mms/buffered_report_wire_helpers.cpp b/src/mms/buffered_report_wire_helpers.cpp new file mode 100644 index 000000000..78e3f13e4 --- /dev/null +++ b/src/mms/buffered_report_wire_helpers.cpp @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Implementation is intentionally split across small translation units so the +// embedded buffered-report path remains auditable and bounded. From 0a2577e0ff502b4739538e43bb9a45fa68332259 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:06:16 +0700 Subject: [PATCH 156/188] Add bounded BRCB runtime API --- .../ariec61850/mms/static_brcb_runtime.hpp | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 include/ariec61850/mms/static_brcb_runtime.hpp diff --git a/include/ariec61850/mms/static_brcb_runtime.hpp b/include/ariec61850/mms/static_brcb_runtime.hpp new file mode 100644 index 000000000..75a0d418f --- /dev/null +++ b/include/ariec61850/mms/static_brcb_runtime.hpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/buffered_selective_information_report_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticBrcbStatus : std::uint8_t { + ok, + invalid_runtime, + invalid_definition, + member_out_of_range, + trigger_not_selected, + temporarily_unavailable, + no_report_due, + stale_plan, + data_set_not_found, + object_not_found, + workspace_too_small, + response_buffer_too_small, + slot_too_small, + backend_failure, + report_encode_failed, + entry_not_found, +}; + +enum class MmsStaticBrcbEventReason : std::uint8_t { + data_change, + quality_change, + data_update, +}; + +struct MmsStaticBrcbDefinition 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{}; +}; + +// Payload storage is caller-owned so applications choose the queue RAM budget. +// `storage` must remain valid for the lifetime of the runtime. +struct MmsStaticBrcbSlot final { + std::span storage{}; + std::array entry_id{}; + std::size_t bytes{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; + bool occupied{}; +}; + +struct MmsStaticBrcbPendingState final { + std::array + member_reason_masks{}; + std::uint64_t due_ms{}; + std::uint32_t revision{}; + std::size_t pending_member_count{}; + bool pending{}; +}; + +struct MmsStaticBrcbCapturePlan final { + std::uint32_t pending_revision{}; + std::uint64_t entry_number{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; +}; + +struct MmsStaticBrcbCaptureResult final { + MmsStaticBrcbStatus status{MmsStaticBrcbStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t included_member_count{}; + std::array entry_id{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticBrcbStatus::ok; + } +}; + +struct MmsStaticBrcbEntryView final { + std::span mms_pdu{}; + std::span entry_id{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; +}; + +// First bounded BRCB profile: one runtime instance represents one BRCB. It +// buffers event snapshots in caller-owned RAM across connection resets. A +// non-volatile persistence adapter is intentionally a later storage-HAL slice. +class MmsStaticBrcbRuntime final { +public: + MmsStaticBrcbRuntime( + const MmsStaticBrcbDefinition& definition, + MmsStaticBrcbPendingState& pending, + std::span slots, + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets) noexcept + : definition_{&definition}, pending_{&pending}, slots_{slots}, + objects_{&objects}, data_sets_{&data_sets} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + + [[nodiscard]] MmsStaticBrcbStatus set_enabled(bool enabled) noexcept; + [[nodiscard]] constexpr bool enabled() const noexcept { return enabled_; } + + [[nodiscard]] MmsStaticBrcbStatus notify( + std::size_t data_set_member_index, + MmsStaticBrcbEventReason reason, + std::uint64_t now_ms) noexcept; + + [[nodiscard]] bool next_due( + std::uint64_t now_ms, + MmsStaticBrcbCapturePlan& plan) const noexcept; + + [[nodiscard]] MmsStaticBrcbCaptureResult capture( + const MmsStaticBrcbCapturePlan& plan, + std::span report_time, + std::span encode_buffer, + std::span workspace) noexcept; + + [[nodiscard]] bool front(MmsStaticBrcbEntryView& entry) const noexcept; + [[nodiscard]] MmsStaticBrcbStatus commit_delivery( + std::span expected_entry_id) noexcept; + + [[nodiscard]] constexpr std::size_t queue_size() const noexcept { return count_; } + [[nodiscard]] constexpr std::size_t queue_capacity() const noexcept { + return slots_.size(); + } + [[nodiscard]] constexpr std::uint64_t dropped_reports() const noexcept { + return dropped_reports_; + } + +private: + const MmsStaticBrcbDefinition* definition_{}; + MmsStaticBrcbPendingState* pending_{}; + std::span slots_{}; + const MmsStaticObjectTable* objects_{}; + const MmsStaticDataSetTable* data_sets_{}; + std::size_t head_{}; + std::size_t count_{}; + std::uint64_t next_entry_number_{1U}; + std::uint64_t dropped_reports_{}; + std::uint8_t sequence_number_{}; + bool enabled_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms From f20cf6d6584aa7957a32651d1a1392b126a3572d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:06:54 +0700 Subject: [PATCH 157/188] Add buffered selective report wire helpers --- .../mms/buffered_selective_report_detail.hpp | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 include/ariec61850/mms/buffered_selective_report_detail.hpp diff --git a/include/ariec61850/mms/buffered_selective_report_detail.hpp b/include/ariec61850/mms/buffered_selective_report_detail.hpp new file mode 100644 index 000000000..774b2ab62 --- /dev/null +++ b/include/ariec61850/mms/buffered_selective_report_detail.hpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/buffered_selective_information_report_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms::detail { + +inline constexpr std::uint8_t opt_sequence_number = 0x40U; +inline constexpr std::uint8_t opt_report_timestamp = 0x20U; +inline constexpr std::uint8_t opt_reason_for_inclusion = 0x10U; +inline constexpr std::uint8_t opt_data_set_name = 0x08U; +inline constexpr std::uint8_t opt_data_reference = 0x04U; +inline constexpr std::uint8_t opt_buffer_overflow = 0x02U; +inline constexpr std::uint8_t opt_entry_id = 0x01U; +inline constexpr std::uint8_t opt_conf_revision = 0x80U; + +[[nodiscard]] inline bool optional_selected( + const MmsBufferedSelectiveInformationReportSnapshotInput& report, + const std::size_t byte_index, + const std::uint8_t mask) noexcept { + return report.optional_fields.size() > byte_index && + (report.optional_fields[byte_index] & mask) != 0U; +} + +[[nodiscard]] inline 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]] inline std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] inline bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] inline std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] inline bool write_unsigned_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + for (std::size_t index = bytes; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] inline bool write_positive_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + if (needs_positive_prefix(value, bytes) && !writer.write_byte(0x00U)) return false; + return write_unsigned_content(writer, value); +} + +[[nodiscard]] inline bool valid_mms_data(const std::span encoded) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific) return false; + if (tlv.tag_number == 1 || tlv.tag_number == 2) return tlv.constructed; + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] inline std::optional access_result_size( + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + return valid_mms_data(result.encoded_data) + ? std::optional{result.encoded_data.size()} + : std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(0, positive_integer_size(result.failure_code)); +} + +[[nodiscard]] inline bool write_access_result( + asn1::BerSpanWriter& writer, + const MmsReadAccessResultInput& result) noexcept { + if (result.success) return writer.write_bytes(result.encoded_data); + const auto content = positive_integer_size(result.failure_code); + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 0, content) && + write_positive_content(writer, result.failure_code); +} + +[[nodiscard]] inline std::optional reference_content_size( + const MmsInformationReportReferenceInput& reference) noexcept { + if (!visible_ascii(reference.domain, MmsServiceSpanCodec::maximum_identifier_bytes) || + !visible_ascii(reference.item, MmsServiceSpanCodec::maximum_identifier_bytes)) { + return std::nullopt; + } + if (reference.item.size() > MmsInformationReportSpanCodec::maximum_reference_bytes - 1U || + reference.domain.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U - reference.item.size()) { + return std::nullopt; + } + return reference.domain.size() + 1U + reference.item.size(); +} + +[[nodiscard]] inline bool write_reference( + asn1::BerSpanWriter& writer, + const MmsInformationReportReferenceInput& reference) noexcept { + const auto content = reference_content_size(reference); + return content && + writer.write_tlv_header(asn1::BerClass::context_specific, false, 10, *content) && + writer.write_bytes(std::span{ + reinterpret_cast(reference.domain.data()), + reference.domain.size()}) && + writer.write_byte(static_cast('/')) && + writer.write_bytes(std::span{ + reinterpret_cast(reference.item.data()), + reference.item.size()}); +} + +[[nodiscard]] inline bool write_visible( + asn1::BerSpanWriter& writer, + const std::string_view text) noexcept { + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 10, text.size()) && + writer.write_bytes(std::span{ + reinterpret_cast(text.data()), text.size()}); +} + +[[nodiscard]] inline bool write_bit_string( + asn1::BerSpanWriter& writer, + const std::uint8_t unused_bits, + const std::span bytes) noexcept { + return unused_bits <= 7U && + writer.write_tlv_header(asn1::BerClass::context_specific, false, 4, 1U + bytes.size()) && + writer.write_byte(unused_bits) && writer.write_bytes(bytes); +} + +[[nodiscard]] inline bool write_unsigned( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto content = minimal_unsigned_size(value); + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 6, content) && + write_unsigned_content(writer, value); +} + +[[nodiscard]] inline bool write_boolean( + asn1::BerSpanWriter& writer, + const bool value) noexcept { + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 3, 1U) && + writer.write_byte(value ? 0x01U : 0x00U); +} + +[[nodiscard]] inline bool add_to( + std::size_t& total, + const std::optional value) noexcept { + if (!value || *value > std::numeric_limits::max() - total) return false; + total += *value; + return true; +} + +} // namespace ar::iec61850::mms::detail From 5c286ec7a07d9aefa0ccf28b2087c390f7858312 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:07:32 +0700 Subject: [PATCH 158/188] Implement buffered selective MMS report codec --- ...ered_selective_information_report_span.cpp | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 src/mms/buffered_selective_information_report_span.cpp diff --git a/src/mms/buffered_selective_information_report_span.cpp b/src/mms/buffered_selective_information_report_span.cpp new file mode 100644 index 000000000..621a5c34f --- /dev/null +++ b/src/mms/buffered_selective_information_report_span.cpp @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/buffered_selective_information_report_span.hpp" +#include "ariec61850/mms/buffered_selective_report_detail.hpp" + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/pdu_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool validate_report( + const MmsBufferedSelectiveInformationReportSnapshotInput& report) noexcept { + using namespace detail; + if (!visible_ascii(report.report_id, MmsInformationReportSpanCodec::maximum_report_id_bytes) || + report.optional_fields.size() != MmsInformationReportSpanCodec::optional_field_bytes || + (report.optional_fields[0] & 0x80U) != 0U || + (report.optional_fields[1] & 0x7FU) != 0U || + report.data_set_member_count == 0U || + report.data_set_member_count > MmsInformationReportSpanCodec::maximum_members || + report.included_member_indices.empty() || + report.included_member_indices.size() > report.data_set_member_count || + report.included_member_references.size() != report.included_member_indices.size() || + report.included_member_results.size() != report.included_member_indices.size() || + !reference_content_size(report.data_set_reference)) { + return false; + } + if (optional_selected(report, 0U, opt_report_timestamp) && + report.report_time.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return false; + } + if (optional_selected(report, 0U, opt_entry_id)) { + if (report.entry_id.size() != MmsInformationReportSpanCodec::entry_id_bytes) return false; + } else if (!report.entry_id.empty()) { + return false; + } + if (optional_selected(report, 0U, opt_reason_for_inclusion)) { + if (report.included_reason_for_inclusion.size() != + report.included_member_indices.size()) return false; + for (const auto reason : report.included_reason_for_inclusion) { + if (reason == 0U || (reason & 0x03U) != 0U) return false; + } + } else if (!report.included_reason_for_inclusion.empty()) { + return false; + } + + std::size_t previous = 0U; + bool first = true; + for (const auto index : report.included_member_indices) { + if (index >= report.data_set_member_count || (!first && index <= previous)) return false; + previous = index; + first = false; + } + for (const auto& reference : report.included_member_references) { + if (!reference_content_size(reference)) return false; + } + for (const auto& result : report.included_member_results) { + if (!access_result_size(result)) return false; + } + return true; +} + +[[nodiscard]] std::optional access_content_size( + const MmsBufferedSelectiveInformationReportSnapshotInput& report) noexcept { + using namespace detail; + std::size_t total = 0U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(10, report.report_id.size())) || + !add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + report.optional_fields.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, opt_sequence_number) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.sequence_number)))) return std::nullopt; + if (optional_selected(report, 0U, opt_report_timestamp) && + !add_to(total, asn1::BerSpanWriter::tlv_size(12, report.report_time.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, opt_data_set_name)) { + const auto content = reference_content_size(report.data_set_reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + if (optional_selected(report, 0U, opt_buffer_overflow) && + !add_to(total, asn1::BerSpanWriter::tlv_size(3, 1U))) return std::nullopt; + if (optional_selected(report, 0U, opt_entry_id) && + !add_to(total, asn1::BerSpanWriter::tlv_size(9, report.entry_id.size()))) { + return std::nullopt; + } + if (optional_selected(report, 1U, opt_conf_revision) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.conf_revision)))) return std::nullopt; + + const auto inclusion_bytes = (report.data_set_member_count + 7U) / 8U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + inclusion_bytes))) { + return std::nullopt; + } + if (optional_selected(report, 0U, opt_data_reference)) { + for (const auto& reference : report.included_member_references) { + const auto content = reference_content_size(reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + } + for (const auto& result : report.included_member_results) { + if (!add_to(total, access_result_size(result))) return std::nullopt; + } + if (optional_selected(report, 0U, opt_reason_for_inclusion)) { + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(4, 2U); + for (std::size_t index = 0U; index < report.included_member_indices.size(); ++index) { + if (!add_to(total, reason_tlv)) return std::nullopt; + } + } + return total; +} + +[[nodiscard]] bool write_inclusion( + asn1::BerSpanWriter& writer, + const std::size_t member_count, + const std::span included) noexcept { + const auto byte_count = (member_count + 7U) / 8U; + const auto unused = static_cast((byte_count * 8U) - member_count); + if (!writer.write_tlv_header(asn1::BerClass::context_specific, false, 4, 1U + byte_count) || + !writer.write_byte(unused)) return false; + + std::size_t included_offset = 0U; + for (std::size_t byte_index = 0U; byte_index < byte_count; ++byte_index) { + std::uint8_t value = 0U; + while (included_offset < included.size() && included[included_offset] / 8U == byte_index) { + const auto bit = included[included_offset] % 8U; + value = static_cast(value | static_cast(0x80U >> bit)); + ++included_offset; + } + if (!writer.write_byte(value)) return false; + } + return included_offset == included.size(); +} + +} // namespace + +wire::EncodeResult MmsBufferedSelectiveInformationReportSpanCodec::encode_snapshot_into( + const MmsBufferedSelectiveInformationReportSnapshotInput& report, + const std::span destination) noexcept { + using namespace detail; + if (!validate_report(report)) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + + const auto access_content = access_content_size(report); + const auto access_tlv = access_content ? asn1::BerSpanWriter::tlv_size(0, *access_content) : std::nullopt; + const auto vmd_name_tlv = asn1::BerSpanWriter::tlv_size(0, 3U); + const auto variable_access_tlv = vmd_name_tlv ? asn1::BerSpanWriter::tlv_size(1, *vmd_name_tlv) : std::nullopt; + std::optional information_content; + if (access_tlv && variable_access_tlv && + *access_tlv <= std::numeric_limits::max() - *variable_access_tlv) { + information_content = *variable_access_tlv + *access_tlv; + } + const auto service_tlv = information_content ? asn1::BerSpanWriter::tlv_size(0, *information_content) : std::nullopt; + const auto required = service_tlv ? asn1::BerSpanWriter::tlv_size(3, *service_tlv) : std::nullopt; + if (!access_content || !access_tlv || !vmd_name_tlv || !variable_access_tlv || + !information_content || !service_tlv || !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, 3, *service_tlv) || + !writer.write_tlv_header(asn1::BerClass::context_specific, true, 0, *information_content) || + !writer.write_tlv_header(asn1::BerClass::context_specific, true, 1, *vmd_name_tlv) || + !writer.write_tlv_header(asn1::BerClass::context_specific, false, 0, 3U) || + !writer.write_bytes(std::span{ + reinterpret_cast("RPT"), 3U}) || + !writer.write_tlv_header(asn1::BerClass::context_specific, true, 0, *access_content) || + !write_visible(writer, report.report_id) || + !write_bit_string(writer, 6U, report.optional_fields)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, opt_sequence_number) && + !write_unsigned(writer, report.sequence_number)) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + if (optional_selected(report, 0U, opt_report_timestamp) && + (!writer.write_tlv_header(asn1::BerClass::context_specific, false, 12, report.report_time.size()) || + !writer.write_bytes(report.report_time))) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + if (optional_selected(report, 0U, opt_data_set_name) && !write_reference(writer, report.data_set_reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_buffer_overflow) && !write_boolean(writer, report.buffer_overflow)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_entry_id) && + (!writer.write_tlv_header(asn1::BerClass::context_specific, false, 9, report.entry_id.size()) || + !writer.write_bytes(report.entry_id))) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + if (optional_selected(report, 1U, opt_conf_revision) && !write_unsigned(writer, report.conf_revision)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (!write_inclusion(writer, report.data_set_member_count, report.included_member_indices)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_data_reference)) { + for (const auto& reference : report.included_member_references) { + if (!write_reference(writer, reference)) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + for (const auto& result : report.included_member_results) { + if (!write_access_result(writer, result)) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_reason_for_inclusion)) { + for (const auto reason : report.included_reason_for_inclusion) { + if (!write_bit_string(writer, 2U, std::span{&reason, 1U})) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + if (writer.size() != *required) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::mms From e8ebaf3ac7bd4ef3e1e70621949943ab60496b3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:10:54 +0700 Subject: [PATCH 159/188] Bind BRCB capture plans to queue revision --- include/ariec61850/mms/static_brcb_runtime.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/ariec61850/mms/static_brcb_runtime.hpp b/include/ariec61850/mms/static_brcb_runtime.hpp index 75a0d418f..74259244f 100644 --- a/include/ariec61850/mms/static_brcb_runtime.hpp +++ b/include/ariec61850/mms/static_brcb_runtime.hpp @@ -74,6 +74,7 @@ struct MmsStaticBrcbPendingState final { struct MmsStaticBrcbCapturePlan final { std::uint32_t pending_revision{}; + std::uint32_t queue_revision{}; std::uint64_t entry_number{}; std::uint8_t sequence_number{}; bool buffer_overflow{}; @@ -155,6 +156,7 @@ class MmsStaticBrcbRuntime final { std::size_t count_{}; std::uint64_t next_entry_number_{1U}; std::uint64_t dropped_reports_{}; + std::uint32_t queue_revision_{1U}; std::uint8_t sequence_number_{}; bool enabled_{}; bool initialized_{}; From beaba45a6defe6ff9c13313a3f81edd2f47e302c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:12:09 +0700 Subject: [PATCH 160/188] Implement bounded in-memory BRCB runtime --- src/mms/static_brcb_runtime.cpp | 476 ++++++++++++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 src/mms/static_brcb_runtime.cpp diff --git a/src/mms/static_brcb_runtime.cpp b/src/mms/static_brcb_runtime.cpp new file mode 100644 index 000000000..dff27bc3f --- /dev/null +++ b/src/mms/static_brcb_runtime.cpp @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include "ariec61850/mms/buffered_selective_report_detail.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kTriggerDataChange = 0x40U; +constexpr std::uint8_t kTriggerQualityChange = 0x20U; +constexpr std::uint8_t kTriggerDataUpdate = 0x10U; +constexpr std::uint8_t kAllowedTriggers = 0x70U; + +constexpr std::uint8_t kReasonDataChange = 0x80U; +constexpr std::uint8_t kReasonQualityChange = 0x40U; +constexpr std::uint8_t kReasonDataUpdate = 0x20U; + +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; +constexpr std::uint8_t kOptBufferOverflow = 0x02U; +constexpr std::uint8_t kOptEntryId = 0x01U; +constexpr std::uint8_t kAllowedOptionalFirst = 0x7FU; +constexpr std::uint8_t kAllowedOptionalSecond = 0x80U; + +[[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; +} + +void bump_revision(std::uint32_t& revision) noexcept { + revision = revision == std::numeric_limits::max() + ? 1U + : revision + 1U; +} + +[[nodiscard]] std::uint8_t next_sequence_number( + const std::uint8_t current) noexcept { + return static_cast( + (static_cast(current) + 1U) & 0xFFU); +} + +[[nodiscard]] std::uint64_t next_entry_number( + const std::uint64_t current) noexcept { + return current == std::numeric_limits::max() + ? 1U + : current + 1U; +} + +[[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 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_reference( + const std::string_view domain, + const std::string_view item) noexcept { + return visible_ascii(domain, MmsInformationReportSpanCodec::maximum_reference_bytes) && + visible_ascii(item, MmsInformationReportSpanCodec::maximum_reference_bytes) && + domain.size() + 1U + item.size() <= + MmsInformationReportSpanCodec::maximum_reference_bytes; +} + +void clear_pending(MmsStaticBrcbPendingState& pending) noexcept { + std::fill( + pending.member_reason_masks.begin(), + pending.member_reason_masks.end(), + std::uint8_t{0U}); + pending.due_ms = 0U; + pending.pending_member_count = 0U; + pending.pending = false; +} + +[[nodiscard]] bool event_mapping( + const MmsStaticBrcbEventReason reason, + std::uint8_t& trigger_mask, + std::uint8_t& report_reason) noexcept { + switch (reason) { + case MmsStaticBrcbEventReason::data_change: + trigger_mask = kTriggerDataChange; + report_reason = kReasonDataChange; + return true; + case MmsStaticBrcbEventReason::quality_change: + trigger_mask = kTriggerQualityChange; + report_reason = kReasonQualityChange; + return true; + case MmsStaticBrcbEventReason::data_update: + trigger_mask = kTriggerDataUpdate; + report_reason = kReasonDataUpdate; + return true; + } + return false; +} + +[[nodiscard]] std::array +encode_entry_id(const std::uint64_t entry_number) noexcept { + std::array result{}; + for (std::size_t index = 0U; index < result.size(); ++index) { + const auto byte_index = result.size() - 1U - index; + const auto shift = static_cast(index * 8U); + result[byte_index] = static_cast( + (entry_number >> shift) & 0xFFU); + } + return result; +} + +[[nodiscard]] bool entry_id_equal( + const std::span left, + const std::array& right) + noexcept { + return left.size() == right.size() && + std::equal(left.begin(), left.end(), right.begin()); +} + +} // namespace + +bool MmsStaticBrcbRuntime::initialize() noexcept { + initialized_ = false; + if (definition_ == nullptr || pending_ == nullptr || objects_ == nullptr || + data_sets_ == nullptr || !objects_->valid() || !data_sets_->valid() || + !data_sets_->valid_against(*objects_) || slots_.empty()) { + return false; + } + if (!valid_reference(definition_->domain, definition_->item) || + !visible_ascii( + definition_->report_id, + MmsInformationReportSpanCodec::maximum_report_id_bytes) || + !valid_reference(definition_->data_set_domain, definition_->data_set_item) || + (definition_->optional_fields[0] & + static_cast(~kAllowedOptionalFirst)) != 0U || + (definition_->optional_fields[1] & + static_cast(~kAllowedOptionalSecond)) != 0U || + (definition_->optional_fields[0] & + static_cast(kOptBufferOverflow | kOptEntryId)) != + static_cast(kOptBufferOverflow | kOptEntryId) || + definition_->trigger_options == 0U || + (definition_->trigger_options & static_cast(~kAllowedTriggers)) != 0U) { + return false; + } + + const auto* data_set = data_sets_->find(object_name( + definition_->data_set_domain, definition_->data_set_item)); + if (data_set == nullptr || data_set->members.empty() || + data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return false; + } + for (const auto& slot : slots_) { + if (slot.storage.empty()) { + return false; + } + } + + *pending_ = {}; + pending_->revision = 1U; + for (auto& slot : slots_) { + const auto storage = slot.storage; + slot = {}; + slot.storage = storage; + } + head_ = 0U; + count_ = 0U; + next_entry_number_ = 1U; + dropped_reports_ = 0U; + queue_revision_ = 1U; + sequence_number_ = 0U; + enabled_ = false; + initialized_ = true; + return true; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::set_enabled( + const bool enabled) noexcept { + if (!initialized_ || pending_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (enabled_ == enabled) { + return MmsStaticBrcbStatus::ok; + } + enabled_ = enabled; + clear_pending(*pending_); + bump_revision(pending_->revision); + return MmsStaticBrcbStatus::ok; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::notify( + const std::size_t data_set_member_index, + const MmsStaticBrcbEventReason reason, + const std::uint64_t now_ms) noexcept { + if (!initialized_ || definition_ == nullptr || pending_ == nullptr || + data_sets_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (!enabled_) { + return MmsStaticBrcbStatus::temporarily_unavailable; + } + + std::uint8_t trigger_mask = 0U; + std::uint8_t report_reason = 0U; + if (!event_mapping(reason, trigger_mask, report_reason) || + (definition_->trigger_options & trigger_mask) == 0U) { + return MmsStaticBrcbStatus::trigger_not_selected; + } + + const auto* data_set = data_sets_->find(object_name( + definition_->data_set_domain, definition_->data_set_item)); + if (data_set == nullptr) { + return MmsStaticBrcbStatus::data_set_not_found; + } + if (data_set_member_index >= data_set->members.size() || + data_set_member_index >= MmsInformationReportSpanCodec::maximum_members) { + return MmsStaticBrcbStatus::member_out_of_range; + } + + if (!pending_->pending) { + pending_->pending = true; + pending_->due_ms = saturating_add(now_ms, definition_->buffer_time_ms); + } + if (pending_->member_reason_masks[data_set_member_index] == 0U) { + ++pending_->pending_member_count; + } + pending_->member_reason_masks[data_set_member_index] = static_cast( + pending_->member_reason_masks[data_set_member_index] | report_reason); + bump_revision(pending_->revision); + return MmsStaticBrcbStatus::ok; +} + +bool MmsStaticBrcbRuntime::next_due( + const std::uint64_t now_ms, + MmsStaticBrcbCapturePlan& plan) const noexcept { + plan = {}; + if (!initialized_ || pending_ == nullptr || !enabled_ || + !pending_->pending || pending_->pending_member_count == 0U || + now_ms < pending_->due_ms || slots_.empty()) { + return false; + } + plan.pending_revision = pending_->revision; + plan.queue_revision = queue_revision_; + plan.entry_number = next_entry_number_; + plan.sequence_number = next_sequence_number(sequence_number_); + plan.buffer_overflow = count_ == slots_.size(); + return true; +} + +MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( + const MmsStaticBrcbCapturePlan& plan, + const std::span report_time, + const std::span encode_buffer, + const std::span workspace) noexcept { + MmsStaticBrcbCaptureResult result; + if (!initialized_ || definition_ == nullptr || pending_ == nullptr || + objects_ == nullptr || data_sets_ == nullptr || slots_.empty()) { + result.status = MmsStaticBrcbStatus::invalid_runtime; + return result; + } + const bool full = count_ == slots_.size(); + if (!enabled_ || !pending_->pending || pending_->pending_member_count == 0U || + plan.pending_revision != pending_->revision || + plan.queue_revision != queue_revision_ || + plan.entry_number != next_entry_number_ || + plan.sequence_number != next_sequence_number(sequence_number_) || + plan.buffer_overflow != full) { + result.status = MmsStaticBrcbStatus::stale_plan; + return result; + } + + const auto* data_set = data_sets_->find(object_name( + definition_->data_set_domain, definition_->data_set_item)); + if (data_set == nullptr) { + result.status = MmsStaticBrcbStatus::data_set_not_found; + return result; + } + + std::array indices{}; + std::array references{}; + std::array results{}; + std::array reasons{}; + + std::size_t included = 0U; + std::size_t workspace_offset = 0U; + for (std::size_t member_index = 0U; + member_index < data_set->members.size(); + ++member_index) { + const auto reason_mask = pending_->member_reason_masks[member_index]; + if (reason_mask == 0U) { + continue; + } + const auto& member = data_set->members[member_index]; + const auto* object = objects_->find(object_name(member.domain, member.item)); + if (object == nullptr) { + result.status = MmsStaticBrcbStatus::object_not_found; + result.included_member_count = included; + return result; + } + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + result.status = MmsStaticBrcbStatus::workspace_too_small; + if (read.required_bytes <= + std::numeric_limits::max() - workspace_offset) { + result.required_bytes = workspace_offset + read.required_bytes; + } + result.included_member_count = included; + return result; + } + if (!read.success() || read.bytes_written > remaining.size() || + !detail::valid_mms_data(remaining.first(read.bytes_written))) { + result.status = MmsStaticBrcbStatus::backend_failure; + result.included_member_count = included; + return result; + } + + indices[included] = member_index; + references[included] = {member.domain, member.item}; + results[included] = {true, remaining.first(read.bytes_written), 0U}; + reasons[included] = reason_mask; + workspace_offset += read.bytes_written; + ++included; + } + if (included == 0U || included != pending_->pending_member_count) { + result.status = MmsStaticBrcbStatus::stale_plan; + result.included_member_count = included; + return result; + } + + const auto entry_id = encode_entry_id(plan.entry_number); + MmsBufferedSelectiveInformationReportSnapshotInput report; + report.report_id = definition_->report_id; + report.optional_fields = definition_->optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.data_set_reference = { + definition_->data_set_domain, definition_->data_set_item}; + report.buffer_overflow = plan.buffer_overflow; + report.entry_id = entry_id; + report.conf_revision = definition_->conf_revision; + report.data_set_member_count = data_set->members.size(); + report.included_member_indices = + std::span{indices}.first(included); + report.included_member_references = + std::span{references}.first(included); + report.included_member_results = + std::span{results}.first(included); + if ((definition_->optional_fields[0] & kOptReasonForInclusion) != 0U) { + report.included_reason_for_inclusion = + std::span{reasons}.first(included); + } + + const auto encoded = + MmsBufferedSelectiveInformationReportSpanCodec::encode_snapshot_into( + report, encode_buffer); + if (!encoded.success()) { + result.status = encoded.status == wire::EncodeStatus::buffer_too_small + ? MmsStaticBrcbStatus::response_buffer_too_small + : MmsStaticBrcbStatus::report_encode_failed; + result.required_bytes = encoded.required_bytes; + result.included_member_count = included; + result.entry_id = entry_id; + return result; + } + + const auto target_index = full + ? head_ + : (head_ + count_) % slots_.size(); + auto& slot = slots_[target_index]; + if (slot.storage.size() < encoded.bytes_written) { + result.status = MmsStaticBrcbStatus::slot_too_small; + result.required_bytes = encoded.bytes_written; + result.included_member_count = included; + result.entry_id = entry_id; + return result; + } + + std::copy_n(encode_buffer.begin(), encoded.bytes_written, slot.storage.begin()); + slot.entry_id = entry_id; + slot.bytes = encoded.bytes_written; + slot.sequence_number = plan.sequence_number; + slot.buffer_overflow = plan.buffer_overflow; + slot.occupied = true; + + if (full) { + head_ = (head_ + 1U) % slots_.size(); + ++dropped_reports_; + } else { + ++count_; + } + sequence_number_ = plan.sequence_number; + next_entry_number_ = next_entry_number(plan.entry_number); + clear_pending(*pending_); + bump_revision(pending_->revision); + bump_revision(queue_revision_); + + result.status = MmsStaticBrcbStatus::ok; + result.bytes_written = encoded.bytes_written; + result.required_bytes = encoded.required_bytes; + result.included_member_count = included; + result.entry_id = entry_id; + return result; +} + +bool MmsStaticBrcbRuntime::front(MmsStaticBrcbEntryView& entry) const noexcept { + entry = {}; + if (!initialized_ || count_ == 0U || head_ >= slots_.size()) { + return false; + } + const auto& slot = slots_[head_]; + if (!slot.occupied || slot.bytes == 0U || slot.bytes > slot.storage.size()) { + return false; + } + entry.mms_pdu = std::span{slot.storage}.first(slot.bytes); + entry.entry_id = slot.entry_id; + entry.sequence_number = slot.sequence_number; + entry.buffer_overflow = slot.buffer_overflow; + return true; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::commit_delivery( + const std::span expected_entry_id) noexcept { + if (!initialized_ || slots_.empty()) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (count_ == 0U || head_ >= slots_.size()) { + return MmsStaticBrcbStatus::entry_not_found; + } + auto& slot = slots_[head_]; + if (!slot.occupied || !entry_id_equal(expected_entry_id, slot.entry_id)) { + return MmsStaticBrcbStatus::entry_not_found; + } + + const auto storage = slot.storage; + slot = {}; + slot.storage = storage; + head_ = (head_ + 1U) % slots_.size(); + --count_; + bump_revision(queue_revision_); + return MmsStaticBrcbStatus::ok; +} + +} // namespace ar::iec61850::mms From 3f55bb256d3249d31843470dad506c9c2ab60de7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:13:34 +0700 Subject: [PATCH 161/188] Add bounded BRCB runtime smoke --- embedded/mms_brcb_hard_profile_smoke.cpp | 271 +++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 embedded/mms_brcb_hard_profile_smoke.cpp diff --git a/embedded/mms_brcb_hard_profile_smoke.cpp b/embedded/mms_brcb_hard_profile_smoke.cpp new file mode 100644 index 000000000..0dd71f886 --- /dev/null +++ b/embedded/mms_brcb_hard_profile_smoke.cpp @@ -0,0 +1,271 @@ +// 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_brcb_runtime.hpp" + +#include +#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}; + +[[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}; +} + +[[nodiscard]] bool decode_boolean( + const mms::MmsReadAccessResultView& item, + bool& value) noexcept { + value = false; + 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 != 3 || tlv.constructed || tlv.value.size() != 1U) { + return false; + } + value = tlv.value[0] != 0U; + return true; +} + +[[nodiscard]] bool decode_bit_string( + const mms::MmsReadAccessResultView& item, + const std::uint8_t unused, + const std::uint8_t value) noexcept { + if (!item.success) return false; + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 4 && !tlv.constructed && tlv.value.size() == 2U && + tlv.value[0] == unused && tlv.value[1] == value; +} + +[[nodiscard]] bool decode_octet_string( + const mms::MmsReadAccessResultView& item, + const std::span expected) noexcept { + 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 != 9 || tlv.constructed || tlv.value.size() != expected.size()) { + return false; + } + for (std::size_t index = 0U; index < expected.size(); ++index) { + if (tlv.value[index] != expected[index]) return false; + } + return true; +} + +[[nodiscard]] bool expected_entry( + const std::array& entry, + const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < entry.size(); ++index) { + const auto shift = static_cast((entry.size() - 1U - index) * 8U); + if (entry[index] != static_cast((value >> shift) & 0xFFU)) { + return false; + } + } + return true; +} + +} // namespace + +int main() { + bool first = true; + bool second = false; + bool third = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &first, false}, + mms::MmsStaticObjectEntry{"LD0", "X2", kBooleanType, read_boolean, &second, false}, + mms::MmsStaticObjectEntry{"LD0", "X3", kBooleanType, read_boolean, &third, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}, + mms::MmsStaticDataSetMember{"LD0", "X2"}, + mms::MmsStaticDataSetMember{"LD0", "X3"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + + const mms::MmsStaticBrcbDefinition definition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Events", + "LD0", + "LLN0$Events", + 11U, + {0x7FU, 0x80U}, + 20U, + 0x70U}; + + std::array slot0{}; + std::array slot1{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}, + mms::MmsStaticBrcbSlot{slot1}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime{ + definition, pending, slots, object_table, data_set_table}; + if (!runtime.initialize() || !runtime.valid() || runtime.enabled() || + runtime.queue_size() != 0U || runtime.queue_capacity() != 2U) { + return 1; + } + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 0U) != + mms::MmsStaticBrcbStatus::temporarily_unavailable || + runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 2; + } + + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 100U) != + mms::MmsStaticBrcbStatus::ok || + runtime.notify(2U, mms::MmsStaticBrcbEventReason::quality_change, 105U) != + mms::MmsStaticBrcbStatus::ok) { + return 3; + } + mms::MmsStaticBrcbCapturePlan plan; + if (runtime.next_due(119U, plan) || !runtime.next_due(120U, plan) || + plan.entry_number != 1U || plan.sequence_number != 1U || plan.buffer_overflow) { + return 4; + } + + std::array tiny{}; + std::array staging{}; + std::array workspace{}; + auto capture = runtime.capture(plan, kReportTime, tiny, workspace); + if (capture.status != mms::MmsStaticBrcbStatus::response_buffer_too_small || + capture.required_bytes <= tiny.size() || runtime.queue_size() != 0U || + !runtime.next_due(120U, plan) || plan.entry_number != 1U) { + return 5; + } + + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 1U || + capture.included_member_count != 2U || !expected_entry(capture.entry_id, 1U)) { + return 6; + } + + mms::MmsStaticBrcbEntryView entry; + mms::MmsInformationReportView report; + if (!runtime.front(entry) || entry.sequence_number != 1U || entry.buffer_overflow || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + entry.mms_pdu, report) || report.item_count != 15U) { + return 7; + } + mms::MmsReadAccessResultView item; + bool boolean_value = false; + if (!report.try_item(5U, item) || !decode_boolean(item, boolean_value) || boolean_value || + !report.try_item(6U, item) || !decode_octet_string(item, capture.entry_id) || + !report.try_item(8U, item) || !decode_bit_string(item, 5U, 0xA0U) || + !report.try_item(11U, item) || !decode_boolean(item, boolean_value) || !boolean_value || + !report.try_item(12U, item) || !decode_boolean(item, boolean_value) || !boolean_value || + !report.try_item(13U, item) || !decode_bit_string(item, 2U, 0x80U) || + !report.try_item(14U, item) || !decode_bit_string(item, 2U, 0x40U)) { + return 8; + } + + if (runtime.notify(1U, mms::MmsStaticBrcbEventReason::data_update, 200U) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(220U, plan)) { + return 9; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 2U || + !expected_entry(capture.entry_id, 2U)) { + return 10; + } + + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 300U) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(320U, plan) || !plan.buffer_overflow) { + return 11; + } + const auto stale_plan = plan; + const auto first_entry_id = std::array{ + 0U, 0U, 0U, 0U, 0U, 0U, 0U, 1U}; + if (runtime.commit_delivery(first_entry_id) != mms::MmsStaticBrcbStatus::ok || + runtime.capture(stale_plan, kReportTime, staging, workspace).status != + mms::MmsStaticBrcbStatus::stale_plan || + !runtime.next_due(320U, plan) || plan.buffer_overflow) { + return 12; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 2U || + !expected_entry(capture.entry_id, 3U) || runtime.dropped_reports() != 0U) { + return 13; + } + + if (runtime.notify(2U, mms::MmsStaticBrcbEventReason::quality_change, 400U) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(420U, plan) || !plan.buffer_overflow) { + return 14; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 2U || + !expected_entry(capture.entry_id, 4U) || runtime.dropped_reports() != 1U) { + return 15; + } + + // Entry 2 was the oldest and must have been dropped. Entry 3 is now front. + if (!runtime.front(entry) || entry.entry_id.size() != 8U || entry.entry_id[7] != 3U || + runtime.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok || + !runtime.front(entry) || entry.entry_id[7] != 4U || !entry.buffer_overflow || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + entry.mms_pdu, report) || + !report.try_item(5U, item) || !decode_boolean(item, boolean_value) || !boolean_value || + !report.try_item(6U, item)) { + return 16; + } + const std::array fourth_id{0U,0U,0U,0U,0U,0U,0U,4U}; + if (!decode_octet_string(item, fourth_id) || + runtime.commit_delivery(fourth_id) != mms::MmsStaticBrcbStatus::ok || + runtime.queue_size() != 0U) { + return 17; + } + + // Repeated capture/delivery proves bounded steady-state behavior and EntryID progression. + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + const auto now = static_cast(1'000U + (iteration * 25U)); + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_update, now) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(now + 20U, plan)) { + return 18; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || !runtime.front(entry) || + runtime.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok || + runtime.queue_size() != 0U) { + return 19; + } + } + + if (runtime.set_enabled(false) != mms::MmsStaticBrcbStatus::ok || runtime.enabled() || + runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 999'999U) != + mms::MmsStaticBrcbStatus::temporarily_unavailable) { + return 20; + } + + return 0; +} From 4495163c87d6ae1c5ef0257aaa6bf50702947be3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:14:08 +0700 Subject: [PATCH 162/188] Add BRCB hard embedded profile --- embedded/brcb_hard_profile/CMakeLists.txt | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 embedded/brcb_hard_profile/CMakeLists.txt diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt new file mode 100644 index 000000000..2e00afd54 --- /dev/null +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -0,0 +1,46 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850BrcbHardProfile + VERSION 0.1.0 + DESCRIPTION "No-exception bounded IEC 61850 buffered-report profile" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "BRCB hard profile is validated with GCC/Clang -fno-exceptions.") +endif() + +add_executable(ariec61850_hard_mms_brcb_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_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) + +target_compile_features(ariec61850_hard_mms_brcb_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_hard_mms_brcb_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_hard_mms_brcb_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) +target_compile_options(ariec61850_hard_mms_brcb_smoke PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) + +enable_testing() +add_test(NAME ariec61850_hard_mms_brcb_smoke COMMAND ariec61850_hard_mms_brcb_smoke) + +message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=ON") +message(STATUS "arstack61850 BRCB hard profile: exceptions/RTTI disabled=ON") +message(STATUS "arstack61850 BRCB hard profile: non-volatile persistence=NOT_CLAIMED") From 644afc2c0a7ff9b70c5c6481e5a3b97d6705acfd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:14:45 +0700 Subject: [PATCH 163/188] Add BRCB hard profile CI --- .github/workflows/brcb-hard-profile-ci.yml | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/brcb-hard-profile-ci.yml diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml new file mode 100644 index 000000000..6016b365d --- /dev/null +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -0,0 +1,75 @@ +name: BRCB Hard Profile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + brcb-hard-profile: + name: BRCB Hard / ${{ 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 BRCB hard profile + run: cmake -S embedded/brcb_hard_profile -B build-brcb-hard + + - name: Build BRCB hard profile + run: cmake --build build-brcb-hard --parallel + + - name: Run BRCB smoke + run: >- + ctest --test-dir build-brcb-hard + --output-on-failure + --output-junit brcb-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + binary=build-brcb-hard/ariec61850_hard_mms_brcb_smoke + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + size build-brcb-hard/ariec61850_hard_mms_brcb_smoke + } | tee build-brcb-hard/brcb-footprint.txt + + - name: Upload BRCB evidence + uses: actions/upload-artifact@v4 + with: + name: brcb-hard-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-brcb-hard/brcb-ctest.xml + build-brcb-hard/brcb-footprint.txt From 5f4644a787fefcff41ab02a984c956c15dfe4f5d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:15:53 +0700 Subject: [PATCH 164/188] Add retry-safe BRCB connection API --- .../ariec61850/mms/static_brcb_connection.hpp | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 include/ariec61850/mms/static_brcb_connection.hpp diff --git a/include/ariec61850/mms/static_brcb_connection.hpp b/include/ariec61850/mms/static_brcb_connection.hpp new file mode 100644 index 000000000..1055b8c8f --- /dev/null +++ b/include/ariec61850/mms/static_brcb_connection.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticBrcbConnectionStatus : std::uint8_t { + no_report_available, + response_ready, + not_established, + response_buffer_too_small, + workspace_too_small, + frame_encode_failed, + stale_entry, +}; + +struct MmsStaticBrcbConnectionResult final { + MmsStaticBrcbConnectionStatus status{ + MmsStaticBrcbConnectionStatus::no_report_available}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + std::array entry_id{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticBrcbConnectionStatus::response_ready; + } +}; + +class MmsStaticBrcbConnection final { +public: + // Deliver at most one already-buffered report. The queue front is removed + // only after a complete TPKT image is staged successfully. Capacity errors + // and disconnected sessions preserve the exact same EntryID for retry. + [[nodiscard]] static MmsStaticBrcbConnectionResult poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbRuntime& reports, + std::span response, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms From e9c856dfd0dcde55111396df3bb14865bfde7c99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:16:14 +0700 Subject: [PATCH 165/188] Implement retry-safe BRCB delivery --- src/mms/static_brcb_connection.cpp | 127 +++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/mms/static_brcb_connection.cpp diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp new file mode 100644 index 000000000..bf30e5ec9 --- /dev/null +++ b/src/mms/static_brcb_connection.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_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]] MmsStaticBrcbConnectionResult make_result( + const MmsStaticBrcbConnectionStatus status, + const MmsStaticBrcbEntryView* entry = nullptr) noexcept { + MmsStaticBrcbConnectionResult result; + result.status = status; + if (entry != nullptr) { + if (entry->entry_id.size() == result.entry_id.size()) { + std::copy(entry->entry_id.begin(), entry->entry_id.end(), result.entry_id.begin()); + } + result.sequence_number = entry->sequence_number; + result.buffer_overflow = entry->buffer_overflow; + } + return result; +} + +} // namespace + +MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbRuntime& reports, + 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); + } + + MmsStaticBrcbEntryView entry; + if (!reports.front(entry)) { + return make_result(MmsStaticBrcbConnectionStatus::no_report_available); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + entry.mms_pdu, + workspace, + connection.mms_presentation_context_id(), + true); + if (!p_data.success()) { + if (p_data.status != wire::EncodeStatus::buffer_too_small) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + std::size_t final_required{}; + if (!add_size(p_data.required_bytes, 7U, final_required)) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + auto result = make_result( + MmsStaticBrcbConnectionStatus::workspace_too_small, &entry); + result.required_workspace_bytes = final_required; + result.required_response_bytes = final_required; + return result; + } + + std::size_t final_required{}; + if (!add_size(p_data.bytes_written, 7U, final_required)) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + if (response.size() < final_required) { + auto result = make_result( + MmsStaticBrcbConnectionStatus::response_buffer_too_small, &entry); + result.required_response_bytes = final_required; + return result; + } + if (workspace.size() < final_required) { + auto result = make_result( + MmsStaticBrcbConnectionStatus::workspace_too_small, &entry); + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; + } + + 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(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + + 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(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + + const auto committed = reports.commit_delivery(entry.entry_id); + if (committed != MmsStaticBrcbStatus::ok) { + return make_result(MmsStaticBrcbConnectionStatus::stale_entry, &entry); + } + + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry); + result.bytes_written = tpkt.bytes_written; + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; +} + +} // namespace ar::iec61850::mms From a77f3e35a096d6d4edf202c25f9edd830a5a6a8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:19:06 +0700 Subject: [PATCH 166/188] Snapshot BRCB delivery metadata before dequeue --- src/mms/static_brcb_connection.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp index bf30e5ec9..fabee8c67 100644 --- a/src/mms/static_brcb_connection.cpp +++ b/src/mms/static_brcb_connection.cpp @@ -111,13 +111,14 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); } + auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry); const auto committed = reports.commit_delivery(entry.entry_id); if (committed != MmsStaticBrcbStatus::ok) { - return make_result(MmsStaticBrcbConnectionStatus::stale_entry, &entry); + result.status = MmsStaticBrcbConnectionStatus::stale_entry; + return result; } std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); - auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry); result.bytes_written = tpkt.bytes_written; result.required_response_bytes = final_required; result.required_workspace_bytes = final_required; From 21d8d4c8638da52ae8b2e62748af7f6435630d8d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:20:36 +0700 Subject: [PATCH 167/188] Add BRCB reconnect delivery smoke --- ...mms_brcb_connection_hard_profile_smoke.cpp | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 embedded/mms_brcb_connection_hard_profile_smoke.cpp diff --git a/embedded/mms_brcb_connection_hard_profile_smoke.cpp b/embedded/mms_brcb_connection_hard_profile_smoke.cpp new file mode 100644 index 000000000..0d326132a --- /dev/null +++ b/embedded/mms_brcb_connection_hard_profile_smoke.cpp @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_brcb_connection.hpp" +#include "ariec61850/mms/static_dispatcher.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}; +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + 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 = connection.process_tcp_window( + request.first(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( + scratch.first(cotp.bytes_written), request); + if (!association.success()) return false; + result = connection.process_tcp_window( + request.first(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_boolean( + const mms::MmsReadAccessResultView& item, + bool& value) noexcept { + value = false; + 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 != 3 || tlv.constructed || tlv.value.size() != 1U) return false; + value = tlv.value[0] != 0U; + return true; +} + +} // namespace + +int main() { + bool value = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &value, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + 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::MmsStaticConnectionRuntime connection{dispatcher}; + + const mms::MmsStaticBrcbDefinition definition{ + "LD0", "LLN0$BR$Events", "LD0/LLN0$BR$Events", + "LD0", "LLN0$Events", 4U, {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() || reports.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + reports.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 100U) != + mms::MmsStaticBrcbStatus::ok) { + return 1; + } + + mms::MmsStaticBrcbCapturePlan plan; + std::array staging{}; + std::array value_workspace{}; + if (!reports.next_due(100U, plan) || + !reports.capture(plan, kReportTime, staging, value_workspace).success() || + reports.queue_size() != 1U) { + return 2; + } + + // 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); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || + reports.queue_size() != 1U) { + return 3; + } + if (!establish(connection, request, response, workspace, scratch)) return 4; + + std::array tiny_response{}; + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, 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 5; + } + + std::array tiny_workspace{}; + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, 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 6; + } + + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, 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 7; + } + + mms::MmsInformationReportView decoded; + if (!decode_report_frame( + std::span{response}.first(delivery.bytes_written), decoded) || + decoded.item_count != 12U) { + return 8; + } + mms::MmsReadAccessResultView item; + bool historical_value = false; + if (!decoded.try_item(10U, item) || !decode_boolean(item, historical_value) || + !historical_value) { + return 9; + } + + // Queue another historical entry, then drop the association. The queue must survive reset. + 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 10; + } + connection.reset(); + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || + reports.queue_size() != 1U) { + return 11; + } + if (!establish(connection, request, response, workspace, scratch)) return 12; + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace); + if (!delivery.response_ready() || delivery.entry_id[7] != 2U || + reports.queue_size() != 0U || + mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace).status != + mms::MmsStaticBrcbConnectionStatus::no_report_available) { + return 13; + } + + return 0; +} From a0c36101550b87a44e53667ce0d1f9e5f37dde09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:21:07 +0700 Subject: [PATCH 168/188] Gate BRCB reconnect delivery in hard profile --- embedded/brcb_hard_profile/CMakeLists.txt | 60 +++++++++++++++++------ 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt index 2e00afd54..13692010b 100644 --- a/embedded/brcb_hard_profile/CMakeLists.txt +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -11,6 +11,24 @@ if(MSVC) message(FATAL_ERROR "BRCB hard profile is validated with GCC/Clang -fno-exceptions.") endif() +function(ariec61850_configure_brcb_hard_target target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + target_compile_definitions(${target} PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) + target_compile_options(${target} PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) +endfunction() + add_executable(ariec61850_hard_mms_brcb_smoke ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_hard_profile_smoke.cpp ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp @@ -21,26 +39,38 @@ add_executable(ariec61850_hard_mms_brcb_smoke ${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_configure_brcb_hard_target(ariec61850_hard_mms_brcb_smoke) -target_compile_features(ariec61850_hard_mms_brcb_smoke PRIVATE cxx_std_20) -target_include_directories(ariec61850_hard_mms_brcb_smoke PRIVATE ${ARIEC61850_ROOT}/include) -target_compile_definitions(ariec61850_hard_mms_brcb_smoke PRIVATE - ARIEC61850_EMBEDDED_PROFILE=1 - ARIEC61850_HOST_SERVICES=0 - ARIEC61850_NO_EXCEPTIONS=1) -target_compile_options(ariec61850_hard_mms_brcb_smoke PRIVATE - -Wall - -Wextra - -Wpedantic - -Wconversion - -Wsign-conversion - -Werror - -fno-exceptions - -fno-rtti) +add_executable(ariec61850_hard_mms_brcb_connection_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_connection_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_connection.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_connection_smoke) enable_testing() add_test(NAME ariec61850_hard_mms_brcb_smoke COMMAND ariec61850_hard_mms_brcb_smoke) +add_test( + NAME ariec61850_hard_mms_brcb_connection_smoke + COMMAND ariec61850_hard_mms_brcb_connection_smoke) message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=ON") +message(STATUS "arstack61850 BRCB hard profile: retry-safe reconnect delivery=ON") message(STATUS "arstack61850 BRCB hard profile: exceptions/RTTI disabled=ON") message(STATUS "arstack61850 BRCB hard profile: non-volatile persistence=NOT_CLAIMED") From 7f34025bd14927e0ae1f958da980f73850a148dd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:21:48 +0700 Subject: [PATCH 169/188] Audit BRCB core and connection binaries --- .github/workflows/brcb-hard-profile-ci.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index 6016b365d..f3284f130 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -38,7 +38,7 @@ jobs: - name: Build BRCB hard profile run: cmake --build build-brcb-hard --parallel - - name: Run BRCB smoke + - name: Run BRCB smokes run: >- ctest --test-dir build-brcb-hard --output-on-failure @@ -48,11 +48,14 @@ jobs: shell: bash run: | set -euo pipefail - binary=build-brcb-hard/ariec61850_hard_mms_brcb_smoke - if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then - echo "exception runtime symbol found in $binary" >&2 - exit 1 - fi + for binary in \ + build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke; do + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + done echo 'NO_EXCEPTION_RUNTIME=PASS' - name: Report footprint @@ -61,7 +64,9 @@ jobs: set -euo pipefail { echo "compiler=${{ matrix.compiler }}" - size build-brcb-hard/ariec61850_hard_mms_brcb_smoke + size \ + build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke } | tee build-brcb-hard/brcb-footprint.txt - name: Upload BRCB evidence From 43556b0636617a843f9cfa0de342f67874e177c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:23:30 +0700 Subject: [PATCH 170/188] Remove unused buffered report placeholder --- src/mms/buffered_report_wire_helpers.cpp | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/mms/buffered_report_wire_helpers.cpp diff --git a/src/mms/buffered_report_wire_helpers.cpp b/src/mms/buffered_report_wire_helpers.cpp deleted file mode 100644 index 78e3f13e4..000000000 --- a/src/mms/buffered_report_wire_helpers.cpp +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -// Implementation is intentionally split across small translation units so the -// embedded buffered-report path remains auditable and bounded. From 43a8f6512b851491606983d1388bb7a090213d38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:48:01 +0700 Subject: [PATCH 171/188] Add bounded BRCB state codec contract --- .../mms/static_brcb_state_codec.hpp | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 include/ariec61850/mms/static_brcb_state_codec.hpp diff --git a/include/ariec61850/mms/static_brcb_state_codec.hpp b/include/ariec61850/mms/static_brcb_state_codec.hpp new file mode 100644 index 000000000..18f58da74 --- /dev/null +++ b/include/ariec61850/mms/static_brcb_state_codec.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsStaticBrcbRuntime; + +enum class MmsStaticBrcbStateStatus : std::uint8_t { + ok, + invalid_runtime, + buffer_too_small, + invalid_state, + definition_mismatch, + capacity_mismatch, +}; + +struct MmsStaticBrcbStateResult final { + MmsStaticBrcbStateStatus status{MmsStaticBrcbStateStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticBrcbStateStatus::ok; + } +}; + +// Storage-agnostic bounded state image for the already-buffered BRCB queue. +// Pending BufTm coalescing is intentionally transient and is not restored. +class MmsStaticBrcbStateCodec final { +public: + static constexpr std::uint16_t format_version = 1U; + + [[nodiscard]] static MmsStaticBrcbStateResult encode( + const MmsStaticBrcbRuntime& runtime, + std::span destination) noexcept; + + [[nodiscard]] static MmsStaticBrcbStateResult restore( + MmsStaticBrcbRuntime& runtime, + std::span source) noexcept; +}; + +} // namespace ar::iec61850::mms From dbf2f71f4cf013512a5f1d4de62341454b4bba29 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:48:30 +0700 Subject: [PATCH 172/188] Allow bounded BRCB state serialization --- include/ariec61850/mms/static_brcb_runtime.hpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/mms/static_brcb_runtime.hpp b/include/ariec61850/mms/static_brcb_runtime.hpp index 74259244f..72e605d4b 100644 --- a/include/ariec61850/mms/static_brcb_runtime.hpp +++ b/include/ariec61850/mms/static_brcb_runtime.hpp @@ -14,6 +14,8 @@ namespace ar::iec61850::mms { +class MmsStaticBrcbStateCodec; + enum class MmsStaticBrcbStatus : std::uint8_t { ok, invalid_runtime, @@ -100,8 +102,8 @@ struct MmsStaticBrcbEntryView final { }; // First bounded BRCB profile: one runtime instance represents one BRCB. It -// buffers event snapshots in caller-owned RAM across connection resets. A -// non-volatile persistence adapter is intentionally a later storage-HAL slice. +// buffers event snapshots in caller-owned RAM across connection resets. Queue +// persistence is storage-agnostic and mediated by MmsStaticBrcbStateCodec. class MmsStaticBrcbRuntime final { public: MmsStaticBrcbRuntime( @@ -147,6 +149,8 @@ class MmsStaticBrcbRuntime final { } private: + friend class MmsStaticBrcbStateCodec; + const MmsStaticBrcbDefinition* definition_{}; MmsStaticBrcbPendingState* pending_{}; std::span slots_{}; From d5f75b22d87731c0224fa43d7767a9ef67ed025f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:49:28 +0700 Subject: [PATCH 173/188] Implement transactional BRCB queue state codec --- src/mms/static_brcb_state_codec.cpp | 326 ++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 src/mms/static_brcb_state_codec.cpp diff --git a/src/mms/static_brcb_state_codec.cpp b/src/mms/static_brcb_state_codec.cpp new file mode 100644 index 000000000..846ed6fdb --- /dev/null +++ b/src/mms/static_brcb_state_codec.cpp @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_state_codec.hpp" + +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kMagic{ + 'A','R','B','R','C','B','Q','1'}; +constexpr std::size_t kHeaderBytes = 48U; +constexpr std::size_t kEntryHeaderBytes = 16U; +constexpr std::uint64_t kFnvOffset = 14695981039346656037ULL; +constexpr std::uint64_t kFnvPrime = 1099511628211ULL; + +void write_u16( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +void write_u32( + const std::span destination, + const std::size_t offset, + const std::uint32_t value) noexcept { + for (std::size_t index = 0U; index < 4U; ++index) { + const auto shift = static_cast((3U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +void write_u64( + const std::span destination, + const std::size_t offset, + const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] std::uint16_t read_u16( + const std::span source, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(source[offset]) << 8U) | + static_cast(source[offset + 1U])); +} + +[[nodiscard]] std::uint32_t read_u32( + const std::span source, + const std::size_t offset) noexcept { + std::uint32_t value = 0U; + for (std::size_t index = 0U; index < 4U; ++index) { + value = static_cast((value << 8U) | source[offset + index]); + } + return value; +} + +[[nodiscard]] std::uint64_t read_u64( + const std::span source, + const std::size_t offset) noexcept { + std::uint64_t value = 0U; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | source[offset + index]; + } + return value; +} + +[[nodiscard]] std::uint64_t next_entry_number(const std::uint64_t value) noexcept { + return value == std::numeric_limits::max() ? 1U : value + 1U; +} + +void hash_byte(std::uint64_t& hash, const std::uint8_t value) noexcept { + hash ^= value; + hash *= kFnvPrime; +} + +void hash_u64(std::uint64_t& hash, const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + hash_byte(hash, static_cast((value >> shift) & 0xFFU)); + } +} + +void hash_text(std::uint64_t& hash, const std::string_view text) noexcept { + hash_u64(hash, static_cast(text.size())); + for (const auto character : text) { + hash_byte(hash, static_cast(character)); + } +} + +[[nodiscard]] std::uint64_t definition_fingerprint( + const MmsStaticBrcbDefinition& definition) noexcept { + std::uint64_t hash = kFnvOffset; + hash_text(hash, definition.domain); + hash_text(hash, definition.item); + hash_text(hash, definition.report_id); + hash_text(hash, definition.data_set_domain); + hash_text(hash, definition.data_set_item); + hash_u64(hash, definition.conf_revision); + for (const auto field : definition.optional_fields) { + hash_byte(hash, field); + } + hash_u64(hash, definition.buffer_time_ms); + hash_byte(hash, definition.trigger_options); + return hash; +} + +[[nodiscard]] bool add_required( + std::size_t& required, + const std::size_t extra) noexcept { + if (extra > std::numeric_limits::max() - required) { + return false; + } + required += extra; + return true; +} + +[[nodiscard]] std::uint64_t decode_entry_id( + const std::span entry_id) noexcept { + std::uint64_t value = 0U; + for (const auto byte : entry_id) { + value = (value << 8U) | byte; + } + return value; +} + +void clear_slots(MmsStaticBrcbRuntime& runtime) noexcept { + for (auto& slot : runtime.slots_) { + const auto storage = slot.storage; + slot = {}; + slot.storage = storage; + } +} + +} // namespace + +MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::encode( + const MmsStaticBrcbRuntime& runtime, + const std::span destination) noexcept { + MmsStaticBrcbStateResult result; + if (!runtime.initialized_ || runtime.definition_ == nullptr || + runtime.pending_ == nullptr || runtime.slots_.empty() || + runtime.count_ > runtime.slots_.size() || + runtime.count_ > std::numeric_limits::max()) { + result.status = MmsStaticBrcbStateStatus::invalid_runtime; + return result; + } + + std::size_t required = kHeaderBytes; + for (std::size_t logical = 0U; logical < runtime.count_; ++logical) { + const auto physical = (runtime.head_ + logical) % runtime.slots_.size(); + const auto& slot = runtime.slots_[physical]; + if (!slot.occupied || slot.bytes == 0U || slot.bytes > slot.storage.size() || + slot.bytes > std::numeric_limits::max() || + !add_required(required, kEntryHeaderBytes) || + !add_required(required, slot.bytes)) { + result.status = MmsStaticBrcbStateStatus::invalid_runtime; + return result; + } + } + result.required_bytes = required; + if (destination.size() < required) { + result.status = MmsStaticBrcbStateStatus::buffer_too_small; + return result; + } + + auto output = destination.first(required); + std::fill(output.begin(), output.end(), std::uint8_t{0U}); + std::copy(kMagic.begin(), kMagic.end(), output.begin()); + write_u16(output, 8U, format_version); + write_u16(output, 10U, static_cast(kHeaderBytes)); + write_u64(output, 12U, definition_fingerprint(*runtime.definition_)); + write_u32(output, 20U, static_cast(runtime.count_)); + write_u64(output, 24U, runtime.next_entry_number_); + write_u64(output, 32U, runtime.dropped_reports_); + output[40U] = runtime.sequence_number_; + + std::size_t offset = kHeaderBytes; + for (std::size_t logical = 0U; logical < runtime.count_; ++logical) { + const auto physical = (runtime.head_ + logical) % runtime.slots_.size(); + const auto& slot = runtime.slots_[physical]; + write_u32(output, offset, static_cast(slot.bytes)); + std::copy(slot.entry_id.begin(), slot.entry_id.end(), output.begin() + offset + 4U); + output[offset + 12U] = slot.sequence_number; + output[offset + 13U] = slot.buffer_overflow ? 1U : 0U; + offset += kEntryHeaderBytes; + std::copy_n(slot.storage.begin(), slot.bytes, output.begin() + offset); + offset += slot.bytes; + } + + result.status = MmsStaticBrcbStateStatus::ok; + result.bytes_written = required; + return result; +} + +MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( + MmsStaticBrcbRuntime& runtime, + const std::span source) noexcept { + MmsStaticBrcbStateResult result; + result.required_bytes = source.size(); + if (!runtime.initialized_ || runtime.definition_ == nullptr || + runtime.pending_ == nullptr || runtime.slots_.empty()) { + result.status = MmsStaticBrcbStateStatus::invalid_runtime; + return result; + } + if (source.size() < kHeaderBytes || + !std::equal(kMagic.begin(), kMagic.end(), source.begin()) || + read_u16(source, 8U) != format_version || + read_u16(source, 10U) != kHeaderBytes) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + for (std::size_t index = 41U; index < kHeaderBytes; ++index) { + if (source[index] != 0U) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + } + if (read_u64(source, 12U) != definition_fingerprint(*runtime.definition_)) { + result.status = MmsStaticBrcbStateStatus::definition_mismatch; + return result; + } + + const auto count = static_cast(read_u32(source, 20U)); + const auto restored_next_entry = read_u64(source, 24U); + const auto restored_dropped = read_u64(source, 32U); + const auto restored_sequence = source[40U]; + if (count > runtime.slots_.size()) { + result.status = MmsStaticBrcbStateStatus::capacity_mismatch; + return result; + } + if (restored_next_entry == 0U) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + + std::size_t offset = kHeaderBytes; + std::uint64_t previous_entry_number = 0U; + std::uint8_t last_sequence = 0U; + for (std::size_t logical = 0U; logical < count; ++logical) { + if (source.size() - offset < kEntryHeaderBytes) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + const auto pdu_bytes = static_cast(read_u32(source, offset)); + const auto entry_id = source.subspan(offset + 4U, 8U); + const auto sequence = source[offset + 12U]; + const auto overflow = source[offset + 13U]; + if (pdu_bytes == 0U || overflow > 1U || source[offset + 14U] != 0U || + source[offset + 15U] != 0U || + pdu_bytes > runtime.slots_[logical].storage.size()) { + result.status = pdu_bytes > runtime.slots_[logical].storage.size() + ? MmsStaticBrcbStateStatus::capacity_mismatch + : MmsStaticBrcbStateStatus::invalid_state; + return result; + } + offset += kEntryHeaderBytes; + if (pdu_bytes > source.size() - offset) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + + const auto entry_number = decode_entry_id(entry_id); + if (entry_number == 0U || + (logical != 0U && entry_number != next_entry_number(previous_entry_number))) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + previous_entry_number = entry_number; + last_sequence = sequence; + offset += pdu_bytes; + } + if (offset != source.size() || + (count != 0U && restored_next_entry != next_entry_number(previous_entry_number)) || + (count != 0U && restored_sequence != last_sequence)) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + + clear_slots(runtime); + offset = kHeaderBytes; + for (std::size_t logical = 0U; logical < count; ++logical) { + auto& slot = runtime.slots_[logical]; + const auto pdu_bytes = static_cast(read_u32(source, offset)); + std::copy_n(source.begin() + offset + 4U, slot.entry_id.size(), slot.entry_id.begin()); + slot.sequence_number = source[offset + 12U]; + slot.buffer_overflow = source[offset + 13U] != 0U; + slot.bytes = pdu_bytes; + slot.occupied = true; + offset += kEntryHeaderBytes; + std::copy_n(source.begin() + offset, pdu_bytes, slot.storage.begin()); + offset += pdu_bytes; + } + + runtime.head_ = 0U; + runtime.count_ = count; + runtime.next_entry_number_ = restored_next_entry; + runtime.dropped_reports_ = restored_dropped; + runtime.sequence_number_ = restored_sequence; + runtime.queue_revision_ = runtime.queue_revision_ == + std::numeric_limits::max() + ? 1U + : runtime.queue_revision_ + 1U; + *runtime.pending_ = {}; + runtime.pending_->revision = 1U; + runtime.enabled_ = false; + + result.status = MmsStaticBrcbStateStatus::ok; + result.bytes_written = source.size(); + return result; +} + +} // namespace ar::iec61850::mms From 631f2e508381cefbc76293ff71d83e5765491746 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:50:01 +0700 Subject: [PATCH 174/188] Add dual-bank BRCB checkpoint store contract --- .../mms/static_brcb_checkpoint_store.hpp | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 include/ariec61850/mms/static_brcb_checkpoint_store.hpp diff --git a/include/ariec61850/mms/static_brcb_checkpoint_store.hpp b/include/ariec61850/mms/static_brcb_checkpoint_store.hpp new file mode 100644 index 000000000..3c8cd80ac --- /dev/null +++ b/include/ariec61850/mms/static_brcb_checkpoint_store.hpp @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_brcb_state_codec.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsStaticBrcbRuntime; + +struct MmsStaticBrcbStorageBackend final { + using ReadFn = bool (*)( + void* context, + std::size_t offset, + std::span destination) noexcept; + using WriteFn = bool (*)( + void* context, + std::size_t offset, + std::span source) noexcept; + using EraseFn = bool (*)( + void* context, + std::size_t offset, + std::size_t bytes) noexcept; + using SyncFn = bool (*)(void* context) noexcept; + + void* context{}; + std::size_t capacity_bytes{}; + ReadFn read{}; + WriteFn write{}; + EraseFn erase{}; + SyncFn sync{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return capacity_bytes != 0U && read != nullptr && write != nullptr && + erase != nullptr && sync != nullptr; + } +}; + +enum class MmsStaticBrcbCheckpointStatus : std::uint8_t { + ok, + no_checkpoint, + invalid_backend, + invalid_layout, + state_buffer_too_small, + storage_failure, + state_encode_failed, + state_restore_failed, +}; + +struct MmsStaticBrcbCheckpointResult final { + MmsStaticBrcbCheckpointStatus status{ + MmsStaticBrcbCheckpointStatus::invalid_backend}; + MmsStaticBrcbStateStatus state_status{MmsStaticBrcbStateStatus::ok}; + std::uint64_t generation{}; + std::size_t bank_index{}; + std::size_t state_bytes{}; + std::size_t required_state_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticBrcbCheckpointStatus::ok; + } +}; + +// Two fixed banks are used as an A/B journal. The inactive bank is erased and +// rewritten first; its commit footer is written only after the complete state +// image has been written and synchronized. The previous committed bank is kept +// intact as the recovery fallback. +class MmsStaticBrcbCheckpointStore final { +public: + static constexpr std::uint16_t format_version = 1U; + static constexpr std::size_t bank_count = 2U; + static constexpr std::size_t record_header_bytes = 40U; + static constexpr std::size_t record_footer_bytes = 32U; + + MmsStaticBrcbCheckpointStore( + MmsStaticBrcbStorageBackend backend, + std::size_t bank_bytes) noexcept + : backend_{backend}, bank_bytes_{bank_bytes} {} + + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] constexpr std::size_t bank_bytes() const noexcept { + return bank_bytes_; + } + [[nodiscard]] constexpr std::size_t maximum_state_bytes() const noexcept { + return bank_bytes_ >= record_header_bytes + record_footer_bytes + ? bank_bytes_ - record_header_bytes - record_footer_bytes + : 0U; + } + + [[nodiscard]] MmsStaticBrcbCheckpointResult checkpoint( + const MmsStaticBrcbRuntime& runtime, + std::span state_buffer) const noexcept; + + [[nodiscard]] MmsStaticBrcbCheckpointResult restore( + MmsStaticBrcbRuntime& runtime, + std::span state_buffer) const noexcept; + +private: + MmsStaticBrcbStorageBackend backend_{}; + std::size_t bank_bytes_{}; +}; + +} // namespace ar::iec61850::mms From 05b6739395e96e83b871c8720fbe07d39187579c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:50:53 +0700 Subject: [PATCH 175/188] Implement power-loss-safe BRCB checkpoint journal --- src/mms/static_brcb_checkpoint_store.cpp | 395 +++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 src/mms/static_brcb_checkpoint_store.cpp diff --git a/src/mms/static_brcb_checkpoint_store.cpp b/src/mms/static_brcb_checkpoint_store.cpp new file mode 100644 index 000000000..cf9e4ccf5 --- /dev/null +++ b/src/mms/static_brcb_checkpoint_store.cpp @@ -0,0 +1,395 @@ +// 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 +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kHeaderMagic{ + 'A','R','B','R','C','B','P','1'}; +constexpr std::array kFooterMagic{ + 'A','R','C','O','M','M','I','T'}; +constexpr std::size_t kCrcChunkBytes = 256U; + +struct BankInfo final { + bool valid{}; + bool io_error{}; + std::uint64_t generation{}; + std::uint32_t payload_bytes{}; + std::uint32_t payload_crc{}; +}; + +void write_u16( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +void write_u32( + const std::span destination, + const std::size_t offset, + const std::uint32_t value) noexcept { + for (std::size_t index = 0U; index < 4U; ++index) { + const auto shift = static_cast((3U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +void write_u64( + const std::span destination, + const std::size_t offset, + const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] std::uint16_t read_u16( + const std::span source, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(source[offset]) << 8U) | + static_cast(source[offset + 1U])); +} + +[[nodiscard]] std::uint32_t read_u32( + const std::span source, + const std::size_t offset) noexcept { + std::uint32_t value = 0U; + for (std::size_t index = 0U; index < 4U; ++index) { + value = static_cast((value << 8U) | source[offset + index]); + } + return value; +} + +[[nodiscard]] std::uint64_t read_u64( + const std::span source, + const std::size_t offset) noexcept { + std::uint64_t value = 0U; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | source[offset + index]; + } + return value; +} + +[[nodiscard]] std::uint32_t crc32_update( + std::uint32_t state, + const std::span bytes) noexcept { + for (const auto byte : bytes) { + state ^= byte; + for (std::uint8_t bit = 0U; bit < 8U; ++bit) { + const auto mask = static_cast( + 0U - static_cast(state & 1U)); + state = (state >> 1U) ^ (0xEDB88320U & mask); + } + } + return state; +} + +[[nodiscard]] std::uint32_t crc32( + const std::span bytes) noexcept { + return ~crc32_update(0xFFFF'FFFFU, bytes); +} + +[[nodiscard]] bool generation_newer( + const std::uint64_t left, + const std::uint64_t right) noexcept { + if (left == right) { + return false; + } + return static_cast(left - right) < (std::uint64_t{1U} << 63U); +} + +[[nodiscard]] std::uint64_t next_generation(const std::uint64_t current) noexcept { + return current == 0U || current == std::numeric_limits::max() + ? 1U + : current + 1U; +} + +[[nodiscard]] std::size_t bank_offset( + const std::size_t bank_index, + const std::size_t bank_bytes) noexcept { + return bank_index * bank_bytes; +} + +[[nodiscard]] bool read_payload_crc( + const MmsStaticBrcbStorageBackend& backend, + const std::size_t payload_offset, + const std::size_t payload_bytes, + std::uint32_t& result) noexcept { + std::array chunk{}; + std::size_t consumed = 0U; + std::uint32_t state = 0xFFFF'FFFFU; + while (consumed < payload_bytes) { + const auto remaining = payload_bytes - consumed; + const auto bytes = std::min(remaining, chunk.size()); + if (!backend.read( + backend.context, + payload_offset + consumed, + std::span{chunk}.first(bytes))) { + return false; + } + state = crc32_update( + state, + std::span{chunk}.first(bytes)); + consumed += bytes; + } + result = ~state; + return true; +} + +[[nodiscard]] BankInfo inspect_bank( + const MmsStaticBrcbStorageBackend& backend, + const std::size_t bank_index, + const std::size_t bank_bytes) noexcept { + BankInfo info; + std::array header{}; + std::array footer{}; + const auto base = bank_offset(bank_index, bank_bytes); + const auto footer_offset = base + bank_bytes - footer.size(); + if (!backend.read(backend.context, base, header) || + !backend.read(backend.context, footer_offset, footer)) { + info.io_error = true; + return info; + } + + if (!std::equal(kHeaderMagic.begin(), kHeaderMagic.end(), header.begin()) || + read_u16(header, 8U) != MmsStaticBrcbCheckpointStore::format_version || + read_u16(header, 10U) != MmsStaticBrcbCheckpointStore::record_header_bytes || + crc32(std::span{header}.first(36U)) != read_u32(header, 36U) || + !std::equal(kFooterMagic.begin(), kFooterMagic.end(), footer.begin()) || + crc32(std::span{footer}.first(28U)) != read_u32(footer, 28U)) { + return info; + } + for (std::size_t index = 28U; index < 36U; ++index) { + if (header[index] != 0U) { + return info; + } + } + for (std::size_t index = 24U; index < 28U; ++index) { + if (footer[index] != 0U) { + return info; + } + } + + const auto generation = read_u64(header, 12U); + const auto payload_bytes = read_u32(header, 20U); + const auto payload_crc = read_u32(header, 24U); + if (generation == 0U || payload_bytes == 0U || + generation != read_u64(footer, 8U) || + payload_bytes != read_u32(footer, 16U) || + payload_crc != read_u32(footer, 20U) || + static_cast(payload_bytes) > + bank_bytes - MmsStaticBrcbCheckpointStore::record_header_bytes - + MmsStaticBrcbCheckpointStore::record_footer_bytes) { + return info; + } + + std::uint32_t measured_crc = 0U; + if (!read_payload_crc( + backend, + base + MmsStaticBrcbCheckpointStore::record_header_bytes, + payload_bytes, + measured_crc)) { + info.io_error = true; + return info; + } + if (measured_crc != payload_crc) { + return info; + } + + info.valid = true; + info.generation = generation; + info.payload_bytes = payload_bytes; + info.payload_crc = payload_crc; + return info; +} + +void build_header( + const std::uint64_t generation, + const std::uint32_t payload_bytes, + const std::uint32_t payload_crc, + std::span header) noexcept { + std::fill(header.begin(), header.end(), std::uint8_t{0U}); + std::copy(kHeaderMagic.begin(), kHeaderMagic.end(), header.begin()); + write_u16(header, 8U, MmsStaticBrcbCheckpointStore::format_version); + write_u16( + header, + 10U, + static_cast(MmsStaticBrcbCheckpointStore::record_header_bytes)); + write_u64(header, 12U, generation); + write_u32(header, 20U, payload_bytes); + write_u32(header, 24U, payload_crc); + write_u32(header, 36U, crc32(std::span{header}.first(36U))); +} + +void build_footer( + const std::uint64_t generation, + const std::uint32_t payload_bytes, + const std::uint32_t payload_crc, + std::span footer) noexcept { + std::fill(footer.begin(), footer.end(), std::uint8_t{0U}); + std::copy(kFooterMagic.begin(), kFooterMagic.end(), footer.begin()); + write_u64(footer, 8U, generation); + write_u32(footer, 16U, payload_bytes); + write_u32(footer, 20U, payload_crc); + write_u32(footer, 28U, crc32(std::span{footer}.first(28U))); +} + +} // namespace + +bool MmsStaticBrcbCheckpointStore::valid() const noexcept { + return backend_.valid() && + bank_bytes_ > record_header_bytes + record_footer_bytes && + bank_bytes_ <= backend_.capacity_bytes / bank_count; +} + +MmsStaticBrcbCheckpointResult MmsStaticBrcbCheckpointStore::checkpoint( + const MmsStaticBrcbRuntime& runtime, + const std::span state_buffer) const noexcept { + MmsStaticBrcbCheckpointResult result; + if (!valid()) { + result.status = MmsStaticBrcbCheckpointStatus::invalid_backend; + return result; + } + + const auto state = MmsStaticBrcbStateCodec::encode(runtime, state_buffer); + result.state_status = state.status; + result.required_state_bytes = state.required_bytes; + if (!state.success()) { + result.status = state.status == MmsStaticBrcbStateStatus::buffer_too_small + ? MmsStaticBrcbCheckpointStatus::state_buffer_too_small + : MmsStaticBrcbCheckpointStatus::state_encode_failed; + return result; + } + if (state.bytes_written > maximum_state_bytes() || + state.bytes_written > std::numeric_limits::max()) { + result.status = MmsStaticBrcbCheckpointStatus::invalid_layout; + result.state_bytes = state.bytes_written; + return result; + } + + 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; + } + + std::size_t target = 0U; + std::uint64_t current_generation = 0U; + if (first.valid && second.valid) { + const auto newest = generation_newer(second.generation, first.generation) ? 1U : 0U; + target = 1U - newest; + current_generation = newest == 0U ? first.generation : second.generation; + } else if (first.valid) { + target = 1U; + current_generation = first.generation; + } else if (second.valid) { + target = 0U; + current_generation = second.generation; + } + const auto generation = next_generation(current_generation); + const auto payload_bytes = static_cast(state.bytes_written); + const auto payload_crc = crc32(state_buffer.first(state.bytes_written)); + + std::array header{}; + std::array footer{}; + build_header(generation, payload_bytes, payload_crc, header); + build_footer(generation, payload_bytes, payload_crc, footer); + + const auto base = bank_offset(target, bank_bytes_); + const auto footer_offset = base + bank_bytes_ - footer.size(); + if (!backend_.erase(backend_.context, base, bank_bytes_) || + !backend_.sync(backend_.context) || + !backend_.write(backend_.context, base, header) || + !backend_.write( + backend_.context, + base + record_header_bytes, + state_buffer.first(state.bytes_written)) || + !backend_.sync(backend_.context) || + !backend_.write(backend_.context, footer_offset, footer) || + !backend_.sync(backend_.context)) { + result.status = MmsStaticBrcbCheckpointStatus::storage_failure; + result.generation = generation; + result.bank_index = target; + result.state_bytes = state.bytes_written; + return result; + } + + result.status = MmsStaticBrcbCheckpointStatus::ok; + result.generation = generation; + result.bank_index = target; + result.state_bytes = state.bytes_written; + return result; +} + +MmsStaticBrcbCheckpointResult MmsStaticBrcbCheckpointStore::restore( + MmsStaticBrcbRuntime& runtime, + const std::span state_buffer) const noexcept { + MmsStaticBrcbCheckpointResult result; + if (!valid()) { + result.status = MmsStaticBrcbCheckpointStatus::invalid_backend; + return result; + } + + 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; + 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; + } + + 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; + return result; + } + + const auto restored = MmsStaticBrcbStateCodec::restore(runtime, payload); + result.state_status = restored.status; + if (!restored.success()) { + result.status = MmsStaticBrcbCheckpointStatus::state_restore_failed; + return result; + } + result.status = MmsStaticBrcbCheckpointStatus::ok; + return result; +} + +} // namespace ar::iec61850::mms From 470d46c8b5ce889fb84b7809b0508185ad2798c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:51:53 +0700 Subject: [PATCH 176/188] Fix BRCB state codec friend boundary --- src/mms/static_brcb_state_codec.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mms/static_brcb_state_codec.cpp b/src/mms/static_brcb_state_codec.cpp index 846ed6fdb..5498949ff 100644 --- a/src/mms/static_brcb_state_codec.cpp +++ b/src/mms/static_brcb_state_codec.cpp @@ -137,8 +137,8 @@ void hash_text(std::uint64_t& hash, const std::string_view text) noexcept { return value; } -void clear_slots(MmsStaticBrcbRuntime& runtime) noexcept { - for (auto& slot : runtime.slots_) { +void clear_slots(const std::span slots) noexcept { + for (auto& slot : slots) { const auto storage = slot.storage; slot = {}; slot.storage = storage; @@ -219,7 +219,7 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( if (source.size() < kHeaderBytes || !std::equal(kMagic.begin(), kMagic.end(), source.begin()) || read_u16(source, 8U) != format_version || - read_u16(source, 10U) != kHeaderBytes) { + read_u16(source, 10U) != static_cast(kHeaderBytes)) { result.status = MmsStaticBrcbStateStatus::invalid_state; return result; } @@ -251,7 +251,7 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( std::uint64_t previous_entry_number = 0U; std::uint8_t last_sequence = 0U; for (std::size_t logical = 0U; logical < count; ++logical) { - if (source.size() - offset < kEntryHeaderBytes) { + if (offset > source.size() || source.size() - offset < kEntryHeaderBytes) { result.status = MmsStaticBrcbStateStatus::invalid_state; return result; } @@ -268,7 +268,7 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( return result; } offset += kEntryHeaderBytes; - if (pdu_bytes > source.size() - offset) { + if (offset > source.size() || pdu_bytes > source.size() - offset) { result.status = MmsStaticBrcbStateStatus::invalid_state; return result; } @@ -290,7 +290,7 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( return result; } - clear_slots(runtime); + clear_slots(runtime.slots_); offset = kHeaderBytes; for (std::size_t logical = 0U; logical < count; ++logical) { auto& slot = runtime.slots_[logical]; From 5f738270efa7f94801a4f9081a2c3a3913edaa9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:53:16 +0700 Subject: [PATCH 177/188] Add BRCB power-cycle persistence oracle --- ...ms_brcb_persistence_hard_profile_smoke.cpp | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 embedded/mms_brcb_persistence_hard_profile_smoke.cpp diff --git a/embedded/mms_brcb_persistence_hard_profile_smoke.cpp b/embedded/mms_brcb_persistence_hard_profile_smoke.cpp new file mode 100644 index 000000000..98b0cd3e4 --- /dev/null +++ b/embedded/mms_brcb_persistence_hard_profile_smoke.cpp @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_checkpoint_store.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" +#include "ariec61850/mms/static_brcb_state_codec.hpp" + +#include +#include +#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::size_t kBankBytes = 4096U; +constexpr std::size_t kStorageBytes = kBankBytes * 2U; + +bool g_value = 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, &g_value, 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}; + +const mms::MmsStaticBrcbDefinition kDifferentDefinition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Different", + "LD0", + "LLN0$Events", + 17U, + {0x7FU, 0x80U}, + 20U, + 0x70U}; + +struct RuntimeFixture final { + std::array slot0{}; + std::array slot1{}; + std::array slot2{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}, + mms::MmsStaticBrcbSlot{slot1}, + mms::MmsStaticBrcbSlot{slot2}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime; + + explicit RuntimeFixture(const mms::MmsStaticBrcbDefinition& definition) noexcept + : runtime{definition, pending, slots, kObjectTable, kDataSetTable} {} +}; + +struct SmallRuntimeFixture final { + std::array slot0{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime{ + kDefinition, pending, slots, kObjectTable, kDataSetTable}; +}; + +struct FakeStorage final { + std::array bytes{}; + std::size_t write_budget{std::numeric_limits::max()}; + bool fail_reads{}; + bool fail_erases{}; + bool fail_sync{}; + + 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 || storage->fail_reads || offset > storage->bytes.size() || + destination.size() > storage->bytes.size() - offset) { + return false; + } + std::copy_n(storage->bytes.begin() + 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; + } + const auto writable = std::min(source.size(), storage->write_budget); + std::copy_n(source.begin(), writable, storage->bytes.begin() + offset); + if (storage->write_budget != std::numeric_limits::max()) { + storage->write_budget -= writable; + } + return writable == source.size(); +} + +[[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 || storage->fail_erases || offset > storage->bytes.size() || + bytes > storage->bytes.size() - offset) { + return false; + } + std::fill_n(storage->bytes.begin() + offset, bytes, std::uint8_t{0xFFU}); + return true; +} + +[[nodiscard]] bool storage_sync(void* context) noexcept { + const auto* storage = static_cast(context); + return storage != nullptr && !storage->fail_sync; +} + +[[nodiscard]] bool capture_one( + mms::MmsStaticBrcbRuntime& runtime, + const std::uint64_t now_ms, + const std::span staging, + const std::span workspace) noexcept { + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, now_ms) != + mms::MmsStaticBrcbStatus::ok) { + return false; + } + mms::MmsStaticBrcbCapturePlan plan; + if (!runtime.next_due(now_ms + 20U, plan)) { + return false; + } + return runtime.capture(plan, kReportTime, staging, workspace).success(); +} + +[[nodiscard]] bool entry_is( + const mms::MmsStaticBrcbEntryView& entry, + const std::uint8_t low_byte) noexcept { + return entry.entry_id.size() == 8U && entry.entry_id[7U] == low_byte; +} + +} // 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() || store.maximum_state_bytes() == 0U) { + return 1; + } + + std::array state_buffer{}; + std::array staging{}; + std::array workspace{}; + + RuntimeFixture empty{kDefinition}; + if (!empty.runtime.initialize() || + store.restore(empty.runtime, state_buffer).status != + mms::MmsStaticBrcbCheckpointStatus::no_checkpoint) { + return 2; + } + + RuntimeFixture source{kDefinition}; + if (!source.runtime.initialize() || + source.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 3; + } + g_value = true; + if (!capture_one(source.runtime, 100U, staging, workspace)) { + return 4; + } + g_value = false; + if (!capture_one(source.runtime, 200U, staging, workspace) || + source.runtime.queue_size() != 2U) { + return 5; + } + + mms::MmsStaticBrcbEntryView source_front; + std::array expected_front{}; + std::array expected_entry_id{}; + if (!source.runtime.front(source_front) || source_front.mms_pdu.size() > expected_front.size()) { + return 6; + } + const auto expected_front_bytes = source_front.mms_pdu.size(); + std::copy(source_front.mms_pdu.begin(), source_front.mms_pdu.end(), expected_front.begin()); + std::copy(source_front.entry_id.begin(), source_front.entry_id.end(), expected_entry_id.begin()); + + std::array tiny_state{}; + const auto tiny_checkpoint = store.checkpoint(source.runtime, tiny_state); + if (tiny_checkpoint.status != mms::MmsStaticBrcbCheckpointStatus::state_buffer_too_small || + tiny_checkpoint.required_state_bytes <= tiny_state.size()) { + return 7; + } + + const auto first_checkpoint = store.checkpoint(source.runtime, state_buffer); + if (!first_checkpoint.success() || first_checkpoint.generation != 1U || + first_checkpoint.bank_index != 0U || first_checkpoint.state_bytes == 0U) { + return 8; + } + + RuntimeFixture recovered{kDefinition}; + if (!recovered.runtime.initialize()) { + return 9; + } + const auto first_restore = store.restore(recovered.runtime, state_buffer); + mms::MmsStaticBrcbEntryView recovered_front; + if (!first_restore.success() || first_restore.generation != 1U || + recovered.runtime.enabled() || recovered.runtime.queue_size() != 2U || + !recovered.runtime.front(recovered_front) || + recovered_front.mms_pdu.size() != expected_front_bytes || + !std::equal( + recovered_front.mms_pdu.begin(), + recovered_front.mms_pdu.end(), + expected_front.begin()) || + !std::equal( + recovered_front.entry_id.begin(), + recovered_front.entry_id.end(), + expected_entry_id.begin())) { + return 10; + } + + RuntimeFixture wrong_definition{kDifferentDefinition}; + if (!wrong_definition.runtime.initialize()) { + return 11; + } + const auto mismatch = store.restore(wrong_definition.runtime, state_buffer); + if (mismatch.status != mms::MmsStaticBrcbCheckpointStatus::state_restore_failed || + mismatch.state_status != mms::MmsStaticBrcbStateStatus::definition_mismatch || + wrong_definition.runtime.queue_size() != 0U) { + return 12; + } + + SmallRuntimeFixture too_small; + if (!too_small.runtime.initialize()) { + return 13; + } + const auto capacity = store.restore(too_small.runtime, state_buffer); + if (capacity.status != mms::MmsStaticBrcbCheckpointStatus::state_restore_failed || + capacity.state_status != mms::MmsStaticBrcbStateStatus::capacity_mismatch || + too_small.runtime.queue_size() != 0U) { + return 14; + } + + // Build generation 2: deliver EntryID 1, then append EntryID 3. + if (!entry_is(recovered_front, 1U) || + recovered.runtime.commit_delivery(recovered_front.entry_id) != + mms::MmsStaticBrcbStatus::ok || + recovered.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 15; + } + g_value = true; + if (!capture_one(recovered.runtime, 300U, staging, workspace) || + recovered.runtime.queue_size() != 2U || + !recovered.runtime.front(recovered_front) || !entry_is(recovered_front, 2U)) { + return 16; + } + const auto second_checkpoint = store.checkpoint(recovered.runtime, state_buffer); + if (!second_checkpoint.success() || second_checkpoint.generation != 2U || + second_checkpoint.bank_index != 1U) { + return 17; + } + + // Queue now advances to EntryID 4, but checkpoint generation 3 is torn + // while writing the final commit footer. Generation 2 must survive reboot. + g_value = false; + if (!capture_one(recovered.runtime, 400U, staging, workspace) || + recovered.runtime.queue_size() != 3U) { + return 18; + } + const auto projected = mms::MmsStaticBrcbStateCodec::encode( + recovered.runtime, state_buffer); + if (!projected.success()) { + return 19; + } + storage.write_budget = + mms::MmsStaticBrcbCheckpointStore::record_header_bytes + + projected.bytes_written + 8U; + const auto torn = store.checkpoint(recovered.runtime, state_buffer); + if (torn.status != mms::MmsStaticBrcbCheckpointStatus::storage_failure || + torn.generation != 3U || torn.bank_index != 0U) { + return 20; + } + storage.write_budget = std::numeric_limits::max(); + + RuntimeFixture after_torn{kDefinition}; + if (!after_torn.runtime.initialize()) { + return 21; + } + const auto torn_restore = store.restore(after_torn.runtime, state_buffer); + mms::MmsStaticBrcbEntryView torn_front; + if (!torn_restore.success() || torn_restore.generation != 2U || + torn_restore.bank_index != 1U || after_torn.runtime.queue_size() != 2U || + !after_torn.runtime.front(torn_front) || !entry_is(torn_front, 2U)) { + return 22; + } + + // Retry generation 3 successfully. The store alternates back to bank 0. + const auto third_checkpoint = store.checkpoint(recovered.runtime, state_buffer); + if (!third_checkpoint.success() || third_checkpoint.generation != 3U || + third_checkpoint.bank_index != 0U) { + return 23; + } + + // Corrupt the newest payload. Recovery must reject bank 0 by CRC and fall + // back to still-valid generation 2 in bank 1. + storage.bytes[mms::MmsStaticBrcbCheckpointStore::record_header_bytes + 10U] ^= + std::uint8_t{0x01U}; + RuntimeFixture after_corruption{kDefinition}; + if (!after_corruption.runtime.initialize()) { + return 24; + } + const auto corrupt_restore = store.restore(after_corruption.runtime, state_buffer); + if (!corrupt_restore.success() || corrupt_restore.generation != 2U || + corrupt_restore.bank_index != 1U || after_corruption.runtime.queue_size() != 2U || + !after_corruption.runtime.front(torn_front) || !entry_is(torn_front, 2U)) { + return 25; + } + + // Erasing both banks produces a clean no-checkpoint state, not partial data. + std::fill(storage.bytes.begin(), storage.bytes.end(), std::uint8_t{0xFFU}); + RuntimeFixture erased{kDefinition}; + if (!erased.runtime.initialize() || + store.restore(erased.runtime, state_buffer).status != + mms::MmsStaticBrcbCheckpointStatus::no_checkpoint || + erased.runtime.queue_size() != 0U) { + return 26; + } + + return 0; +} From 50d5e3ba244ba6f0d6641c2870edd82a3e2b795e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:53:56 +0700 Subject: [PATCH 178/188] Gate BRCB persistence in hard profile --- embedded/brcb_hard_profile/CMakeLists.txt | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt index 13692010b..79d1052b5 100644 --- a/embedded/brcb_hard_profile/CMakeLists.txt +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850BrcbHardProfile - VERSION 0.1.0 + VERSION 0.2.0 DESCRIPTION "No-exception bounded IEC 61850 buffered-report profile" LANGUAGES CXX) @@ -64,13 +64,31 @@ add_executable(ariec61850_hard_mms_brcb_connection_smoke ${ARIEC61850_ROOT}/src/mms/static_brcb_connection.cpp) ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_connection_smoke) +add_executable(ariec61850_hard_mms_brcb_persistence_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_persistence_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_persistence_smoke) + enable_testing() add_test(NAME ariec61850_hard_mms_brcb_smoke COMMAND ariec61850_hard_mms_brcb_smoke) add_test( NAME ariec61850_hard_mms_brcb_connection_smoke COMMAND ariec61850_hard_mms_brcb_connection_smoke) +add_test( + NAME ariec61850_hard_mms_brcb_persistence_smoke + COMMAND ariec61850_hard_mms_brcb_persistence_smoke) message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=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: platform non-volatile adapter=NOT_CLAIMED") message(STATUS "arstack61850 BRCB hard profile: exceptions/RTTI disabled=ON") -message(STATUS "arstack61850 BRCB hard profile: non-volatile persistence=NOT_CLAIMED") From 9a8e6ace6dbdec79cdfa8276802e148f75739002 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 18:54:20 +0700 Subject: [PATCH 179/188] Extend BRCB hard gate to persistence --- .github/workflows/brcb-hard-profile-ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index f3284f130..5d3ea5cfa 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -50,7 +50,8 @@ jobs: set -euo pipefail for binary in \ build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ - build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke; do + build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 exit 1 @@ -66,7 +67,8 @@ jobs: echo "compiler=${{ matrix.compiler }}" size \ build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ - build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke + build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke } | tee build-brcb-hard/brcb-footprint.txt - name: Upload BRCB evidence From e531196cf2db7644aa82e3a60087f436196305f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 19:01:45 +0700 Subject: [PATCH 180/188] Hold buffered reports while BRCB is disabled --- include/ariec61850/mms/static_brcb_connection.hpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/include/ariec61850/mms/static_brcb_connection.hpp b/include/ariec61850/mms/static_brcb_connection.hpp index 1055b8c8f..2bf50ee43 100644 --- a/include/ariec61850/mms/static_brcb_connection.hpp +++ b/include/ariec61850/mms/static_brcb_connection.hpp @@ -13,6 +13,7 @@ namespace ar::iec61850::mms { enum class MmsStaticBrcbConnectionStatus : std::uint8_t { no_report_available, + reporting_disabled, response_ready, not_established, response_buffer_too_small, @@ -38,9 +39,10 @@ struct MmsStaticBrcbConnectionResult final { class MmsStaticBrcbConnection final { public: - // Deliver at most one already-buffered report. The queue front is removed - // only after a complete TPKT image is staged successfully. Capacity errors - // and disconnected sessions preserve the exact same EntryID for retry. + // 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. [[nodiscard]] static MmsStaticBrcbConnectionResult poll( const MmsStaticConnectionRuntime& connection, MmsStaticBrcbRuntime& reports, From 4c88d87ca13abdc8f2130b9088f8fe01e2637b06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 19:02:12 +0700 Subject: [PATCH 181/188] Gate BRCB delivery on reporting enable --- src/mms/static_brcb_connection.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp index fabee8c67..e5a82557a 100644 --- a/src/mms/static_brcb_connection.cpp +++ b/src/mms/static_brcb_connection.cpp @@ -53,6 +53,9 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( connection.mms_presentation_context_id() == 0U) { return make_result(MmsStaticBrcbConnectionStatus::not_established); } + if (!reports.enabled()) { + return make_result(MmsStaticBrcbConnectionStatus::reporting_disabled); + } MmsStaticBrcbEntryView entry; if (!reports.front(entry)) { From e1acbde39849530d7ea5bdcd1df613ede721c653 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 19:03:10 +0700 Subject: [PATCH 182/188] Test disabled BRCB delivery hold --- ...mms_brcb_connection_hard_profile_smoke.cpp | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/embedded/mms_brcb_connection_hard_profile_smoke.cpp b/embedded/mms_brcb_connection_hard_profile_smoke.cpp index 0d326132a..b3ce88e09 100644 --- a/embedded/mms_brcb_connection_hard_profile_smoke.cpp +++ b/embedded/mms_brcb_connection_hard_profile_smoke.cpp @@ -176,13 +176,28 @@ int main() { } 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; + } + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, 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; + } + std::array tiny_response{}; delivery = mms::MmsStaticBrcbConnection::poll( connection, reports, 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 5; + return 7; } std::array tiny_workspace{}; @@ -191,7 +206,7 @@ int main() { 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 6; + return 8; } delivery = mms::MmsStaticBrcbConnection::poll( @@ -199,20 +214,20 @@ int main() { 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 7; + return 9; } mms::MmsInformationReportView decoded; if (!decode_report_frame( std::span{response}.first(delivery.bytes_written), decoded) || decoded.item_count != 12U) { - return 8; + return 10; } mms::MmsReadAccessResultView item; bool historical_value = false; if (!decoded.try_item(10U, item) || !decode_boolean(item, historical_value) || !historical_value) { - return 9; + return 11; } // Queue another historical entry, then drop the association. The queue must survive reset. @@ -221,16 +236,16 @@ int main() { !reports.next_due(200U, plan) || !reports.capture(plan, kReportTime, staging, value_workspace).success() || reports.queue_size() != 1U) { - return 10; + return 12; } connection.reset(); delivery = mms::MmsStaticBrcbConnection::poll( connection, reports, response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || reports.queue_size() != 1U) { - return 11; + return 13; } - if (!establish(connection, request, response, workspace, scratch)) return 12; + 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 || @@ -238,7 +253,7 @@ int main() { mms::MmsStaticBrcbConnection::poll( connection, reports, response, workspace).status != mms::MmsStaticBrcbConnectionStatus::no_report_available) { - return 13; + return 15; } return 0; From aa3054e1dc81c9462f67185012b88aa262442e7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 19:05:36 +0700 Subject: [PATCH 183/188] Fix strict BRCB state iterator conversions --- src/mms/static_brcb_state_codec.cpp | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/mms/static_brcb_state_codec.cpp b/src/mms/static_brcb_state_codec.cpp index 5498949ff..47ec8666d 100644 --- a/src/mms/static_brcb_state_codec.cpp +++ b/src/mms/static_brcb_state_codec.cpp @@ -193,11 +193,17 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::encode( const auto physical = (runtime.head_ + logical) % runtime.slots_.size(); const auto& slot = runtime.slots_[physical]; write_u32(output, offset, static_cast(slot.bytes)); - std::copy(slot.entry_id.begin(), slot.entry_id.end(), output.begin() + offset + 4U); + std::copy( + slot.entry_id.begin(), + slot.entry_id.end(), + output.subspan(offset + 4U).begin()); output[offset + 12U] = slot.sequence_number; output[offset + 13U] = slot.buffer_overflow ? 1U : 0U; offset += kEntryHeaderBytes; - std::copy_n(slot.storage.begin(), slot.bytes, output.begin() + offset); + std::copy_n( + slot.storage.begin(), + slot.bytes, + output.subspan(offset).begin()); offset += slot.bytes; } @@ -295,13 +301,19 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( for (std::size_t logical = 0U; logical < count; ++logical) { auto& slot = runtime.slots_[logical]; const auto pdu_bytes = static_cast(read_u32(source, offset)); - std::copy_n(source.begin() + offset + 4U, slot.entry_id.size(), slot.entry_id.begin()); + std::copy_n( + source.subspan(offset + 4U).begin(), + slot.entry_id.size(), + slot.entry_id.begin()); slot.sequence_number = source[offset + 12U]; slot.buffer_overflow = source[offset + 13U] != 0U; slot.bytes = pdu_bytes; slot.occupied = true; offset += kEntryHeaderBytes; - std::copy_n(source.begin() + offset, pdu_bytes, slot.storage.begin()); + std::copy_n( + source.subspan(offset).begin(), + pdu_bytes, + slot.storage.begin()); offset += pdu_bytes; } From 7fee5e2dd98a13a70d71ae2680ecb40caebc967d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:25:58 +0700 Subject: [PATCH 184/188] noop --- noop | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 noop diff --git a/noop b/noop new file mode 100644 index 000000000..e69de29bb From e03149393e3f19f1889a78e9e7a7546b6d355755 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:26:58 +0700 Subject: [PATCH 185/188] Remove accidental empty file --- noop | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 noop diff --git a/noop b/noop deleted file mode 100644 index e69de29bb..000000000 From 65d4451712665b5fb3b706b16e31432b31fe17cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:27:35 +0700 Subject: [PATCH 186/188] Add ESP32-P4 BRCB flash backend and probe --- .github/workflows/esp-idf-ci.yml | 81 +++- ports/esp_idf/CMakeLists.txt | 35 +- .../brcb_flash_probe_app/CMakeLists.txt | 8 + ports/esp_idf/brcb_flash_probe_app/README.md | 78 ++++ .../brcb_flash_probe_app/main/CMakeLists.txt | 4 + .../brcb_flash_probe_app/main/main.cpp | 77 ++++ .../brcb_flash_probe_app/partitions.csv | 5 + .../brcb_flash_probe_app/sdkconfig.defaults | 5 + ports/esp_idf/brcb_flash_storage.cpp | 431 ++++++++++++++++++ .../ports/esp_idf/brcb_flash_storage.hpp | 202 ++++++++ ports/esp_idf/smoke_app/main/main.cpp | 17 +- ports/esp_idf/smoke_app/partitions.csv | 5 + ports/esp_idf/smoke_app/sdkconfig.defaults | 6 +- ports/esp_idf/sv_trial_app/partitions.csv | 5 + ports/esp_idf/sv_trial_app/sdkconfig.defaults | 6 +- 15 files changed, 942 insertions(+), 23 deletions(-) create mode 100644 ports/esp_idf/brcb_flash_probe_app/CMakeLists.txt create mode 100644 ports/esp_idf/brcb_flash_probe_app/README.md create mode 100644 ports/esp_idf/brcb_flash_probe_app/main/CMakeLists.txt create mode 100644 ports/esp_idf/brcb_flash_probe_app/main/main.cpp create mode 100644 ports/esp_idf/brcb_flash_probe_app/partitions.csv create mode 100644 ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults create mode 100644 ports/esp_idf/brcb_flash_storage.cpp create mode 100644 ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp create mode 100644 ports/esp_idf/smoke_app/partitions.csv create mode 100644 ports/esp_idf/sv_trial_app/partitions.csv diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml index 62a4ae163..d8573ec6f 100644 --- a/.github/workflows/esp-idf-ci.yml +++ b/.github/workflows/esp-idf-ci.yml @@ -25,6 +25,13 @@ jobs: target: esp32p4 path: ports/esp_idf/smoke_app + - name: Build ESP32-P4 BRCB flash probe firmware + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v6.0.2 + target: esp32p4 + path: ports/esp_idf/brcb_flash_probe_app + - name: Build flashable ESP32-P4 SV trial firmware uses: espressif/esp-idf-ci-action@v1 with: @@ -32,6 +39,49 @@ jobs: target: esp32p4 path: ports/esp_idf/sv_trial_app + - name: Verify BRCB flash probe outputs + shell: bash + run: | + set -euo pipefail + app=ports/esp_idf/brcb_flash_probe_app + build="$app/build" + test -s "$build/arstack61850_esp32p4_brcb_flash_probe.bin" + test -s "$build/arstack61850_esp32p4_brcb_flash_probe.elf" + test -s "$build/arstack61850_esp32p4_brcb_flash_probe.map" + test -s "$build/bootloader/bootloader.bin" + test -s "$build/partition_table/partition-table.bin" + test -s "$build/flasher_args.json" + test -s "$app/partitions.csv" + test -s "$app/sdkconfig.defaults" + test -s "$app/README.md" + grep -Eq '^brcb_state,[[:space:]]*0x40,[[:space:]]*0x01,[[:space:]]*,[[:space:]]*0x3000' "$app/partitions.csv" + grep -q 'CONFIG_PARTITION_TABLE_CUSTOM 1' "$build/config/sdkconfig.h" + python3 -m json.tool "$build/flasher_args.json" >/dev/null + python3 - <<'PY' + import json + from pathlib import Path + + app = Path('ports/esp_idf/brcb_flash_probe_app') + build = app / 'build' + manifest = json.loads((build / 'flasher_args.json').read_text(encoding='utf-8')) + chip = manifest.get('extra_esptool_args', {}).get('chip') + if chip != 'esp32p4': + raise SystemExit(f'expected flasher chip esp32p4, got {chip!r}') + raw = manifest.get('flash_files') + if not isinstance(raw, dict): + raise SystemExit('probe flasher manifest has no flash_files object') + flash_files = {int(str(k), 0): str(v) for k, v in raw.items()} + expected = { + 0x2000: 'bootloader/bootloader.bin', + 0x8000: 'partition_table/partition-table.bin', + 0x10000: 'arstack61850_esp32p4_brcb_flash_probe.bin', + } + if flash_files != expected: + raise SystemExit( + f'unexpected probe flash layout: expected {expected!r}, got {flash_files!r}') + print('ESP32P4/BRCB-FLASH-PROBE/CROSS-COMPILED') + PY + - name: Verify flashable firmware outputs shell: bash run: | @@ -48,7 +98,10 @@ jobs: test -s "$build/flash_args" test -s "$build/flasher_args.json" test -s "$app/sdkconfig.defaults" + test -s "$app/partitions.csv" test -s "$app/README.md" + grep -Eq '^brcb_state,[[:space:]]*0x40,[[:space:]]*0x01,[[:space:]]*,[[:space:]]*0x3000' "$app/partitions.csv" + grep -q 'CONFIG_PARTITION_TABLE_CUSTOM 1' "$build/config/sdkconfig.h" python3 -m json.tool "$build/flasher_args.json" >/dev/null python3 - <<'PY' import json @@ -106,6 +159,7 @@ jobs: build / 'flash_args', build / 'flasher_args.json', app / 'sdkconfig.defaults', + app / 'partitions.csv', app / 'README.md', ] inventory = [] @@ -123,6 +177,8 @@ jobs: 'authoritative_manifest': 'build/flasher_args.json', 'companion_flash_args': 'build/flash_args', 'sdkconfig_defaults': 'sdkconfig.defaults', + 'brcb_partition_table': 'partitions.csv', + 'brcb_flash_measurement': 'READY_NOT_MEASURED', } (qa / 'ARSTACK_BUILD_PROFILE.json').write_text( json.dumps(evidence, indent=2, sort_keys=True) + '\n', @@ -141,9 +197,10 @@ jobs: ci-artifacts/ARSTACK_BUILD_PROFILE.json \ ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt \ sdkconfig.defaults \ + partitions.csv \ README.md > ci-artifacts/SHA256SUMS ) - echo "FLASHABLE/READY: ESP32-P4 flasher manifest and complete firmware set verified." + echo "FLASHABLE/READY: ESP32-P4 flasher manifest, BRCB partition layout and firmware set verified." - name: Upload failed Gate-C diagnostics if: failure() @@ -161,6 +218,27 @@ jobs: ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.bin ports/esp_idf/sv_trial_app/build/bootloader/bootloader.bin ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin + ports/esp_idf/brcb_flash_probe_app/build/flasher_args.json + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.bin + ports/esp_idf/brcb_flash_probe_app/build/partition_table/partition-table.bin + + - name: Upload ESP32-P4 BRCB flash probe firmware + uses: actions/upload-artifact@v4 + with: + name: esp32p4-brcb-flash-probe-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.bin + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.elf + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.map + ports/esp_idf/brcb_flash_probe_app/build/bootloader/bootloader.bin + ports/esp_idf/brcb_flash_probe_app/build/partition_table/partition-table.bin + ports/esp_idf/brcb_flash_probe_app/build/flash_args + ports/esp_idf/brcb_flash_probe_app/build/flasher_args.json + ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults + ports/esp_idf/brcb_flash_probe_app/partitions.csv + ports/esp_idf/brcb_flash_probe_app/README.md - name: Upload ESP32-P4 flashable firmware uses: actions/upload-artifact@v4 @@ -180,4 +258,5 @@ jobs: ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt ports/esp_idf/sv_trial_app/ci-artifacts/SHA256SUMS ports/esp_idf/sv_trial_app/sdkconfig.defaults + ports/esp_idf/sv_trial_app/partitions.csv ports/esp_idf/sv_trial_app/README.md diff --git a/ports/esp_idf/CMakeLists.txt b/ports/esp_idf/CMakeLists.txt index 2e532aed0..9243edebe 100644 --- a/ports/esp_idf/CMakeLists.txt +++ b/ports/esp_idf/CMakeLists.txt @@ -1,12 +1,12 @@ # arstack61850 ESP-IDF component entry point. # # Add this directory to EXTRA_COMPONENT_DIRS from an ESP-IDF application. -# The MCU component is deliberately narrower than the desktop/host core: it -# contains only the bounded dependencies required to build and transmit Sampled -# Values Ethernet frames plus the ESP-IDF raw-Ethernet adapter. Host discovery, -# dynamic BER decode, generic Ethernet object codecs, MMS-over-TCP runtime, -# reporting, GOOSE, SCL, PCAP, COMTRADE, filesystem and CLI sources stay outside -# this first target-specific link boundary. +# The MCU component remains deliberately narrower than the desktop/host core: it +# contains the bounded dependencies required to build/transmit Sampled Values, +# the ESP-IDF raw-Ethernet adapter, and the raw-partition adapter used by the +# storage-agnostic BRCB checkpoint journal. Host discovery, dynamic BER decode, +# generic Ethernet object codecs, MMS-over-TCP runtime, filesystem and CLI +# sources stay outside this target-specific link boundary. set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") @@ -17,14 +17,15 @@ set(ARIEC61850_ESP_IDF_SOURCES ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp - ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp) + ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp + ${CMAKE_CURRENT_LIST_DIR}/brcb_flash_storage.cpp) idf_component_register( SRCS ${ARIEC61850_ESP_IDF_SOURCES} INCLUDE_DIRS ${ARIEC61850_ROOT}/include ${CMAKE_CURRENT_LIST_DIR}/include - REQUIRES esp_eth esp_timer) + REQUIRES esp_eth esp_timer esp_partition) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) target_compile_definitions(${COMPONENT_LIB} PUBLIC @@ -32,19 +33,19 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC ARIEC61850_HOST_SERVICES=0 ARIEC61850_NO_EXCEPTIONS=1) -# The physical SV transmit slice is now a hard embedded profile: no C++ -# exceptions and no RTTI. Host convenience/decode APIs are excluded at compile -# time; the linked wire path uses status-returning encode_into() operations and -# caller-owned frame storage. +# The physical transmit/storage adapters remain part of the hard embedded +# profile: no C++ exceptions and no RTTI. Host convenience/decode APIs are +# excluded at compile time and all protocol/storage paths use status returns and +# caller-owned buffers. target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -fno-exceptions -fno-rtti) -# Keep -Wpedantic on the portable protocol sources. The one ESP-IDF adapter must -# include vendor C headers which intentionally use GNU extensions such as -# #include_next and anonymous structs. Suppress only pedantic diagnostics for this -# translation unit; ESP-IDF's global -Werror and all other arstack warnings remain -# active. This prevents vendor-header syntax from weakening the core warning gate. +# ESP-IDF vendor C headers intentionally use GNU extensions such as #include_next +# and anonymous structs. Suppress only pedantic diagnostics for the platform +# adapter translation units; ESP-IDF's global -Werror and every other arstack +# warning remain active. set_source_files_properties( ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp + ${CMAKE_CURRENT_LIST_DIR}/brcb_flash_storage.cpp PROPERTIES COMPILE_OPTIONS "-Wno-pedantic") diff --git a/ports/esp_idf/brcb_flash_probe_app/CMakeLists.txt b/ports/esp_idf/brcb_flash_probe_app/CMakeLists.txt new file mode 100644 index 000000000..aae71fba3 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) + +# Destructive-on-probe-sector flash geometry/latency measurement image. +# The BRCB journal banks themselves are never touched by the probe routine. +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/..") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack61850_esp32p4_brcb_flash_probe) diff --git a/ports/esp_idf/brcb_flash_probe_app/README.md b/ports/esp_idf/brcb_flash_probe_app/README.md new file mode 100644 index 000000000..f4e975cf1 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/README.md @@ -0,0 +1,78 @@ +# ESP32-P4 BRCB flash geometry / latency probe + +This image validates the physical non-volatile backend used by the bounded BRCB +A/B checkpoint journal. It is intentionally separate from the normal SV trial +firmware because the latency probe performs destructive flash operations. + +## Partition policy + +The application-defined `brcb_state` partition uses type `0x40`, subtype `0x01`. +The adapter reads the actual `esp_partition_t::erase_size` at boot and requires at +least three erase units: + +1. erase unit 0: journal bank A; +2. erase unit 1: journal bank B; +3. erase unit 2: destructive measurement probe. + +Only the first two units are exposed through `MmsStaticBrcbStorageBackend`. +`run_latency_probe()` cannot address either journal bank. + +Each checkpoint attempt that reaches the storage erase stage erases exactly one +journal erase unit. A/B alternation means one bank sees at most +`ceil(checkpoint_erase_attempts / 2)` erase cycles. No flash endurance rating is +hard-coded; use the actual flash/device rating when converting this counter into +an endurance budget. + +The current CSV reserves `0x3000` bytes. On a target reporting a `0x1000` erase +size this maps exactly to three erase units. If the runtime geometry differs, the +adapter fails closed instead of silently changing the journal layout. + +Encrypted and read-only partitions are rejected by this first backend. Encrypted +partition write alignment and power-loss behavior require a separate acceptance +slice before they can be enabled. + +## Measurement transaction + +One probe run performs, only in the dedicated probe unit: + +1. full erase; +2. 40-byte header write; +3. 3072-byte synthetic state-payload write; +4. 32-byte footer write at the fixed bank footer position; +5. full read/verify of header, payload and footer; +6. cleanup erase. + +A successful run therefore consumes two erase cycles on the **probe** unit and +zero erase cycles on the journal banks. + +The UART output uses machine-readable records: + +```text +ARBRCB_FLASH {"stage":"geometry",...} +ARBRCB_FLASH {"stage":"latency-probe",...} +``` + +The second record reports prepare-erase, header/payload/footer write, verify-read +and cleanup-erase latency in microseconds. Timing is measured with +`esp_timer_get_time()`. + +## Build and physical run + +From an ESP-IDF v6.0.2 environment: + +```text +idf.py set-target esp32p4 +idf.py build +idf.py -p flash monitor +``` + +Run the probe only when a destructive measurement of its reserved scratch sector +is intended. Do not use repeated probe executions as an endurance test unless a +separate wear-test plan and erase-cycle budget have been approved. + +## Evidence boundary + +Hosted CI can prove that the adapter, custom partition table and probe firmware +cross-compile for ESP32-P4. It cannot provide physical flash latency, real +power-cut survival or endurance data. Those labels are reserved for captured +board output and controlled hardware tests. diff --git a/ports/esp_idf/brcb_flash_probe_app/main/CMakeLists.txt b/ports/esp_idf/brcb_flash_probe_app/main/CMakeLists.txt new file mode 100644 index 000000000..6fea1cef5 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES esp_idf) diff --git a/ports/esp_idf/brcb_flash_probe_app/main/main.cpp b/ports/esp_idf/brcb_flash_probe_app/main/main.cpp new file mode 100644 index 000000000..a447d39b4 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/main/main.cpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ports/esp_idf/brcb_flash_storage.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::ports::esp_idf::BrcbFlashStorage; + +std::array g_probe_payload{}; + +} // namespace + +extern "C" void app_main(void) { + using namespace ar::iec61850::ports::esp_idf; + + BrcbFlashStorage storage; + const auto status = storage.initialize(); + const auto& geometry = storage.geometry(); + + std::printf( + "ARBRCB_FLASH {\"stage\":\"geometry\",\"status\":\"%s\"," + "\"partitionBytes\":%u,\"eraseBytes\":%u,\"bankBytes\":%u," + "\"journalBytes\":%u,\"probeOffset\":%u,\"probeBytes\":%u," + "\"maximumStateBytes\":%u,\"eraseUnitsPerCheckpoint\":%u," + "\"unusedBytes\":%u,\"encrypted\":%s,\"readOnly\":%s}\n", + brcb_flash_status_name(status), + static_cast(geometry.partition_bytes), + static_cast(geometry.erase_bytes), + static_cast(geometry.bank_bytes), + static_cast(geometry.journal_bytes), + static_cast(geometry.probe_offset), + static_cast(geometry.probe_bytes), + static_cast(geometry.maximum_state_bytes), + static_cast(geometry.erase_units_per_checkpoint), + static_cast(geometry.unused_bytes), + geometry.encrypted ? "true" : "false", + geometry.read_only ? "true" : "false"); + std::fflush(stdout); + + if (!storage.ready()) { + return; + } + + for (std::size_t index = 0U; index < g_probe_payload.size(); ++index) { + g_probe_payload[index] = static_cast( + (index * 37U + 0x5AU) & 0xFFU); + } + + const auto result = storage.run_latency_probe( + std::span{g_probe_payload}); + std::printf( + "ARBRCB_FLASH {\"stage\":\"latency-probe\",\"status\":\"%s\"," + "\"payloadBytes\":%u,\"prepareEraseUs\":%" PRIu64 "," + "\"headerWriteUs\":%" PRIu64 ",\"payloadWriteUs\":%" PRIu64 "," + "\"footerWriteUs\":%" PRIu64 ",\"totalWriteUs\":%" PRIu64 "," + "\"verifyReadUs\":%" PRIu64 ",\"cleanupEraseUs\":%" PRIu64 "," + "\"probeEraseUnits\":%" PRIu64 ",\"cleanupSucceeded\":%s}\n", + brcb_flash_probe_status_name(result.status), + static_cast(result.payload_bytes), + result.prepare_erase_us, + result.header_write_us, + result.payload_write_us, + result.footer_write_us, + result.total_write_us(), + result.verify_read_us, + result.cleanup_erase_us, + result.probe_erase_units, + result.cleanup_succeeded ? "true" : "false"); + std::fflush(stdout); +} diff --git a/ports/esp_idf/brcb_flash_probe_app/partitions.csv b/ports/esp_idf/brcb_flash_probe_app/partitions.csv new file mode 100644 index 000000000..f45303621 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +brcb_state, 0x40, 0x01, 0x3000, diff --git a/ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults b/ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults new file mode 100644 index 000000000..cb07216fc --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults @@ -0,0 +1,5 @@ +CONFIG_COMPILER_CXX_EXCEPTIONS=n +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 diff --git a/ports/esp_idf/brcb_flash_storage.cpp b/ports/esp_idf/brcb_flash_storage.cpp new file mode 100644 index 000000000..5797cd4ba --- /dev/null +++ b/ports/esp_idf/brcb_flash_storage.cpp @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ports/esp_idf/brcb_flash_storage.hpp" + +#include "esp_partition.h" +#include "esp_timer.h" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::ports::esp_idf { +namespace { + +[[nodiscard]] std::uint64_t timer_us() noexcept { + const auto value = esp_timer_get_time(); + return value <= 0 ? 0U : static_cast(value); +} + +[[nodiscard]] std::uint64_t elapsed_us(const std::uint64_t start) noexcept { + const auto end = timer_us(); + return end >= start ? end - start : 0U; +} + +[[nodiscard]] bool range_valid( + const std::size_t offset, + const std::size_t bytes, + const std::size_t capacity) noexcept { + return offset <= capacity && bytes <= capacity - offset; +} + +void record_latency( + std::uint64_t& total, + std::uint64_t& maximum, + const std::uint64_t elapsed) noexcept { + total += elapsed; + maximum = std::max(maximum, elapsed); +} + +} // namespace + +BrcbFlashStatus BrcbFlashStorage::initialize(const char* label) noexcept { + partition_ = nullptr; + geometry_ = {}; + metrics_ = {}; + status_ = BrcbFlashStatus::partition_not_found; + + if (label == nullptr) { + return status_; + } + + partition_ = esp_partition_find_first( + static_cast(partition_type), + static_cast(partition_subtype), + label); + if (partition_ == nullptr) { + return status_; + } + + geometry_.partition_bytes = static_cast(partition_->size); + geometry_.erase_bytes = static_cast(partition_->erase_size); + geometry_.encrypted = partition_->encrypted; + geometry_.read_only = partition_->readonly; + + if (partition_->readonly) { + status_ = BrcbFlashStatus::read_only_partition; + return status_; + } + if (partition_->encrypted) { + // The current journal emits byte-sized writes including a 40-byte header. + // Keep this first physical backend fail-closed until encrypted-partition + // alignment and power-fail behavior are characterized separately. + status_ = BrcbFlashStatus::encrypted_partition_not_supported; + return status_; + } + + const auto erase_bytes = geometry_.erase_bytes; + if (erase_bytes == 0U || + (static_cast(partition_->address) % erase_bytes) != 0U || + (geometry_.partition_bytes % erase_bytes) != 0U) { + status_ = BrcbFlashStatus::invalid_geometry; + return status_; + } + + constexpr std::size_t required_units = journal_bank_count + probe_sector_count; + if (erase_bytes > (std::numeric_limits::max() / required_units)) { + status_ = BrcbFlashStatus::invalid_geometry; + return status_; + } + const auto required_bytes = erase_bytes * required_units; + if (geometry_.partition_bytes < required_bytes) { + status_ = BrcbFlashStatus::partition_too_small; + return status_; + } + if (erase_bytes <= + mms::MmsStaticBrcbCheckpointStore::record_header_bytes + + mms::MmsStaticBrcbCheckpointStore::record_footer_bytes) { + status_ = BrcbFlashStatus::invalid_geometry; + return status_; + } + + geometry_.bank_bytes = erase_bytes; + geometry_.journal_bytes = erase_bytes * journal_bank_count; + geometry_.probe_offset = geometry_.journal_bytes; + geometry_.probe_bytes = erase_bytes; + geometry_.unused_bytes = geometry_.partition_bytes - required_bytes; + geometry_.maximum_state_bytes = + erase_bytes - mms::MmsStaticBrcbCheckpointStore::record_header_bytes - + mms::MmsStaticBrcbCheckpointStore::record_footer_bytes; + geometry_.erase_units_per_checkpoint = 1U; + + status_ = geometry_.valid() + ? BrcbFlashStatus::ok + : BrcbFlashStatus::invalid_geometry; + return status_; +} + +mms::MmsStaticBrcbStorageBackend BrcbFlashStorage::backend() noexcept { + if (!ready()) { + return {}; + } + return mms::MmsStaticBrcbStorageBackend{ + this, + geometry_.journal_bytes, + &BrcbFlashStorage::storage_read, + &BrcbFlashStorage::storage_write, + &BrcbFlashStorage::storage_erase, + &BrcbFlashStorage::storage_sync}; +} + +bool BrcbFlashStorage::storage_read( + void* context, + const std::size_t offset, + const std::span destination) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready() || + !range_valid(offset, destination.size(), storage->geometry_.journal_bytes)) { + return false; + } + ++storage->metrics_.read_calls; + if (destination.empty()) { + return true; + } + + const auto start = timer_us(); + const auto status = esp_partition_read( + storage->partition_, offset, destination.data(), destination.size()); + const auto elapsed = elapsed_us(start); + record_latency( + storage->metrics_.read_us_total, + storage->metrics_.read_us_max, + elapsed); + if (status != ESP_OK) { + ++storage->metrics_.read_failures; + return false; + } + storage->metrics_.read_bytes += static_cast(destination.size()); + return true; +} + +bool BrcbFlashStorage::storage_write( + void* context, + const std::size_t offset, + const std::span source) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready() || + !range_valid(offset, source.size(), storage->geometry_.journal_bytes)) { + return false; + } + ++storage->metrics_.write_calls; + if (source.empty()) { + return true; + } + + const auto start = timer_us(); + const auto status = esp_partition_write( + storage->partition_, offset, source.data(), source.size()); + const auto elapsed = elapsed_us(start); + record_latency( + storage->metrics_.write_us_total, + storage->metrics_.write_us_max, + elapsed); + if (status != ESP_OK) { + ++storage->metrics_.write_failures; + return false; + } + storage->metrics_.write_bytes += static_cast(source.size()); + return true; +} + +bool BrcbFlashStorage::storage_erase( + void* context, + const std::size_t offset, + const std::size_t bytes) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready() || bytes == 0U || + !range_valid(offset, bytes, storage->geometry_.journal_bytes) || + (offset % storage->geometry_.erase_bytes) != 0U || + (bytes % storage->geometry_.erase_bytes) != 0U) { + return false; + } + ++storage->metrics_.erase_calls; + + const auto start = timer_us(); + const auto status = esp_partition_erase_range(storage->partition_, offset, bytes); + const auto elapsed = elapsed_us(start); + record_latency( + storage->metrics_.erase_us_total, + storage->metrics_.erase_us_max, + elapsed); + if (status != ESP_OK) { + ++storage->metrics_.erase_failures; + return false; + } + storage->metrics_.erased_bytes += static_cast(bytes); + return true; +} + +bool BrcbFlashStorage::storage_sync(void* context) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready()) { + return false; + } + // esp_partition read/write/erase calls are blocking operations. There is no + // additional asynchronous flash queue owned by this adapter, so completion + // of the preceding API call is the durable ordering boundary available here. + ++storage->metrics_.sync_calls; + return true; +} + +BrcbFlashProbeResult BrcbFlashStorage::run_latency_probe( + const std::span payload) noexcept { + BrcbFlashProbeResult result; + result.payload_bytes = payload.size(); + if (!ready()) { + result.status = BrcbFlashProbeStatus::not_ready; + return result; + } + if (payload.empty() || payload.size() > geometry_.maximum_state_bytes) { + result.status = BrcbFlashProbeStatus::payload_too_large; + return result; + } + + std::array + header{}; + std::array + footer{}; + for (std::size_t index = 0U; index < header.size(); ++index) { + header[index] = static_cast(0x40U + (index & 0x3FU)); + } + for (std::size_t index = 0U; index < footer.size(); ++index) { + footer[index] = static_cast(0xA0U + (index & 0x1FU)); + } + + const auto cleanup = [&]() noexcept { + const auto start = timer_us(); + const auto erased = esp_partition_erase_range( + partition_, geometry_.probe_offset, geometry_.probe_bytes); + result.cleanup_erase_us = elapsed_us(start); + result.cleanup_succeeded = erased == ESP_OK; + if (result.cleanup_succeeded) { + ++result.probe_erase_units; + } + return result.cleanup_succeeded; + }; + + auto start = timer_us(); + auto status = esp_partition_erase_range( + partition_, geometry_.probe_offset, geometry_.probe_bytes); + result.prepare_erase_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::prepare_erase_failed; + return result; + } + ++result.probe_erase_units; + + start = timer_us(); + status = esp_partition_write( + partition_, geometry_.probe_offset, header.data(), header.size()); + result.header_write_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::header_write_failed; + (void)cleanup(); + return result; + } + + start = timer_us(); + status = esp_partition_write( + partition_, + geometry_.probe_offset + header.size(), + payload.data(), + payload.size()); + result.payload_write_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::payload_write_failed; + (void)cleanup(); + return result; + } + + const auto footer_offset = + geometry_.probe_offset + geometry_.probe_bytes - footer.size(); + start = timer_us(); + status = esp_partition_write( + partition_, footer_offset, footer.data(), footer.size()); + result.footer_write_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::footer_write_failed; + (void)cleanup(); + return result; + } + + std::array + header_read{}; + std::array + footer_read{}; + std::array chunk{}; + + const auto read_start = timer_us(); + status = esp_partition_read( + partition_, geometry_.probe_offset, header_read.data(), header_read.size()); + if (status != ESP_OK) { + result.verify_read_us = elapsed_us(read_start); + result.status = BrcbFlashProbeStatus::header_read_failed; + (void)cleanup(); + return result; + } + + bool payload_matches = true; + std::size_t consumed = 0U; + while (consumed < payload.size()) { + const auto bytes = std::min(chunk.size(), payload.size() - consumed); + status = esp_partition_read( + partition_, + geometry_.probe_offset + header.size() + consumed, + chunk.data(), + bytes); + if (status != ESP_OK) { + result.verify_read_us = elapsed_us(read_start); + result.status = BrcbFlashProbeStatus::payload_read_failed; + (void)cleanup(); + return result; + } + if (!std::equal( + chunk.begin(), + chunk.begin() + static_cast(bytes), + payload.subspan(consumed, bytes).begin())) { + payload_matches = false; + } + consumed += bytes; + } + + status = esp_partition_read( + partition_, footer_offset, footer_read.data(), footer_read.size()); + result.verify_read_us = elapsed_us(read_start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::footer_read_failed; + (void)cleanup(); + return result; + } + + const auto verified = + std::equal(header.begin(), header.end(), header_read.begin()) && + payload_matches && + std::equal(footer.begin(), footer.end(), footer_read.begin()); + if (!verified) { + result.status = BrcbFlashProbeStatus::verify_failed; + (void)cleanup(); + return result; + } + + if (!cleanup()) { + result.status = BrcbFlashProbeStatus::cleanup_erase_failed; + return result; + } + + result.status = BrcbFlashProbeStatus::ok; + return result; +} + +const char* brcb_flash_status_name(const BrcbFlashStatus status) noexcept { + switch (status) { + case BrcbFlashStatus::ok: + return "ok"; + case BrcbFlashStatus::partition_not_found: + return "partition-not-found"; + case BrcbFlashStatus::read_only_partition: + return "read-only-partition"; + case BrcbFlashStatus::encrypted_partition_not_supported: + return "encrypted-partition-not-supported"; + case BrcbFlashStatus::invalid_geometry: + return "invalid-geometry"; + case BrcbFlashStatus::partition_too_small: + return "partition-too-small"; + } + return "unknown"; +} + +const char* brcb_flash_probe_status_name(const BrcbFlashProbeStatus status) noexcept { + switch (status) { + case BrcbFlashProbeStatus::ok: + return "ok"; + case BrcbFlashProbeStatus::not_ready: + return "not-ready"; + case BrcbFlashProbeStatus::payload_too_large: + return "payload-too-large"; + case BrcbFlashProbeStatus::prepare_erase_failed: + return "prepare-erase-failed"; + case BrcbFlashProbeStatus::header_write_failed: + return "header-write-failed"; + case BrcbFlashProbeStatus::payload_write_failed: + return "payload-write-failed"; + case BrcbFlashProbeStatus::footer_write_failed: + return "footer-write-failed"; + case BrcbFlashProbeStatus::header_read_failed: + return "header-read-failed"; + case BrcbFlashProbeStatus::payload_read_failed: + return "payload-read-failed"; + case BrcbFlashProbeStatus::footer_read_failed: + return "footer-read-failed"; + case BrcbFlashProbeStatus::verify_failed: + return "verify-failed"; + case BrcbFlashProbeStatus::cleanup_erase_failed: + return "cleanup-erase-failed"; + } + return "unknown"; +} + +} // namespace ar::iec61850::ports::esp_idf diff --git a/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp b/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp new file mode 100644 index 000000000..045be0297 --- /dev/null +++ b/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_brcb_checkpoint_store.hpp" + +#include +#include +#include +#include + +struct esp_partition_t; + +namespace ar::iec61850::ports::esp_idf { + +enum class BrcbFlashStatus : std::uint8_t { + ok, + partition_not_found, + read_only_partition, + encrypted_partition_not_supported, + invalid_geometry, + partition_too_small, +}; + +struct BrcbFlashGeometry final { + std::size_t partition_bytes{}; + std::size_t erase_bytes{}; + std::size_t bank_bytes{}; + std::size_t journal_bytes{}; + std::size_t probe_offset{}; + std::size_t probe_bytes{}; + std::size_t unused_bytes{}; + std::size_t maximum_state_bytes{}; + std::size_t erase_units_per_checkpoint{}; + bool encrypted{}; + bool read_only{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return erase_bytes != 0U && bank_bytes == erase_bytes && + journal_bytes == bank_bytes * 2U && probe_bytes == erase_bytes && + probe_offset == journal_bytes && maximum_state_bytes != 0U && + erase_units_per_checkpoint == 1U; + } +}; + +struct BrcbFlashMetrics final { + std::uint64_t read_calls{}; + std::uint64_t write_calls{}; + std::uint64_t erase_calls{}; + std::uint64_t sync_calls{}; + std::uint64_t read_failures{}; + std::uint64_t write_failures{}; + std::uint64_t erase_failures{}; + std::uint64_t read_bytes{}; + std::uint64_t write_bytes{}; + std::uint64_t erased_bytes{}; + std::uint64_t read_us_total{}; + std::uint64_t write_us_total{}; + std::uint64_t erase_us_total{}; + std::uint64_t read_us_max{}; + std::uint64_t write_us_max{}; + std::uint64_t erase_us_max{}; + + [[nodiscard]] constexpr std::uint64_t logical_erase_units( + const std::size_t erase_bytes) const noexcept { + return erase_bytes == 0U + ? 0U + : erased_bytes / static_cast(erase_bytes); + } +}; + +enum class BrcbFlashProbeStatus : std::uint8_t { + ok, + not_ready, + payload_too_large, + prepare_erase_failed, + header_write_failed, + payload_write_failed, + footer_write_failed, + header_read_failed, + payload_read_failed, + footer_read_failed, + verify_failed, + cleanup_erase_failed, +}; + +struct BrcbFlashProbeResult final { + BrcbFlashProbeStatus status{BrcbFlashProbeStatus::not_ready}; + std::size_t payload_bytes{}; + std::uint64_t prepare_erase_us{}; + std::uint64_t header_write_us{}; + std::uint64_t payload_write_us{}; + std::uint64_t footer_write_us{}; + std::uint64_t verify_read_us{}; + std::uint64_t cleanup_erase_us{}; + std::uint64_t probe_erase_units{}; + bool cleanup_succeeded{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == BrcbFlashProbeStatus::ok; + } + + [[nodiscard]] constexpr std::uint64_t total_write_us() const noexcept { + return header_write_us + payload_write_us + footer_write_us; + } +}; + +// ESP-IDF raw-partition adapter for the bounded BRCB A/B checkpoint journal. +// +// Layout policy: +// erase unit 0 = journal bank A +// erase unit 1 = journal bank B +// erase unit 2 = destructive latency-probe scratch area +// +// Exactly one erase unit is therefore erased per checkpoint attempt that reaches +// the storage erase stage. The probe sector is deliberately outside the backend +// capacity exposed to MmsStaticBrcbCheckpointStore so a latency measurement can +// never overwrite either committed journal bank. +class BrcbFlashStorage final { +public: + static constexpr std::uint8_t partition_type = 0x40U; + static constexpr std::uint8_t partition_subtype = 0x01U; + static constexpr std::size_t journal_bank_count = 2U; + static constexpr std::size_t probe_sector_count = 1U; + static constexpr const char* default_partition_label = "brcb_state"; + + [[nodiscard]] BrcbFlashStatus initialize( + const char* label = default_partition_label) noexcept; + + [[nodiscard]] constexpr bool ready() const noexcept { + return status_ == BrcbFlashStatus::ok && partition_ != nullptr && geometry_.valid(); + } + + [[nodiscard]] constexpr BrcbFlashStatus status() const noexcept { + return status_; + } + + [[nodiscard]] constexpr const BrcbFlashGeometry& geometry() const noexcept { + return geometry_; + } + + [[nodiscard]] constexpr const BrcbFlashMetrics& metrics() const noexcept { + return metrics_; + } + + void reset_metrics() noexcept { + metrics_ = {}; + } + + [[nodiscard]] mms::MmsStaticBrcbStorageBackend backend() noexcept; + + [[nodiscard]] mms::MmsStaticBrcbCheckpointStore checkpoint_store() noexcept { + return mms::MmsStaticBrcbCheckpointStore{backend(), geometry_.bank_bytes}; + } + + // Destructive only inside the dedicated probe erase unit. The payload is + // written using the same header/payload/footer write shape as the journal, + // then read back and verified before the probe unit is erased again. + [[nodiscard]] BrcbFlashProbeResult run_latency_probe( + std::span payload) noexcept; + + // A/B alternation means each journal bank is erased at most every other + // checkpoint erase attempt. These helpers intentionally avoid assuming any + // vendor-specific flash endurance rating; feed the actual flash rating into + // checkpoint_attempts_for_bank_cycles() when hardware data is available. + [[nodiscard]] static constexpr std::uint64_t maximum_bank_erase_cycles( + const std::uint64_t checkpoint_erase_attempts) noexcept { + return (checkpoint_erase_attempts + 1U) / 2U; + } + + [[nodiscard]] static constexpr std::uint64_t checkpoint_attempts_for_bank_cycles( + const std::uint64_t rated_bank_erase_cycles) noexcept { + return rated_bank_erase_cycles > + (std::numeric_limits::max() / 2U) + ? std::numeric_limits::max() + : rated_bank_erase_cycles * 2U; + } + +private: + [[nodiscard]] static bool storage_read( + void* context, + std::size_t offset, + std::span destination) noexcept; + [[nodiscard]] static bool storage_write( + void* context, + std::size_t offset, + std::span source) noexcept; + [[nodiscard]] static bool storage_erase( + void* context, + std::size_t offset, + std::size_t bytes) noexcept; + [[nodiscard]] static bool storage_sync(void* context) noexcept; + + const struct esp_partition_t* partition_{}; + BrcbFlashStatus status_{BrcbFlashStatus::partition_not_found}; + BrcbFlashGeometry geometry_{}; + BrcbFlashMetrics metrics_{}; +}; + +[[nodiscard]] const char* brcb_flash_status_name(BrcbFlashStatus status) noexcept; +[[nodiscard]] const char* brcb_flash_probe_status_name(BrcbFlashProbeStatus status) noexcept; + +} // namespace ar::iec61850::ports::esp_idf diff --git a/ports/esp_idf/smoke_app/main/main.cpp b/ports/esp_idf/smoke_app/main/main.cpp index 50f8dade0..b4938db88 100644 --- a/ports/esp_idf/smoke_app/main/main.cpp +++ b/ports/esp_idf/smoke_app/main/main.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/embedded/profile.hpp" +#include "ariec61850/ports/esp_idf/brcb_flash_storage.hpp" #include "ariec61850/ports/esp_idf/ethernet_port.hpp" #include "ariec61850/sampled_values/payload_writer.hpp" #include "ariec61850/sampled_values/publisher.hpp" @@ -15,19 +16,29 @@ extern "C" void app_main(void) { static_assert(embedded::Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); static_assert(sampled_values::SampledValuesPublisher::maximum_supported_sample_rate_hz >= 4'000U); static_assert(sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes == 8U); + static_assert(ports::esp_idf::BrcbFlashStorage::maximum_bank_erase_cycles(3U) == 2U); + static_assert(ports::esp_idf::BrcbFlashStorage::checkpoint_attempts_for_bank_cycles(10U) == 20U); ports::esp_idf::RawEthernetContext context{}; const auto port = ports::esp_idf::make_raw_ethernet_port(context); const auto clock = ports::esp_idf::make_monotonic_clock(); + ports::esp_idf::BrcbFlashStorage flash_storage; + const auto flash_status = flash_storage.initialize(); + const auto flash_backend = flash_storage.backend(); + const auto flash_store = flash_storage.checkpoint_store(); + std::array payload{}; const auto wrote = sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( std::span{payload}, 0U, 1234, 0U); - // Build-only smoke: no Ethernet driver is installed on CI hardware. Keep the - // objects live so headers, adapter symbols and C++20 span usage are compiled - // and linked for the real ESP32-P4 toolchain. + // Build-only smoke: no Ethernet/flash hardware is exercised on hosted CI. + // Keep the objects live so adapter symbols, partition APIs and C++20 span + // usage are compiled and linked for the real ESP32-P4 toolchain. (void)port; (void)clock; + (void)flash_status; + (void)flash_backend; + (void)flash_store; (void)wrote; } diff --git a/ports/esp_idf/smoke_app/partitions.csv b/ports/esp_idf/smoke_app/partitions.csv new file mode 100644 index 000000000..f45303621 --- /dev/null +++ b/ports/esp_idf/smoke_app/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +brcb_state, 0x40, 0x01, 0x3000, diff --git a/ports/esp_idf/smoke_app/sdkconfig.defaults b/ports/esp_idf/smoke_app/sdkconfig.defaults index d7c3e6cb5..d66b198eb 100644 --- a/ports/esp_idf/smoke_app/sdkconfig.defaults +++ b/ports/esp_idf/smoke_app/sdkconfig.defaults @@ -1,2 +1,6 @@ -# The MCU component is compiled as a hard no-exception/no-RTTI SV transmit slice. +# The MCU component is compiled as a hard no-exception/no-RTTI transmit/storage slice. CONFIG_COMPILER_CXX_EXCEPTIONS=n + +# Reserve two raw BRCB journal banks plus one destructive latency-probe erase unit. +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" diff --git a/ports/esp_idf/sv_trial_app/partitions.csv b/ports/esp_idf/sv_trial_app/partitions.csv new file mode 100644 index 000000000..f45303621 --- /dev/null +++ b/ports/esp_idf/sv_trial_app/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +brcb_state, 0x40, 0x01, 0x3000, diff --git a/ports/esp_idf/sv_trial_app/sdkconfig.defaults b/ports/esp_idf/sv_trial_app/sdkconfig.defaults index 9745c3cf7..ac3c9b1b7 100644 --- a/ports/esp_idf/sv_trial_app/sdkconfig.defaults +++ b/ports/esp_idf/sv_trial_app/sdkconfig.defaults @@ -1,9 +1,13 @@ -# The linked arstack61850 SV transmit component is a hard no-exception profile. +# The linked arstack61850 component is a hard no-exception profile. CONFIG_COMPILER_CXX_EXCEPTIONS=n # First physical proof uses the ESP32-P4 internal EMAC/RMII path. CONFIG_ETH_USE_ESP32_EMAC=y +# Reserve two raw BRCB journal banks plus one destructive latency-probe erase unit. +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + # Board management/control uses the onboard Type-C USB-to-UART bridge wired to # ESP32-P4 UART0 (TX GPIO37 / RX GPIO38). Keep UART0 as primary stdio so the # same COM port used for flashing/monitoring can also carry line-delimited JSON From 203ad705ccfb8ee6da1343ae22bd1fce9802ffe6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:31:26 +0700 Subject: [PATCH 187/188] Fix ESP32-P4 BRCB partition columns --- ports/esp_idf/brcb_flash_probe_app/partitions.csv | 2 +- ports/esp_idf/smoke_app/partitions.csv | 2 +- ports/esp_idf/sv_trial_app/partitions.csv | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ports/esp_idf/brcb_flash_probe_app/partitions.csv b/ports/esp_idf/brcb_flash_probe_app/partitions.csv index f45303621..87077131c 100644 --- a/ports/esp_idf/brcb_flash_probe_app/partitions.csv +++ b/ports/esp_idf/brcb_flash_probe_app/partitions.csv @@ -2,4 +2,4 @@ nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M, -brcb_state, 0x40, 0x01, 0x3000, +brcb_state, 0x40, 0x01, , 0x3000, diff --git a/ports/esp_idf/smoke_app/partitions.csv b/ports/esp_idf/smoke_app/partitions.csv index f45303621..87077131c 100644 --- a/ports/esp_idf/smoke_app/partitions.csv +++ b/ports/esp_idf/smoke_app/partitions.csv @@ -2,4 +2,4 @@ nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M, -brcb_state, 0x40, 0x01, 0x3000, +brcb_state, 0x40, 0x01, , 0x3000, diff --git a/ports/esp_idf/sv_trial_app/partitions.csv b/ports/esp_idf/sv_trial_app/partitions.csv index f45303621..87077131c 100644 --- a/ports/esp_idf/sv_trial_app/partitions.csv +++ b/ports/esp_idf/sv_trial_app/partitions.csv @@ -2,4 +2,4 @@ nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M, -brcb_state, 0x40, 0x01, 0x3000, +brcb_state, 0x40, 0x01, , 0x3000, From 9f6ee34bdace80d89a812f60fce2a545451b7055 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 9 Aug 2026 20:36:26 +0700 Subject: [PATCH 188/188] Fix ESP-IDF partition descriptor type --- .../include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp b/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp index 045be0297..b34e0ac76 100644 --- a/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp +++ b/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp @@ -3,13 +3,13 @@ #include "ariec61850/mms/static_brcb_checkpoint_store.hpp" +#include "esp_partition.h" + #include #include #include #include -struct esp_partition_t; - namespace ar::iec61850::ports::esp_idf { enum class BrcbFlashStatus : std::uint8_t { @@ -190,7 +190,7 @@ class BrcbFlashStorage final { std::size_t bytes) noexcept; [[nodiscard]] static bool storage_sync(void* context) noexcept; - const struct esp_partition_t* partition_{}; + const esp_partition_t* partition_{}; BrcbFlashStatus status_{BrcbFlashStatus::partition_not_found}; BrcbFlashGeometry geometry_{}; BrcbFlashMetrics metrics_{};