Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down
6 changes: 0 additions & 6 deletions embedded/esp32p4_smv_injector/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
76 changes: 47 additions & 29 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -86,6 +89,7 @@ std::atomic<std::uint32_t> g_sample_tick_total{0U};
struct PacketTemplate final {
std::vector<std::uint8_t> bytes;
std::size_t sample_count_offset{std::numeric_limits<std::size_t>::max()};
std::size_t sample_synchronization_offset{std::numeric_limits<std::size_t>::max()};
std::size_t sample_payload_offset{std::numeric_limits<std::size_t>::max()};
};

Expand Down Expand Up @@ -204,57 +208,66 @@ SampledValuesFrame make_runtime_frame(
return frame;
}

bool find_fixed_field(
const std::vector<std::uint8_t>& 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<std::uint8_t, 6>& source_mac,
const RuntimePublisherProfile& profile,
const bool broadcast_destination,
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<SampledValueAsduEncodeLayout, 1> 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]);
Expand Down Expand Up @@ -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<std::uint8_t>(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) {
Expand All @@ -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;
Expand Down
99 changes: 14 additions & 85 deletions embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp
Original file line number Diff line number Diff line change
@@ -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 <atomic>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>

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<std::uint8_t> g_mode{
static_cast<std::uint8_t>(SvSyncPolicyMode::external_ptp_auto)};
// -1 means PTP-P2 has not supplied measured lock evidence yet.
std::atomic<int> g_measured_value{-1};
std::atomic<std::uint32_t> g_consecutive_canonical_tx_failures{0U};
std::atomic_bool g_transport_fault_reported{false};

[[nodiscard]] std::optional<SmpSynchValue> measured_value() noexcept {
const int raw = g_measured_value.load(std::memory_order_acquire);
Expand All @@ -46,51 +30,9 @@ std::atomic_bool g_transport_fault_reported{false};
}
}

[[nodiscard]] SmpSynchDecision current_decision() noexcept {
const auto mode = static_cast<SvSyncPolicyMode>(
[[nodiscard]] SvSyncPolicyMode current_mode() noexcept {
return static_cast<SvSyncPolicyMode>(
g_mode.load(std::memory_order_acquire));
return resolve_sv_sync_policy(mode, measured_value());
}

[[nodiscard]] bool diagnostic_mirror_frame(const std::span<const std::uint8_t> 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<std::uint16_t>(
(static_cast<std::uint16_t>(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<std::uint16_t>(
(static_cast<std::uint16_t>(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<unsigned long>(failures),
esp_err_to_name(result));
}
live_control_force_stop();
}

} // namespace
Expand All @@ -100,42 +42,29 @@ void smp_synch_lab_set_mode(const SvSyncPolicyMode mode) noexcept {
}

SvSyncPolicyMode smp_synch_lab_mode() noexcept {
return static_cast<SvSyncPolicyMode>(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<SmpSynchValue> value) noexcept {
if (!value.has_value()) {
g_measured_value.store(-1, std::memory_order_release);
return;
}
g_measured_value.store(static_cast<int>(static_cast<std::uint8_t>(*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<std::uint8_t>{};
if (buffer != nullptr) {
bytes = std::span<std::uint8_t>{static_cast<std::uint8_t*>(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<std::uint8_t>(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<int>(static_cast<std::uint8_t>(*value)),
std::memory_order_release);
}

} // namespace ar::esp32p4::smv
26 changes: 4 additions & 22 deletions embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@

#include "ariec61850/time_sync/smp_synch_policy.hpp"

#include "esp_eth.h"

#include <cstddef>
#include <cstdint>
#include <optional>

namespace ar::esp32p4::smv {
Expand All @@ -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<SmpSynchValue> 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
8 changes: 8 additions & 0 deletions include/ariec61850/sampled_values/frame_codec.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cstddef>
Expand All @@ -22,6 +23,13 @@ class SampledValuesFrameCodec final {
const SampledValuesFrame& frame,
std::span<std::uint8_t> 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<std::uint8_t> destination,
std::span<SampledValueAsduEncodeLayout> layouts) noexcept;

// Host convenience wrapper. Embedded SV publishers should use encode_into
// with a persistent Ethernet-sized buffer.
[[nodiscard]] static std::vector<std::uint8_t> encode(const SampledValuesFrame& frame);
Expand Down
Loading
Loading