From 94d2ec75c210a5aa3c31f55107b66274a0d3e24a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 19:10:21 +0700 Subject: [PATCH 1/2] P0.1: generate deterministic SV patch layout during encode Record ASDU field regions while BER is encoded, translate them to absolute Ethernet offsets, and consume those offsets directly in the ESP32-P4 publisher. Remove per-transmit smpSynch BER scanning and the duplicate transport-failure guard so the realtime path has one TX authority. Tracks #139. --- .../esp32p4_smv_injector/main/CMakeLists.txt | 6 -- .../esp32p4_smv_injector/main/app_main.cpp | 76 ++++++++------ .../main/smp_synch_lab.cpp | 99 +++---------------- .../main/smp_synch_lab.hpp | 26 +---- .../ariec61850/sampled_values/frame_codec.hpp | 8 ++ .../ariec61850/sampled_values/pdu_codec.hpp | 32 ++++++ src/sampled_values/frame_codec.cpp | 42 +++++++- src/sampled_values/pdu_codec.cpp | 56 +++++++++-- tests/test_sampled_values.cpp | 72 ++++++++++++++ 9 files changed, 264 insertions(+), 153 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 8cf84571a..520d53df3 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -38,9 +38,3 @@ if(CONFIG_AR_PTP_LAB_TX) "-Dar_ptp_lab_get_status=ar_ptp_source_get_status") endif() -# Only the deterministic SV publisher translation unit is intercepted. The shim -# patches the already encoded smpSynch byte immediately before transmit; PTP and -# every other Ethernet user continue calling ESP-IDF's esp_eth_transmit directly. -set_property(SOURCE "app_main.cpp" APPEND PROPERTY COMPILE_OPTIONS - "-include" "${CMAKE_CURRENT_LIST_DIR}/smp_synch_lab.hpp" - "-DAR_SMP_SYNCH_INTERCEPT_TX=1") diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 2e49e16d4..8adfa3fbb 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -10,6 +10,7 @@ #include "ethernet_port.h" #include "live_control.hpp" #include "runtime_profile.hpp" +#include "smp_synch_lab.hpp" #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -42,10 +43,12 @@ using ar::esp32p4::smv::live_tx_running; using ar::esp32p4::smv::runtime_profile_initialize; using ar::esp32p4::smv::runtime_profile_snapshot; using ar::esp32p4::smv::runtime_profile_validate; +using ar::esp32p4::smv::smp_synch_lab_decision; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; using ar::iec61850::sampled_values::SampledValueAsdu; +using ar::iec61850::sampled_values::SampledValueAsduEncodeLayout; using ar::iec61850::sampled_values::SampledValuesFrame; using ar::iec61850::sampled_values::SampledValuesFrameCodec; using ar::iec61850::sampled_values::SampledValuesPdu; @@ -86,6 +89,7 @@ std::atomic g_sample_tick_total{0U}; struct PacketTemplate final { std::vector bytes; std::size_t sample_count_offset{std::numeric_limits::max()}; + std::size_t sample_synchronization_offset{std::numeric_limits::max()}; std::size_t sample_payload_offset{std::numeric_limits::max()}; }; @@ -204,20 +208,6 @@ SampledValuesFrame make_runtime_frame( return frame; } -bool find_fixed_field( - const std::vector& bytes, - const std::uint8_t tag, - const std::uint8_t length, - std::size_t& value_offset) noexcept { - for (std::size_t index = 20U; index + 2U + length <= bytes.size(); ++index) { - if (bytes[index] == tag && bytes[index + 1U] == length) { - value_offset = index + 2U; - return true; - } - } - return false; -} - bool build_packet_template( const std::array& source_mac, const RuntimePublisherProfile& profile, @@ -225,36 +215,59 @@ bool build_packet_template( const bool diagnostic_mirror, PacketTemplate& packet) { packet = {}; - packet.bytes = SampledValuesFrameCodec::encode( - make_runtime_frame(source_mac, profile, diagnostic_mirror)); - - if (broadcast_destination) { - std::fill_n(packet.bytes.begin(), 6U, std::uint8_t{0xFFU}); + const auto frame = make_runtime_frame(source_mac, profile, diagnostic_mirror); + const auto required = SampledValuesFrameCodec::encoded_size(frame); + if (!required.has_value()) { + ESP_LOGE(kTag, "SV template size validation failed"); + return false; } - std::size_t conf_rev_offset = 0U; - if (!find_fixed_field(packet.bytes, 0x82U, 2U, packet.sample_count_offset) || - !find_fixed_field(packet.bytes, 0x83U, 4U, conf_rev_offset) || - !find_fixed_field(packet.bytes, 0x87U, 64U, packet.sample_payload_offset)) { - ESP_LOGE(kTag, "Failed to locate fixed SV patch fields in encoded template"); + packet.bytes.resize(*required); + std::array layouts{}; + const auto encoded = SampledValuesFrameCodec::encode_into_with_layout( + frame, packet.bytes, layouts); + if (!encoded.success() || encoded.bytes_written != packet.bytes.size()) { + ESP_LOGE(kTag, "SV template encode/layout generation failed"); return false; } - if (packet.sample_payload_offset + 64U > packet.bytes.size()) { - ESP_LOGE(kTag, "SV payload patch region exceeds encoded frame"); + + const auto& layout = layouts.front(); + const auto exact_region = [&packet](const auto& region, const std::size_t expected_size) { + return region.present && region.value_size == expected_size && + region.value_offset <= packet.bytes.size() && + region.value_size <= packet.bytes.size() - region.value_offset; + }; + if (!exact_region(layout.sample_count, 2U) || + !exact_region(layout.configuration_revision, 4U) || + !exact_region(layout.sample_synchronization, 1U) || + !exact_region(layout.sample_payload, 64U)) { + ESP_LOGE(kTag, "SV template patch layout does not match the bounded runtime contract"); return false; } + packet.sample_count_offset = layout.sample_count.value_offset; + packet.sample_synchronization_offset = layout.sample_synchronization.value_offset; + packet.sample_payload_offset = layout.sample_payload.value_offset; + + if (broadcast_destination) { + std::fill_n(packet.bytes.begin(), 6U, std::uint8_t{0xFFU}); + } + write_u16_be(packet.bytes.data() + packet.sample_count_offset, 0U); - write_u32_be(packet.bytes.data() + conf_rev_offset, profile.configuration_revision); + write_u32_be( + packet.bytes.data() + layout.configuration_revision.value_offset, + profile.configuration_revision); return true; } void patch_packet( PacketTemplate& packet, const std::uint16_t sample_count, + const std::uint8_t sample_synchronization, const WaveformRow& values, const SvLiveSignalState& signal_state) noexcept { write_u16_be(packet.bytes.data() + packet.sample_count_offset, sample_count); + packet.bytes[packet.sample_synchronization_offset] = sample_synchronization; for (std::size_t channel = 0U; channel < values.size(); ++channel) { const auto base = packet.sample_payload_offset + channel * 8U; write_i32_be(packet.bytes.data() + base, values[channel]); @@ -541,7 +554,11 @@ void publisher_task(void* argument) { ++local.mirror_fail; #endif } else { - patch_packet(canonical, canonical_sample_count, row, signal_state); + const auto sync_decision = smp_synch_lab_decision(); + const auto sample_synchronization = + static_cast(sync_decision.value); + patch_packet( + canonical, canonical_sample_count, sample_synchronization, row, signal_state); const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); if (canonical_result == ESP_OK) { @@ -555,7 +572,8 @@ void publisher_task(void* argument) { } #if CONFIG_AR_SMV_BROADCAST_MIRROR - patch_packet(mirror, mirror_sample_count, row, signal_state); + patch_packet( + mirror, mirror_sample_count, sample_synchronization, row, signal_state); const esp_err_t mirror_result = esp_eth_transmit(eth_handle, mirror.bytes.data(), mirror.bytes.size()); if (mirror_result == ESP_OK) ++local.mirror_ok; diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 628e4f454..820b3b1ad 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -1,36 +1,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "smp_synch_lab.hpp" -#include "live_control.hpp" - -#include "ariec61850/sampled_values/wire_field_offsets.hpp" - -#include "esp_log.h" #include -#include #include #include -#include namespace ar::esp32p4::smv { namespace { -using ar::iec61850::sampled_values::find_smp_synch_value_offset; using ar::iec61850::time_sync::resolve_sv_sync_policy; -constexpr char kTag[] = "ar_smv_txguard"; -constexpr std::uint16_t kDiagnosticMirrorAppId = 0x4F01U; -// At the production 4000 fps profile this trips after 100 ms of uninterrupted -// canonical SV transport failure. Mirror failures never drive this guard. -constexpr std::uint32_t kCanonicalFailureStopThreshold = 400U; - std::atomic g_mode{ static_cast(SvSyncPolicyMode::external_ptp_auto)}; // -1 means PTP-P2 has not supplied measured lock evidence yet. std::atomic g_measured_value{-1}; -std::atomic g_consecutive_canonical_tx_failures{0U}; -std::atomic_bool g_transport_fault_reported{false}; [[nodiscard]] std::optional measured_value() noexcept { const int raw = g_measured_value.load(std::memory_order_acquire); @@ -46,51 +30,9 @@ std::atomic_bool g_transport_fault_reported{false}; } } -[[nodiscard]] SmpSynchDecision current_decision() noexcept { - const auto mode = static_cast( +[[nodiscard]] SvSyncPolicyMode current_mode() noexcept { + return static_cast( g_mode.load(std::memory_order_acquire)); - return resolve_sv_sync_policy(mode, measured_value()); -} - -[[nodiscard]] bool diagnostic_mirror_frame(const std::span bytes) noexcept { - // Untagged IEC 61850-9-2: EtherType 0x88BA, APPID immediately follows. - if (bytes.size() >= 16U && bytes[12] == 0x88U && bytes[13] == 0xBAU) { - const auto app_id = static_cast( - (static_cast(bytes[14]) << 8U) | bytes[15]); - return app_id == kDiagnosticMirrorAppId; - } - - // 802.1Q tagged IEC 61850-9-2: inner EtherType 0x88BA, then APPID. - if (bytes.size() >= 20U && bytes[12] == 0x81U && bytes[13] == 0x00U && - bytes[16] == 0x88U && bytes[17] == 0xBAU) { - const auto app_id = static_cast( - (static_cast(bytes[18]) << 8U) | bytes[19]); - return app_id == kDiagnosticMirrorAppId; - } - return false; -} - -void observe_canonical_tx_result(const esp_err_t result) noexcept { - if (result == ESP_OK) { - g_consecutive_canonical_tx_failures.store(0U, std::memory_order_release); - g_transport_fault_reported.store(false, std::memory_order_release); - return; - } - - const auto failures = - g_consecutive_canonical_tx_failures.fetch_add(1U, std::memory_order_acq_rel) + 1U; - if (failures < kCanonicalFailureStopThreshold) return; - - // The sample clock can be perfectly healthy while the Ethernet data plane is - // completely dead. Fail closed instead of leaving Studio green at - // "Injection running" with txFail=4000. - if (!g_transport_fault_reported.exchange(true, std::memory_order_acq_rel)) { - ESP_LOGE(kTag, - "Canonical SV transport fault: %lu consecutive TX failures (%s); forcing STOP", - static_cast(failures), - esp_err_to_name(result)); - } - live_control_force_stop(); } } // namespace @@ -100,12 +42,19 @@ void smp_synch_lab_set_mode(const SvSyncPolicyMode mode) noexcept { } SvSyncPolicyMode smp_synch_lab_mode() noexcept { - return static_cast(g_mode.load(std::memory_order_acquire)); + return current_mode(); +} + +SmpSynchDecision smp_synch_lab_decision() noexcept { + return resolve_sv_sync_policy(current_mode(), measured_value()); } SmpSynchLabStatus smp_synch_lab_status() noexcept { const auto measured = measured_value(); - return {current_decision(), measured.has_value()}; + return { + resolve_sv_sync_policy(current_mode(), measured), + measured.has_value(), + }; } void smp_synch_lab_set_measured(const std::optional value) noexcept { @@ -113,29 +62,9 @@ void smp_synch_lab_set_measured(const std::optional value) noexce g_measured_value.store(-1, std::memory_order_release); return; } - g_measured_value.store(static_cast(static_cast(*value)), - std::memory_order_release); -} - -esp_err_t smp_synch_lab_transmit( - const esp_eth_handle_t handle, - void* buffer, - const std::size_t length) noexcept { - auto bytes = std::span{}; - if (buffer != nullptr) { - bytes = std::span{static_cast(buffer), length}; - const auto value_offset = find_smp_synch_value_offset(bytes); - if (value_offset.has_value()) { - const auto decision = current_decision(); - bytes[*value_offset] = static_cast(decision.value); - } - } - - const auto result = ::esp_eth_transmit(handle, buffer, length); - if (!diagnostic_mirror_frame(bytes)) { - observe_canonical_tx_result(result); - } - return result; + g_measured_value.store( + static_cast(static_cast(*value)), + std::memory_order_release); } } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp index 61895c368..f35544d52 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp @@ -4,10 +4,6 @@ #include "ariec61850/time_sync/smp_synch_policy.hpp" -#include "esp_eth.h" - -#include -#include #include namespace ar::esp32p4::smv { @@ -25,28 +21,14 @@ struct SmpSynchLabStatus final { void smp_synch_lab_set_mode(SvSyncPolicyMode mode) noexcept; [[nodiscard]] SvSyncPolicyMode smp_synch_lab_mode() noexcept; +[[nodiscard]] SmpSynchDecision smp_synch_lab_decision() noexcept; [[nodiscard]] SmpSynchLabStatus smp_synch_lab_status() noexcept; /** - * Future PTP-P2 hook. AUTO consumes this value only when the discipline/lock - * engine has real measured evidence. Passing std::nullopt removes that evidence - * and immediately returns AUTO to the safe smpSynch=0 default. + * PTP-P2 measurement hook. AUTO consumes this value only when the + * discipline/lock engine has real measured evidence. Passing std::nullopt + * removes that evidence and immediately returns AUTO to smpSynch=0. */ void smp_synch_lab_set_measured(std::optional value) noexcept; -/** - * Transmit shim used only by app_main.cpp. It patches the already-encoded SV - * smpSynch TLV immediately before TX, allowing live lab transitions without - * rebuilding the deterministic packet template. Non-SV frames are untouched. - */ -esp_err_t smp_synch_lab_transmit( - esp_eth_handle_t handle, - void* buffer, - std::size_t length) noexcept; - } // namespace ar::esp32p4::smv - -#if defined(AR_SMP_SYNCH_INTERCEPT_TX) && AR_SMP_SYNCH_INTERCEPT_TX -#define esp_eth_transmit(handle, buffer, length) \ - ::ar::esp32p4::smv::smp_synch_lab_transmit((handle), (buffer), (length)) -#endif diff --git a/include/ariec61850/sampled_values/frame_codec.hpp b/include/ariec61850/sampled_values/frame_codec.hpp index ba9ddce0d..327e10d70 100644 --- a/include/ariec61850/sampled_values/frame_codec.hpp +++ b/include/ariec61850/sampled_values/frame_codec.hpp @@ -3,6 +3,7 @@ #pragma once #include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/pdu_codec.hpp" #include "ariec61850/wire/encode_result.hpp" #include @@ -22,6 +23,13 @@ class SampledValuesFrameCodec final { const SampledValuesFrame& frame, std::span destination) noexcept; + // Layout offsets returned here are absolute offsets into the encoded + // Ethernet frame. Empty layout span disables metadata collection. + [[nodiscard]] static wire::EncodeResult encode_into_with_layout( + const SampledValuesFrame& frame, + std::span destination, + std::span layouts) noexcept; + // Host convenience wrapper. Embedded SV publishers should use encode_into // with a persistent Ethernet-sized buffer. [[nodiscard]] static std::vector encode(const SampledValuesFrame& frame); diff --git a/include/ariec61850/sampled_values/pdu_codec.hpp b/include/ariec61850/sampled_values/pdu_codec.hpp index 9fd31c8ce..3642bffb6 100644 --- a/include/ariec61850/sampled_values/pdu_codec.hpp +++ b/include/ariec61850/sampled_values/pdu_codec.hpp @@ -13,6 +13,30 @@ namespace ar::iec61850::sampled_values { +struct SampledValuesEncodedFieldRegion final { + std::size_t value_offset{}; + std::size_t value_size{}; + bool present{}; + + friend bool operator==(const SampledValuesEncodedFieldRegion&, + const SampledValuesEncodedFieldRegion&) = default; +}; + +// Offsets are PDU-relative when produced by SampledValuesPduCodec and become +// Ethernet-frame absolute when produced by SampledValuesFrameCodec. +struct SampledValueAsduEncodeLayout final { + SampledValuesEncodedFieldRegion sample_count; + SampledValuesEncodedFieldRegion configuration_revision; + SampledValuesEncodedFieldRegion reference_time; + SampledValuesEncodedFieldRegion sample_synchronization; + SampledValuesEncodedFieldRegion sample_rate; + SampledValuesEncodedFieldRegion sample_payload; + SampledValuesEncodedFieldRegion sample_mode; + + friend bool operator==(const SampledValueAsduEncodeLayout&, + const SampledValueAsduEncodeLayout&) = default; +}; + class SampledValuesPduCodec final { public: [[nodiscard]] static std::optional encoded_size( @@ -22,6 +46,14 @@ class SampledValuesPduCodec final { const SampledValuesPdu& pdu, std::span destination) noexcept; + // Encodes once and records field value regions at the moment bytes are + // written. An empty layout span disables metadata collection; otherwise it + // must provide at least one slot per ASDU. No BER rescan is performed. + [[nodiscard]] static wire::EncodeResult encode_into_with_layout( + const SampledValuesPdu& pdu, + std::span destination, + std::span layouts) noexcept; + // Host convenience wrapper. Embedded steady-state publishers should use // encode_into with caller-owned storage to avoid per-frame allocation. [[nodiscard]] static std::vector encode(const SampledValuesPdu& pdu); diff --git a/src/sampled_values/frame_codec.cpp b/src/sampled_values/frame_codec.cpp index 5b7ecc8fc..3f44bbc5c 100644 --- a/src/sampled_values/frame_codec.cpp +++ b/src/sampled_values/frame_codec.cpp @@ -26,6 +26,26 @@ void write_u16_be( destination[offset + 1U] = static_cast(value & 0xFFU); } +void shift_region( + SampledValuesEncodedFieldRegion& region, + const std::size_t delta) noexcept { + if (region.present) { + region.value_offset += delta; + } +} + +void shift_layout( + SampledValueAsduEncodeLayout& layout, + const std::size_t delta) noexcept { + shift_region(layout.sample_count, delta); + shift_region(layout.configuration_revision, delta); + shift_region(layout.reference_time, delta); + shift_region(layout.sample_synchronization, delta); + shift_region(layout.sample_rate, delta); + shift_region(layout.sample_payload, delta); + shift_region(layout.sample_mode, delta); +} + [[nodiscard]] std::optional vlan_tci( const ethernet::VlanTag& vlan) noexcept { if (!ethernet::is_valid_vlan_priority(vlan.priority_code_point) || @@ -67,6 +87,19 @@ std::optional SampledValuesFrameCodec::encoded_size( wire::EncodeResult SampledValuesFrameCodec::encode_into( const SampledValuesFrame& frame, const std::span destination) noexcept { + return encode_into_with_layout(frame, destination, {}); +} + +wire::EncodeResult SampledValuesFrameCodec::encode_into_with_layout( + const SampledValuesFrame& frame, + const std::span destination, + const std::span layouts) noexcept { + if (!layouts.empty() && layouts.size() < frame.pdu.asdus.size()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + for (auto& layout : layouts) { + layout = {}; + } const auto required = encoded_size(frame); if (!required) { return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; @@ -109,11 +142,16 @@ wire::EncodeResult SampledValuesFrameCodec::encode_into( write_u16_be(destination, offset + 6U, frame.reserved2); offset += ethernet::ProcessBusFrameCodec::header_length; - const auto pdu_result = SampledValuesPduCodec::encode_into( - frame.pdu, destination.subspan(offset, *apdu_size)); + const auto pdu_offset = offset; + const auto pdu_result = SampledValuesPduCodec::encode_into_with_layout( + frame.pdu, destination.subspan(offset, *apdu_size), layouts); if (!pdu_result.success() || pdu_result.bytes_written != *apdu_size) { + for (auto& layout : layouts) layout = {}; return {pdu_result.status, 0U, *required}; } + for (std::size_t index = 0U; index < frame.pdu.asdus.size() && index < layouts.size(); ++index) { + shift_layout(layouts[index], pdu_offset); + } offset += pdu_result.bytes_written; return {wire::EncodeStatus::ok, offset, *required}; } diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp index 253b624b6..a8839970e 100644 --- a/src/sampled_values/pdu_codec.cpp +++ b/src/sampled_values/pdu_codec.cpp @@ -207,12 +207,16 @@ void write_unsigned( const std::span destination, std::size_t& offset, const std::uint8_t tag_number, - const std::uint64_t value) noexcept { + const std::uint64_t value, + SampledValuesEncodedFieldRegion* const region = nullptr) noexcept { const auto value_size = unsigned_integer_size(value); write_tlv_header( destination, offset, static_cast(context_primitive_base | tag_number), value_size); + if (region != nullptr) { + *region = {offset, value_size, true}; + } for (std::size_t index = value_size; index-- > 0U;) { const auto shift = static_cast(index * 8U); @@ -223,7 +227,11 @@ void write_unsigned( [[nodiscard]] bool write_asdu( const SampledValueAsdu& asdu, const std::span destination, - std::size_t& offset) noexcept { + std::size_t& offset, + SampledValueAsduEncodeLayout* const layout) noexcept { + if (layout != nullptr) { + *layout = {}; + } const auto content_size = asdu_content_size(asdu); if (!content_size) { return false; @@ -234,8 +242,12 @@ void write_unsigned( if (!asdu.data_set_reference.empty()) { write_string(destination, offset, 1U, asdu.data_set_reference); } - write_unsigned(destination, offset, 2U, asdu.sample_count); - write_unsigned(destination, offset, 3U, asdu.configuration_revision); + write_unsigned( + destination, offset, 2U, asdu.sample_count, + layout != nullptr ? &layout->sample_count : nullptr); + write_unsigned( + destination, offset, 3U, asdu.configuration_revision, + layout != nullptr ? &layout->configuration_revision : nullptr); if (asdu.reference_time.has_value()) { std::array timestamp{}; @@ -246,22 +258,34 @@ void write_unsigned( destination, offset, static_cast(context_primitive_base | 4U), timestamp.size()); + if (layout != nullptr) { + layout->reference_time = {offset, timestamp.size(), true}; + } write_bytes(destination, offset, timestamp); } - write_unsigned(destination, offset, 5U, asdu.sample_synchronization); + write_unsigned( + destination, offset, 5U, asdu.sample_synchronization, + layout != nullptr ? &layout->sample_synchronization : nullptr); if (asdu.sample_rate.has_value()) { - write_unsigned(destination, offset, 6U, *asdu.sample_rate); + write_unsigned( + destination, offset, 6U, *asdu.sample_rate, + layout != nullptr ? &layout->sample_rate : nullptr); } write_tlv_header( destination, offset, static_cast(context_primitive_base | 7U), asdu.sample_payload.size()); + if (layout != nullptr) { + layout->sample_payload = {offset, asdu.sample_payload.size(), true}; + } write_bytes(destination, offset, asdu.sample_payload); if (asdu.sample_mode.has_value()) { - write_unsigned(destination, offset, 8U, *asdu.sample_mode); + write_unsigned( + destination, offset, 8U, *asdu.sample_mode, + layout != nullptr ? &layout->sample_mode : nullptr); } return true; } @@ -392,6 +416,19 @@ std::optional SampledValuesPduCodec::encoded_size( wire::EncodeResult SampledValuesPduCodec::encode_into( const SampledValuesPdu& pdu, const std::span destination) noexcept { + return encode_into_with_layout(pdu, destination, {}); +} + +wire::EncodeResult SampledValuesPduCodec::encode_into_with_layout( + const SampledValuesPdu& pdu, + const std::span destination, + const std::span layouts) noexcept { + if (!layouts.empty() && layouts.size() < pdu.asdus.size()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + for (auto& layout : layouts) { + layout = {}; + } const auto required = encoded_size(pdu); if (!required) { return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; @@ -419,8 +456,9 @@ wire::EncodeResult SampledValuesPduCodec::encode_into( static_cast(context_constructed_base | 2U), *sequence_size); - for (const auto& asdu : pdu.asdus) { - if (!write_asdu(asdu, destination, offset)) { + for (std::size_t index = 0U; index < pdu.asdus.size(); ++index) { + auto* const layout = layouts.empty() ? nullptr : &layouts[index]; + if (!write_asdu(pdu.asdus[index], destination, offset, layout)) { return {wire::EncodeStatus::value_out_of_range, 0U, *required}; } } diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index 9a871015a..f7326cc08 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -12,6 +12,7 @@ #include "ariec61850/sampled_values/stream_supervisor.hpp" #include +#include #include #include #include @@ -104,6 +105,42 @@ void sampled_values_frame_round_trips_vlan_process_bus_header() { SampledValuesPdu{{make_reference_asdu()}}}; const auto encoded = SampledValuesFrameCodec::encode(frame); + std::vector encoded_with_layout(encoded.size()); + std::array frame_layout{}; + const auto layout_result = SampledValuesFrameCodec::encode_into_with_layout( + frame, encoded_with_layout, frame_layout); + CHECK(layout_result.success()); + CHECK(layout_result.bytes_written == encoded.size()); + CHECK(encoded_with_layout == encoded); + CHECK(frame_layout[0].sample_count.present); + CHECK(frame_layout[0].sample_count.value_size == 1U); + CHECK(encoded[frame_layout[0].sample_count.value_offset] == 120U); + CHECK(frame_layout[0].configuration_revision.present); + CHECK(frame_layout[0].configuration_revision.value_size == 1U); + CHECK(encoded[frame_layout[0].configuration_revision.value_offset] == 3U); + CHECK(frame_layout[0].reference_time.present); + CHECK(frame_layout[0].reference_time.value_size == 8U); + CHECK(frame_layout[0].sample_synchronization.present); + CHECK(frame_layout[0].sample_synchronization.value_size == 1U); + CHECK(encoded[frame_layout[0].sample_synchronization.value_offset] == 2U); + CHECK(frame_layout[0].sample_rate.present); + CHECK(frame_layout[0].sample_payload.present); + CHECK(frame_layout[0].sample_payload.value_size == 16U); + CHECK(frame_layout[0].sample_mode.present); + + auto untagged_frame = frame; + untagged_frame.vlan.reset(); + const auto untagged_size = SampledValuesFrameCodec::encoded_size(untagged_frame); + CHECK(untagged_size.has_value()); + std::vector untagged_bytes(*untagged_size); + std::array untagged_layout{}; + CHECK(SampledValuesFrameCodec::encode_into_with_layout( + untagged_frame, untagged_bytes, untagged_layout).success()); + CHECK(frame_layout[0].sample_count.value_offset == + untagged_layout[0].sample_count.value_offset + 4U); + CHECK(frame_layout[0].sample_payload.value_offset == + untagged_layout[0].sample_payload.value_offset + 4U); + CHECK(to_hex(encoded) == "010CCD040001020000000002810080C888BA4001006800000000" "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" @@ -159,6 +196,41 @@ void sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input() { const SampledValuesPdu pdu{{first, second}}; const auto encoded = SampledValuesPduCodec::encode(pdu); + std::vector encoded_with_layout(encoded.size()); + std::array layouts{}; + const auto layout_result = SampledValuesPduCodec::encode_into_with_layout( + pdu, encoded_with_layout, layouts); + CHECK(layout_result.success()); + CHECK(encoded_with_layout == encoded); + CHECK(layouts[0].sample_count.present); + CHECK(layouts[1].sample_count.present); + CHECK(layouts[0].sample_count.value_offset < layouts[1].sample_count.value_offset); + CHECK(layouts[0].reference_time.present); + CHECK(layouts[0].sample_rate.present); + CHECK(layouts[0].sample_mode.present); + CHECK(!layouts[1].reference_time.present); + CHECK(!layouts[1].sample_rate.present); + CHECK(!layouts[1].sample_mode.present); + CHECK(layouts[0].sample_payload.present); + CHECK(layouts[1].sample_payload.present); + CHECK(layouts[0].sample_payload.value_size == first.sample_payload.size()); + CHECK(layouts[1].sample_payload.value_size == second.sample_payload.size()); + + std::array insufficient_layout{}; + CHECK(!SampledValuesPduCodec::encode_into_with_layout( + pdu, encoded_with_layout, insufficient_layout).success()); + + auto patched = encoded_with_layout; + CHECK(layouts[0].sample_count.value_size == 1U); + CHECK(layouts[1].sample_count.value_size == 1U); + patched[layouts[0].sample_count.value_offset] = 122U; + patched[layouts[1].sample_count.value_offset] = 123U; + SampledValuesPdu patched_decoded; + CHECK(SampledValuesPduCodec::try_decode(patched, patched_decoded)); + CHECK(patched_decoded.asdus.size() == 2U); + CHECK(patched_decoded.asdus[0].sample_count == 122U); + CHECK(patched_decoded.asdus[1].sample_count == 123U); + SampledValuesPdu decoded; CHECK(SampledValuesPduCodec::try_decode(encoded, decoded)); CHECK(decoded == pdu); From d63cec73edc6467e0d448ce06f05ad8bc7062f36 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 19:13:42 +0700 Subject: [PATCH 2/2] CI: lock deterministic SV publication boundaries Reject reintroduction of raw fixed-field scans, TX interception, smpSynch wire parsing, or a second transport-failure authority in the ESP32-P4 publication path. Tracks #139. --- .github/workflows/esp32p4-smv-injector.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 5a734db97..4c9652fa5 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -84,6 +84,16 @@ jobs: from pathlib import Path source = Path('main/app_main.cpp').read_text(encoding='utf-8') + cmake = Path('main/CMakeLists.txt').read_text(encoding='utf-8') + smp_synch = Path('main/smp_synch_lab.cpp').read_text(encoding='utf-8') + assert 'find_fixed_field' not in source + assert 'AR_SMP_SYNCH_INTERCEPT_TX' not in cmake + assert 'encode_into_with_layout' in source + assert 'sample_synchronization_offset' in source + assert 'smp_synch_lab_decision()' in source + assert 'wire_field_offsets.hpp' not in smp_synch + assert 'esp_eth_transmit' not in smp_synch + assert 'observe_canonical_tx_result' not in smp_synch start_begin = source.index('if (take_start_request())') start_end = source.index('if (!live_tx_running())', start_begin) start_block = source[start_begin:start_end] @@ -99,7 +109,7 @@ jobs: assert 'kTransportFailureStopThreshold = 400U' in source assert 'live_control_force_stop();' in source assert 'SV transport fault:' in source - print('Realtime boundary policy: PASS - post-log timer start + elapsed-time telemetry + 100 ms fail-closed canonical TX') + print('Realtime boundary policy: PASS - encoder-owned WirePlan + direct smpSynch patch + one canonical TX failure authority') PY - name: Build firmware