From f1822365d11f56c4239ce71fd4771a2e81486f08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:40:25 +0700 Subject: [PATCH 1/5] Add CommandTermination and LastApplError decoder API --- .../control/command_termination.hpp | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 include/ariec61850/control/command_termination.hpp diff --git a/include/ariec61850/control/command_termination.hpp b/include/ariec61850/control/command_termination.hpp new file mode 100644 index 000000000..9e493d2b6 --- /dev/null +++ b/include/ariec61850/control/command_termination.hpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/control/guarded_control.hpp" +#include "ariec61850/mms/reporting.hpp" + +#include +#include +#include + +namespace ar::iec61850::control { + +struct LastApplError final { + std::string control_object; + std::int64_t raw_control_error{}; + std::int64_t raw_add_cause{25}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + std::string control_error_name; + std::string add_cause_name; +}; + +struct CommandTermination final { + bool is_for_control_object{}; + bool is_termination{}; + bool positive{}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + std::int64_t raw_control_error{}; + std::int64_t raw_add_cause{25}; + std::string control_error_name; + std::string add_cause_name; + std::optional last_appl_error; +}; + +class CommandTerminationDecoder final { +public: + [[nodiscard]] static CommandTermination decode( + const mms::MmsInformationReport& report, + const ControlObjectReference& object); + + [[nodiscard]] static std::optional try_decode_last_appl_error( + const mms::MmsDataValue& value); + + [[nodiscard]] static bool matches_operate_reference( + const ControlObjectReference& object, + const std::string& reference); + + [[nodiscard]] static bool matches_reported_reference( + const ControlObjectReference& object, + const std::string& reference); + + [[nodiscard]] static std::string control_error_name(std::int64_t value); + [[nodiscard]] static std::string add_cause_name(std::int64_t value); +}; + +} // namespace ar::iec61850::control From c961d841fb6c5a468feee02d094d504359450f34 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:41:12 +0700 Subject: [PATCH 2/5] Implement correlated CommandTermination decoding --- src/control/command_termination.cpp | 366 ++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 src/control/command_termination.cpp diff --git a/src/control/command_termination.cpp b/src/control/command_termination.cpp new file mode 100644 index 000000000..b415c42be --- /dev/null +++ b/src/control/command_termination.cpp @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/command_termination.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +[[nodiscard]] char ascii_lower(const char value) noexcept { + return value >= 'A' && value <= 'Z' + ? static_cast(value - 'A' + 'a') + : value; +} + +[[nodiscard]] bool ascii_equal( + const std::string& left, + const std::string& right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0U; index < left.size(); ++index) { + if (ascii_lower(left[index]) != ascii_lower(right[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_ends_with( + const std::string& value, + const std::string& suffix) noexcept { + if (suffix.size() > value.size()) { + return false; + } + const auto offset = value.size() - suffix.size(); + for (std::size_t index = 0U; index < suffix.size(); ++index) { + if (ascii_lower(value[offset + index]) != ascii_lower(suffix[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_starts_with( + const std::string& value, + const std::string& prefix) noexcept { + if (prefix.size() > value.size()) { + return false; + } + for (std::size_t index = 0U; index < prefix.size(); ++index) { + if (ascii_lower(value[index]) != ascii_lower(prefix[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_contains( + const std::string& value, + const std::string& needle) noexcept { + if (needle.empty()) { + return true; + } + if (needle.size() > value.size()) { + return false; + } + for (std::size_t offset = 0U; offset + needle.size() <= value.size(); ++offset) { + bool match = true; + for (std::size_t index = 0U; index < needle.size(); ++index) { + if (ascii_lower(value[offset + index]) != ascii_lower(needle[index])) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; +} + +[[nodiscard]] std::string trim_delimiter(std::string value, const char delimiter) { + while (!value.empty() && value.front() == delimiter) { + value.erase(value.begin()); + } + while (!value.empty() && value.back() == delimiter) { + value.pop_back(); + } + return value; +} + +[[nodiscard]] std::string object_path_dollars(const ControlObjectReference& object) { + std::string result{object.data_object_path}; + std::replace(result.begin(), result.end(), '.', '$'); + return result; +} + +[[nodiscard]] std::string object_reference(const ControlObjectReference& object) { + return std::string{object.domain} + "/" + std::string{object.logical_node} + "." + + std::string{object.data_object_path}; +} + +[[nodiscard]] std::string mms_reference(const mms::MmsObjectName& name) { + switch (name.kind) { + case mms::MmsObjectNameKind::domain_specific: + return name.domain + "/" + name.item; + case mms::MmsObjectNameKind::vmd_specific: + case mms::MmsObjectNameKind::aa_specific: + return name.item; + } + return {}; +} + +[[nodiscard]] std::string read_text(const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::visible_string && + value.kind() != mms::MmsDataKind::mms_string) { + return {}; + } + const auto* text = std::get_if(&value.value()); + return text == nullptr ? std::string{} : *text; +} + +[[nodiscard]] std::optional read_number( + const mms::MmsDataValue& value) noexcept { + if (value.kind() == mms::MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) { + return *number; + } + return std::nullopt; + } + if (value.kind() == mms::MmsDataKind::unsigned_integer) { + if (const auto* number = std::get_if(&value.value())) { + if (*number <= static_cast( + std::numeric_limits::max())) { + return static_cast(*number); + } + } + } + return std::nullopt; +} + +[[nodiscard]] ControlError map_control_error(const std::int64_t value) noexcept { + switch (value) { + case 0: return ControlError::no_error; + case 1: return ControlError::unknown; + case 2: return ControlError::timeout_test; + case 3: return ControlError::operator_test; + default: return ControlError::unknown; + } +} + +[[nodiscard]] AddCause map_add_cause(const std::int64_t value) noexcept { + if (value >= 0 && value <= 27) { + return static_cast(static_cast(value)); + } + return AddCause::unknown; +} + +[[nodiscard]] bool positive_termination( + const std::int64_t error, + const std::int64_t add_cause) noexcept { + return error == 0 && (add_cause == 0 || add_cause == 25); +} + +} // namespace + +std::string CommandTerminationDecoder::control_error_name(const std::int64_t value) { + switch (value) { + case 0: return "no-error"; + case 1: return "unknown"; + case 2: return "timeout-test"; + case 3: return "operator-test"; + default: return "control-error-" + std::to_string(value); + } +} + +std::string CommandTerminationDecoder::add_cause_name(const std::int64_t value) { + switch (value) { + case 0: return "unknown"; + case 1: return "not-supported"; + case 2: return "blocked-by-switching-hierarchy"; + case 3: return "select-failed"; + case 4: return "invalid-position"; + case 5: return "position-reached"; + case 6: return "parameter-change-in-execution"; + case 7: return "step-limit"; + case 8: return "blocked-by-mode"; + case 9: return "blocked-by-process"; + case 10: return "blocked-by-interlocking"; + case 11: return "blocked-by-synchrocheck"; + case 12: return "command-already-in-execution"; + case 13: return "blocked-by-health"; + case 14: return "one-of-n-control"; + case 15: return "abortion-by-cancel"; + case 16: return "time-limit-over"; + case 17: return "abortion-by-trip"; + case 18: return "object-not-selected"; + case 19: return "object-already-selected"; + case 20: return "no-access-authority"; + case 21: return "ended-with-overshoot"; + case 22: return "abortion-due-to-deviation"; + case 23: return "abortion-by-communication-loss"; + case 24: return "abortion-by-command"; + case 25: return "none"; + case 26: return "inconsistent-parameters"; + case 27: return "locked-by-other-client"; + default: return "add-cause-" + std::to_string(value); + } +} + +bool CommandTerminationDecoder::matches_operate_reference( + const ControlObjectReference& object, + const std::string& reference) { + if (!object.valid() || reference.empty()) { + return false; + } + std::string normalized = reference; + std::replace(normalized.begin(), normalized.end(), '.', '$'); + std::replace(normalized.begin(), normalized.end(), '/', '$'); + normalized = trim_delimiter(std::move(normalized), '$'); + + const auto data_path = object_path_dollars(object); + const auto expected = std::string{object.domain} + "$" + + std::string{object.logical_node} + "$CO$" + data_path + "$Oper"; + const auto suffix = std::string{object.logical_node} + "$CO$" + + data_path + "$Oper"; + return ascii_equal(normalized, expected) || ascii_ends_with(normalized, suffix); +} + +bool CommandTerminationDecoder::matches_reported_reference( + const ControlObjectReference& object, + const std::string& reference) { + if (!object.valid() || reference.empty()) { + return false; + } + std::string normalized = reference; + std::replace(normalized.begin(), normalized.end(), '$', '.'); + const auto expected = object_reference(object); + const auto logical_suffix = "/" + std::string{object.logical_node} + "." + + std::string{object.data_object_path}; + const auto co_fragment = "/" + std::string{object.logical_node} + ".CO." + + std::string{object.data_object_path} + "."; + return ascii_equal(normalized, expected) || + ascii_starts_with(normalized, expected + ".") || + ascii_ends_with(normalized, logical_suffix) || + ascii_contains(normalized, co_fragment); +} + +std::optional CommandTerminationDecoder::try_decode_last_appl_error( + const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::structure) { + return std::nullopt; + } + const auto& children = value.children(); + if (children.size() < 2U) { + return std::nullopt; + } + + std::vector> numeric; + numeric.reserve(children.size()); + for (std::size_t index = 0U; index < children.size(); ++index) { + if (const auto number = read_number(children[index]); number.has_value()) { + numeric.emplace_back(index, number.value()); + } + } + if (numeric.size() < 2U) { + return std::nullopt; + } + + std::optional error; + for (const auto& [index, number] : numeric) { + if (index == 0U || index == 1U) { + error = number; + break; + } + } + if (!error.has_value()) { + return std::nullopt; + } + + const auto add_cause = numeric.back().second; + LastApplError result; + result.control_object = read_text(children.front()); + result.raw_control_error = error.value(); + result.raw_add_cause = add_cause; + result.control_error = map_control_error(error.value()); + result.add_cause = map_add_cause(add_cause); + result.control_error_name = control_error_name(error.value()); + result.add_cause_name = add_cause_name(add_cause); + return result; +} + +CommandTermination CommandTerminationDecoder::decode( + const mms::MmsInformationReport& report, + const ControlObjectReference& object) { + CommandTermination result; + if (!object.valid()) { + return result; + } + + bool matching_object = false; + bool matching_operate = false; + for (const auto& reference : report.variable_references) { + const auto text = mms_reference(reference); + matching_object = matching_object || matches_reported_reference(object, text); + matching_operate = matching_operate || matches_operate_reference(object, text); + } + + for (const auto& item : report.items) { + if (!item.value.has_value() || + item.value->kind() != mms::MmsDataKind::structure) { + continue; + } + const auto last_error = try_decode_last_appl_error(item.value.value()); + if (!last_error.has_value()) { + continue; + } + + const bool embedded_match = !last_error->control_object.empty() && + (matches_reported_reference(object, last_error->control_object) || + matches_operate_reference(object, last_error->control_object)); + if (!matching_object && !matching_operate && !embedded_match) { + continue; + } + + result.is_for_control_object = true; + result.is_termination = true; + result.positive = positive_termination( + last_error->raw_control_error, last_error->raw_add_cause); + result.control_error = last_error->control_error; + result.add_cause = last_error->add_cause; + result.raw_control_error = last_error->raw_control_error; + result.raw_add_cause = last_error->raw_add_cause; + result.control_error_name = last_error->control_error_name; + result.add_cause_name = last_error->add_cause_name; + result.last_appl_error = last_error; + return result; + } + + // Mirror the C# oracle: a positive enhanced-security termination may carry + // only the exact CO/Oper variable. Ordinary ST/MX process reports must not + // complete the pending command. + if (!matching_operate) { + return result; + } + + result.is_for_control_object = true; + result.is_termination = true; + result.positive = true; + result.control_error = ControlError::no_error; + result.add_cause = AddCause::none; + result.raw_control_error = 0; + result.raw_add_cause = 25; + result.control_error_name = "no-error"; + result.add_cause_name = "none"; + return result; +} + +} // namespace ar::iec61850::control From 717c6674a1b1ab5450ece1b86964c5feedc6fcbf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:02 +0700 Subject: [PATCH 3/5] Add LastApplError and CommandTermination parity tests --- tests/test_command_termination.cpp | 229 +++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/test_command_termination.cpp diff --git a/tests/test_command_termination.cpp b/tests/test_command_termination.cpp new file mode 100644 index 000000000..a5b61a293 --- /dev/null +++ b/tests/test_command_termination.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/command_termination.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +ControlObjectReference object() { + ControlObjectReference result; + CHECK(try_parse_control_object_reference("LD0/CSWI1.Pos", result)); + return result; +} + +MmsObjectName reference(std::string item) { + MmsObjectName result; + result.kind = MmsObjectNameKind::domain_specific; + result.domain = "LD0"; + result.item = std::move(item); + return result; +} + +MmsInformationReportItem value_item(MmsDataValue value, const std::size_t index = 0U) { + MmsInformationReportItem item; + item.index = index; + item.value = std::move(value); + return item; +} + +MmsDataValue last_appl_error( + std::string control_object, + const std::int64_t error, + const std::int64_t add_cause) { + return MmsDataValue::structure({ + MmsDataValue::visible_string(std::move(control_object)), + MmsDataValue::integer(error), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(add_cause), + }); +} + +bool allow_all( + void*, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + return true; +} + +void reference_matching_follows_csharp_oracle() { + const auto target = object(); + CHECK(CommandTerminationDecoder::matches_operate_reference( + target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(CommandTerminationDecoder::matches_operate_reference( + target, "CSWI1.CO.Pos.Oper")); + CHECK(!CommandTerminationDecoder::matches_operate_reference( + target, "LD0/CSWI1$ST$Pos$stVal")); + + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1.Pos")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1.Pos.stVal")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(!CommandTerminationDecoder::matches_reported_reference( + target, "LD0/XCBR1.Pos.stVal")); +} + +void positive_termination_requires_exact_oper_reference() { + MmsInformationReport report; + report.variable_references.push_back(reference("CSWI1$CO$Pos$Oper")); + auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(result.positive); + CHECK(result.control_error == ControlError::no_error); + CHECK(result.add_cause == AddCause::none); + CHECK(result.raw_add_cause == 25); + + MmsInformationReport ordinary; + ordinary.variable_references.push_back(reference("CSWI1$ST$Pos$stVal")); + ordinary.items.push_back(value_item(MmsDataValue::boolean(true))); + result = CommandTerminationDecoder::decode(ordinary, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + +void negative_last_appl_error_is_correlated_and_mapped() { + MmsInformationReport report; + // Embedded ctlObj is sufficient even if the report variable reference itself + // is not the target object. + report.variable_references.push_back(reference("LLN0$ST$Mod$stVal")); + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 0, 10))); + + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.control_error == ControlError::no_error); + CHECK(result.add_cause == AddCause::blocked_by_interlocking); + CHECK(result.control_error_name == "no-error"); + CHECK(result.add_cause_name == "blocked-by-interlocking"); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object == "LD0/CSWI1.Pos"); +} + +void omitted_ctl_obj_layout_is_supported_when_report_correlates() { + MmsInformationReport report; + report.variable_references.push_back(reference("CSWI1$CO$Pos$Oper")); + report.items.push_back(value_item(MmsDataValue::structure({ + MmsDataValue::integer(0), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(25), + }))); + + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_termination); + CHECK(result.positive); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object.empty()); + CHECK(result.add_cause == AddCause::none); +} + +void uncorrelated_last_appl_error_cannot_complete_command() { + MmsInformationReport report; + report.variable_references.push_back(reference("XCBR1$ST$Pos$stVal")); + report.items.push_back(value_item(last_appl_error( + "LD0/XCBR1.Pos", 0, 11))); + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + +void unknown_numeric_codes_are_preserved_without_false_positive() { + MmsInformationReport report; + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 99, 88))); + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.raw_control_error == 99); + CHECK(result.raw_add_cause == 88); + CHECK(result.control_error == ControlError::unknown); + CHECK(result.add_cause == AddCause::unknown); + CHECK(result.control_error_name == "control-error-99"); + CHECK(result.add_cause_name == "add-cause-88"); +} + +void decoder_output_drives_enhanced_planner_completion() { + GuardedControlPolicy policy; + policy.authorize = allow_all; + const auto target = object(); + GuardedControlPlanner planner{target, ControlModel::direct_enhanced, policy}; + constexpr std::uint8_t encoded_boolean[] = {0x83U, 0x01U, 0xFFU}; + constexpr std::uint8_t origin[] = {'H', 'M', 'I'}; + ControlSequenceView sequence; + sequence.control_value = encoded_boolean; + sequence.origin_identifier = origin; + sequence.timestamp_token = 1U; + const ControlClientIdentity client{0x1234U}; + auto decision = planner.operate(client, sequence, 100U); + CHECK(decision.status == GuardedControlStatus::accepted_waiting_termination); + + MmsInformationReport report; + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 0, 11))); + const auto termination = CommandTerminationDecoder::decode(report, target); + CHECK(termination.is_termination); + CHECK(!termination.positive); + + decision = planner.command_termination( + termination.control_error, termination.add_cause); + CHECK(decision.status == GuardedControlStatus::negative_termination); + CHECK(decision.add_cause == AddCause::blocked_by_synchrocheck); + CHECK(!planner.state(101U).waiting_for_termination); +} + +void last_appl_error_needs_two_numeric_fields() { + const auto invalid = MmsDataValue::structure({ + MmsDataValue::visible_string("LD0/CSWI1.Pos"), + MmsDataValue::integer(0), + }); + CHECK(!CommandTerminationDecoder::try_decode_last_appl_error(invalid).has_value()); +} + +} // namespace + +int main() { + try { + reference_matching_follows_csharp_oracle(); + positive_termination_requires_exact_oper_reference(); + negative_last_appl_error_is_correlated_and_mapped(); + omitted_ctl_obj_layout_is_supported_when_report_correlates(); + uncorrelated_last_appl_error_cannot_complete_command(); + unknown_numeric_codes_are_preserved_without_false_positive(); + decoder_output_drives_enhanced_planner_completion(); + last_appl_error_needs_two_numeric_fields(); + std::cout << "CommandTermination tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From 416980b6ef3c8569e250ceeaa26c74b1bdfa2b78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:22 +0700 Subject: [PATCH 4/5] Add strict CommandTermination validation profile --- .../CMakeLists.txt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/control_termination_profile/CMakeLists.txt diff --git a/tests/control_termination_profile/CMakeLists.txt b/tests/control_termination_profile/CMakeLists.txt new file mode 100644 index 000000000..d55bc0b73 --- /dev/null +++ b/tests/control_termination_profile/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlTerminationProfile + VERSION 0.1.0 + DESCRIPTION "IEC 61850 CommandTermination and LastApplError parity validation" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +add_executable(ariec61850_command_termination_tests + ${ARIEC61850_ROOT}/tests/test_command_termination.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp + ${ARIEC61850_ROOT}/src/control/command_termination.cpp) + +target_compile_features(ariec61850_command_termination_tests PRIVATE cxx_std_20) +target_include_directories(ariec61850_command_termination_tests PRIVATE + ${ARIEC61850_ROOT}/include) + +if(MSVC) + target_compile_options(ariec61850_command_termination_tests PRIVATE + /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_command_termination_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_command_termination_tests + COMMAND ariec61850_command_termination_tests) + +message(STATUS "arstack61850 Control termination profile: LastApplError mapping=ON") +message(STATUS "arstack61850 Control termination profile: exact CO/Oper positive boundary=ON") +message(STATUS "arstack61850 Control termination profile: ordinary ST/MX completion=DENIED") +message(STATUS "arstack61850 Control termination profile: unknown numeric diagnostics preserved=ON") From bd2b089669f26c5464e1f2a30d9ada8dd93f86b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:36 +0700 Subject: [PATCH 5/5] Gate CommandTermination parity on GCC and Clang --- .../control-termination-profile-ci.yml | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/control-termination-profile-ci.yml diff --git a/.github/workflows/control-termination-profile-ci.yml b/.github/workflows/control-termination-profile-ci.yml new file mode 100644 index 000000000..56e0730a2 --- /dev/null +++ b/.github/workflows/control-termination-profile-ci.yml @@ -0,0 +1,62 @@ +name: Control Termination Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_command_termination.cpp' + - 'tests/control_termination_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/reporting.hpp' + - 'src/mms/data_value.cpp' + - '.github/workflows/control-termination-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-termination-profile: + name: Control Termination / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure control termination profile + run: cmake -S tests/control_termination_profile -B build-control-termination + + - name: Build control termination profile + run: cmake --build build-control-termination --parallel + + - name: Run CommandTermination parity tests + run: >- + ctest --test-dir build-control-termination + --output-on-failure + --output-junit control-termination-ctest.xml + + - name: Upload termination evidence + uses: actions/upload-artifact@v4 + with: + name: control-termination-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-control-termination/control-termination-ctest.xml