From 17323c0adced1fba3a2774858441b74cb7f22718 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 09:50:31 +0700 Subject: [PATCH 1/3] P3-A1: bounded ESP32-P4 binary profile decoder and STOPPED atomic activation Use the canonical V1 CRC codec; map only supported 4I+4V layouts into the existing RuntimePublisherProfile; stage <=48-byte ASCII chunks under transaction/offset bounds; commit through the single runtime generation owner. Retain legacy PROFILE control and realtime WirePlan. Add host-sim negative and positive regression coverage. Tracks #36 #33. --- embedded/esp32p4_smv_injector/README.md | 34 ++++- .../arstack_process_bus/CMakeLists.txt | 1 + .../main/binary_profile_staging.hpp | 101 +++++++++++++ .../main/compiled_profile_adapter.hpp | 81 +++++++++++ .../main/live_control.cpp | 1 + .../main/profile_control.cpp | 133 ++++++++++++++++++ .../main/profile_control.hpp | 2 + tests/test_sampled_values.cpp | 107 ++++++++++++++ 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp create mode 100644 embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp diff --git a/embedded/esp32p4_smv_injector/README.md b/embedded/esp32p4_smv_injector/README.md index d0c883fc1..4bdc28efe 100644 --- a/embedded/esp32p4_smv_injector/README.md +++ b/embedded/esp32p4_smv_injector/README.md @@ -226,4 +226,36 @@ Ethernet RX In a switched lab, the analyzer still requires the relevant frames to reach its physical port. Use a TAP or managed-switch mirror/SPAN configuration that preserves the traffic and VLAN tags. -Use an isolated bench network. Do not connect this development image to an operational process bus. \ No newline at end of file +Use an isolated bench network. Do not connect this development image to an operational process bus. + +## P3-A1 binary profile staging (engineering console, V1) + +The shared host binary envelope is accepted from SCL engineering intent only after +the existing Class-A compiler has resolved a supported device profile. Firmware +uses that same `SvDeviceProfileBinaryCodec` (magic/version/length/CRC/leaf +descriptors); it does not parse SCL or maintain a second active profile model. + +For a locally compiled binary V1 envelope, send the following STOPPED-only +commands to the firmware console (decimal transaction and byte offsets; chunk +hex without separators; max 48 raw bytes per command): + +```text +PROFILE BINBEGIN 1 +PROFILE BINCHUNK 1 0 +PROFILE BINCHUNK 1 48 +PROFILE BINCOMMIT 1 +``` + +A complete transaction is decoded and mapped into the existing +`RuntimePublisherProfile` only if the current device supports it (one ASDU, +SmpPerSec, ordered 4I+4V INT32+Quality leaves, 64-byte payload, supported +SmvOpts). The existing atomic `runtime_profile_commit()` owns generation, +while START owns packet-template preparation and the realtime timing lifecycle. + +Missing, duplicate or out-of-order chunks, replayed transaction numbers, wrong +CRC/schema, unsupported wire semantics, and RUNNING state are rejected without +changing the active stream. `PROFILE BINABORT ` cancels staging, +and START discards all unfinished profile staging. The old textual PROFILE path +remains available until Studio binary transport/ACK equivalence is independently +verified. This is a software integration gate, not physical interoperability, +PTP-lock or wire-jitter proof. diff --git a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt index b1075901e..6c91cd0fe 100644 --- a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( "${ARSTACK_ROOT}/src/mms/utc_time.cpp" "${ARSTACK_ROOT}/src/sampled_values/pdu_codec.cpp" "${ARSTACK_ROOT}/src/sampled_values/frame_codec.cpp" + "${ARSTACK_ROOT}/src/sampled_values/compiled_device_profile.cpp" "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" INCLUDE_DIRS "${ARSTACK_ROOT}/include") diff --git a/embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp b/embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp new file mode 100644 index 000000000..a84e2fc53 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/compiled_device_profile.hpp" + +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { + +// Transfer-only storage, never an alternative active SV profile authority. +// Fixed bounds fit the legacy 192-byte ASCII console using <=48-byte chunks. +class BinaryProfileStaging final { +public: + static constexpr std::size_t max_record_bytes = + ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::header_bytes + + ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::fixed_payload_bytes + + ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::leaf_descriptor_bytes * + ar::iec61850::sampled_values::compiled_sv_device_profile_max_leaves + + ar::iec61850::sampled_values::compiled_sv_device_profile_max_sv_id_bytes + + ar::iec61850::sampled_values::compiled_sv_device_profile_max_dataset_bytes; + static constexpr std::size_t max_chunk_bytes = 48U; + static constexpr std::size_t min_record_bytes = + ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::header_bytes + + ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::fixed_payload_bytes + + ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::leaf_descriptor_bytes; + + // BINCHUNK worst-case ASCII length, including decimal token/offset. + static_assert(17U + 10U + 1U + 10U + 1U + 2U * max_chunk_bytes < 192U); + + [[nodiscard]] bool begin(const std::uint32_t transaction, + const std::size_t total_bytes) noexcept { + if (active_ || transaction == 0U || transaction <= last_transaction_ || + total_bytes < min_record_bytes || total_bytes > max_record_bytes) { + return false; + } + data_.fill(std::uint8_t{0U}); + expected_ = total_bytes; + received_ = 0U; + transaction_ = transaction; + last_transaction_ = transaction; + active_ = true; + return true; + } + + [[nodiscard]] bool owns(const std::uint32_t transaction) const noexcept { + return active_ && transaction != 0U && transaction == transaction_; + } + + [[nodiscard]] bool append(const std::uint32_t transaction, + const std::size_t offset, + const std::span bytes) noexcept { + if (!owns(transaction)) return false; + if (offset != received_ || bytes.empty() || + bytes.size() > max_chunk_bytes || + bytes.size() > expected_ - received_) { + abort(); + return false; + } + std::copy(bytes.begin(), bytes.end(), data_.begin() + received_); + received_ += bytes.size(); + return true; + } + + [[nodiscard]] bool complete(const std::uint32_t transaction) const noexcept { + return owns(transaction) && received_ == expected_; + } + + [[nodiscard]] std::span complete_record( + const std::uint32_t transaction) const noexcept { + return complete(transaction) + ? std::span{data_}.first(expected_) + : std::span{}; + } + + [[nodiscard]] std::size_t received() const noexcept { return received_; } + [[nodiscard]] std::size_t expected() const noexcept { return expected_; } + [[nodiscard]] bool active() const noexcept { return active_; } + + void abort() noexcept { + data_.fill(std::uint8_t{0U}); + expected_ = 0U; + received_ = 0U; + transaction_ = 0U; + active_ = false; + // Keep last_transaction_ to reject replay inside this console session. + } + +private: + std::array data_{}; + std::size_t expected_{}; + std::size_t received_{}; + std::uint32_t transaction_{}; + std::uint32_t last_transaction_{}; + bool active_{}; +}; + +} // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp new file mode 100644 index 000000000..79b78dba6 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "runtime_profile.hpp" +#include "ariec61850/sampled_values/compiled_device_profile.hpp" + +#include +#include +#include +#include + +namespace ar::esp32p4::smv { + +enum class BinaryRuntimeProfileStatus : std::uint8_t { + ok, + invalid_envelope, + unsupported_device_capability, +}; + +// Device capability is narrower than host SCL validity. Do not reinterpret +// unsupported wire fields or invent an alternative active profile model. +[[nodiscard]] inline BinaryRuntimeProfileStatus decode_binary_runtime_profile( + const std::span bytes, + RuntimePublisherProfile& output) noexcept { + using namespace ar::iec61850::sampled_values; + CompiledSvDeviceProfile decoded{}; + if (!SvDeviceProfileBinaryCodec::decode(bytes, decoded).success()) { + return BinaryRuntimeProfileStatus::invalid_envelope; + } + if ((decoded.profile_family != SvProfileFamily::iec61850_9_2 && + decoded.profile_family != SvProfileFamily::legacy_9_2le) || + decoded.sampling_basis != SvSampleMode::samples_per_second || + decoded.no_asdu != 1U || + decoded.frame_rate_hz != decoded.configured_sample_rate || + decoded.frame_rate_hz == 0U || decoded.frame_rate_hz > 65535U || + decoded.leaf_count != 16U || decoded.payload_size_bytes != 64U || + !decoded.asdu_options.sample_synchronized || + decoded.asdu_options.refresh_time || + decoded.asdu_options.security || + decoded.asdu_options.synch_source_id) { + return BinaryRuntimeProfileStatus::unsupported_device_capability; + } + + // The current fixed 4I+4V TX template writes eight ordered INT32+Quality + // pairs. Refuse every other valid IEC layout instead of altering its wire. + for (std::size_t i = 0U; i < 8U; ++i) { + const auto& value = decoded.leaves[2U * i]; + const auto& quality = decoded.leaves[2U * i + 1U]; + if (value.wire_type != SvDeviceWireType::int32 || + value.wire_width_bytes != 4U || value.quality || value.timestamp || + quality.wire_type != SvDeviceWireType::quality || + quality.wire_width_bytes != 4U || !quality.quality || + quality.timestamp) { + return BinaryRuntimeProfileStatus::unsupported_device_capability; + } + } + + RuntimePublisherProfile candidate{}; + candidate.schema_version = 1U; + candidate.destination_mac = decoded.destination_mac; + candidate.app_id = decoded.app_id; + candidate.vlan_present = decoded.vlan_present; + candidate.vlan_id = decoded.vlan_id; + candidate.vlan_priority = decoded.vlan_priority; + candidate.configuration_revision = decoded.configuration_revision; + candidate.publisher_rate_hz = decoded.frame_rate_hz; + candidate.sample_counter_modulus = decoded.sample_counter_modulus; + candidate.no_asdu = decoded.no_asdu; + candidate.include_data_set = decoded.asdu_options.data_set; + candidate.include_sample_rate = decoded.asdu_options.sample_rate; + std::copy_n(decoded.sv_id.begin(), decoded.sv_id_length, + candidate.sv_id.begin()); + std::copy_n(decoded.data_set_reference.begin(), + decoded.data_set_reference_length, + candidate.data_set_reference.begin()); + // Generation is assigned exclusively by runtime_profile_commit(). + output = candidate; + return BinaryRuntimeProfileStatus::ok; +} + +} // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 5e962448b..2b205c124 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -432,6 +432,7 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "START rejected: Studio control-session lease is unavailable"); return; } + profile_control_abort_staging(); g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); wake_publisher(); diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index 3b6044a62..8b5455a48 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "profile_control.hpp" +#include "binary_profile_staging.hpp" +#include "compiled_profile_adapter.hpp" #include "live_control.hpp" #include "runtime_profile.hpp" @@ -19,6 +21,8 @@ #include #include #include +#include +#include #include #include #include @@ -30,6 +34,7 @@ constexpr char kTag[] = "ar_smv_profile"; constexpr char kDelimiters[] = " \t\r\n"; RuntimePublisherProfile g_staging{}; bool g_staging_active = false; +BinaryProfileStaging g_binary_staging{}; void uppercase_ascii(char* text) noexcept { if (text == nullptr) return; @@ -85,6 +90,26 @@ bool decode_mac(const char* text, std::array& mac) noexcept { return true; } + +bool decode_binary_chunk(const char* hex, + std::array& output, + std::size_t& bytes) noexcept { + bytes = 0U; + if (hex == nullptr) return false; + const std::size_t length = std::strlen(hex); + if (length == 0U || (length % 2U) != 0U || + length / 2U > output.size()) { + return false; + } + for (std::size_t i = 0U; i < length; i += 2U) { + const int high = hex_nibble(hex[i]); + const int low = hex_nibble(hex[i + 1U]); + if (high < 0 || low < 0) return false; + output[bytes++] = static_cast((high << 4) | low); + } + return true; +} + bool no_extra(char** save) noexcept { return strtok_r(nullptr, kDelimiters, save) == nullptr; } @@ -191,8 +216,102 @@ void print_profile(const RuntimePublisherProfile& profile) noexcept { profile.include_sample_rate ? 1U : 0U); } + +void handle_binary_profile_command(const char* command, char** save) noexcept { + const char* transaction_text = strtok_r(nullptr, kDelimiters, save); + std::uint32_t transaction{}; + if (!parse_u32(transaction_text, transaction) || transaction == 0U) { + ESP_LOGE(kTag, "PROFILE binary rejected: invalid transaction"); + return; + } + + if (std::strcmp(command, "BINBEGIN") == 0) { + const char* total_text = strtok_r(nullptr, kDelimiters, save); + std::uint32_t total{}; + if (!parse_u32(total_text, total) || !no_extra(save) || + g_staging_active || !g_binary_staging.begin(transaction, total)) { + ESP_LOGE(kTag, "PROFILE BINBEGIN rejected: staging busy, replay or invalid length"); + return; + } + ESP_LOGI(kTag, "PROFILE BINBEGIN transaction=%lu bytes=%lu", + static_cast(transaction), + static_cast(total)); + return; + } + + if (std::strcmp(command, "BINABORT") == 0) { + if (!no_extra(save) || !g_binary_staging.owns(transaction)) { + ESP_LOGE(kTag, "PROFILE BINABORT rejected: unknown transaction"); + return; + } + g_binary_staging.abort(); + ESP_LOGI(kTag, "PROFILE BINABORT accepted"); + return; + } + + if (std::strcmp(command, "BINCHUNK") == 0) { + const char* offset_text = strtok_r(nullptr, kDelimiters, save); + const char* hex_text = strtok_r(nullptr, kDelimiters, save); + std::uint32_t offset{}; + if (!parse_u32(offset_text, offset) || !no_extra(save) || + !g_binary_staging.owns(transaction)) { + ESP_LOGE(kTag, "PROFILE BINCHUNK rejected: unknown transaction or offset"); + return; + } + std::array chunk{}; + std::size_t count{}; + if (!decode_binary_chunk(hex_text, chunk, count) || + !g_binary_staging.append( + transaction, offset, + std::span{chunk}.first(count))) { + g_binary_staging.abort(); + ESP_LOGE(kTag, "PROFILE BINCHUNK rejected: invalid, duplicate or out-of-order bytes"); + return; + } + ESP_LOGI(kTag, "PROFILE BINCHUNK transaction=%lu received=%lu", + static_cast(transaction), + static_cast(g_binary_staging.received())); + return; + } + + if (std::strcmp(command, "BINCOMMIT") == 0) { + if (!no_extra(save) || !g_binary_staging.owns(transaction)) { + ESP_LOGE(kTag, "PROFILE BINCOMMIT rejected: unknown transaction"); + return; + } + const auto record = g_binary_staging.complete_record(transaction); + RuntimePublisherProfile candidate{}; + const auto decoded = decode_binary_runtime_profile(record, candidate); + if (!g_binary_staging.complete(transaction) || + decoded != BinaryRuntimeProfileStatus::ok || + !runtime_profile_validate(candidate) || + live_tx_running() || !runtime_profile_commit(candidate)) { + g_binary_staging.abort(); + ESP_LOGE(kTag, "PROFILE BINCOMMIT rejected: incomplete, invalid or unsupported device profile"); + return; + } + g_binary_staging.abort(); + const auto active = runtime_profile_snapshot(); + ESP_LOGI(kTag, + "PROFILE committed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u", + static_cast(active.generation), + active.sv_id.data(), + static_cast(active.app_id), + static_cast(active.publisher_rate_hz), + static_cast(active.sample_counter_modulus)); + return; + } + + ESP_LOGE(kTag, "Unknown PROFILE binary subcommand"); +} + } // namespace +void profile_control_abort_staging() noexcept { + g_binary_staging.abort(); + g_staging_active = false; +} + void handle_profile_command(char* arguments) noexcept { char* save{}; char* subcommand = strtok_r(arguments, kDelimiters, &save); @@ -260,12 +379,26 @@ void handle_profile_command(char* arguments) noexcept { } #endif + // Abort remains available even while RUNNING; all mutations require STOPPED. + if (std::strcmp(subcommand, "BINABORT") == 0) { + handle_binary_profile_command(subcommand, &save); + return; + } if (live_tx_running()) { + g_binary_staging.abort(); ESP_LOGE(kTag, "PROFILE rejected: stop the publisher before changing stream identity/layout"); return; } + if (std::strncmp(subcommand, "BIN", 3U) == 0) { + handle_binary_profile_command(subcommand, &save); + return; + } if (std::strcmp(subcommand, "BEGIN") == 0) { + if (g_binary_staging.active()) { + ESP_LOGE(kTag, "PROFILE BEGIN rejected: finish or abort binary transfer first"); + return; + } if (!no_extra(&save)) { ESP_LOGE(kTag, "Usage: PROFILE BEGIN"); return; diff --git a/embedded/esp32p4_smv_injector/main/profile_control.hpp b/embedded/esp32p4_smv_injector/main/profile_control.hpp index 542546268..7797c1efa 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.hpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.hpp @@ -8,5 +8,7 @@ namespace ar::esp32p4::smv { // The operator-facing surface remains the GUI; this text transport is a // bounded development control protocol, not a user workflow. void handle_profile_command(char* arguments) noexcept; +// Discard incomplete text/binary staging at each publisher START. +void profile_control_abort_staging() noexcept; } // namespace ar::esp32p4::smv diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index 956025f8b..e0a4dd824 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -5,6 +5,8 @@ #include "ariec61850/mms/utc_time.hpp" #include "ariec61850/sampled_values/asdu.hpp" #include "ariec61850/sampled_values/compiled_device_profile.hpp" +#include "../embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp" +#include "../embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp" #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" #include "ariec61850/sampled_values/payload_inspector.hpp" @@ -409,6 +411,110 @@ void compiled_device_profile_binary_is_canonical_and_integrity_checked() { SvDeviceProfileCodecStatus::invalid_length); } + +void compiled_binary_profile_activates_only_supported_4i4v_device_layout() { + using namespace ar::iec61850::sampled_values; + using namespace ar::esp32p4::smv; + + CompiledSvDeviceProfile device{}; + device.profile_family = SvProfileFamily::iec61850_9_2; + device.transport_mode = SvTransportMode::multicast; + device.destination_mac = {0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + device.app_id = 0x4001U; + device.vlan_present = true; + device.vlan_id = 100U; + device.vlan_priority = 4U; + device.configuration_revision = 9U; + device.sampling_basis = SvSampleMode::samples_per_second; + device.configured_sample_rate = 4800U; + device.frame_rate_hz = 4800U; + device.sample_counter_modulus = 4800U; + device.no_asdu = 1U; + device.asdu_options.element_present = true; + device.asdu_options.sample_synchronized = true; + device.asdu_options.data_set = true; + device.asdu_options.sample_rate = true; + device.payload_size_bytes = 64U; + device.leaf_count = 16U; + device.sv_id_length = 3U; + device.sv_id[0] = 'S'; device.sv_id[1] = 'V'; device.sv_id[2] = '2'; + device.data_set_reference_length = 2U; + device.data_set_reference[0] = 'D'; + device.data_set_reference[1] = 'S'; + for (std::size_t i = 0U; i < 8U; ++i) { + device.leaves[2U * i] = {SvDeviceWireType::int32, 4U, false, false}; + device.leaves[2U * i + 1U] = {SvDeviceWireType::quality, 4U, true, false}; + } + const auto size = SvDeviceProfileBinaryCodec::encoded_size(device); + CHECK(size.has_value()); + std::vector encoded(*size); + CHECK(SvDeviceProfileBinaryCodec::encode_into(device, encoded).success()); + + BinaryProfileStaging chunks; + CHECK(chunks.begin(1U, encoded.size())); + for (std::size_t offset = 0U; offset < encoded.size();) { + const auto count = std::min(BinaryProfileStaging::max_chunk_bytes, + encoded.size() - offset); + CHECK(chunks.append(1U, offset, std::span{encoded}.subspan(offset, count))); + offset += count; + } + CHECK(chunks.complete(1U)); + RuntimePublisherProfile runtime{}; + CHECK(decode_binary_runtime_profile(chunks.complete_record(1U), runtime) == + BinaryRuntimeProfileStatus::ok); + CHECK(runtime.publisher_rate_hz == 4800U); + CHECK(runtime.sample_counter_modulus == 4800U); + CHECK(runtime.configuration_revision == 9U); + CHECK(runtime.app_id == 0x4001U); + CHECK(runtime.sv_id[0] == 'S' && runtime.sv_id[2] == '2'); + CHECK(runtime.include_data_set && runtime.include_sample_rate); + CHECK(runtime.data_set_reference[0] == 'D'); + const auto previous_generation = runtime.generation; + chunks.abort(); + CHECK(!chunks.begin(1U, encoded.size())); // Replayed transaction ID. + CHECK(chunks.begin(2U, encoded.size())); + CHECK(!chunks.append(2U, 1U, std::span{encoded}.first(8U))); + CHECK(!chunks.active()); // An out-of-order chunk destroys staging. + CHECK(chunks.begin(3U, encoded.size())); + CHECK(!chunks.append(3U, 0U, std::span{encoded}.first(1U)) + || chunks.received() == 1U); + chunks.abort(); + CHECK(!chunks.complete(3U)); + + auto corrupted = encoded; + corrupted.back() ^= 0x01U; + CHECK(decode_binary_runtime_profile(corrupted, runtime) == + BinaryRuntimeProfileStatus::invalid_envelope); + CHECK(runtime.generation == previous_generation); + CHECK(runtime.sv_id[2] == '2'); + + auto unsupported = device; + unsupported.profile_family = SvProfileFamily::iec61869_9; + CHECK(SvDeviceProfileBinaryCodec::encode_into(unsupported, encoded).success()); + CHECK(decode_binary_runtime_profile(encoded, runtime) == + BinaryRuntimeProfileStatus::unsupported_device_capability); + CHECK(runtime.app_id == 0x4001U); + + unsupported = device; + unsupported.asdu_options.sample_synchronized = false; + CHECK(SvDeviceProfileBinaryCodec::encode_into(unsupported, encoded).success()); + CHECK(decode_binary_runtime_profile(encoded, runtime) == + BinaryRuntimeProfileStatus::unsupported_device_capability); + + unsupported = device; + unsupported.leaves[0] = {SvDeviceWireType::uint32, 4U, false, false}; + CHECK(SvDeviceProfileBinaryCodec::encode_into(unsupported, encoded).success()); + CHECK(decode_binary_runtime_profile(encoded, runtime) == + BinaryRuntimeProfileStatus::unsupported_device_capability); + + unsupported = device; + unsupported.frame_rate_hz = 4000U; // Cannot silently change 4800 sample cadence. + CHECK(SvDeviceProfileBinaryCodec::encode_into(unsupported, encoded).success()); + CHECK(decode_binary_runtime_profile(encoded, runtime) == + BinaryRuntimeProfileStatus::unsupported_device_capability); + CHECK(runtime.publisher_rate_hz == 4800U); +} + void sample_counter_tracker_distinguishes_wrap_gap_duplicate_and_order() { using namespace ar::iec61850::sampled_values; @@ -529,6 +635,7 @@ int main() { {"SV Ethernet frame", sampled_values_frame_round_trips_vlan_process_bus_header}, {"SV malformed input", sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input}, {"SV compiled device profile binary", compiled_device_profile_binary_is_canonical_and_integrity_checked}, + {"SV binary ESP32-P4 activation gate", compiled_binary_profile_activates_only_supported_4i4v_device_layout}, {"SV sample counter", sample_counter_tracker_distinguishes_wrap_gap_duplicate_and_order}, {"SV stream supervisor", stream_supervisor_tracks_identity_configuration_and_statistics}, {"SV quality and payload diagnostics", quality_and_generic_payload_diagnostics_preserve_wire_evidence}}; From b09b5206d5d47c82ccbef82733fe4bf004a5951a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 09:56:47 +0700 Subject: [PATCH 2/3] P3-A1 test: prove duplicate/oversize/incomplete binary chunks fail closed --- tests/test_sampled_values.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index e0a4dd824..e37923575 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -476,10 +476,21 @@ void compiled_binary_profile_activates_only_supported_4i4v_device_layout() { CHECK(!chunks.append(2U, 1U, std::span{encoded}.first(8U))); CHECK(!chunks.active()); // An out-of-order chunk destroys staging. CHECK(chunks.begin(3U, encoded.size())); - CHECK(!chunks.append(3U, 0U, std::span{encoded}.first(1U)) - || chunks.received() == 1U); + CHECK(chunks.append(3U, 0U, std::span{encoded}.first(8U))); + CHECK(!chunks.append(3U, 0U, std::span{encoded}.first(8U))); + CHECK(!chunks.active()); // Duplicate offset fails closed. + CHECK(chunks.begin(4U, encoded.size())); + CHECK(!chunks.complete(4U)); // No partial commit. + CHECK(chunks.complete_record(4U).empty()); chunks.abort(); - CHECK(!chunks.complete(3U)); + CHECK(chunks.begin(5U, encoded.size())); + CHECK(!chunks.append( + 5U, 0U, + std::span{encoded}.first( + BinaryProfileStaging::max_chunk_bytes + 1U))); + CHECK(!chunks.active()); // Oversized chunks cannot bypass the console bound. + CHECK(!chunks.begin(5U, encoded.size())); // No replay after abort. + CHECK(!chunks.begin(6U, BinaryProfileStaging::max_record_bytes + 1U)); auto corrupted = encoded; corrupted.back() ^= 0x01U; From b438d7e40538bfd54fc4e583b6558284a240d4b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 09:58:40 +0700 Subject: [PATCH 3/3] P3-A1: advertise negotiated PROFILE-BINARY-V1 firmware capability Expose binary staging in IDENTIFY and the firmware manifest without changing legacy protocol=1, textual PROFILE commands, or realtime publication. Assert release manifest capability. --- .github/workflows/esp32p4-smv-injector.yml | 3 ++- embedded/esp32p4_smv_injector/README.md | 4 ++++ embedded/esp32p4_smv_injector/main/live_control.cpp | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 4c9652fa5..f1909791d 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -142,7 +142,7 @@ jobs: "chipRevisionPolicy": "pre-v3", "version": os.environ["ARSTACK_FIRMWARE_VERSION"], "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]), - "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], + "capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, "image": image, "sha256": sha256, @@ -166,6 +166,7 @@ jobs: assert manifest['protocol'] == 1 assert 'PTP-P2' in manifest['capabilities'] assert 'SMPSYNCH-AUTO' in manifest['capabilities'] + assert 'PROFILE-BINARY-V1' in manifest['capabilities'] assert actual == manifest['sha256'] print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}") PY diff --git a/embedded/esp32p4_smv_injector/README.md b/embedded/esp32p4_smv_injector/README.md index 4bdc28efe..77a58d7d2 100644 --- a/embedded/esp32p4_smv_injector/README.md +++ b/embedded/esp32p4_smv_injector/README.md @@ -230,6 +230,10 @@ Use an isolated bench network. Do not connect this development image to an opera ## P3-A1 binary profile staging (engineering console, V1) +The optional capability is explicitly advertised as `PROFILE-BINARY-V1` in +IDENTIFY and the firmware manifest; older firmware without the token must +continue using the legacy textual PROFILE route. + The shared host binary envelope is accepted from SCL engineering intent only after the existing Class-A compiler has resolved a supported device profile. Firmware uses that same `SvDeviceProfileBinaryCodec` (magic/version/length/CRC/leaf diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 2b205c124..df02f1991 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -238,12 +238,12 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1"; #else const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PROFILE-BINARY-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PROFILE-BINARY-V1"; #endif ESP_LOGI(kTag, "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s build=%s boot_id=%016llX capabilities=%s",