From 2e40907b6598a3c333cbef65025892865daff215 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 21:25:33 +0700 Subject: [PATCH 1/6] P1.1: establish explicit SV transport and profile-family authority Preserve SampledValueControl multicast intent, centralize IEC SV APPID/MAC rules, require explicit profile-family context for deployment, share the fixed 9-2LE 4I+4V semantic validator, and keep IEC 61869-9 representable but non-deployable until variant/scaling rules are complete. Tracks #139. --- .../src/SclWorkspaceController_part_00.inc | 3 +- .../esp32p4_profile_support.hpp | 120 ++-------- .../sampled_values/profile_family.hpp | 86 +++++++ .../sampled_values/publisher_profile.hpp | 212 +++++++++++++++++- include/ariec61850/scl/exporter_part_02.inc | 4 +- include/ariec61850/scl/model.hpp | 6 + src/scl/parser_part_05_02.inc | 13 ++ tests/test_scl.cpp | 109 ++++++++- 8 files changed, 439 insertions(+), 114 deletions(-) create mode 100644 include/ariec61850/sampled_values/profile_family.hpp diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc index 8b603bd02..50e6a4c33 100644 --- a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc +++ b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc @@ -173,7 +173,8 @@ bool sampledValuesEquivalent( lhs.data_set_name != rhs.data_set_name || lhs.data_set_reference != rhs.data_set_reference || lhs.configuration_revision != rhs.configuration_revision || !(lhs.address == rhs.address) || !entriesEquivalent(lhs.entries, rhs.entries) || lhs.sv_id != rhs.sv_id || - lhs.smv_id != rhs.smv_id || lhs.sample_rate != rhs.sample_rate || + lhs.smv_id != rhs.smv_id || lhs.multicast != rhs.multicast || + lhs.sample_rate != rhs.sample_rate || lhs.sample_mode != rhs.sample_mode || lhs.no_asdu != rhs.no_asdu || !(lhs.smv_options == rhs.smv_options)) { return false; diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp index dd03546e8..7e324cb15 100644 --- a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -3,16 +3,15 @@ #include "ariec61850/sampled_values/publisher_profile.hpp" -#include -#include -#include #include namespace ar::iec61850::sampled_values { enum class Esp32P4SvProfileSupport { ready, + needs_profile_family_confirmation, needs_counter_confirmation, + unsupported_profile_family, unsupported_layout, }; @@ -21,8 +20,12 @@ enum class Esp32P4SvProfileSupport { switch (support) { case Esp32P4SvProfileSupport::ready: return "ready"; + case Esp32P4SvProfileSupport::needs_profile_family_confirmation: + return "needs-profile-family-confirmation"; case Esp32P4SvProfileSupport::needs_counter_confirmation: return "needs-counter-confirmation"; + case Esp32P4SvProfileSupport::unsupported_profile_family: + return "unsupported-profile-family"; case Esp32P4SvProfileSupport::unsupported_layout: return "unsupported-layout"; } @@ -30,115 +33,20 @@ enum class Esp32P4SvProfileSupport { } namespace detail { -[[nodiscard]] inline std::string sv_support_lower_copy(const std::string_view text) { - std::string result; - result.reserve(text.size()); - for (const char ch : text) { - result.push_back(static_cast( - std::tolower(static_cast(ch)))); - } - return result; -} - -[[nodiscard]] inline bool sv_reference_matches_either( - const std::string_view reference, - const std::string_view canonical, - const std::string_view legacy_9_2le) { - return reference.find(canonical) != std::string_view::npos || - reference.find(legacy_9_2le) != std::string_view::npos; -} - [[nodiscard]] inline bool esp32p4_4i4v_layout_matches( const SvPublisherProfile& profile) { const auto frame_rate_hz = profile.timing.exact_frame_rate_hz(); if (profile.timing.sampling_basis != SvSampleMode::samples_per_second || - profile.timing.asdus_per_frame != 1U || profile.payload_size_bytes != 64U || - profile.channels.size() != 16U || !frame_rate_hz.has_value() || - *frame_rate_hz == 0U || *frame_rate_hz > 65535U) { + profile.timing.asdus_per_frame != 1U || + !legacy_9_2le_4i4v_layout_matches(profile) || + !frame_rate_hz.has_value() || *frame_rate_hz == 0U || + *frame_rate_hz > 65535U) { return false; } if (profile.asdu_options.refresh_time || profile.asdu_options.security || profile.asdu_options.synch_source_id) { return false; } - - // The current ESP32-P4 runtime emits a fixed wire order: - // IA, IB, IC, IN, UA, UB, UC, UN, with one Quality word after each value. - // Do not accept a merely shape-compatible DataSet: that would silently map - // unrelated INT32 members onto the fixed injector channels. - // - // Both Amp/Vol and the 9-2LE-style AmpSv/VolSv DO naming are accepted only - // when the LN class/instance, order, width, basic type and Quality pairing - // all match the same proven 4I+4V runtime layout. - constexpr std::array expected_values{ - "tctr1.amp.instmag.i", - "tctr2.amp.instmag.i", - "tctr3.amp.instmag.i", - "tctr4.amp.instmag.i", - "tvtr1.vol.instmag.i", - "tvtr2.vol.instmag.i", - "tvtr3.vol.instmag.i", - "tvtr4.vol.instmag.i", - }; - constexpr std::array expected_values_9_2le{ - "tctr1.ampsv.instmag.i", - "tctr2.ampsv.instmag.i", - "tctr3.ampsv.instmag.i", - "tctr4.ampsv.instmag.i", - "tvtr1.volsv.instmag.i", - "tvtr2.volsv.instmag.i", - "tvtr3.volsv.instmag.i", - "tvtr4.volsv.instmag.i", - }; - constexpr std::array expected_qualities{ - "tctr1.amp.q", - "tctr2.amp.q", - "tctr3.amp.q", - "tctr4.amp.q", - "tvtr1.vol.q", - "tvtr2.vol.q", - "tvtr3.vol.q", - "tvtr4.vol.q", - }; - constexpr std::array expected_qualities_9_2le{ - "tctr1.ampsv.q", - "tctr2.ampsv.q", - "tctr3.ampsv.q", - "tctr4.ampsv.q", - "tvtr1.volsv.q", - "tvtr2.volsv.q", - "tvtr3.volsv.q", - "tvtr4.volsv.q", - }; - - for (std::size_t i = 0U; i < profile.channels.size(); ++i) { - const auto& channel = profile.channels[i]; - if (channel.wire_width_bytes != 4U) return false; - if ((i % 2U) == 0U) { - if (channel.is_quality || sv_support_lower_copy(channel.basic_type) != "int32") { - return false; - } - } else if (!channel.is_quality) { - return false; - } - } - - for (std::size_t signal = 0U; signal < expected_values.size(); ++signal) { - const auto value_reference = - sv_support_lower_copy(profile.channels[signal * 2U].signal_reference); - const auto quality_reference = - sv_support_lower_copy(profile.channels[signal * 2U + 1U].signal_reference); - if (!sv_reference_matches_either( - value_reference, - expected_values[signal], - expected_values_9_2le[signal]) || - !sv_reference_matches_either( - quality_reference, - expected_qualities[signal], - expected_qualities_9_2le[signal])) { - return false; - } - } return true; } } // namespace detail @@ -148,6 +56,14 @@ namespace detail { // the embedded-device deployment gate and deliberately fails closed. [[nodiscard]] inline Esp32P4SvProfileSupport classify_esp32p4_sv_profile( const SvPublisherProfile& profile) { + if (profile.profile_family == SvProfileFamily::unspecified || + profile.profile_family_resolution == SvProfileFamilyResolution::unresolved) { + return Esp32P4SvProfileSupport::needs_profile_family_confirmation; + } + if (profile.profile_family == SvProfileFamily::iec61869_9 || + profile.profile_family_resolution != SvProfileFamilyResolution::resolved) { + return Esp32P4SvProfileSupport::unsupported_profile_family; + } if (!detail::esp32p4_4i4v_layout_matches(profile)) { return Esp32P4SvProfileSupport::unsupported_layout; } diff --git a/include/ariec61850/sampled_values/profile_family.hpp b/include/ariec61850/sampled_values/profile_family.hpp new file mode 100644 index 000000000..7f42b6e86 --- /dev/null +++ b/include/ariec61850/sampled_values/profile_family.hpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class SvProfileFamily : std::uint8_t { + unspecified, + iec61850_9_2, + legacy_9_2le, + iec61869_9, +}; + +enum class SvProfileFamilyResolution : std::uint8_t { + unresolved, + resolved, + incomplete, +}; + +enum class SvTransportMode : std::uint8_t { + multicast, + unicast, +}; + +inline constexpr std::uint16_t sv_app_id_minimum = 0x4000U; +inline constexpr std::uint16_t sv_app_id_maximum = 0x7FFFU; + +[[nodiscard]] constexpr bool is_valid_sv_app_id(const std::uint16_t app_id) noexcept { + return app_id >= sv_app_id_minimum && app_id <= sv_app_id_maximum; +} + +[[nodiscard]] constexpr bool is_multicast_mac( + const std::array& mac) noexcept { + return (mac[0] & 0x01U) != 0U; +} + +[[nodiscard]] constexpr bool is_zero_mac( + const std::array& mac) noexcept { + return mac[0] == 0U && mac[1] == 0U && mac[2] == 0U && + mac[3] == 0U && mac[4] == 0U && mac[5] == 0U; +} + +// IEC 61850 Sampled Values multicast allocation: +// 01-0C-CD-04-00-00 .. 01-0C-CD-04-01-FF. +[[nodiscard]] constexpr bool is_iec_sv_multicast_mac( + const std::array& mac) noexcept { + return mac[0] == 0x01U && mac[1] == 0x0CU && + mac[2] == 0xCDU && mac[3] == 0x04U && + mac[4] <= 0x01U; +} + +[[nodiscard]] constexpr bool is_valid_sv_unicast_mac( + const std::array& mac) noexcept { + return !is_zero_mac(mac) && !is_multicast_mac(mac); +} + +[[nodiscard]] constexpr std::string_view sv_profile_family_name( + const SvProfileFamily family) noexcept { + switch (family) { + case SvProfileFamily::iec61850_9_2: return "iec61850-9-2"; + case SvProfileFamily::legacy_9_2le: return "9-2le"; + case SvProfileFamily::iec61869_9: return "iec61869-9"; + case SvProfileFamily::unspecified: break; + } + return "unspecified"; +} + +[[nodiscard]] constexpr std::string_view sv_profile_family_resolution_name( + const SvProfileFamilyResolution resolution) noexcept { + switch (resolution) { + case SvProfileFamilyResolution::resolved: return "resolved"; + case SvProfileFamilyResolution::incomplete: return "incomplete"; + case SvProfileFamilyResolution::unresolved: break; + } + return "unresolved"; +} + +[[nodiscard]] constexpr std::string_view sv_transport_mode_name( + const SvTransportMode mode) noexcept { + return mode == SvTransportMode::multicast ? "multicast" : "unicast"; +} + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp index 1f635ee6d..42e2ad46f 100644 --- a/include/ariec61850/sampled_values/publisher_profile.hpp +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -3,6 +3,7 @@ #pragma once #include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/profile_family.hpp" #include "ariec61850/sampled_values/timing_semantics.hpp" #include "ariec61850/scl/model.hpp" @@ -50,6 +51,10 @@ struct SvPublisherChannel final { }; struct SvPublisherProfileCompileContext final { + // Profile family is never inferred from vendor, filename, svID or DataSet + // shape. Unspecified remains inspectable but non-deployable. + SvProfileFamily profile_family{SvProfileFamily::unspecified}; + // SCL describes sampling semantics but does not universally establish // every runtime sample-counter wrap policy. A standards/profile rule or // independently observed evidence may validate a modulus explicitly. @@ -61,13 +66,17 @@ struct SvPublisherProfileCompileContext final { }; struct SvPublisherProfile final { - std::uint32_t schema_version{2U}; + std::uint32_t schema_version{3U}; std::string control_block_reference; std::string sv_id; std::string data_set_reference; std::array destination_mac{}; std::uint16_t app_id{}; + SvTransportMode transport_mode{SvTransportMode::multicast}; + SvProfileFamily profile_family{SvProfileFamily::unspecified}; + SvProfileFamilyResolution profile_family_resolution{ + SvProfileFamilyResolution::unresolved}; bool vlan_present{}; std::uint16_t vlan_id{}; std::uint8_t vlan_priority{}; @@ -84,6 +93,119 @@ struct SvPublisherProfile final { friend bool operator==(const SvPublisherProfile&, const SvPublisherProfile&) = default; }; +namespace detail { +[[nodiscard]] inline std::string profile_lower_copy(const std::string_view text) { + std::string result; + result.reserve(text.size()); + for (const char ch : text) { + result.push_back(static_cast( + std::tolower(static_cast(ch)))); + } + return result; +} + +[[nodiscard]] inline bool profile_reference_matches_either( + const std::string_view reference, + const std::string_view canonical, + const std::string_view legacy_9_2le) { + return reference.find(canonical) != std::string_view::npos || + reference.find(legacy_9_2le) != std::string_view::npos; +} +} // namespace detail + +// Shared semantic authority for the proven fixed 9-2LE-style 4I+4V layout. +// Device classifiers consume this instead of maintaining a second copy. +[[nodiscard]] inline bool legacy_9_2le_4i4v_layout_matches( + const SvPublisherProfile& profile) { + if (profile.payload_size_bytes != 64U || profile.channels.size() != 16U) { + return false; + } + + constexpr std::array expected_values{ + "tctr1.amp.instmag.i", + "tctr2.amp.instmag.i", + "tctr3.amp.instmag.i", + "tctr4.amp.instmag.i", + "tvtr1.vol.instmag.i", + "tvtr2.vol.instmag.i", + "tvtr3.vol.instmag.i", + "tvtr4.vol.instmag.i", + }; + constexpr std::array expected_values_9_2le{ + "tctr1.ampsv.instmag.i", + "tctr2.ampsv.instmag.i", + "tctr3.ampsv.instmag.i", + "tctr4.ampsv.instmag.i", + "tvtr1.volsv.instmag.i", + "tvtr2.volsv.instmag.i", + "tvtr3.volsv.instmag.i", + "tvtr4.volsv.instmag.i", + }; + constexpr std::array expected_qualities{ + "tctr1.amp.q", + "tctr2.amp.q", + "tctr3.amp.q", + "tctr4.amp.q", + "tvtr1.vol.q", + "tvtr2.vol.q", + "tvtr3.vol.q", + "tvtr4.vol.q", + }; + constexpr std::array expected_qualities_9_2le{ + "tctr1.ampsv.q", + "tctr2.ampsv.q", + "tctr3.ampsv.q", + "tctr4.ampsv.q", + "tvtr1.volsv.q", + "tvtr2.volsv.q", + "tvtr3.volsv.q", + "tvtr4.volsv.q", + }; + + for (std::size_t i = 0U; i < profile.channels.size(); ++i) { + const auto& channel = profile.channels[i]; + if (channel.wire_width_bytes != 4U) return false; + if ((i % 2U) == 0U) { + if (channel.is_quality || + detail::profile_lower_copy(channel.basic_type) != "int32") { + return false; + } + } else if (!channel.is_quality) { + return false; + } + } + + for (std::size_t signal = 0U; signal < expected_values.size(); ++signal) { + const auto value_reference = detail::profile_lower_copy( + profile.channels[signal * 2U].signal_reference); + const auto quality_reference = detail::profile_lower_copy( + profile.channels[signal * 2U + 1U].signal_reference); + if (!detail::profile_reference_matches_either( + value_reference, + expected_values[signal], + expected_values_9_2le[signal]) || + !detail::profile_reference_matches_either( + quality_reference, + expected_qualities[signal], + expected_qualities_9_2le[signal])) { + return false; + } + } + return true; +} + +[[nodiscard]] inline bool legacy_9_2le_timing_matches( + const SvPublicationTiming& timing) noexcept { + if (!timing.resolved()) return true; + const auto sample_rate = timing.exact_sample_rate_hz(); + if (!sample_rate.has_value()) return false; + const auto asdus = timing.asdus_per_frame; + return (*sample_rate == 4000U && asdus == 1U) || + (*sample_rate == 4800U && asdus == 1U) || + (*sample_rate == 12800U && asdus == 8U) || + (*sample_rate == 15360U && asdus == 8U); +} + struct SvPublisherProfileCompileResult final { std::optional profile; std::vector errors; @@ -104,6 +226,9 @@ class SvPublisherProfileCompiler final { profile.control_block_reference = stream.control_block_reference; profile.sv_id = stream.sv_id.empty() ? stream.smv_id : stream.sv_id; + profile.profile_family = context.profile_family; + profile.transport_mode = + stream.multicast ? SvTransportMode::multicast : SvTransportMode::unicast; profile.data_set_reference = stream.data_set_reference; profile.configuration_revision = stream.configuration_revision; profile.timing = resolve_sv_publication_timing( @@ -117,11 +242,25 @@ class SvPublisherProfileCompiler final { result.errors.push_back("SV stream has no valid destination MAC address."); } else { profile.destination_mac = *stream.address.destination_mac; + if (profile.transport_mode == SvTransportMode::multicast) { + if (!is_iec_sv_multicast_mac(profile.destination_mac)) { + result.errors.push_back( + "Multicast SV destination MAC must be within " + "01-0C-CD-04-00-00..01-0C-CD-04-01-FF."); + } + } else if (!is_valid_sv_unicast_mac(profile.destination_mac)) { + result.errors.push_back( + "Unicast SV destination MAC must be a nonzero unicast Ethernet address."); + } } if (!stream.address.app_id.has_value()) { result.errors.push_back("SV stream has no valid APPID."); } else { profile.app_id = *stream.address.app_id; + if (!is_valid_sv_app_id(profile.app_id)) { + result.errors.push_back( + "SV APPID must be within the IEC 61850-9-2 allocation 0x4000..0x7FFF."); + } } const bool has_vlan_id = stream.address.vlan_id.has_value(); @@ -224,6 +363,7 @@ class SvPublisherProfileCompiler final { }); } profile.payload_size_bytes = payload_size; + apply_profile_family_rules(profile, result); if (result.errors.empty()) { result.profile = std::move(profile); @@ -233,13 +373,7 @@ class SvPublisherProfileCompiler final { private: [[nodiscard]] static std::string lower_copy(const std::string_view text) { - std::string result; - result.reserve(text.size()); - for (const char ch : text) { - result.push_back(static_cast( - std::tolower(static_cast(ch)))); - } - return result; + return detail::profile_lower_copy(text); } [[nodiscard]] static SvSampleMode parse_sample_mode(const std::string_view text) { @@ -286,6 +420,68 @@ class SvPublisherProfileCompiler final { return std::nullopt; } + static void apply_profile_family_rules( + SvPublisherProfile& profile, + SvPublisherProfileCompileResult& result) { + switch (profile.profile_family) { + case SvProfileFamily::unspecified: + profile.profile_family_resolution = SvProfileFamilyResolution::unresolved; + result.warnings.push_back( + "SV profile family is unspecified; select IEC 61850-9-2, " + "legacy 9-2LE, or IEC 61869-9 explicitly before deployment."); + return; + + case SvProfileFamily::iec61850_9_2: + profile.profile_family_resolution = SvProfileFamilyResolution::resolved; + return; + + case SvProfileFamily::legacy_9_2le: { + bool valid = true; + if (profile.transport_mode != SvTransportMode::multicast) { + result.errors.push_back( + "Legacy 9-2LE compatibility target requires multicast Sampled Values."); + valid = false; + } + if (profile.app_id != 0x4000U) { + result.errors.push_back( + "Legacy 9-2LE compatibility target requires APPID 0x4000."); + valid = false; + } + if (!legacy_9_2le_4i4v_layout_matches(profile)) { + result.errors.push_back( + "Legacy 9-2LE compatibility target requires the fixed 4I+4V " + "INT32+Quality DataSet layout."); + valid = false; + } + if (!legacy_9_2le_timing_matches(profile.timing)) { + result.errors.push_back( + "Legacy 9-2LE compatibility target requires the supported " + "80/256 samples-per-cycle packetization families."); + valid = false; + } + profile.profile_family_resolution = valid + ? SvProfileFamilyResolution::resolved + : SvProfileFamilyResolution::unresolved; + if (valid) { + result.warnings.push_back( + "Legacy 9-2LE compatibility rules are applied as an explicit " + "interoperability target; this is not a formal UCA conformance claim."); + } + return; + } + + case SvProfileFamily::iec61869_9: + // P1.1 deliberately stops at transport/address family identity. + // Scaling, configurable variant constraints and full IEC 61869-9 + // dataset rules are a later authority and must not be guessed here. + profile.profile_family_resolution = SvProfileFamilyResolution::incomplete; + result.warnings.push_back( + "IEC 61869-9 family selected: transport/address semantics are represented, " + "but scaling and variant rules are not yet complete; deployment remains blocked."); + return; + } + } + [[nodiscard]] static SvAsduOptions compile_options( const scl::SclSmvOptions& options) { return { diff --git a/include/ariec61850/scl/exporter_part_02.inc b/include/ariec61850/scl/exporter_part_02.inc index 72c684e68..73c9aa581 100644 --- a/include/ariec61850/scl/exporter_part_02.inc +++ b/include/ariec61850/scl/exporter_part_02.inc @@ -71,9 +71,9 @@ inline void write_goose_and_smv( << " nofASDU=\"" << stream.no_asdu << "\""; if (!stream.sample_mode.empty()) out << " smpMod=\"" << xml_escape(stream.sample_mode) << "\""; if (!stream.smv_options.element_present) { - out << " multicast=\"true\" />\n"; + out << " multicast=\"" << bool_text(stream.multicast) << "\" />\n"; } else { - out << " multicast=\"true\">\n" + out << " multicast=\"" << bool_text(stream.multicast) << "\">\n" << " entries : data_set->expanded_entries; } + const auto multicast_text = attribute(control, "multicast"); + if (multicast_text.empty() || same(multicast_text, "true")) { + stream.multicast = true; + } else if (same(multicast_text, "false")) { + stream.multicast = false; + } else { + document.warnings.push_back( + "SV " + stream.control_block_reference + + " has invalid multicast='" + multicast_text + + "'; IEC 61850-6 default true retained."); + stream.multicast = true; + } + stream.sample_rate = ushort_attribute(*control, "smpRate"); stream.sample_mode = attribute(control, "smpMod"); stream.no_asdu = std::max( diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index 9fe7bef64..13fb85de8 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -197,6 +197,7 @@ void parser_compiles_structured_4800_sv_profile_without_drift() { } SvPublisherProfileCompileContext context; + context.profile_family = SvProfileFamily::iec61850_9_2; context.sample_counter_modulus = static_cast(4800U); const auto compiled = SvPublisherProfileCompiler::compile(stream, context); CHECK(compiled.ok()); @@ -206,7 +207,10 @@ void parser_compiles_structured_4800_sv_profile_without_drift() { CHECK(profile.vlan_present); CHECK(profile.vlan_id == 0U); CHECK(profile.vlan_priority == 4U); - CHECK(profile.schema_version == 2U); + CHECK(profile.schema_version == 3U); + CHECK(profile.profile_family == SvProfileFamily::iec61850_9_2); + CHECK(profile.profile_family_resolution == SvProfileFamilyResolution::resolved); + CHECK(profile.transport_mode == SvTransportMode::multicast); CHECK(profile.configuration_revision == 100U); CHECK(profile.timing.sampling_basis == SvSampleMode::samples_per_second); CHECK(profile.timing.configured_sample_rate == 4800U); @@ -282,6 +286,7 @@ void publisher_profile_separates_sampling_from_frame_cadence() { const auto source = document.sampled_values_streams.front(); SvPublisherProfileCompileContext context; + context.profile_family = SvProfileFamily::iec61850_9_2; context.sample_counter_modulus = static_cast(4800U); auto two_asdu = source; @@ -398,6 +403,106 @@ void publisher_profile_separates_sampling_from_frame_cadence() { slow_stream.profile->timing.samples_per_second); } +void sampled_values_profile_family_and_transport_are_explicit() { + using namespace ar::iec61850::sampled_values; + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); + CHECK(document.sampled_values_streams.size() == 1U); + const auto source = document.sampled_values_streams.front(); + CHECK(source.multicast); + + SvPublisherProfileCompileContext context; + context.profile_family = SvProfileFamily::iec61850_9_2; + context.sample_counter_modulus = static_cast(4800U); + + const auto generic = SvPublisherProfileCompiler::compile(source, context); + CHECK(generic.ok()); + CHECK(generic.profile.has_value()); + CHECK(generic.profile->profile_family == SvProfileFamily::iec61850_9_2); + CHECK(generic.profile->profile_family_resolution == SvProfileFamilyResolution::resolved); + CHECK(generic.profile->transport_mode == SvTransportMode::multicast); + CHECK(is_valid_sv_app_id(generic.profile->app_id)); + CHECK(is_iec_sv_multicast_mac(generic.profile->destination_mac)); + + auto unicast = source; + unicast.multicast = false; + unicast.address.destination_mac = + std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + unicast.address.destination_mac_text = "02:00:00:00:00:01"; + const auto unicast_compiled = SvPublisherProfileCompiler::compile(unicast, context); + CHECK(unicast_compiled.ok()); + CHECK(unicast_compiled.profile.has_value()); + CHECK(unicast_compiled.profile->transport_mode == SvTransportMode::unicast); + + auto bad_multicast = source; + bad_multicast.address.destination_mac = + std::array{0x01U, 0x0CU, 0xCDU, 0x05U, 0x00U, 0x01U}; + CHECK(!SvPublisherProfileCompiler::compile(bad_multicast, context).ok()); + + auto bad_unicast = source; + bad_unicast.multicast = false; + CHECK(!SvPublisherProfileCompiler::compile(bad_unicast, context).ok()); + + auto bad_appid = source; + bad_appid.address.app_id = static_cast(0x3FFFU); + CHECK(!SvPublisherProfileCompiler::compile(bad_appid, context).ok()); + + context.profile_family = SvProfileFamily::legacy_9_2le; + CHECK(!SvPublisherProfileCompiler::compile(source, context).ok()); + + auto legacy = source; + legacy.address.app_id = static_cast(0x4000U); + legacy.address.app_id_text = "4000"; + const auto legacy_result = SvPublisherProfileCompiler::compile(legacy, context); + CHECK(legacy_result.ok()); + CHECK(legacy_result.profile.has_value()); + CHECK(legacy_result.profile->profile_family_resolution == + SvProfileFamilyResolution::resolved); + CHECK(legacy_9_2le_4i4v_layout_matches(*legacy_result.profile)); + CHECK(legacy_9_2le_timing_matches(legacy_result.profile->timing)); + + context.profile_family = SvProfileFamily::iec61869_9; + const auto iec61869 = SvPublisherProfileCompiler::compile(source, context); + CHECK(iec61869.ok()); + CHECK(iec61869.profile.has_value()); + CHECK(iec61869.profile->profile_family_resolution == + SvProfileFamilyResolution::incomplete); + CHECK(std::any_of( + iec61869.warnings.begin(), iec61869.warnings.end(), + [](const std::string& warning) { + return warning.find("deployment remains blocked") != std::string::npos; + })); + + context.profile_family = SvProfileFamily::unspecified; + const auto unspecified = SvPublisherProfileCompiler::compile(source, context); + CHECK(unspecified.ok()); + CHECK(unspecified.profile.has_value()); + CHECK(unspecified.profile->profile_family_resolution == + SvProfileFamilyResolution::unresolved); +} + +void parser_preserves_sampled_value_multicast_semantics() { + using namespace ar::iec61850::scl; + + constexpr std::string_view xml = R"xml( + + + + + + + +)xml"; + + const auto document = SclParser{}.parse(xml, "sv-transport.scd"); + CHECK(document.sampled_values_streams.size() == 2U); + CHECK(!document.sampled_values_streams[0].multicast); + CHECK(document.sampled_values_streams[1].multicast); +} + void dataset_reference_resolver_accepts_canonical_and_local_forms() { using namespace ar::iec61850::scl; @@ -679,6 +784,8 @@ int main() { {"SCL multi-stream", parser_extracts_multiple_sampled_values_streams_and_conflicts}, {"SCL structured 4800 SV profile", parser_compiles_structured_4800_sv_profile_without_drift}, {"SV sampling and frame cadence semantics", publisher_profile_separates_sampling_from_frame_cadence}, + {"SV profile family and transport semantics", sampled_values_profile_family_and_transport_are_explicit}, + {"SCL SV multicast semantics", parser_preserves_sampled_value_multicast_semantics}, {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms}, {"SCL configured control model", parser_preserves_configured_control_model_value}, {"Simulator semantic defaults and indexed RCBs", simulator_compiles_semantic_defaults_and_indexed_report_instances}, From bb36dca0e59891915634695bc4be9807e0fd4731 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 21:36:43 +0700 Subject: [PATCH 2/6] P1.1: wire explicit SV family resolution through engineering surfaces Expose profile-family and transport state in Studio/CLI, keep arbitrary SCL unresolved until the engineer selects a family, wire the legacy GUI and its CI to the same CLI authority, retain IEC 61869-9 as non-deployable until its profile rules mature, and regression-test multicast parse/export preservation. Tracks #139. --- .github/workflows/smv-injector-gui.yml | 14 ++++- NORTH_STAR.md | 8 ++- apps/arstack_studio/qml/ProfileInspector.qml | 57 +++++++++++++++++++ apps/arstack_studio/src/SclProfileModel.cpp | 55 ++++++++++++++++++ apps/arstack_studio/src/SclProfileModel.hpp | 3 + apps/smv_injector_gui/host_server.py | 8 +++ apps/smv_injector_gui/profile_bridge.js | 27 ++++++++- docs/SV_INTEROPERABILITY_ROADMAP.md | 20 ++++--- embedded/esp32p4_smv_injector/ARCHITECTURE.md | 7 ++- tests/test_scl.cpp | 15 +++++ tools/smv_profile_inspect.cpp | 39 +++++++++++-- 11 files changed, 231 insertions(+), 22 deletions(-) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 1a96d3934..4198b0783 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -6,6 +6,7 @@ on: - 'apps/smv_injector_gui/**' - 'tools/smv_profile_inspect.cpp' - 'include/ariec61850/sampled_values/publisher_profile.hpp' + - 'include/ariec61850/sampled_values/profile_family.hpp' - 'include/ariec61850/scl/**' - 'src/scl/**' - 'tests/fixtures/scl/**' @@ -15,6 +16,7 @@ on: - 'apps/smv_injector_gui/**' - 'tools/smv_profile_inspect.cpp' - 'include/ariec61850/sampled_values/publisher_profile.hpp' + - 'include/ariec61850/sampled_values/profile_family.hpp' - 'include/ariec61850/scl/**' - 'src/scl/**' - 'tests/fixtures/scl/**' @@ -67,10 +69,16 @@ jobs: fixture=tests/fixtures/scl/sv-4800-structured-4i4v.scd build-smv-gui/ariec61850_smv_profile_inspect "$fixture" > /tmp/profile-b.json grep -q '"compatibilityClass":"B"' /tmp/profile-b.json + grep -q '"deviceSupport":"needs-profile-family-confirmation"' /tmp/profile-b.json grep -q '"publisherRateHz":4800' /tmp/profile-b.json - build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --counter-modulus 4800 > /tmp/profile-a.json + build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --profile-family iec61850-9-2 --counter-modulus 4800 > /tmp/profile-a.json grep -q '"compatibilityClass":"A"' /tmp/profile-a.json + grep -q '"profileFamily":"iec61850-9-2"' /tmp/profile-a.json grep -q '"deviceSupport":"ready"' /tmp/profile-a.json + build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --profile-family iec61869-9 --counter-modulus 4800 > /tmp/profile-61869.json + grep -q '"compatibilityClass":"B"' /tmp/profile-61869.json + grep -q '"profileFamilyResolution":"incomplete"' /tmp/profile-61869.json + grep -q '"deviceSupport":"unsupported-profile-family"' /tmp/profile-61869.json - name: Local backend smoke shell: bash @@ -86,7 +94,7 @@ jobs: curl --fail --silent -X POST \ -H 'X-File-Name: fixture.scd' \ --data-binary @tests/fixtures/scl/sv-4800-structured-4i4v.scd \ - 'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800' >/tmp/api.json + 'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800&profileFamily=iec61850-9-2' >/tmp/api.json grep -q '"deviceSupport":"ready"' /tmp/api.json windows-launcher-smoke: @@ -108,7 +116,7 @@ jobs: run: | $tool = Join-Path $PWD 'build-smv-gui\ariec61850_smv_profile_inspect.exe' if (-not (Test-Path $tool)) { throw "profile compiler missing: $tool" } - & $tool 'tests\fixtures\scl\sv-4800-structured-4i4v.scd' '--counter-modulus' '4800' | Tee-Object -FilePath profile-a.json + & $tool 'tests\fixtures\scl\sv-4800-structured-4i4v.scd' '--profile-family' 'iec61850-9-2' '--counter-modulus' '4800' | Tee-Object -FilePath profile-a.json if (-not (Select-String -Path profile-a.json -SimpleMatch '"deviceSupport":"ready"')) { throw 'Windows profile compiler did not produce a deployable Class-A fixture result' } diff --git a/NORTH_STAR.md b/NORTH_STAR.md index 55832f468..5214576fd 100644 --- a/NORTH_STAR.md +++ b/NORTH_STAR.md @@ -102,12 +102,14 @@ ARStack must track standards explicitly rather than use “IEC 61850” as a gen | Redundancy | IEC 62439-3 | PRP/HSR observation, publication and validation roadmap | | IEC 61850 security | IEC 62351-6 | Security-aware future protocol/tooling boundary | -Profile labels are not interchangeable: +Profile labels are not interchangeable and are never inferred from vendor, filename, `svID`, or DataSet shape: -1. **Generic IEC 61850-9-2 / SCL-driven** — exact selected SCL stream drives the output. -2. **IEC 61869-9** — only when its constraints are explicitly implemented and evidenced. +1. **Generic IEC 61850-9-2 / SCL-driven** — exact selected SCL stream and its multicast/unicast engineering intent drive the output. +2. **IEC 61869-9** — representable as an explicit family, but deployable only when its scaling/variant constraints are explicitly implemented and evidenced. 3. **Legacy 9-2LE compatibility** — an explicit compatibility mode, never silently promoted to the generic standard. +SV link identity is also gated independently: APPID belongs to the SV allocation and multicast publication must use the IEC Sampled Values multicast MAC block. Device capability remains a separate gate from standards/profile validity. + ## 5. Core architecture principles ### 5.1 Separate facts, expectations, claims and synchronization diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 0bb8a11de..558ebc830 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -190,6 +190,15 @@ SurfacePanel { Label { Layout.fillWidth: true; text: panel.profile.dataSetReference || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideMiddle } Label { text: "MAC / APPID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } Label { Layout.fillWidth: true; text: (panel.profile.destinationMac || "—") + " · " + (panel.profile.appIdHex || "—"); color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideRight } + Label { text: "Profile"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { + Layout.fillWidth: true + text: (panel.profile.profileFamily || "unspecified") + " · " + (panel.profile.transportMode || "—") + color: panel.profile.profileFamilyResolution === "resolved" ? panel.theme.textSoft : panel.theme.amber + font.family: panel.monoFont + font.pixelSize: panel.theme.captionSize + elide: Text.ElideRight + } Label { text: "Sampling"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } Label { text: panel.profile.sampleRate @@ -250,6 +259,54 @@ SurfacePanel { font.letterSpacing: 0.9 } + ColumnLayout { + visible: !panel.profiles.referenceTemplateActive + Layout.fillWidth: true + spacing: 5 + + Label { + Layout.fillWidth: true + text: "Profile family is explicit. ARStack never infers it from vendor, filename, svID, or DataSet shape." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + wrapMode: Text.WordWrap + } + + ComboBox { + id: profileFamilySelector + Layout.fillWidth: true + enabled: panel.session && panel.session.engineeringEditable + model: ["Select family…", "IEC 61850-9-2", "Legacy 9-2LE", "IEC 61869-9"] + currentIndex: panel.profile.profileFamily === "iec61850-9-2" ? 1 + : panel.profile.profileFamily === "9-2le" ? 2 + : panel.profile.profileFamily === "iec61869-9" ? 3 : 0 + font.family: panel.uiFont + font.pixelSize: panel.theme.labelSize + onActivated: { + var keys = ["unspecified", "iec61850-9-2", "9-2le", "iec61869-9"] + if (panel.profiles.selectProfileFamily(keys[currentIndex])) { + panel.controller.profileDirty = true + panel.controller.showMessage("SV profile family updated.", false) + } + } + } + } + + ColumnLayout { + visible: panel.profile.profileFamilyResolution === "incomplete" + Layout.fillWidth: true + spacing: 5 + Label { + Layout.fillWidth: true + text: "IEC 61869-9 transport/address semantics are represented, but scaling and variant rules are not complete yet. Device deployment remains blocked." + color: panel.theme.amber + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + wrapMode: Text.WordWrap + } + } + ColumnLayout { visible: panel.profile.timingResolved === false && panel.profile.sampleMode === "SmpPerPeriod" diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 3cdfce097..f017c009c 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -19,10 +19,15 @@ using ar::iec61850::sampled_values::Esp32P4SvProfileSupport; using ar::iec61850::sampled_values::SvPublisherProfile; using ar::iec61850::sampled_values::SvPublisherProfileCompileContext; using ar::iec61850::sampled_values::SvPublisherProfileCompiler; +using ar::iec61850::sampled_values::SvProfileFamily; +using ar::iec61850::sampled_values::SvProfileFamilyResolution; using ar::iec61850::sampled_values::SvSampleCounterPolicy; using ar::iec61850::sampled_values::SvSampleMode; using ar::iec61850::sampled_values::classify_esp32p4_sv_profile; using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name; +using ar::iec61850::sampled_values::sv_profile_family_name; +using ar::iec61850::sampled_values::sv_profile_family_resolution_name; +using ar::iec61850::sampled_values::sv_transport_mode_name; constexpr std::uint16_t kReferenceCounterModulus = 4000; constexpr auto kReferenceResource = ":/arstack/templates/arstack_4i4v_9-2le_reference.scd"; @@ -59,6 +64,16 @@ QString sampleModeText(const SvSampleMode mode) { } } +QString profileFamilyText(const SvProfileFamily family) { + const auto value = sv_profile_family_name(family); + return QString::fromLatin1(value.data(), static_cast(value.size())); +} + +QString profileFamilyResolutionText(const SvProfileFamilyResolution resolution) { + const auto value = sv_profile_family_resolution_name(resolution); + return QString::fromLatin1(value.data(), static_cast(value.size())); +} + QString counterPolicyText(const SvSampleCounterPolicy policy) { switch (policy) { case SvSampleCounterPolicy::explicit_modulus: return QStringLiteral("explicit"); @@ -193,6 +208,9 @@ void SclProfileModel::installDocument( ? std::optional{kReferenceCounterModulus} : std::nullopt; confirmedNominalFrequencyMilliHz_.reset(); + profileFamily_ = referenceTemplate + ? SvProfileFamily::legacy_9_2le + : SvProfileFamily::unspecified; rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -254,6 +272,7 @@ void SclProfileModel::clear() { document_.reset(); confirmedCounterModulus_.reset(); confirmedNominalFrequencyMilliHz_.reset(); + profileFamily_ = SvProfileFamily::unspecified; rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -299,6 +318,33 @@ void SclProfileModel::clearNominalFrequency() { rebuildRows(); } +bool SclProfileModel::selectProfileFamily(const QString& familyKey) { + if (!document_.has_value()) return false; + const auto key = familyKey.trimmed().toLower(); + SvProfileFamily selected = SvProfileFamily::unspecified; + if (key.isEmpty() || key == QStringLiteral("unspecified")) { + selected = SvProfileFamily::unspecified; + } else if (key == QStringLiteral("iec61850-9-2")) { + selected = SvProfileFamily::iec61850_9_2; + } else if (key == QStringLiteral("9-2le")) { + selected = SvProfileFamily::legacy_9_2le; + } else if (key == QStringLiteral("iec61869-9")) { + selected = SvProfileFamily::iec61869_9; + } else { + return false; + } + + // The built-in reference is an explicitly named 9-2LE compatibility + // artifact, not a family-detection heuristic. + if (referenceTemplateActive_ && selected != SvProfileFamily::legacy_9_2le) { + return false; + } + if (profileFamily_ == selected) return true; + profileFamily_ = selected; + rebuildRows(); + return true; +} + void SclProfileModel::rebuildRows() { if (!document_.has_value()) return; @@ -309,6 +355,7 @@ void SclProfileModel::rebuildRows() { for (const auto& stream : document_->sampled_values_streams) { SvPublisherProfileCompileContext context; + context.profile_family = profileFamily_; context.sample_counter_modulus = confirmedCounterModulus_; context.nominal_frequency_millihz = confirmedNominalFrequencyMilliHz_; auto compiled = SvPublisherProfileCompiler::compile(stream, context); @@ -327,6 +374,7 @@ void SclProfileModel::rebuildRows() { row.profile = std::move(compiled.profile); const auto& profile = *row.profile; row.compatibilityClass = + profile.profile_family_resolution == SvProfileFamilyResolution::resolved && profile.timing.resolved() && profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus ? QStringLiteral("A") @@ -356,6 +404,13 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co map.insert(QStringLiteral("appId"), static_cast(p.app_id)); map.insert(QStringLiteral("appIdHex"), QStringLiteral("0x%1").arg(p.app_id, 4, 16, QLatin1Char('0')).toUpper()); + map.insert(QStringLiteral("profileFamily"), profileFamilyText(p.profile_family)); + map.insert(QStringLiteral("profileFamilyResolution"), + profileFamilyResolutionText(p.profile_family_resolution)); + const auto transportMode = sv_transport_mode_name(p.transport_mode); + map.insert(QStringLiteral("transportMode"), + QString::fromLatin1( + transportMode.data(), static_cast(transportMode.size()))); map.insert(QStringLiteral("vlanPresent"), p.vlan_present); map.insert(QStringLiteral("vlanId"), static_cast(p.vlan_id)); map.insert(QStringLiteral("vlanPriority"), static_cast(p.vlan_priority)); diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index 0971692e8..bc9d3e80c 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -64,6 +64,7 @@ class SclProfileModel : public QAbstractListModel { Q_INVOKABLE void clearCounterConfirmation(); Q_INVOKABLE bool confirmNominalFrequencyHz(int frequencyHz); Q_INVOKABLE void clearNominalFrequency(); + Q_INVOKABLE bool selectProfileFamily(const QString& familyKey); signals: void sourceChanged(); @@ -91,6 +92,8 @@ class SclProfileModel : public QAbstractListModel { std::optional document_; std::optional confirmedCounterModulus_; std::optional confirmedNominalFrequencyMilliHz_; + ar::iec61850::sampled_values::SvProfileFamily profileFamily_{ + ar::iec61850::sampled_values::SvProfileFamily::unspecified}; std::vector rows_; int selectedIndex_{-1}; QString fatalError_; diff --git a/apps/smv_injector_gui/host_server.py b/apps/smv_injector_gui/host_server.py index e047e077d..1d7ed5805 100644 --- a/apps/smv_injector_gui/host_server.py +++ b/apps/smv_injector_gui/host_server.py @@ -94,6 +94,12 @@ def do_POST(self) -> None: body = self.rfile.read(length) query = urllib.parse.parse_qs(parsed.query) counter = query.get("counterModulus", [None])[0] + profile_family = query.get("profileFamily", [None])[0] + if profile_family is not None and profile_family not in { + "iec61850-9-2", "9-2le", "iec61869-9" + }: + self._json(400, {"fatalError": "Unsupported SV profile family."}) + return if counter is not None: try: value = int(counter, 10) @@ -114,6 +120,8 @@ def do_POST(self) -> None: temp_path = pathlib.Path(handle.name) command = [str(self.server.profile_tool), str(temp_path)] + if profile_family is not None: + command += ["--profile-family", profile_family] if counter is not None: command += ["--counter-modulus", counter] completed = subprocess.run( diff --git a/apps/smv_injector_gui/profile_bridge.js b/apps/smv_injector_gui/profile_bridge.js index e3de88b4b..21b5a46ac 100644 --- a/apps/smv_injector_gui/profile_bridge.js +++ b/apps/smv_injector_gui/profile_bridge.js @@ -4,6 +4,7 @@ const profileBridge = { file: null, inspection: null, selectedIndex: 0, + profileFamily: "", deployed: false, deploying: false, currentCountsPerAmp: 1000, @@ -42,6 +43,12 @@ function installProfileUi() {
+
Destination—
APPID—
Sample rate—
@@ -74,6 +81,11 @@ function installProfileUi() { profileBridge.deployed = false; renderProfileSelection(); }); + $("profileFamilySelect").addEventListener("change", async () => { + profileBridge.profileFamily = $("profileFamilySelect").value; + profileBridge.deployed = false; + await inspectEngineeringFile(null); + }); $("validateProfileButton").addEventListener("click", validateCounterPolicy); $("deployProfileButton").addEventListener("click", deploySelectedProfile); $("currentScale").addEventListener("change", updateScaling); @@ -83,6 +95,8 @@ function installProfileUi() { const file = els.sclFile.files?.[0]; if (!file) return; profileBridge.file = file; + profileBridge.profileFamily = ""; + $("profileFamilySelect").value = ""; profileBridge.deployed = false; await inspectEngineeringFile(null); }); @@ -145,8 +159,11 @@ async function inspectEngineeringFile(counterModulus) { els.sclState.textContent = `${file.name} · compiling…`; $("bridgeMessages").textContent = "Running smart IEC 61850 profile compiler…"; try { - const query = counterModulus ? `?counterModulus=${encodeURIComponent(counterModulus)}` : ""; - const response = await fetch(`/api/scl/inspect${query}`, { + const query = new URLSearchParams(); + if (counterModulus) query.set("counterModulus", String(counterModulus)); + if (profileBridge.profileFamily) query.set("profileFamily", profileBridge.profileFamily); + const suffix = query.toString() ? `?${query.toString()}` : ""; + const response = await fetch(`/api/scl/inspect${suffix}`, { method: "POST", headers: { "Content-Type": "application/xml", "X-File-Name": file.name }, body: await file.arrayBuffer(), @@ -201,13 +218,17 @@ function renderProfileSelection() { } $("bridgeTitle").textContent = p.svID || stream.controlBlockReference || "SV profile"; + $("profileFamilySelect").value = p.profileFamily === "unspecified" + ? profileBridge.profileFamily + : p.profileFamily; $("compatBadge").textContent = `CLASS ${stream.compatibilityClass}`; $("compatBadge").dataset.class = stream.compatibilityClass; $("bridgeMac").textContent = p.destinationMac; $("bridgeAppid").textContent = `0x${Number(p.appID).toString(16).toUpperCase().padStart(4, "0")}`; $("bridgeRate").textContent = p.publisherRateHz ? `${p.publisherRateHz} fps` : `${p.sampleRate} ${p.sampleMode}`; $("bridgePayload").textContent = `${p.payloadBytes} B · ${p.channels.length} leaves`; - $("bridgeSupport").textContent = stream.deviceSupport; + $("bridgeSupport").textContent = + `${stream.deviceSupport} · ${p.transportMode || "—"}`; $("counterConfirm").hidden = stream.compatibilityClass === "A"; $("scalingRow").hidden = false; if (stream.compatibilityClass !== "A" && p.counterModulus) { diff --git a/docs/SV_INTEROPERABILITY_ROADMAP.md b/docs/SV_INTEROPERABILITY_ROADMAP.md index 6a68cd892..7ccc1d20e 100644 --- a/docs/SV_INTEROPERABILITY_ROADMAP.md +++ b/docs/SV_INTEROPERABILITY_ROADMAP.md @@ -97,11 +97,14 @@ The practical product rule is: **parse broadly, explain normalization, deploy na 4. **Optional fields are profile data.** `SmvOpts` is preserved so canonical ASDU field presence is not inferred from what a particular analyzer prefers. 5. **Timing is rational, not truncated.** A requested rate such as 4800 samples/s must not become a fixed 208 us period. The scheduler distributes integer timer ticks so long-term rate has no truncation drift. 6. **Sampling cadence is not frame cadence.** `SmpRate` and `SmpMod` define sampling semantics; `nofASDU` defines how many consecutive ASDUs are carried by one publication. The host derives both samples/s and Ethernet frames/s explicitly instead of storing one ambiguous publisher rate. -7. **Counter policy is independently validated.** `SmpPerSec` can establish sample cadence, but it must not be treated as universal proof of the `smpCnt` wrap rule. A rate-sized modulus may be shown as a candidate for inspection; device deployment requires an applicable profile rule or observed-evidence confirmation. -8. **Synchronization is evidence-driven.** Configuration and packet shape cannot promote `smpSynch`; measured clock state controls synchronization claims. -9. **Diagnostic traffic is separate.** Bench mirrors/probes must never change canonical stream identity or semantics. -10. **Regression evidence is vendor-neutral.** Real lab configurations may inform anonymous fixtures, but proprietary configuration files, commercial names and product comparisons are not committed. -11. **Tolerance is bounded.** Representation quirks can be normalized when equivalence is known; unresolved mandatory semantics remain non-deployable. +7. **Transport intent is preserved.** `SampledValueControl@multicast` is engineering data. Missing means the IEC 61850-6 default `true`; an explicit `false` remains unicast through parse, compile and export rather than being rewritten as multicast. +8. **Profile family is explicit.** Generic IEC 61850-9-2, legacy 9-2LE compatibility and IEC 61869-9 are separate rule families. ARStack never selects one from vendor, filename, `svID` or DataSet shape. +9. **SV addressing is standards-gated.** APPID must be in the SV allocation `0x4000..0x7FFF`; multicast SV uses the IEC SV multicast MAC block, while unicast requires a nonzero unicast destination. +10. **Counter policy is independently validated.** `SmpPerSec` can establish sample cadence, but it must not be treated as universal proof of the `smpCnt` wrap rule. A rate-sized modulus may be shown as a candidate for inspection; device deployment requires an applicable profile rule or observed-evidence confirmation. +11. **Synchronization is evidence-driven.** Configuration and packet shape cannot promote `smpSynch`; measured clock state controls synchronization claims. +12. **Diagnostic traffic is separate.** Bench mirrors/probes must never change canonical stream identity or semantics. +13. **Regression evidence is vendor-neutral.** Real lab configurations may inform anonymous fixtures, but proprietary configuration files, commercial names and product comparisons are not committed. +14. **Tolerance is bounded.** Representation quirks can be normalized when equivalence is known; unresolved mandatory semantics remain non-deployable. ## Compatibility classes @@ -109,7 +112,9 @@ The practical product rule is: **parse broadly, explain normalization, deploy na A stream can be compiled completely: -- destination MAC and APPID resolved; +- destination MAC and APPID resolved and valid for the configured SV transport; +- multicast/unicast intent preserved from engineering data; +- profile family explicitly selected and its implemented rules resolved; - VLAN binding either complete or absent; - svID resolved; - sample rate/mode understood; @@ -129,7 +134,8 @@ The stream is structurally understood but deployment needs additional context. E - samples-per-period mode without nominal system frequency; - an unresolved sample-counter wrap policy; - a capture-derived runtime behavior that conflicts with an older engineering file; -- a profile family whose optional-field semantics require an explicit selection. +- a profile family that has not been selected explicitly; +- IEC 61869-9 selected before its scaling/variant rules are completely implemented and evidenced. Class B is not guessed. The host UI/CLI must request, resolve or validate the missing context explicitly before deployment. diff --git a/embedded/esp32p4_smv_injector/ARCHITECTURE.md b/embedded/esp32p4_smv_injector/ARCHITECTURE.md index fda69c04b..02a3794f8 100644 --- a/embedded/esp32p4_smv_injector/ARCHITECTURE.md +++ b/embedded/esp32p4_smv_injector/ARCHITECTURE.md @@ -52,7 +52,8 @@ The current physically exercised flow is: engineering file -> C++ SCL parser -> resolved SV stream - -> Class A after required counter-policy confirmation + -> explicit profile-family selection + -> Class A after family/timing/counter-policy resolution -> Deploy while STOPPED -> ESP32-P4 profile commit -> START @@ -82,6 +83,8 @@ SvPublisherProfile dataSetReference destinationMac appId + transportMode + profileFamily / familyResolution vlanPresent / vlanId / vlanPriority confRev configuredSampleRate / samplingBasis @@ -94,6 +97,8 @@ SvPublisherProfile The first embedded bridge intentionally supports a bounded subset: +- an explicitly resolved supported profile family; unresolved family context fails closed; +- IEC 61869-9 remains non-deployable until its scaling/variant authority is complete; - one ASDU per Ethernet frame; - resolved absolute sample cadence and frame cadence from `SmpPerSec`; - explicit validated sample-counter modulus; diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index 13fb85de8..903296d32 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -3,6 +3,7 @@ #include "ariec61850/sampled_values/publisher_profile.hpp" #include "ariec61850/sampled_values/rational_schedule.hpp" #include "ariec61850/scl/dataset_reference.hpp" +#include "ariec61850/scl/exporter.hpp" #include "ariec61850/scl/parser.hpp" #include "ariec61850/simulation/ied_simulator_profile.hpp" @@ -501,6 +502,20 @@ void parser_preserves_sampled_value_multicast_semantics() { CHECK(document.sampled_values_streams.size() == 2U); CHECK(!document.sampled_values_streams[0].multicast); CHECK(document.sampled_values_streams[1].multicast); + + auto canonical_document = + SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); + CHECK(canonical_document.sampled_values_streams.size() == 1U); + canonical_document.sampled_values_streams.front().multicast = false; + SclCanonicalExportOptions options; + options.edition = canonical_document.edition; + options.profile = SclExportProfile::scd; + const auto exported = SclExporter::canonical(canonical_document, options); + CHECK(exported.success); + CHECK(exported.xml.find("multicast=\"false\"") != std::string::npos); + const auto reparsed = SclParser{}.parse(exported.xml, "sv-transport-roundtrip.scd"); + CHECK(reparsed.sampled_values_streams.size() == 1U); + CHECK(!reparsed.sampled_values_streams.front().multicast); } void dataset_reference_resolver_accepts_canonical_and_local_forms() { diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp index bc84abd97..ec8fcfc44 100644 --- a/tools/smv_profile_inspect.cpp +++ b/tools/smv_profile_inspect.cpp @@ -18,11 +18,16 @@ namespace { using ar::iec61850::sampled_values::SvPublisherProfile; using ar::iec61850::sampled_values::SvPublisherProfileCompileContext; using ar::iec61850::sampled_values::SvPublisherProfileCompiler; +using ar::iec61850::sampled_values::SvProfileFamily; +using ar::iec61850::sampled_values::SvProfileFamilyResolution; using ar::iec61850::sampled_values::SvSampleCounterPolicy; using ar::iec61850::sampled_values::SvSampleMode; using ar::iec61850::sampled_values::SvTimingResolution; using ar::iec61850::sampled_values::classify_esp32p4_sv_profile; using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name; +using ar::iec61850::sampled_values::sv_profile_family_name; +using ar::iec61850::sampled_values::sv_profile_family_resolution_name; +using ar::iec61850::sampled_values::sv_transport_mode_name; using ar::iec61850::scl::SclDocument; using ar::iec61850::scl::SclEdition; @@ -122,6 +127,10 @@ void emit_profile(std::ostream& out, const SvPublisherProfile& p) { out << "\"dataSetReference\":"; quoted(out, p.data_set_reference); out << ','; out << "\"destinationMac\":"; quoted(out, mac_text(p.destination_mac)); out << ','; out << "\"appID\":" << p.app_id << ','; + out << "\"profileFamily\":"; quoted(out, sv_profile_family_name(p.profile_family)); out << ','; + out << "\"profileFamilyResolution\":"; + quoted(out, sv_profile_family_resolution_name(p.profile_family_resolution)); out << ','; + out << "\"transportMode\":"; quoted(out, sv_transport_mode_name(p.transport_mode)); out << ','; out << "\"vlanPresent\":" << (p.vlan_present ? "true" : "false") << ','; out << "\"vlanID\":" << p.vlan_id << ','; out << "\"vlanPriority\":" << static_cast(p.vlan_priority) << ','; @@ -187,10 +196,11 @@ void emit_profile(std::ostream& out, const SvPublisherProfile& p) { void emit_document( std::ostream& out, const SclDocument& document, + const SvProfileFamily profile_family, const std::optional counter_modulus, const std::optional nominal_frequency_millihz) { out << '{'; - out << "\"schemaVersion\":2,"; + out << "\"schemaVersion\":3,"; out << "\"source\":"; quoted(out, document.source_name); out << ','; out << "\"edition\":"; quoted(out, edition_name(document.edition)); out << ','; out << "\"headerID\":"; quoted(out, document.header_id); out << ','; @@ -211,6 +221,7 @@ void emit_document( if (index != 0U) out << ','; const auto& stream = document.sampled_values_streams[index]; SvPublisherProfileCompileContext context; + context.profile_family = profile_family; context.sample_counter_modulus = counter_modulus; context.nominal_frequency_millihz = nominal_frequency_millihz; const auto compiled = SvPublisherProfileCompiler::compile(stream, context); @@ -220,6 +231,7 @@ void emit_document( if (compiled.ok()) { const auto& profile = *compiled.profile; compatibility = + profile.profile_family_resolution == SvProfileFamilyResolution::resolved && profile.timing.resolved() && profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus ? "A" : "B"; @@ -243,6 +255,14 @@ void emit_document( out << "]}"; } +std::optional parse_profile_family(const std::string_view text) { + if (text == "iec61850-9-2") return SvProfileFamily::iec61850_9_2; + if (text == "9-2le") return SvProfileFamily::legacy_9_2le; + if (text == "iec61869-9") return SvProfileFamily::iec61869_9; + if (text == "unspecified") return SvProfileFamily::unspecified; + return std::nullopt; +} + std::optional parse_u32_nonzero(const std::string_view text) { try { const auto value = std::stoull(std::string{text}); @@ -266,14 +286,22 @@ std::optional parse_u16(const std::string_view text) { int main(int argc, char** argv) { if (argc < 2) { - std::cerr << "usage: ariec61850_smv_profile_inspect [--counter-modulus N] [--nominal-frequency-hz N]\n"; + std::cerr << "usage: ariec61850_smv_profile_inspect [--profile-family iec61850-9-2|9-2le|iec61869-9] [--counter-modulus N] [--nominal-frequency-hz N]\n"; return 2; } + SvProfileFamily profile_family = SvProfileFamily::unspecified; std::optional counter_modulus; std::optional nominal_frequency_millihz; for (int i = 2; i < argc; ++i) { - if (std::string_view{argv[i]} == "--counter-modulus" && i + 1 < argc) { + if (std::string_view{argv[i]} == "--profile-family" && i + 1 < argc) { + const auto parsed = parse_profile_family(argv[++i]); + if (!parsed.has_value() || *parsed == SvProfileFamily::unspecified) { + std::cerr << "invalid profile family\n"; + return 2; + } + profile_family = *parsed; + } else if (std::string_view{argv[i]} == "--counter-modulus" && i + 1 < argc) { counter_modulus = parse_u16(argv[++i]); if (!counter_modulus) { std::cerr << "invalid counter modulus\n"; @@ -295,11 +323,12 @@ int main(int argc, char** argv) { try { const auto document = ar::iec61850::scl::SclParser{}.load( std::filesystem::path{argv[1]}); - emit_document(std::cout, document, counter_modulus, nominal_frequency_millihz); + emit_document( + std::cout, document, profile_family, counter_modulus, nominal_frequency_millihz); std::cout << '\n'; return 0; } catch (const std::exception& error) { - std::cout << "{\"schemaVersion\":2,\"fatalError\":"; + std::cout << "{\"schemaVersion\":3,\"fatalError\":"; quoted(std::cout, error.what()); std::cout << "}\n"; return 1; From 69ea742943f1dc0828b056f28eee1b29379ee34c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 21:45:17 +0700 Subject: [PATCH 3/6] P1.1 review: make SV profile-family resolution per stream Keep profile-family context attached to each SV stream instead of the whole document/session. Add per-stream CLI overrides, propagate the same map through the legacy GUI backend, and regression-test mixed resolved/unresolved streams so one selection cannot silently classify another. Tracks #139. --- .github/workflows/smv-injector-gui.yml | 13 ++++- NORTH_STAR.md | 2 +- apps/arstack_studio/src/SclProfileModel.cpp | 27 ++++++---- apps/arstack_studio/src/SclProfileModel.hpp | 3 +- apps/smv_injector_gui/host_server.py | 17 ++++-- apps/smv_injector_gui/profile_bridge.js | 20 ++++--- docs/SV_INTEROPERABILITY_ROADMAP.md | 2 +- embedded/esp32p4_smv_injector/ARCHITECTURE.md | 4 +- tools/smv_profile_inspect.cpp | 52 +++++++++++++++++-- 9 files changed, 110 insertions(+), 30 deletions(-) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 4198b0783..b1d8e0219 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -80,6 +80,17 @@ jobs: grep -q '"profileFamilyResolution":"incomplete"' /tmp/profile-61869.json grep -q '"deviceSupport":"unsupported-profile-family"' /tmp/profile-61869.json + multi=tests/fixtures/scl/compact-multi-stream.scd + build-smv-gui/ariec61850_smv_profile_inspect "$multi" --stream-profile-family 0:iec61850-9-2 --counter-modulus 4000 > /tmp/profile-multi.json + python3 - <<'PY' + import json + data = json.load(open('/tmp/profile-multi.json', encoding='utf-8')) + assert len(data['streams']) == 2 + assert data['streams'][0]['profile']['profileFamily'] == 'iec61850-9-2' + assert data['streams'][1]['profile']['profileFamily'] == 'unspecified' + assert data['streams'][1]['compatibilityClass'] == 'B' + PY + - name: Local backend smoke shell: bash run: | @@ -94,7 +105,7 @@ jobs: curl --fail --silent -X POST \ -H 'X-File-Name: fixture.scd' \ --data-binary @tests/fixtures/scl/sv-4800-structured-4i4v.scd \ - 'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800&profileFamily=iec61850-9-2' >/tmp/api.json + 'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800&profileFamilies=0:iec61850-9-2' >/tmp/api.json grep -q '"deviceSupport":"ready"' /tmp/api.json windows-launcher-smoke: diff --git a/NORTH_STAR.md b/NORTH_STAR.md index 5214576fd..0c48545f5 100644 --- a/NORTH_STAR.md +++ b/NORTH_STAR.md @@ -102,7 +102,7 @@ ARStack must track standards explicitly rather than use “IEC 61850” as a gen | Redundancy | IEC 62439-3 | PRP/HSR observation, publication and validation roadmap | | IEC 61850 security | IEC 62351-6 | Security-aware future protocol/tooling boundary | -Profile labels are not interchangeable and are never inferred from vendor, filename, `svID`, or DataSet shape: +Profile labels are not interchangeable, are resolved per stream, and are never inferred from vendor, filename, `svID`, or DataSet shape: 1. **Generic IEC 61850-9-2 / SCL-driven** — exact selected SCL stream and its multicast/unicast engineering intent drive the output. 2. **IEC 61869-9** — representable as an explicit family, but deployable only when its scaling/variant constraints are explicitly implemented and evidenced. diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index f017c009c..82b73be2b 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -208,9 +208,10 @@ void SclProfileModel::installDocument( ? std::optional{kReferenceCounterModulus} : std::nullopt; confirmedNominalFrequencyMilliHz_.reset(); - profileFamily_ = referenceTemplate - ? SvProfileFamily::legacy_9_2le - : SvProfileFamily::unspecified; + profileFamilies_.assign( + document_->sampled_values_streams.size(), + referenceTemplate ? SvProfileFamily::legacy_9_2le + : SvProfileFamily::unspecified); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -272,7 +273,7 @@ void SclProfileModel::clear() { document_.reset(); confirmedCounterModulus_.reset(); confirmedNominalFrequencyMilliHz_.reset(); - profileFamily_ = SvProfileFamily::unspecified; + profileFamilies_.clear(); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -319,7 +320,10 @@ void SclProfileModel::clearNominalFrequency() { } bool SclProfileModel::selectProfileFamily(const QString& familyKey) { - if (!document_.has_value()) return false; + if (!document_.has_value() || selectedIndex_ < 0 || + selectedIndex_ >= static_cast(profileFamilies_.size())) { + return false; + } const auto key = familyKey.trimmed().toLower(); SvProfileFamily selected = SvProfileFamily::unspecified; if (key.isEmpty() || key == QStringLiteral("unspecified")) { @@ -339,8 +343,9 @@ bool SclProfileModel::selectProfileFamily(const QString& familyKey) { if (referenceTemplateActive_ && selected != SvProfileFamily::legacy_9_2le) { return false; } - if (profileFamily_ == selected) return true; - profileFamily_ = selected; + auto& current = profileFamilies_[static_cast(selectedIndex_)]; + if (current == selected) return true; + current = selected; rebuildRows(); return true; } @@ -353,9 +358,13 @@ void SclProfileModel::rebuildRows() { rows_.clear(); rows_.reserve(document_->sampled_values_streams.size()); - for (const auto& stream : document_->sampled_values_streams) { + for (std::size_t streamIndex = 0U; + streamIndex < document_->sampled_values_streams.size(); ++streamIndex) { + const auto& stream = document_->sampled_values_streams[streamIndex]; SvPublisherProfileCompileContext context; - context.profile_family = profileFamily_; + context.profile_family = streamIndex < profileFamilies_.size() + ? profileFamilies_[streamIndex] + : SvProfileFamily::unspecified; context.sample_counter_modulus = confirmedCounterModulus_; context.nominal_frequency_millihz = confirmedNominalFrequencyMilliHz_; auto compiled = SvPublisherProfileCompiler::compile(stream, context); diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index bc9d3e80c..8c52cb2ae 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -92,8 +92,7 @@ class SclProfileModel : public QAbstractListModel { std::optional document_; std::optional confirmedCounterModulus_; std::optional confirmedNominalFrequencyMilliHz_; - ar::iec61850::sampled_values::SvProfileFamily profileFamily_{ - ar::iec61850::sampled_values::SvProfileFamily::unspecified}; + std::vector profileFamilies_; std::vector rows_; int selectedIndex_{-1}; QString fatalError_; diff --git a/apps/smv_injector_gui/host_server.py b/apps/smv_injector_gui/host_server.py index 1d7ed5805..0bd6773fb 100644 --- a/apps/smv_injector_gui/host_server.py +++ b/apps/smv_injector_gui/host_server.py @@ -95,11 +95,20 @@ def do_POST(self) -> None: query = urllib.parse.parse_qs(parsed.query) counter = query.get("counterModulus", [None])[0] profile_family = query.get("profileFamily", [None])[0] - if profile_family is not None and profile_family not in { - "iec61850-9-2", "9-2le", "iec61869-9" - }: + allowed_families = {"iec61850-9-2", "9-2le", "iec61869-9"} + if profile_family is not None and profile_family not in allowed_families: self._json(400, {"fatalError": "Unsupported SV profile family."}) return + stream_family_tokens = [] + family_overrides = query.get("profileFamilies", [None])[0] + if family_overrides: + for token in family_overrides.split(","): + index, separator, family = token.partition(":") + if (not separator or not index.isdigit() or + family not in allowed_families): + self._json(400, {"fatalError": "Invalid per-stream SV profile family."}) + return + stream_family_tokens.append(f"{int(index)}:{family}") if counter is not None: try: value = int(counter, 10) @@ -122,6 +131,8 @@ def do_POST(self) -> None: command = [str(self.server.profile_tool), str(temp_path)] if profile_family is not None: command += ["--profile-family", profile_family] + for token in stream_family_tokens: + command += ["--stream-profile-family", token] if counter is not None: command += ["--counter-modulus", counter] completed = subprocess.run( diff --git a/apps/smv_injector_gui/profile_bridge.js b/apps/smv_injector_gui/profile_bridge.js index 21b5a46ac..1b09ce2b5 100644 --- a/apps/smv_injector_gui/profile_bridge.js +++ b/apps/smv_injector_gui/profile_bridge.js @@ -4,7 +4,7 @@ const profileBridge = { file: null, inspection: null, selectedIndex: 0, - profileFamily: "", + profileFamilies: {}, deployed: false, deploying: false, currentCountsPerAmp: 1000, @@ -82,7 +82,9 @@ function installProfileUi() { renderProfileSelection(); }); $("profileFamilySelect").addEventListener("change", async () => { - profileBridge.profileFamily = $("profileFamilySelect").value; + const family = $("profileFamilySelect").value; + if (family) profileBridge.profileFamilies[profileBridge.selectedIndex] = family; + else delete profileBridge.profileFamilies[profileBridge.selectedIndex]; profileBridge.deployed = false; await inspectEngineeringFile(null); }); @@ -95,7 +97,7 @@ function installProfileUi() { const file = els.sclFile.files?.[0]; if (!file) return; profileBridge.file = file; - profileBridge.profileFamily = ""; + profileBridge.profileFamilies = {}; $("profileFamilySelect").value = ""; profileBridge.deployed = false; await inspectEngineeringFile(null); @@ -161,7 +163,11 @@ async function inspectEngineeringFile(counterModulus) { try { const query = new URLSearchParams(); if (counterModulus) query.set("counterModulus", String(counterModulus)); - if (profileBridge.profileFamily) query.set("profileFamily", profileBridge.profileFamily); + const familyOverrides = Object.entries(profileBridge.profileFamilies) + .filter(([, family]) => Boolean(family)) + .map(([index, family]) => `${index}:${family}`) + .join(","); + if (familyOverrides) query.set("profileFamilies", familyOverrides); const suffix = query.toString() ? `?${query.toString()}` : ""; const response = await fetch(`/api/scl/inspect${suffix}`, { method: "POST", @@ -218,9 +224,9 @@ function renderProfileSelection() { } $("bridgeTitle").textContent = p.svID || stream.controlBlockReference || "SV profile"; - $("profileFamilySelect").value = p.profileFamily === "unspecified" - ? profileBridge.profileFamily - : p.profileFamily; + $("profileFamilySelect").value = + profileBridge.profileFamilies[profileBridge.selectedIndex] + || (p.profileFamily === "unspecified" ? "" : p.profileFamily); $("compatBadge").textContent = `CLASS ${stream.compatibilityClass}`; $("compatBadge").dataset.class = stream.compatibilityClass; $("bridgeMac").textContent = p.destinationMac; diff --git a/docs/SV_INTEROPERABILITY_ROADMAP.md b/docs/SV_INTEROPERABILITY_ROADMAP.md index 7ccc1d20e..e3fcaaf19 100644 --- a/docs/SV_INTEROPERABILITY_ROADMAP.md +++ b/docs/SV_INTEROPERABILITY_ROADMAP.md @@ -98,7 +98,7 @@ The practical product rule is: **parse broadly, explain normalization, deploy na 5. **Timing is rational, not truncated.** A requested rate such as 4800 samples/s must not become a fixed 208 us period. The scheduler distributes integer timer ticks so long-term rate has no truncation drift. 6. **Sampling cadence is not frame cadence.** `SmpRate` and `SmpMod` define sampling semantics; `nofASDU` defines how many consecutive ASDUs are carried by one publication. The host derives both samples/s and Ethernet frames/s explicitly instead of storing one ambiguous publisher rate. 7. **Transport intent is preserved.** `SampledValueControl@multicast` is engineering data. Missing means the IEC 61850-6 default `true`; an explicit `false` remains unicast through parse, compile and export rather than being rewritten as multicast. -8. **Profile family is explicit.** Generic IEC 61850-9-2, legacy 9-2LE compatibility and IEC 61869-9 are separate rule families. ARStack never selects one from vendor, filename, `svID` or DataSet shape. +8. **Profile family is explicit per stream.** Generic IEC 61850-9-2, legacy 9-2LE compatibility and IEC 61869-9 are separate rule families. ARStack never selects one from vendor, filename, `svID` or DataSet shape, and one stream's selection cannot silently classify another stream. 9. **SV addressing is standards-gated.** APPID must be in the SV allocation `0x4000..0x7FFF`; multicast SV uses the IEC SV multicast MAC block, while unicast requires a nonzero unicast destination. 10. **Counter policy is independently validated.** `SmpPerSec` can establish sample cadence, but it must not be treated as universal proof of the `smpCnt` wrap rule. A rate-sized modulus may be shown as a candidate for inspection; device deployment requires an applicable profile rule or observed-evidence confirmation. 11. **Synchronization is evidence-driven.** Configuration and packet shape cannot promote `smpSynch`; measured clock state controls synchronization claims. diff --git a/embedded/esp32p4_smv_injector/ARCHITECTURE.md b/embedded/esp32p4_smv_injector/ARCHITECTURE.md index 02a3794f8..2d79018d1 100644 --- a/embedded/esp32p4_smv_injector/ARCHITECTURE.md +++ b/embedded/esp32p4_smv_injector/ARCHITECTURE.md @@ -52,8 +52,8 @@ The current physically exercised flow is: engineering file -> C++ SCL parser -> resolved SV stream - -> explicit profile-family selection - -> Class A after family/timing/counter-policy resolution + -> explicit per-stream profile-family selection + -> Class A after that stream's family/timing/counter-policy resolution -> Deploy while STOPPED -> ESP32-P4 profile commit -> START diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp index ec8fcfc44..d03d03c76 100644 --- a/tools/smv_profile_inspect.cpp +++ b/tools/smv_profile_inspect.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -196,7 +197,8 @@ void emit_profile(std::ostream& out, const SvPublisherProfile& p) { void emit_document( std::ostream& out, const SclDocument& document, - const SvProfileFamily profile_family, + const SvProfileFamily default_profile_family, + const std::map& stream_profile_families, const std::optional counter_modulus, const std::optional nominal_frequency_millihz) { out << '{'; @@ -221,7 +223,10 @@ void emit_document( if (index != 0U) out << ','; const auto& stream = document.sampled_values_streams[index]; SvPublisherProfileCompileContext context; - context.profile_family = profile_family; + const auto selected_family = stream_profile_families.find(index); + context.profile_family = selected_family != stream_profile_families.end() + ? selected_family->second + : default_profile_family; context.sample_counter_modulus = counter_modulus; context.nominal_frequency_millihz = nominal_frequency_millihz; const auto compiled = SvPublisherProfileCompiler::compile(stream, context); @@ -263,6 +268,29 @@ std::optional parse_profile_family(const std::string_view text) return std::nullopt; } +std::optional> parse_stream_profile_family( + const std::string_view text) { + const auto separator = text.find(':'); + if (separator == std::string_view::npos || separator == 0U || + separator + 1U >= text.size()) { + return std::nullopt; + } + try { + const auto index_value = std::stoull(std::string{text.substr(0U, separator)}); + if (index_value > std::numeric_limits::max()) { + return std::nullopt; + } + const auto family = parse_profile_family(text.substr(separator + 1U)); + if (!family.has_value() || *family == SvProfileFamily::unspecified) { + return std::nullopt; + } + return std::pair{ + static_cast(index_value), *family}; + } catch (...) { + return std::nullopt; + } +} + std::optional parse_u32_nonzero(const std::string_view text) { try { const auto value = std::stoull(std::string{text}); @@ -286,11 +314,12 @@ std::optional parse_u16(const std::string_view text) { int main(int argc, char** argv) { if (argc < 2) { - std::cerr << "usage: ariec61850_smv_profile_inspect [--profile-family iec61850-9-2|9-2le|iec61869-9] [--counter-modulus N] [--nominal-frequency-hz N]\n"; + std::cerr << "usage: ariec61850_smv_profile_inspect [--profile-family FAMILY] [--stream-profile-family INDEX:FAMILY] [--counter-modulus N] [--nominal-frequency-hz N]\n"; return 2; } SvProfileFamily profile_family = SvProfileFamily::unspecified; + std::map stream_profile_families; std::optional counter_modulus; std::optional nominal_frequency_millihz; for (int i = 2; i < argc; ++i) { @@ -301,6 +330,13 @@ int main(int argc, char** argv) { return 2; } profile_family = *parsed; + } else if (std::string_view{argv[i]} == "--stream-profile-family" && i + 1 < argc) { + const auto parsed = parse_stream_profile_family(argv[++i]); + if (!parsed.has_value()) { + std::cerr << "invalid stream profile family\n"; + return 2; + } + stream_profile_families[parsed->first] = parsed->second; } else if (std::string_view{argv[i]} == "--counter-modulus" && i + 1 < argc) { counter_modulus = parse_u16(argv[++i]); if (!counter_modulus) { @@ -323,8 +359,16 @@ int main(int argc, char** argv) { try { const auto document = ar::iec61850::scl::SclParser{}.load( std::filesystem::path{argv[1]}); + for (const auto& [index, family] : stream_profile_families) { + (void)family; + if (index >= document.sampled_values_streams.size()) { + std::cerr << "stream profile family index out of range\n"; + return 2; + } + } emit_document( - std::cout, document, profile_family, counter_modulus, nominal_frequency_millihz); + std::cout, document, profile_family, stream_profile_families, + counter_modulus, nominal_frequency_millihz); std::cout << '\n'; return 0; } catch (const std::exception& error) { From 2cb0f8a819df81375b88bbf27db175a14bfe7cb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 6 Oct 2026 21:47:38 +0700 Subject: [PATCH 4/6] CI: make mixed-stream family regression cardinality-agnostic The compact SV fixture contains three streams. Assert the semantic invariant directly: stream 0 may be explicitly resolved while every remaining stream stays unspecified/Class B, without hard-coding the fixture cardinality. Tracks #139. --- .github/workflows/smv-injector-gui.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index b1d8e0219..a73b2417c 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -85,10 +85,11 @@ jobs: python3 - <<'PY' import json data = json.load(open('/tmp/profile-multi.json', encoding='utf-8')) - assert len(data['streams']) == 2 + assert len(data['streams']) >= 2 assert data['streams'][0]['profile']['profileFamily'] == 'iec61850-9-2' - assert data['streams'][1]['profile']['profileFamily'] == 'unspecified' - assert data['streams'][1]['compatibilityClass'] == 'B' + for stream in data['streams'][1:]: + assert stream['profile']['profileFamily'] == 'unspecified' + assert stream['compatibilityClass'] == 'B' PY - name: Local backend smoke From ff0ec6f8891a65151c1cab9fbc994778958e46f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:18:37 +0700 Subject: [PATCH 5/6] P1.1 review: fail closed on malformed SV transport intent Preserve whether SampledValueControl@multicast was syntactically valid, reject malformed explicit transport input during profile compilation, and block canonical export from silently normalizing invalid SCL into a deployable configuration. Tracks #139. --- .../sampled_values/publisher_profile.hpp | 5 ++++ include/ariec61850/scl/exporter_part_01.inc | 7 +++++ include/ariec61850/scl/model.hpp | 3 ++ src/scl/parser_part_05_02.inc | 5 +++- tests/test_scl.cpp | 28 ++++++++++++++++++- 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp index 42e2ad46f..5eb223263 100644 --- a/include/ariec61850/sampled_values/publisher_profile.hpp +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -229,6 +229,11 @@ class SvPublisherProfileCompiler final { profile.profile_family = context.profile_family; profile.transport_mode = stream.multicast ? SvTransportMode::multicast : SvTransportMode::unicast; + if (!stream.multicast_valid) { + result.errors.push_back( + "SV SampledValueControl has an invalid explicit multicast attribute; " + "transport mode must not be inferred from malformed engineering input."); + } profile.data_set_reference = stream.data_set_reference; profile.configuration_revision = stream.configuration_revision; profile.timing = resolve_sv_publication_timing( diff --git a/include/ariec61850/scl/exporter_part_01.inc b/include/ariec61850/scl/exporter_part_01.inc index b8fdae597..46f512da1 100644 --- a/include/ariec61850/scl/exporter_part_01.inc +++ b/include/ariec61850/scl/exporter_part_01.inc @@ -39,6 +39,13 @@ inline bool validate_model( return false; } } + for (const auto& stream : document.sampled_values_streams) { + if (!stream.multicast_valid) { + error = "Canonical SCL export rejected " + stream.control_block_reference + + ": SampledValueControl@multicast was explicitly malformed in the source."; + return false; + } + } for (const auto& entry : document.model_entries) { if (entry.ied_name.empty() || entry.ld_inst.empty() || entry.ln_class.empty() || entry.do_name.empty() || entry.da_name.empty() || diff --git a/include/ariec61850/scl/model.hpp b/include/ariec61850/scl/model.hpp index f97e3efd6..dbfcff88e 100644 --- a/include/ariec61850/scl/model.hpp +++ b/include/ariec61850/scl/model.hpp @@ -173,6 +173,9 @@ struct SclSampledValuesStream final : SclProcessBusStream { // Preserve the resolved engineering semantic so exporters/profile compilers // do not silently force one transport mode. bool multicast{true}; + // False only when an explicit SampledValueControl@multicast token was + // malformed. Omission is valid and resolves to the IEC default true. + bool multicast_valid{true}; std::uint16_t sample_rate{}; std::string sample_mode; diff --git a/src/scl/parser_part_05_02.inc b/src/scl/parser_part_05_02.inc index ba750d010..acfc83e10 100644 --- a/src/scl/parser_part_05_02.inc +++ b/src/scl/parser_part_05_02.inc @@ -11,14 +11,17 @@ const auto multicast_text = attribute(control, "multicast"); if (multicast_text.empty() || same(multicast_text, "true")) { stream.multicast = true; + stream.multicast_valid = true; } else if (same(multicast_text, "false")) { stream.multicast = false; + stream.multicast_valid = true; } else { document.warnings.push_back( "SV " + stream.control_block_reference + " has invalid multicast='" + multicast_text + - "'; IEC 61850-6 default true retained."); + "'; default true is retained for inspection only and deployment/export is blocked."); stream.multicast = true; + stream.multicast_valid = false; } stream.sample_rate = ushort_attribute(*control, "smpRate"); diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index 903296d32..2765bfd60 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -484,6 +484,7 @@ void sampled_values_profile_family_and_transport_are_explicit() { } void parser_preserves_sampled_value_multicast_semantics() { + using namespace ar::iec61850::sampled_values; using namespace ar::iec61850::scl; constexpr std::string_view xml = R"xml( @@ -494,14 +495,33 @@ void parser_preserves_sampled_value_multicast_semantics() { smpMod="SmpPerSec" nofASDU="1" multicast="false"/> + )xml"; const auto document = SclParser{}.parse(xml, "sv-transport.scd"); - CHECK(document.sampled_values_streams.size() == 2U); + CHECK(document.sampled_values_streams.size() == 3U); CHECK(!document.sampled_values_streams[0].multicast); + CHECK(document.sampled_values_streams[0].multicast_valid); CHECK(document.sampled_values_streams[1].multicast); + CHECK(document.sampled_values_streams[1].multicast_valid); + CHECK(document.sampled_values_streams[2].multicast); + CHECK(!document.sampled_values_streams[2].multicast_valid); + CHECK(std::any_of(document.warnings.begin(), document.warnings.end(), [](const std::string& warning) { + return warning.find("multicast='bogus'") != std::string::npos && + warning.find("deployment/export is blocked") != std::string::npos; + })); + + SvPublisherProfileCompileContext malformed_context; + malformed_context.profile_family = SvProfileFamily::iec61850_9_2; + const auto malformed = SvPublisherProfileCompiler::compile( + document.sampled_values_streams[2], malformed_context); + CHECK(!malformed.ok()); + CHECK(std::any_of(malformed.errors.begin(), malformed.errors.end(), [](const std::string& error) { + return error.find("invalid explicit multicast attribute") != std::string::npos; + })); auto canonical_document = SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); @@ -516,6 +536,12 @@ void parser_preserves_sampled_value_multicast_semantics() { const auto reparsed = SclParser{}.parse(exported.xml, "sv-transport-roundtrip.scd"); CHECK(reparsed.sampled_values_streams.size() == 1U); CHECK(!reparsed.sampled_values_streams.front().multicast); + CHECK(reparsed.sampled_values_streams.front().multicast_valid); + + canonical_document.sampled_values_streams.front().multicast_valid = false; + const auto invalid_export = SclExporter::canonical(canonical_document, options); + CHECK(!invalid_export.success); + CHECK(invalid_export.error.find("multicast was explicitly malformed") != std::string::npos); } void dataset_reference_resolver_accepts_canonical_and_local_forms() { From 5ded1506ddb58cf8b3f7eb2183abb501d9ab3e24 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:19:36 +0700 Subject: [PATCH 6/6] P1.1 review: preserve SV transport provenance in semantic comparison Treat malformed multicast provenance as part of modeled Sampled Values semantics so workspace round-trip checks cannot equate invalid engineering input with a valid normalized transport value. Tracks #139. --- apps/ied_simulator/src/SclWorkspaceController_part_00.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc index 50e6a4c33..8742b2fe1 100644 --- a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc +++ b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc @@ -174,7 +174,7 @@ bool sampledValuesEquivalent( lhs.configuration_revision != rhs.configuration_revision || !(lhs.address == rhs.address) || !entriesEquivalent(lhs.entries, rhs.entries) || lhs.sv_id != rhs.sv_id || lhs.smv_id != rhs.smv_id || lhs.multicast != rhs.multicast || - lhs.sample_rate != rhs.sample_rate || + lhs.multicast_valid != rhs.multicast_valid || lhs.sample_rate != rhs.sample_rate || lhs.sample_mode != rhs.sample_mode || lhs.no_asdu != rhs.no_asdu || !(lhs.smv_options == rhs.smv_options)) { return false;