From 9e91139ce51c8b739d31f3ac0e32231632f6272e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:48:37 +0700 Subject: [PATCH 01/14] Add non-fatal bounded MMS poll for control completion waits --- include/ariec61850/mms/association_runtime.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/include/ariec61850/mms/association_runtime.hpp b/include/ariec61850/mms/association_runtime.hpp index 332df7c03..71af1a698 100644 --- a/include/ariec61850/mms/association_runtime.hpp +++ b/include/ariec61850/mms/association_runtime.hpp @@ -151,6 +151,17 @@ class MmsAssociationRuntime final { [[nodiscard]] MmsPduEnvelope poll_once( std::stop_token stop_token = {}); + + // Bounded receive primitive for application workflows where expiry is a + // normal application result rather than an association fault. A transport + // timeout returns false and leaves the association active. Caller + // cancellation is propagated without rewriting association state. Other + // transport/protocol failures retain the normal fail-closed behavior. + [[nodiscard]] bool try_poll_once_for( + std::chrono::milliseconds timeout, + MmsPduEnvelope& envelope, + std::stop_token stop_token = {}); + [[nodiscard]] bool try_pop_information_report( std::vector& presentation_payload); From 4b33ee935a4fb4c65bc9bda4fcefbec5d44f8a22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:49:07 +0700 Subject: [PATCH 02/14] Implement non-fatal bounded MMS poll for control waits --- src/mms/association_runtime_control_wait.cpp | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/mms/association_runtime_control_wait.cpp diff --git a/src/mms/association_runtime_control_wait.cpp b/src/mms/association_runtime_control_wait.cpp new file mode 100644 index 000000000..3461bc62e --- /dev/null +++ b/src/mms/association_runtime_control_wait.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/association_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +bool MmsAssociationRuntime::try_poll_once_for( + const std::chrono::milliseconds timeout, + MmsPduEnvelope& envelope, + const std::stop_token stop_token) { + require_associated(); + if (timeout <= std::chrono::milliseconds::zero()) { + throw std::invalid_argument("Bounded MMS poll timeout must be positive."); + } + require_not_cancelled(stop_token); + + try { + auto payload = receive_application_payload( + deadline_after(timeout), stop_token); + envelope = route_received_payload(payload); + return true; + } catch (const MmsTransportTimeoutError&) { + // Intentional application-level expiry. The byte transport remains + // owned by the active association; do not convert a missing + // CommandTermination into an MMS association fault. + return false; + } catch (const MmsTransportCancelledError&) { + // Caller cancellation means "stop waiting" for this API. Preserve the + // association and let the caller decide whether to disconnect. + throw; + } catch (const std::exception& exception) { + if (stop_token.stop_requested()) { + throw MmsTransportCancelledError(exception.what()); + } + fail(exception.what()); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; + throw; + } +} + +} // namespace ar::iec61850::mms From 1d71dc6efea2bd7b00f76eba4975c7178d49f91b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:50:01 +0700 Subject: [PATCH 03/14] Add guarded live IEC 61850 control session API --- .../ariec61850/control/control_session.hpp | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 include/ariec61850/control/control_session.hpp diff --git a/include/ariec61850/control/control_session.hpp b/include/ariec61850/control/control_session.hpp new file mode 100644 index 000000000..ae0495708 --- /dev/null +++ b/include/ariec61850/control/control_session.hpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/control/command_termination.hpp" +#include "ariec61850/control/guarded_control.hpp" +#include "ariec61850/control/mms_control_structure.hpp" +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { + +struct ControlTransportWriteResult final { + bool success{}; + std::optional failure_code; +}; + +class ControlTransport { +public: + virtual ~ControlTransport() = default; + + [[nodiscard]] virtual bool associated() const noexcept = 0; + [[nodiscard]] virtual std::uint64_t association_id() const noexcept = 0; + + [[nodiscard]] virtual std::optional read( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) = 0; + [[nodiscard]] virtual std::optional variable_specification( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) = 0; + [[nodiscard]] virtual std::vector domain_variable_names( + const std::string& domain, + std::stop_token stop_token = {}) = 0; + [[nodiscard]] virtual ControlTransportWriteResult write( + const mms::MmsObjectName& object, + mms::MmsDataValue value, + std::stop_token stop_token = {}) = 0; + + // Establishes the completion-observation boundary used by C# smart control: + // stale reports are discarded before SBOw/Oper, while reports arriving + // during the confirmed Write exchange remain queued by the association. + virtual void clear_information_reports() = 0; + [[nodiscard]] virtual bool wait_information_report( + std::chrono::milliseconds timeout, + mms::MmsInformationReport& report, + std::stop_token stop_token = {}) = 0; +}; + +// Production adapter over the already-live MmsAssociationRuntime. It performs +// exactly one confirmed Read/GVAA/Write exchange per requested operation and +// uses the non-fatal bounded poll API while waiting for asynchronous reports. +class MmsAssociationControlTransport final : public ControlTransport { +public: + explicit MmsAssociationControlTransport(mms::MmsAssociationRuntime& association) noexcept; + + [[nodiscard]] bool associated() const noexcept override; + [[nodiscard]] std::uint64_t association_id() const noexcept override; + [[nodiscard]] std::optional read( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) override; + [[nodiscard]] std::optional variable_specification( + const mms::MmsObjectName& object, + std::stop_token stop_token = {}) override; + [[nodiscard]] std::vector domain_variable_names( + const std::string& domain, + std::stop_token stop_token = {}) override; + [[nodiscard]] ControlTransportWriteResult write( + const mms::MmsObjectName& object, + mms::MmsDataValue value, + std::stop_token stop_token = {}) override; + void clear_information_reports() override; + [[nodiscard]] bool wait_information_report( + std::chrono::milliseconds timeout, + mms::MmsInformationReport& report, + std::stop_token stop_token = {}) override; + +private: + mms::MmsAssociationRuntime& association_; + std::uint64_t association_id_{}; +}; + +struct ControlObjectDescriptor final { + ControlObjectReference object{}; + ControlModel model{ControlModel::unknown}; + mms::MmsTypeSpecification ctl_val_specification{}; + mms::MmsTypeSpecification operate_specification{}; + std::optional select_with_value_specification; + std::optional cancel_specification; + std::optional status_object; + std::string status_functional_constraint; + std::chrono::milliseconds sbo_timeout{10'000}; + std::chrono::milliseconds operate_timeout{10'000}; + bool supports_time_activated_operate{}; + bool supports_command_termination{}; + std::string cdc; + std::vector discovery_evidence; + + [[nodiscard]] bool requires_select() const noexcept { + return model_requires_select(model); + } + [[nodiscard]] bool enhanced() const noexcept { + return model_is_enhanced(model); + } + [[nodiscard]] bool operationally_ready() const noexcept; +}; + +struct ControlDiscoveryOptions final { + std::chrono::milliseconds default_sbo_timeout{10'000}; + std::chrono::milliseconds default_operate_timeout{10'000}; +}; + +class ControlDescriptorDiscovery final { +public: + [[nodiscard]] static ControlObjectDescriptor discover( + ControlTransport& transport, + const std::string& object_reference, + ControlDiscoveryOptions options = {}, + std::stop_token stop_token = {}); +}; + +struct ControlRequest final { + ControlValue control_value{ControlValue::boolean(false)}; + OriginCategory origin_category{OriginCategory::station_control}; + std::vector origin_identifier; + std::optional control_number; + std::optional operate_at; + bool test{}; + bool interlock_check{true}; + bool synchro_check{true}; + bool auto_select{true}; + std::optional command_termination_timeout; +}; + +enum class ControlCompletionState : std::uint8_t { + accepted, + positive_termination, + negative_termination, + rejected, + unsupported, + timed_out, + association_lost, + cancelled, +}; + +struct ControlActionResult final { + ControlAction action{ControlAction::operate}; + ControlCompletionState completion{ControlCompletionState::rejected}; + bool request_accepted{}; + bool command_termination_received{}; + bool positive_termination{}; + std::uint8_t control_number{}; + 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::optional mms_failure_code; + std::string message; + std::vector diagnostics; + + [[nodiscard]] bool success() const noexcept { + return completion == ControlCompletionState::accepted || + completion == ControlCompletionState::positive_termination; + } +}; + +struct ControlSessionOptions final { + std::chrono::milliseconds application_error_grace_period{400}; + bool require_exact_named_control_fields{true}; + GuardedControlPolicy guarded_policy{}; // authorize==nullptr => default deny. +}; + +class ControlObjectSession final { +public: + ControlObjectSession( + ControlTransport& transport, + ControlObjectDescriptor descriptor, + ControlSessionOptions options = {}); + ~ControlObjectSession(); + + ControlObjectSession(const ControlObjectSession&) = delete; + ControlObjectSession& operator=(const ControlObjectSession&) = delete; + + [[nodiscard]] const ControlObjectDescriptor& descriptor() const noexcept { + return descriptor_; + } + [[nodiscard]] bool has_active_selection() const noexcept { + return active_context_.has_value(); + } + + [[nodiscard]] ControlActionResult select( + const ControlRequest& request, + std::stop_token stop_token = {}); + [[nodiscard]] ControlActionResult select_with_value( + const ControlRequest& request, + std::stop_token stop_token = {}); + [[nodiscard]] ControlActionResult operate( + const ControlRequest& request, + std::stop_token stop_token = {}); + [[nodiscard]] ControlActionResult cancel( + std::stop_token stop_token = {}); + + void on_association_closed() noexcept; + +private: + struct ActiveContext final { + ControlRequest request{}; + MmsControlSequenceContext mms{}; + std::vector encoded_ctl_value; + std::uint64_t timestamp_token{}; + std::uint64_t operate_at_token{}; + std::chrono::steady_clock::time_point created{}; + }; + + [[nodiscard]] ActiveContext create_context(const ControlRequest& request); + [[nodiscard]] ControlSequenceView sequence_view( + const ControlRequest& request, + const ActiveContext& context, + const std::vector& encoded_ctl_value) const noexcept; + [[nodiscard]] ControlActionResult write_failure( + ControlAction action, + const ActiveContext& context, + const ControlTransportWriteResult& write, + const std::optional& app_error = std::nullopt) const; + [[nodiscard]] std::optional wait_for_termination( + std::chrono::milliseconds timeout, + std::stop_token stop_token); + [[nodiscard]] std::optional wait_for_application_error( + std::chrono::milliseconds timeout, + std::stop_token stop_token); + [[nodiscard]] bool selection_expired(const ActiveContext& context) const noexcept; + [[nodiscard]] std::chrono::milliseconds effective_operate_timeout( + const ControlRequest& request) const; + [[nodiscard]] bool positive_sbo_selection(const mms::MmsDataValue& value) const; + void best_effort_cancel() noexcept; + void clear_selection() noexcept; + + ControlTransport& transport_; + ControlObjectDescriptor descriptor_{}; + ControlSessionOptions options_{}; + GuardedControlPlanner planner_; + std::optional active_context_; + std::optional expired_context_; + std::uint8_t next_control_number_{}; +}; + +} // namespace ar::iec61850::control From 8d3eb8c45785d879da175e25faa8d62c616d8e16 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:50:37 +0700 Subject: [PATCH 04/14] Add production control transport over MMS association runtime --- .../mms_association_control_transport.cpp | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/control/mms_association_control_transport.cpp diff --git a/src/control/mms_association_control_transport.cpp b/src/control/mms_association_control_transport.cpp new file mode 100644 index 000000000..16b1ada5f --- /dev/null +++ b/src/control/mms_association_control_transport.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include "ariec61850/mms/pdu.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +std::atomic g_next_control_association_id{1U}; + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) noexcept { + return exchange.presentation_payload.empty() + ? std::span{exchange.envelope.mms_payload} + : std::span{exchange.presentation_payload}; +} + +[[nodiscard]] std::uint64_t allocate_association_id() noexcept { + auto value = g_next_control_association_id.fetch_add(1U, std::memory_order_relaxed); + if (value == 0U) { + value = g_next_control_association_id.fetch_add(1U, std::memory_order_relaxed); + } + return value; +} + +} // namespace + +MmsAssociationControlTransport::MmsAssociationControlTransport( + mms::MmsAssociationRuntime& association) noexcept + : association_{association}, association_id_{allocate_association_id()} {} + +bool MmsAssociationControlTransport::associated() const noexcept { + return association_.associated(); +} + +std::uint64_t MmsAssociationControlTransport::association_id() const noexcept { + return association_id_; +} + +std::optional MmsAssociationControlTransport::read( + const mms::MmsObjectName& object, + const std::stop_token stop_token) { + if (!association_.associated()) { + return std::nullopt; + } + const auto invoke_id = association_.next_invoke_id(); + mms::MmsReadRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(object); + const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + return std::nullopt; + } + const auto response = mms::MmsServiceCodec::decode_read_response( + response_payload(exchange), invoke_id); + if (response.results.size() != 1U || !response.results.front().success()) { + return std::nullopt; + } + return response.results.front().value; +} + +std::optional +MmsAssociationControlTransport::variable_specification( + const mms::MmsObjectName& object, + const std::stop_token stop_token) { + if (!association_.associated()) { + return std::nullopt; + } + const auto invoke_id = association_.next_invoke_id(); + mms::MmsVariableAccessAttributesRequest request; + request.invoke_id = invoke_id; + request.name = object; + const auto encoded = + mms::MmsServiceCodec::encode_variable_access_attributes_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + return std::nullopt; + } + const auto response = + mms::MmsServiceCodec::decode_variable_access_attributes_response( + response_payload(exchange), invoke_id); + return response.type; +} + +std::vector MmsAssociationControlTransport::domain_variable_names( + const std::string& domain, + const std::stop_token stop_token) { + if (!association_.associated() || domain.empty()) { + return {}; + } + + std::vector names; + std::string continue_after; + constexpr std::size_t maximum_pages = 4'096U; + for (std::size_t page = 0U; page < maximum_pages; ++page) { + const auto invoke_id = association_.next_invoke_id(); + mms::MmsGetNameListRequest request; + request.invoke_id = invoke_id; + request.object_class = mms::MmsGetNameListObjectClass::named_variable; + request.scope = mms::MmsObjectScopeKind::domain_specific; + request.domain_id = domain; + request.continue_after = continue_after; + const auto encoded = mms::MmsServiceCodec::encode_get_name_list_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + throw std::runtime_error("GetNameList returned a confirmed MMS error during control discovery."); + } + const auto response = mms::MmsServiceCodec::decode_get_name_list_response( + response_payload(exchange), invoke_id); + names.insert(names.end(), response.names.begin(), response.names.end()); + if (!response.more_follows) { + break; + } + if (response.names.empty()) { + throw std::runtime_error( + "GetNameList asserted moreFollows without returning a continuation name."); + } + const auto next = response.names.back(); + if (next.empty() || next == continue_after) { + throw std::runtime_error("GetNameList continuation did not advance."); + } + continue_after = next; + if (page + 1U == maximum_pages) { + throw std::runtime_error("GetNameList exceeded the bounded control-discovery page limit."); + } + } + + std::sort(names.begin(), names.end()); + names.erase(std::unique(names.begin(), names.end()), names.end()); + return names; +} + +ControlTransportWriteResult MmsAssociationControlTransport::write( + const mms::MmsObjectName& object, + mms::MmsDataValue value, + const std::stop_token stop_token) { + if (!association_.associated()) { + return {}; + } + const auto invoke_id = association_.next_invoke_id(); + mms::MmsWriteRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(object); + request.values.push_back(std::move(value)); + const auto encoded = mms::MmsServiceCodec::encode_write_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed(encoded, invoke_id, stop_token); + if (exchange.envelope.kind == mms::MmsPduKind::confirmed_error) { + return {}; + } + const auto response = mms::MmsServiceCodec::decode_write_response( + response_payload(exchange), invoke_id); + if (response.results.size() != 1U) { + return {}; + } + const auto& result = response.results.front(); + return {result.success, result.failure_code}; +} + +void MmsAssociationControlTransport::clear_information_reports() { + std::vector ignored; + while (association_.try_pop_information_report(ignored)) { + } +} + +bool MmsAssociationControlTransport::wait_information_report( + const std::chrono::milliseconds timeout, + mms::MmsInformationReport& report, + const std::stop_token stop_token) { + report = {}; + if (!association_.associated() || timeout <= std::chrono::milliseconds::zero()) { + return false; + } + + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (association_.associated()) { + std::vector queued; + if (association_.try_pop_information_report(queued)) { + report = mms::MmsInformationReportCodec::decode(queued); + return true; + } + + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return false; + } + auto remaining = std::chrono::duration_cast(deadline - now); + if (remaining <= std::chrono::milliseconds::zero()) { + remaining = std::chrono::milliseconds{1}; + } + + mms::MmsPduEnvelope envelope; + if (!association_.try_poll_once_for(remaining, envelope, stop_token)) { + return false; + } + // route_received_payload() queues InformationReports. Any other PDU is + // intentionally ignored here while preserving its normal router path. + } + return false; +} + +} // namespace ar::iec61850::control From c03fafb938530a808b915a82337a2c05e5e3f6cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:51:44 +0700 Subject: [PATCH 05/14] Implement live ctlModel and TypeSpecification control discovery --- src/control/control_discovery.cpp | 324 ++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 src/control/control_discovery.cpp diff --git a/src/control/control_discovery.cpp b/src/control/control_discovery.cpp new file mode 100644 index 000000000..a024243ab --- /dev/null +++ b/src/control/control_discovery.cpp @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +[[nodiscard]] std::string normalize_name(const std::string& value) { + std::string result; + result.reserve(value.size()); + for (const auto character : value) { + const auto byte = static_cast(character); + if (std::isalnum(byte) != 0) { + result.push_back(static_cast(std::tolower(byte))); + } + } + return result; +} + +[[nodiscard]] mms::MmsObjectName object_name( + const ControlObjectReference& object, + const std::string_view functional_constraint, + const std::string_view leaf) { + std::array item{}; + std::size_t bytes{}; + if (!build_control_item(object, functional_constraint, leaf, item, bytes) || + bytes == 0U || bytes > item.size()) { + throw std::invalid_argument("Control MMS object name exceeds the bounded identifier buffer."); + } + return mms::MmsObjectName::domain_specific( + std::string{object.domain}, std::string{item.data(), bytes}); +} + +[[nodiscard]] std::optional numeric_value( + const mms::MmsDataValue& value) noexcept { + if (value.kind() == mms::MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) { + return *number; + } + } + 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]] ControlModel decode_ctl_model(const mms::MmsDataValue& value) noexcept { + const auto number = numeric_value(value); + if (!number.has_value()) { + return ControlModel::unknown; + } + switch (number.value()) { + case 0: return ControlModel::status_only; + case 1: return ControlModel::direct_normal; + case 2: return ControlModel::select_before_operate_normal; + case 3: return ControlModel::direct_enhanced; + case 4: return ControlModel::select_before_operate_enhanced; + default: return ControlModel::unknown; + } +} + +[[nodiscard]] const mms::MmsTypeSpecification* find_child( + const mms::MmsTypeSpecification& parent, + const std::string& name) noexcept { + const auto target = normalize_name(name); + for (const auto& child : parent.children) { + if (normalize_name(child.name) == target) { + return &child; + } + } + return nullptr; +} + +[[nodiscard]] bool specifications_compatible( + const mms::MmsTypeSpecification& left, + const mms::MmsTypeSpecification& right) noexcept { + if (left.kind != right.kind || left.size != right.size || + left.exponent_width != right.exponent_width || + left.children.size() != right.children.size()) { + return false; + } + for (std::size_t index = 0U; index < left.children.size(); ++index) { + if (!specifications_compatible(left.children[index], right.children[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool contains_name( + const std::vector& names, + const std::string& item) { + return std::any_of(names.begin(), names.end(), [&](const std::string& candidate) { + if (candidate.size() != item.size()) { + return false; + } + for (std::size_t index = 0U; index < item.size(); ++index) { + const auto left = static_cast(candidate[index]); + const auto right = static_cast(item[index]); + if (std::tolower(left) != std::tolower(right)) { + return false; + } + } + return true; + }); +} + +[[nodiscard]] std::chrono::milliseconds read_timeout_or_fallback( + ControlTransport& transport, + const mms::MmsObjectName& object, + const std::vector& names, + const std::chrono::milliseconds fallback, + const std::stop_token stop_token) { + if (!contains_name(names, object.item)) { + return fallback; + } + const auto value = transport.read(object, stop_token); + if (!value.has_value()) { + return fallback; + } + const auto number = numeric_value(value.value()); + if (!number.has_value() || number.value() <= 0) { + return fallback; + } + return std::chrono::milliseconds{number.value()}; +} + +[[nodiscard]] std::optional find_status_object( + const ControlObjectReference& object, + const std::vector& names, + std::string& functional_constraint) { + std::string data_path{object.data_object_path}; + std::replace(data_path.begin(), data_path.end(), '.', '$'); + const std::string ln{object.logical_node}; + const std::array, 4U> candidates{{ + {ln + "$ST$" + data_path + "$stVal", "ST"}, + {ln + "$ST$" + data_path + "$posVal", "ST"}, + {ln + "$MX$" + data_path + "$mag$f", "MX"}, + {ln + "$MX$" + data_path + "$mag$i", "MX"}, + }}; + for (const auto& [item, fc] : candidates) { + if (contains_name(names, item)) { + functional_constraint = fc; + return mms::MmsObjectName::domain_specific( + std::string{object.domain}, item); + } + } + functional_constraint.clear(); + return std::nullopt; +} + +[[nodiscard]] std::string infer_cdc(const mms::MmsTypeSpecification& ctl_val) { + switch (ctl_val.kind) { + case mms::MmsTypeKind::boolean: return "SPC"; + case mms::MmsTypeKind::bit_string: return "DPC"; + case mms::MmsTypeKind::floating_point: return "APC"; + case mms::MmsTypeKind::integer: + case mms::MmsTypeKind::unsigned_integer: + case mms::MmsTypeKind::bcd: + return "INC/ISC"; + case mms::MmsTypeKind::structure: { + bool pos = false; + bool trans = false; + bool f = false; + bool i = false; + for (const auto& child : ctl_val.children) { + const auto name = normalize_name(child.name); + pos = pos || name == "posval" || name == "position"; + trans = trans || name == "transind" || name == "transient"; + f = f || name == "f"; + i = i || name == "i"; + } + if (pos || trans) return "BSC"; + if (f || i) return "APC"; + return "vendor-specific"; + } + default: + return "vendor-specific"; + } +} + +[[nodiscard]] bool has_named_child( + const mms::MmsTypeSpecification& specification, + const std::string& name) noexcept { + return find_child(specification, name) != nullptr; +} + +} // namespace + +bool ControlObjectDescriptor::operationally_ready() const noexcept { + if (!object.valid() || model == ControlModel::status_only || + model == ControlModel::unknown || + operate_specification.kind != mms::MmsTypeKind::structure || + ctl_val_specification.kind == mms::MmsTypeKind::unknown) { + return false; + } + if (requires_select() && !cancel_specification.has_value()) { + return false; + } + if (model == ControlModel::select_before_operate_enhanced && + !select_with_value_specification.has_value()) { + return false; + } + if (enhanced() && !supports_command_termination) { + return false; + } + return true; +} + +ControlObjectDescriptor ControlDescriptorDiscovery::discover( + ControlTransport& transport, + const std::string& object_reference_text, + const ControlDiscoveryOptions options, + const std::stop_token stop_token) { + if (!transport.associated()) { + throw std::invalid_argument("IEC 61850 control discovery requires an active MMS association."); + } + if (options.default_sbo_timeout <= std::chrono::milliseconds::zero() || + options.default_operate_timeout <= std::chrono::milliseconds::zero()) { + throw std::invalid_argument("Control discovery fallback timeouts must be positive."); + } + + ControlObjectReference object; + if (!try_parse_control_object_reference(object_reference_text, object)) { + throw std::invalid_argument( + "Control object reference must use the controllable Data Object root LD/LN.DO."); + } + + ControlObjectDescriptor descriptor; + descriptor.object = object; + + const auto ctl_model_object = object_name(object, "CF", "ctlModel"); + const auto ctl_model_value = transport.read(ctl_model_object, stop_token); + if (!ctl_model_value.has_value()) { + throw std::runtime_error("Cannot discover ctlModel for the control object."); + } + descriptor.model = decode_ctl_model(ctl_model_value.value()); + descriptor.discovery_evidence.push_back( + "ctlModel=" + std::to_string(static_cast(descriptor.model))); + if (descriptor.model == ControlModel::status_only || + descriptor.model == ControlModel::unknown) { + throw std::runtime_error("Control object is not command-ready according to ctlModel."); + } + + const auto oper_object = object_name(object, "CO", "Oper"); + const auto oper_specification = transport.variable_specification(oper_object, stop_token); + if (!oper_specification.has_value() || + oper_specification->kind != mms::MmsTypeKind::structure) { + throw std::runtime_error("Cannot retrieve exact live Oper TypeSpecification."); + } + const auto* ctl_val = find_child(oper_specification.value(), "ctlVal"); + if (ctl_val == nullptr) { + throw std::runtime_error( + "Live Oper TypeSpecification has no named ctlVal field; positional guessing is refused."); + } + descriptor.operate_specification = oper_specification.value(); + descriptor.ctl_val_specification = *ctl_val; + descriptor.discovery_evidence.push_back( + "Oper=" + descriptor.operate_specification.signature()); + + if (descriptor.model == ControlModel::select_before_operate_enhanced) { + const auto sbow_object = object_name(object, "CO", "SBOw"); + const auto sbow = transport.variable_specification(sbow_object, stop_token); + if (!sbow.has_value() || sbow->kind != mms::MmsTypeKind::structure) { + throw std::runtime_error("SBO enhanced requires an exact live SBOw TypeSpecification."); + } + const auto* sbow_ctl_val = find_child(sbow.value(), "ctlVal"); + if (sbow_ctl_val == nullptr || + !specifications_compatible(descriptor.ctl_val_specification, *sbow_ctl_val)) { + throw std::runtime_error( + "Live ctlVal TypeSpecification differs between Oper and SBOw."); + } + descriptor.select_with_value_specification = sbow.value(); + descriptor.discovery_evidence.push_back("SBOw=" + sbow->signature()); + } + + const auto cancel_object = object_name(object, "CO", "Cancel"); + const auto cancel = transport.variable_specification(cancel_object, stop_token); + if (cancel.has_value()) { + descriptor.cancel_specification = cancel.value(); + descriptor.discovery_evidence.push_back("Cancel=" + cancel->signature()); + } + + const auto names = transport.domain_variable_names(std::string{object.domain}, stop_token); + descriptor.sbo_timeout = read_timeout_or_fallback( + transport, + object_name(object, "CF", "sboTimeout"), + names, + options.default_sbo_timeout, + stop_token); + descriptor.operate_timeout = read_timeout_or_fallback( + transport, + object_name(object, "CF", "operTimeout"), + names, + options.default_operate_timeout, + stop_token); + descriptor.status_object = find_status_object( + object, names, descriptor.status_functional_constraint); + descriptor.supports_time_activated_operate = + has_named_child(descriptor.operate_specification, "operTm"); + descriptor.supports_command_termination = model_is_enhanced(descriptor.model); + descriptor.cdc = infer_cdc(descriptor.ctl_val_specification); + + if (!descriptor.operationally_ready()) { + throw std::runtime_error( + "Control descriptor is incomplete and cannot safely execute commands."); + } + return descriptor; +} + +} // namespace ar::iec61850::control From 322fea3ea6ab25c226b32a7198f0015225d5d423 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:53:49 +0700 Subject: [PATCH 06/14] Implement guarded Direct SBO and enhanced live control orchestration --- src/control/control_session.cpp | 742 ++++++++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 src/control/control_session.cpp diff --git a/src/control/control_session.cpp b/src/control/control_session.cpp new file mode 100644 index 000000000..484491f50 --- /dev/null +++ b/src/control/control_session.cpp @@ -0,0 +1,742 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include "ariec61850/mms/data_codec.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +[[nodiscard]] std::string normalize_name(const std::string& value) { + std::string result; + result.reserve(value.size()); + for (const auto character : value) { + if ((character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9')) { + result.push_back(character >= 'A' && character <= 'Z' + ? static_cast(character - 'A' + 'a') + : character); + } + } + return result; +} + +[[nodiscard]] const mms::MmsTypeSpecification* find_child( + const mms::MmsTypeSpecification& parent, + const std::string& name) noexcept { + const auto target = normalize_name(name); + for (const auto& child : parent.children) { + if (normalize_name(child.name) == target) { + return &child; + } + } + return nullptr; +} + +[[nodiscard]] mms::MmsObjectName object_name( + const ControlObjectReference& object, + const std::string_view functional_constraint, + const std::string_view leaf) { + std::array item{}; + std::size_t bytes{}; + if (!build_control_item(object, functional_constraint, leaf, item, bytes)) { + throw std::invalid_argument("Failed to build bounded control MMS object name."); + } + return mms::MmsObjectName::domain_specific( + std::string{object.domain}, std::string{item.data(), bytes}); +} + +[[nodiscard]] std::uint8_t next_control_number(std::uint8_t& value) noexcept { + value = value >= 255U ? 1U : static_cast(value + 1U); + return value; +} + +[[nodiscard]] std::uint64_t time_token( + const std::chrono::system_clock::time_point value) noexcept { + const auto nanos = std::chrono::duration_cast( + value.time_since_epoch()).count(); + return static_cast(nanos); +} + +[[nodiscard]] std::optional timestamp_value( + const std::chrono::system_clock::time_point value, + const mms::MmsTypeSpecification& specification) { + if (specification.kind == mms::MmsTypeKind::utc_time) { + return mms::MmsDataValue::utc_time(mms::Iec61850UtcTime{value, 0U}); + } + if (specification.kind != mms::MmsTypeKind::binary_time) { + return std::nullopt; + } + + using namespace std::chrono; + constexpr sys_days binary_epoch{year{1984}/1/1}; + const auto millis = floor(value); + const auto day = floor(millis); + if (day < binary_epoch) { + std::array zero{}; + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + return mms::MmsDataValue::binary_time( + std::span{zero}.first(size)); + } + + const auto milliseconds_since_midnight = + duration_cast(millis - day).count(); + const auto days_since_epoch = (day - binary_epoch).count(); + if (milliseconds_since_midnight < 0 || + milliseconds_since_midnight > static_cast( + std::numeric_limits::max()) || + days_since_epoch < 0 || days_since_epoch > 65'535) { + return std::nullopt; + } + + const auto ms = static_cast(milliseconds_since_midnight); + std::array bytes{ + static_cast((ms >> 24U) & 0xFFU), + static_cast((ms >> 16U) & 0xFFU), + static_cast((ms >> 8U) & 0xFFU), + static_cast(ms & 0xFFU), + static_cast((static_cast(days_since_epoch) >> 8U) & 0xFFU), + static_cast(static_cast(days_since_epoch) & 0xFFU), + }; + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + return mms::MmsDataValue::binary_time( + std::span{bytes}.first(size)); +} + +[[nodiscard]] GuardedControlPolicy planner_policy( + const ControlObjectDescriptor& descriptor, + const ControlSessionOptions& options) noexcept { + auto policy = options.guarded_policy; + policy.sbo_timeout_ms = descriptor.sbo_timeout.count() <= 0 + ? 10'000U + : static_cast(descriptor.sbo_timeout.count()); + return policy; +} + +[[nodiscard]] ControlActionResult rejected( + const ControlAction action, + std::string message, + const std::uint8_t control_number = 0U) { + ControlActionResult result; + result.action = action; + result.completion = ControlCompletionState::rejected; + result.control_number = control_number; + result.message = std::move(message); + return result; +} + +[[nodiscard]] ControlActionResult unsupported( + const ControlAction action, + std::string message) { + auto result = rejected(action, std::move(message)); + result.completion = ControlCompletionState::unsupported; + return result; +} + +[[nodiscard]] ControlActionResult from_planner_rejection( + const GuardedControlDecision& decision) { + auto result = rejected(decision.action, "Guarded control policy rejected the action.", + decision.control_number); + result.control_error = decision.control_error; + result.add_cause = decision.add_cause; + return result; +} + +[[nodiscard]] bool same_reference_text( + const std::string& selected, + const ControlObjectReference& object) { + if (selected.empty()) { + return false; + } + std::string normalized = selected; + std::replace(normalized.begin(), normalized.end(), '$', '.'); + const auto marker = std::string{".CO."}; + if (const auto position = normalized.find(marker); position != std::string::npos) { + normalized.erase(position, marker.size() - 1U); + } + const auto expected = std::string{object.domain} + "/" + + std::string{object.logical_node} + "." + std::string{object.data_object_path}; + const auto relative = std::string{object.logical_node} + "." + + std::string{object.data_object_path}; + if (normalized == expected || normalized == relative) { + return true; + } + if (normalized.size() >= relative.size() + 1U && + normalized.ends_with("/" + relative)) { + return true; + } + // C# oracle permits opaque short selection tokens, but not a token that + // looks like a different IEC 61850 object reference. + return normalized.find('/') == std::string::npos && + normalized.find('.') == std::string::npos && + normalized.find('$') == std::string::npos; +} + +} // namespace + +ControlObjectSession::ControlObjectSession( + ControlTransport& transport, + ControlObjectDescriptor descriptor, + ControlSessionOptions options) + : transport_{transport}, + descriptor_{std::move(descriptor)}, + options_{std::move(options)}, + planner_{descriptor_.object, descriptor_.model, + planner_policy(descriptor_, options_)} { + if (!transport_.associated()) { + throw std::invalid_argument("Control session requires an active MMS association."); + } + if (!descriptor_.operationally_ready()) { + throw std::invalid_argument("Control descriptor is not operationally ready."); + } + if (options_.application_error_grace_period < std::chrono::milliseconds::zero()) { + throw std::invalid_argument("Application error grace period must not be negative."); + } +} + +ControlObjectSession::~ControlObjectSession() { + best_effort_cancel(); +} + +ControlObjectSession::ActiveContext ControlObjectSession::create_context( + const ControlRequest& request) { + ActiveContext context; + context.request = request; + context.created = std::chrono::steady_clock::now(); + + const auto bound = MmsControlStructureBuilder::bind_control_value( + request.control_value, descriptor_.ctl_val_specification); + if (!bound.success()) { + throw std::invalid_argument( + "Control value does not match live ctlVal TypeSpecification at " + bound.path + "."); + } + context.encoded_ctl_value = mms::MmsDataCodec::encode(bound.value.value()); + + const auto assigned = request.control_number.value_or( + next_control_number(next_control_number_)); + const auto now = std::chrono::system_clock::now(); + const auto* timestamp_specification = find_child( + descriptor_.operate_specification, "T"); + if (timestamp_specification == nullptr) { + throw std::runtime_error("Live Oper TypeSpecification has no named T field."); + } + const auto timestamp = timestamp_value(now, *timestamp_specification); + if (!timestamp.has_value()) { + throw std::runtime_error("Live T field has an unsupported timestamp type."); + } + + context.mms.control_value = request.control_value; + context.mms.origin_category = request.origin_category; + context.mms.origin_identifier = request.origin_identifier; + context.mms.control_number = assigned; + context.mms.timestamp = timestamp.value(); + context.mms.test = request.test; + context.mms.interlock_check = request.interlock_check; + context.mms.synchro_check = request.synchro_check; + context.timestamp_token = time_token(now); + + if (request.operate_at.has_value()) { + const auto* operate_at_specification = find_child( + descriptor_.operate_specification, "operTm"); + if (operate_at_specification == nullptr) { + throw std::invalid_argument( + "Time-activated operation requested but live Oper has no operTm field."); + } + const auto operate_at = timestamp_value( + request.operate_at.value(), *operate_at_specification); + if (!operate_at.has_value()) { + throw std::invalid_argument("Live operTm field has an unsupported timestamp type."); + } + context.mms.operate_at = operate_at.value(); + context.operate_at_token = time_token(request.operate_at.value()); + } + return context; +} + +ControlSequenceView ControlObjectSession::sequence_view( + const ControlRequest& request, + const ActiveContext& context, + const std::vector& encoded_ctl_value) const noexcept { + ControlSequenceView sequence; + sequence.control_value = encoded_ctl_value; + sequence.origin_category = request.origin_category; + sequence.origin_identifier = request.origin_identifier; + sequence.control_number = request.control_number.value_or(context.mms.control_number); + sequence.timestamp_token = context.timestamp_token; + sequence.operate_at_token = request.operate_at.has_value() + ? time_token(request.operate_at.value()) + : 0U; + sequence.test = request.test; + sequence.interlock_check = request.interlock_check; + sequence.synchro_check = request.synchro_check; + return sequence; +} + +bool ControlObjectSession::positive_sbo_selection( + const mms::MmsDataValue& value) const { + if (value.kind() == mms::MmsDataKind::boolean) { + if (const auto* selected = std::get_if(&value.value())) { + return *selected; + } + return false; + } + if (value.kind() == mms::MmsDataKind::visible_string || + value.kind() == mms::MmsDataKind::mms_string) { + if (const auto* selected = std::get_if(&value.value())) { + return same_reference_text(*selected, descriptor_.object); + } + } + return false; +} + +ControlActionResult ControlObjectSession::select( + const ControlRequest& request, + const std::stop_token stop_token) { + if (descriptor_.model != ControlModel::select_before_operate_normal) { + return unsupported(ControlAction::select, + "Select read is valid only for SBO normal security."); + } + if (!transport_.associated()) { + return rejected(ControlAction::select, "MMS association is not active."); + } + + auto context = create_context(request); + const auto planner_decision = planner_.select( + {transport_.association_id()}, + sequence_view(request, context, context.encoded_ctl_value), + 0U); + if (!planner_decision.accepted()) { + return from_planner_rejection(planner_decision); + } + + const auto selected = transport_.read( + object_name(descriptor_.object, "CO", "SBO"), stop_token); + if (!selected.has_value() || !positive_sbo_selection(selected.value())) { + planner_.on_association_closed(transport_.association_id()); + return rejected(ControlAction::select, + "SBO read did not return a positive selected-object reference.", + context.mms.control_number); + } + + active_context_ = context; + expired_context_.reset(); + ControlActionResult result; + result.action = ControlAction::select; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "SBO select accepted; exact sequence retained until Operate/Cancel/timeout."; + return result; +} + +ControlActionResult ControlObjectSession::select_with_value( + const ControlRequest& request, + const std::stop_token stop_token) { + if (descriptor_.model != ControlModel::select_before_operate_enhanced || + !descriptor_.select_with_value_specification.has_value()) { + return unsupported(ControlAction::select_with_value, + "SelectWithValue is valid only for SBO enhanced security."); + } + if (!transport_.associated()) { + return rejected(ControlAction::select_with_value, "MMS association is not active."); + } + + auto context = create_context(request); + const auto planner_decision = planner_.select_with_value( + {transport_.association_id()}, + sequence_view(request, context, context.encoded_ctl_value), + 0U); + if (!planner_decision.accepted()) { + return from_planner_rejection(planner_decision); + } + + const auto value = MmsControlStructureBuilder::build_select_with_value( + context.mms, + descriptor_.select_with_value_specification.value(), + options_.require_exact_named_control_fields); + if (!value.success()) { + planner_.on_association_closed(transport_.association_id()); + return rejected(ControlAction::select_with_value, + "Cannot construct exact live SBOw structure at " + value.path + ".", + context.mms.control_number); + } + + transport_.clear_information_reports(); + const auto write = transport_.write( + object_name(descriptor_.object, "CO", "SBOw"), + value.value.value(), stop_token); + if (!write.success) { + const auto app_error = wait_for_application_error( + options_.application_error_grace_period, stop_token); + planner_.on_association_closed(transport_.association_id()); + return write_failure(ControlAction::select_with_value, context, write, app_error); + } + + const auto post_error = wait_for_application_error( + options_.application_error_grace_period, stop_token); + if (post_error.has_value() && !post_error->positive) { + planner_.on_association_closed(transport_.association_id()); + ControlActionResult result; + result.action = ControlAction::select_with_value; + result.completion = ControlCompletionState::rejected; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.control_error = post_error->control_error; + result.add_cause = post_error->add_cause; + result.raw_control_error = post_error->raw_control_error; + result.raw_add_cause = post_error->raw_add_cause; + result.message = "SBOw received asynchronous LastApplError after MMS service acceptance."; + return result; + } + + active_context_ = context; + expired_context_.reset(); + ControlActionResult result; + result.action = ControlAction::select_with_value; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "SBOw accepted; exact selected sequence retained for Operate."; + return result; +} + +ControlActionResult ControlObjectSession::operate( + const ControlRequest& request, + const std::stop_token stop_token) { + if (!descriptor_.operationally_ready()) { + return unsupported(ControlAction::operate, "Control descriptor is not operationally ready."); + } + if (request.operate_at.has_value() && !descriptor_.supports_time_activated_operate) { + return unsupported(ControlAction::operate, + "Time-activated operation requested but live Oper has no operTm."); + } + if (!transport_.associated()) { + return rejected(ControlAction::operate, "MMS association is not active."); + } + + if (descriptor_.requires_select() && !active_context_.has_value()) { + if (expired_context_.has_value()) { + try { + const auto rebound = MmsControlStructureBuilder::bind_control_value( + request.control_value, descriptor_.ctl_val_specification); + if (rebound.success() && + mms::MmsDataCodec::encode(rebound.value.value()) == + expired_context_->encoded_ctl_value) { + const auto number = expired_context_->mms.control_number; + expired_context_.reset(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::timed_out; + result.control_number = number; + result.message = "SBO selection timeout expired before Operate."; + return result; + } + } catch (...) { + } + expired_context_.reset(); + } + if (!request.auto_select) { + return rejected(ControlAction::operate, + "Object is not selected and auto_select=false."); + } + const auto selected = descriptor_.model == + ControlModel::select_before_operate_enhanced + ? select_with_value(request, stop_token) + : select(request, stop_token); + if (!selected.success()) { + return selected; + } + } + + ActiveContext context = active_context_.has_value() + ? active_context_.value() + : create_context(request); + + if (active_context_.has_value() && selection_expired(context)) { + expired_context_ = context; + best_effort_cancel(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::timed_out; + result.control_number = context.mms.control_number; + result.message = "SBO selection timeout expired before Operate."; + return result; + } + + const auto incoming_bound = MmsControlStructureBuilder::bind_control_value( + request.control_value, descriptor_.ctl_val_specification); + if (!incoming_bound.success()) { + best_effort_cancel(); + return rejected(ControlAction::operate, + "Operate ctlVal no longer matches the selected live type.", + context.mms.control_number); + } + const auto incoming_encoded = mms::MmsDataCodec::encode(incoming_bound.value.value()); + const auto planner_decision = planner_.operate( + {transport_.association_id()}, + sequence_view(request, context, incoming_encoded), + 0U); + if (!planner_decision.accepted()) { + best_effort_cancel(); + return from_planner_rejection(planner_decision); + } + + const auto oper_value = MmsControlStructureBuilder::build_operate( + context.mms, + descriptor_.operate_specification, + options_.require_exact_named_control_fields); + if (!oper_value.success()) { + clear_selection(); + return rejected(ControlAction::operate, + "Cannot construct exact live Oper structure at " + oper_value.path + ".", + context.mms.control_number); + } + + transport_.clear_information_reports(); + ControlTransportWriteResult write; + try { + write = transport_.write( + object_name(descriptor_.object, "CO", "Oper"), + oper_value.value.value(), stop_token); + } catch (const mms::MmsTransportCancelledError&) { + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::cancelled; + result.control_number = context.mms.control_number; + result.message = "Control operation cancelled before confirmed Write completion."; + return result; + } + + if (!write.success) { + const auto app_error = wait_for_application_error( + options_.application_error_grace_period, stop_token); + clear_selection(); + return write_failure(ControlAction::operate, context, write, app_error); + } + + if (!descriptor_.enhanced()) { + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "Operate service accepted (normal-security completion boundary)."; + return result; + } + + const auto termination = wait_for_termination( + effective_operate_timeout(request), stop_token); + if (!termination.has_value()) { + const bool still_associated = transport_.associated(); + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = still_associated + ? ControlCompletionState::timed_out + : ControlCompletionState::association_lost; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = still_associated + ? "Operate was accepted, but no correlated CommandTermination arrived before timeout." + : "MMS association was lost while waiting for CommandTermination."; + return result; + } + + const auto planner_completion = planner_.command_termination( + termination->control_error, termination->add_cause); + clear_selection(); + ControlActionResult result; + result.action = ControlAction::operate; + result.completion = termination->positive + ? ControlCompletionState::positive_termination + : ControlCompletionState::negative_termination; + result.request_accepted = true; + result.command_termination_received = true; + result.positive_termination = termination->positive; + result.control_number = context.mms.control_number; + result.control_error = termination->control_error; + result.add_cause = termination->add_cause; + result.raw_control_error = termination->raw_control_error; + result.raw_add_cause = termination->raw_add_cause; + result.message = termination->positive + ? "Positive CommandTermination received." + : "Negative CommandTermination / LastApplError received."; + result.diagnostics.push_back( + "planner_completion=" + std::to_string(static_cast(planner_completion.status))); + return result; +} + +ControlActionResult ControlObjectSession::cancel( + const std::stop_token stop_token) { + if (!active_context_.has_value()) { + return rejected(ControlAction::cancel, + "No active selection exists for this control session."); + } + if (!descriptor_.cancel_specification.has_value()) { + clear_selection(); + return unsupported(ControlAction::cancel, + "IED did not expose a decodable Cancel TypeSpecification."); + } + + const auto context = active_context_.value(); + const auto planner_decision = planner_.cancel( + {transport_.association_id()}, 0U); + if (!planner_decision.accepted()) { + return from_planner_rejection(planner_decision); + } + + const auto cancel_value = MmsControlStructureBuilder::build_cancel( + context.mms, + descriptor_.cancel_specification.value(), + options_.require_exact_named_control_fields); + if (!cancel_value.success()) { + clear_selection(); + return rejected(ControlAction::cancel, + "Cannot construct exact live Cancel structure at " + cancel_value.path + ".", + context.mms.control_number); + } + + const auto write = transport_.write( + object_name(descriptor_.object, "CO", "Cancel"), + cancel_value.value.value(), stop_token); + clear_selection(); + if (!write.success) { + return write_failure(ControlAction::cancel, context, write); + } + + ControlActionResult result; + result.action = ControlAction::cancel; + result.completion = ControlCompletionState::accepted; + result.request_accepted = true; + result.control_number = context.mms.control_number; + result.message = "Cancel accepted; local SBO ownership released."; + return result; +} + +ControlActionResult ControlObjectSession::write_failure( + const ControlAction action, + const ActiveContext& context, + const ControlTransportWriteResult& write, + const std::optional& app_error) const { + ControlActionResult result; + result.action = action; + result.completion = ControlCompletionState::rejected; + result.control_number = context.mms.control_number; + result.mms_failure_code = write.failure_code; + result.message = "MMS control Write was rejected."; + if (app_error.has_value()) { + result.control_error = app_error->control_error; + result.add_cause = app_error->add_cause; + result.raw_control_error = app_error->raw_control_error; + result.raw_add_cause = app_error->raw_add_cause; + result.diagnostics.push_back( + app_error->control_error_name + "/" + app_error->add_cause_name); + } + return result; +} + +std::optional ControlObjectSession::wait_for_termination( + const std::chrono::milliseconds timeout, + const std::stop_token stop_token) { + if (timeout <= std::chrono::milliseconds::zero()) { + return std::nullopt; + } + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (transport_.associated()) { + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return std::nullopt; + } + auto remaining = std::chrono::duration_cast(deadline - now); + if (remaining <= std::chrono::milliseconds::zero()) { + remaining = std::chrono::milliseconds{1}; + } + mms::MmsInformationReport report; + if (!transport_.wait_information_report(remaining, report, stop_token)) { + return std::nullopt; + } + auto termination = CommandTerminationDecoder::decode(report, descriptor_.object); + if (termination.is_for_control_object && termination.is_termination) { + return termination; + } + } + return std::nullopt; +} + +std::optional ControlObjectSession::wait_for_application_error( + const std::chrono::milliseconds timeout, + const std::stop_token stop_token) { + return wait_for_termination(timeout, stop_token); +} + +bool ControlObjectSession::selection_expired(const ActiveContext& context) const noexcept { + return std::chrono::steady_clock::now() - context.created > descriptor_.sbo_timeout; +} + +std::chrono::milliseconds ControlObjectSession::effective_operate_timeout( + const ControlRequest& request) const { + auto timeout = request.command_termination_timeout.value_or( + descriptor_.operate_timeout); + if (timeout <= std::chrono::milliseconds::zero()) { + timeout = descriptor_.operate_timeout; + } + if (!request.operate_at.has_value()) { + return timeout; + } + const auto now = std::chrono::system_clock::now(); + if (request.operate_at.value() <= now) { + return timeout; + } + return std::chrono::duration_cast( + request.operate_at.value() - now) + timeout; +} + +void ControlObjectSession::best_effort_cancel() noexcept { + if (!active_context_.has_value()) { + return; + } + const auto context = active_context_.value(); + try { + if (descriptor_.cancel_specification.has_value() && transport_.associated()) { + const auto value = MmsControlStructureBuilder::build_cancel( + context.mms, + descriptor_.cancel_specification.value(), + options_.require_exact_named_control_fields); + if (value.success()) { + static_cast(transport_.write( + object_name(descriptor_.object, "CO", "Cancel"), + value.value.value(), {})); + } + } + } catch (...) { + } + clear_selection(); +} + +void ControlObjectSession::clear_selection() noexcept { + planner_.on_association_closed(transport_.association_id()); + active_context_.reset(); +} + +void ControlObjectSession::on_association_closed() noexcept { + if (active_context_.has_value()) { + expired_context_.reset(); + } + planner_.on_association_closed(transport_.association_id()); + active_context_.reset(); +} + +} // namespace ar::iec61850::control From a77b752249482bd00ec88b5e771a90ba9bb0cef5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:55:36 +0700 Subject: [PATCH 07/14] Add deterministic smart-control session parity tests --- tests/test_control_session.cpp | 457 +++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 tests/test_control_session.cpp diff --git a/tests/test_control_session.cpp b/tests/test_control_session.cpp new file mode 100644 index 000000000..dbd6e40e2 --- /dev/null +++ b/tests/test_control_session.cpp @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +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) + +bool allow_all( + void*, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + return true; +} + +MmsTypeSpecification scalar( + const MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt) { + MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + return result; +} + +MmsTypeSpecification structure( + std::string name, + std::vector children) { + MmsTypeSpecification result; + result.kind = MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +MmsTypeSpecification origin_spec() { + return structure("origin", { + scalar(MmsTypeKind::unsigned_integer, "orCat"), + scalar(MmsTypeKind::octet_string, "orIdent", 64U), + }); +} + +MmsTypeSpecification oper_spec(const bool include_oper_tm = false) { + std::vector children{ + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + }; + if (include_oper_tm) { + children.push_back(scalar(MmsTypeKind::utc_time, "operTm")); + } + children.push_back(origin_spec()); + children.push_back(scalar(MmsTypeKind::unsigned_integer, "ctlNum")); + children.push_back(scalar(MmsTypeKind::utc_time, "T")); + children.push_back(scalar(MmsTypeKind::boolean, "Test")); + children.push_back(scalar(MmsTypeKind::bit_string, "Check", 2U)); + return structure("Oper", std::move(children)); +} + +MmsTypeSpecification cancel_spec() { + return structure("Cancel", { + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + origin_spec(), + scalar(MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(MmsTypeKind::utc_time, "T"), + scalar(MmsTypeKind::boolean, "Test"), + scalar(MmsTypeKind::bit_string, "Check", 2U), + }); +} + +MmsObjectName name(std::string item) { + return MmsObjectName::domain_specific("LD0", std::move(item)); +} + +MmsInformationReport positive_oper_report() { + MmsInformationReport report; + report.variable_references.push_back(name("CSWI1$CO$Pos$Oper")); + return report; +} + +MmsInformationReport ordinary_status_report() { + MmsInformationReport report; + report.variable_references.push_back(name("CSWI1$ST$Pos$stVal")); + MmsInformationReportItem item; + item.index = 0U; + item.value = MmsDataValue::boolean(true); + report.items.push_back(std::move(item)); + return report; +} + +MmsInformationReport negative_error_report(const std::int64_t add_cause) { + MmsInformationReport report; + MmsInformationReportItem item; + item.index = 0U; + item.value = MmsDataValue::structure({ + MmsDataValue::visible_string("LD0/CSWI1.Pos"), + MmsDataValue::integer(0), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(add_cause), + }); + report.items.push_back(std::move(item)); + return report; +} + +class ScriptedControlTransport final : public ControlTransport { +public: + bool is_associated{true}; + std::uint64_t id{0xA55AU}; + std::map reads; + std::map specifications; + std::map> names; + std::deque write_results; + std::deque reports; + std::vector events; + std::vector writes; + std::size_t clear_count{}; + + [[nodiscard]] bool associated() const noexcept override { return is_associated; } + [[nodiscard]] std::uint64_t association_id() const noexcept override { return id; } + + [[nodiscard]] std::optional read( + const MmsObjectName& object, + std::stop_token = {}) override { + events.push_back("read:" + object.reference()); + const auto found = reads.find(object.reference()); + return found == reads.end() ? std::nullopt + : std::optional{found->second}; + } + + [[nodiscard]] std::optional variable_specification( + const MmsObjectName& object, + std::stop_token = {}) override { + events.push_back("gvaa:" + object.reference()); + const auto found = specifications.find(object.reference()); + return found == specifications.end() + ? std::nullopt + : std::optional{found->second}; + } + + [[nodiscard]] std::vector domain_variable_names( + const std::string& domain, + std::stop_token = {}) override { + events.push_back("names:" + domain); + const auto found = names.find(domain); + return found == names.end() ? std::vector{} : found->second; + } + + [[nodiscard]] ControlTransportWriteResult write( + const MmsObjectName& object, + MmsDataValue, + std::stop_token = {}) override { + events.push_back("write:" + object.reference()); + writes.push_back(object.reference()); + if (write_results.empty()) { + return {true, std::nullopt}; + } + const auto result = write_results.front(); + write_results.pop_front(); + return result; + } + + void clear_information_reports() override { + ++clear_count; + events.push_back("clear-reports"); + reports.clear(); + for (auto& report : reports_after_clear) { + reports.push_back(std::move(report)); + } + reports_after_clear.clear(); + } + + [[nodiscard]] bool wait_information_report( + std::chrono::milliseconds, + MmsInformationReport& report, + std::stop_token = {}) override { + events.push_back("wait-report"); + if (reports.empty()) { + return false; + } + report = std::move(reports.front()); + reports.pop_front(); + return true; + } + + std::vector reports_after_clear; +}; + +ControlObjectReference parsed_object() { + ControlObjectReference object; + CHECK(try_parse_control_object_reference("LD0/CSWI1.Pos", object)); + return object; +} + +ControlObjectDescriptor descriptor(const ControlModel model) { + ControlObjectDescriptor result; + result.object = parsed_object(); + result.model = model; + result.ctl_val_specification = scalar(MmsTypeKind::bit_string, "ctlVal", 2U); + result.operate_specification = oper_spec(); + result.cancel_specification = cancel_spec(); + if (model == ControlModel::select_before_operate_enhanced) { + auto sbow = oper_spec(); + sbow.name = "SBOw"; + result.select_with_value_specification = sbow; + } + result.sbo_timeout = std::chrono::milliseconds{10'000}; + result.operate_timeout = std::chrono::milliseconds{10'000}; + result.supports_command_termination = model_is_enhanced(model); + result.supports_time_activated_operate = false; + result.cdc = "DPC"; + return result; +} + +ControlSessionOptions options() { + ControlSessionOptions result; + result.application_error_grace_period = std::chrono::milliseconds{1}; + result.guarded_policy.authorize = allow_all; + return result; +} + +ControlRequest request(const DoublePointValue value = DoublePointValue::on) { + ControlRequest result; + result.control_value = ControlValue::double_point(value); + result.origin_category = OriginCategory::station_control; + result.origin_identifier = {'H', 'M', 'I'}; + result.interlock_check = true; + result.synchro_check = true; + return result; +} + +void discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CF$Pos$ctlModel"] = MmsDataValue::integer(2); + transport.reads["LD0/CSWI1$CF$Pos$sboTimeout"] = MmsDataValue::unsigned_integer(1'500U); + transport.reads["LD0/CSWI1$CF$Pos$operTimeout"] = MmsDataValue::integer(2'500); + transport.specifications["LD0/CSWI1$CO$Pos$Oper"] = oper_spec(); + transport.specifications["LD0/CSWI1$CO$Pos$Cancel"] = cancel_spec(); + transport.names["LD0"] = { + "CSWI1$CF$Pos$sboTimeout", + "CSWI1$CF$Pos$operTimeout", + "CSWI1$ST$Pos$stVal", + }; + + const auto found = ControlDescriptorDiscovery::discover( + transport, "LD0/CSWI1.Pos"); + CHECK(found.model == ControlModel::select_before_operate_normal); + CHECK(found.cdc == "DPC"); + CHECK(found.sbo_timeout == std::chrono::milliseconds{1'500}); + CHECK(found.operate_timeout == std::chrono::milliseconds{2'500}); + CHECK(found.status_object.has_value()); + CHECK(found.status_object->reference() == "LD0/CSWI1$ST$Pos$stVal"); + CHECK(found.status_functional_constraint == "ST"); + CHECK(found.operationally_ready()); + + // Cancel is mandatory for a model that requires Select. + transport.specifications.erase("LD0/CSWI1$CO$Pos$Cancel"); + bool rejected = false; + try { + static_cast(ControlDescriptorDiscovery::discover( + transport, "LD0/CSWI1.Pos")); + } catch (const std::runtime_error&) { + rejected = true; + } + CHECK(rejected); +} + +void direct_normal_is_exactly_one_oper_write() { + ScriptedControlTransport transport; + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_normal), options()}; + const auto result = session.operate(request()); + CHECK(result.success()); + CHECK(result.completion == ControlCompletionState::accepted); + CHECK(result.request_accepted); + CHECK(result.control_number == 1U); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$Oper"); + CHECK(transport.clear_count == 1U); + const auto clear = std::find( + transport.events.begin(), transport.events.end(), "clear-reports"); + const auto write = std::find( + transport.events.begin(), transport.events.end(), + "write:LD0/CSWI1$CO$Pos$Oper"); + CHECK(clear < write); +} + +void sbo_normal_select_oper_and_cancel_are_immutable() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CO$Pos$SBO"] = + MmsDataValue::visible_string("LD0/CSWI1.Pos"); + ControlObjectSession session{ + transport, descriptor(ControlModel::select_before_operate_normal), options()}; + + auto selected = session.select(request()); + CHECK(selected.success()); + CHECK(session.has_active_selection()); + CHECK(transport.writes.empty()); // normal SBO Select is a Read. + + const auto operated = session.operate(request()); + CHECK(operated.success()); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$Oper"); + CHECK(!session.has_active_selection()); + + selected = session.select(request()); + CHECK(selected.success()); + const auto cancelled = session.cancel(); + CHECK(cancelled.success()); + CHECK(transport.writes.back() == "LD0/CSWI1$CO$Pos$Cancel"); + CHECK(!session.has_active_selection()); +} + +void stale_sbo_sequence_is_cancelled_not_operated() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::boolean(true); + ControlObjectSession session{ + transport, descriptor(ControlModel::select_before_operate_normal), options()}; + CHECK(session.select(request(DoublePointValue::on)).success()); + + const auto result = session.operate(request(DoublePointValue::off)); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::rejected); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$Cancel"); + CHECK(!session.has_active_selection()); +} + +void auto_select_performs_sbo_read_then_one_oper_write() { + ScriptedControlTransport transport; + transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::visible_string("SEL-TOKEN"); + ControlObjectSession session{ + transport, descriptor(ControlModel::select_before_operate_normal), options()}; + const auto result = session.operate(request()); + CHECK(result.success()); + CHECK(transport.writes.size() == 1U); + CHECK(std::find(transport.events.begin(), transport.events.end(), + "read:LD0/CSWI1$CO$Pos$SBO") != transport.events.end()); +} + +void enhanced_oper_ignores_status_and_requires_exact_termination() { + ScriptedControlTransport transport; + transport.reports_after_clear.push_back(ordinary_status_report()); + transport.reports_after_clear.push_back(positive_oper_report()); + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_enhanced), options()}; + const auto result = session.operate(request()); + CHECK(result.success()); + CHECK(result.completion == ControlCompletionState::positive_termination); + CHECK(result.command_termination_received); + CHECK(result.positive_termination); + CHECK(transport.writes.size() == 1U); +} + +void enhanced_negative_last_appl_error_is_returned() { + ScriptedControlTransport transport; + transport.reports_after_clear.push_back(negative_error_report(10)); + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_enhanced), options()}; + const auto result = session.operate(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::negative_termination); + CHECK(result.request_accepted); + CHECK(result.command_termination_received); + CHECK(result.add_cause == AddCause::blocked_by_interlocking); + CHECK(transport.writes.size() == 1U); +} + +void enhanced_timeout_is_not_association_failure() { + ScriptedControlTransport transport; + auto desc = descriptor(ControlModel::direct_enhanced); + desc.operate_timeout = std::chrono::milliseconds{1}; + ControlObjectSession session{transport, desc, options()}; + const auto result = session.operate(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::timed_out); + CHECK(result.request_accepted); + CHECK(transport.associated()); + CHECK(transport.writes.size() == 1U); +} + +void sbow_application_error_after_write_rejects_selection() { + ScriptedControlTransport transport; + transport.reports_after_clear.push_back(negative_error_report(11)); + ControlObjectSession session{ + transport, + descriptor(ControlModel::select_before_operate_enhanced), + options()}; + const auto result = session.select_with_value(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::rejected); + CHECK(result.request_accepted); + CHECK(result.add_cause == AddCause::blocked_by_synchrocheck); + CHECK(!session.has_active_selection()); + CHECK(transport.writes.size() == 1U); + CHECK(transport.writes.front() == "LD0/CSWI1$CO$Pos$SBOw"); +} + +void default_authorization_still_fails_closed_at_live_session() { + ScriptedControlTransport transport; + ControlSessionOptions denied_options; + ControlObjectSession session{ + transport, descriptor(ControlModel::direct_normal), denied_options}; + const auto result = session.operate(request()); + CHECK(!result.success()); + CHECK(result.completion == ControlCompletionState::rejected); + CHECK(result.add_cause == AddCause::no_access_authority); + CHECK(transport.writes.empty()); +} + +} // namespace + +int main() { + try { + discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules(); + direct_normal_is_exactly_one_oper_write(); + sbo_normal_select_oper_and_cancel_are_immutable(); + stale_sbo_sequence_is_cancelled_not_operated(); + auto_select_performs_sbo_read_then_one_oper_write(); + enhanced_oper_ignores_status_and_requires_exact_termination(); + enhanced_negative_last_appl_error_is_returned(); + enhanced_timeout_is_not_association_failure(); + sbow_application_error_after_write_rejects_selection(); + default_authorization_still_fails_closed_at_live_session(); + std::cout << "Control session parity tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From 6452971b405bfd1d49c4f77f6aa09dc8343876f5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:56:03 +0700 Subject: [PATCH 08/14] Add strict live control session parity profile --- tests/control_session_profile/CMakeLists.txt | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/control_session_profile/CMakeLists.txt diff --git a/tests/control_session_profile/CMakeLists.txt b/tests/control_session_profile/CMakeLists.txt new file mode 100644 index 000000000..13b19f178 --- /dev/null +++ b/tests/control_session_profile/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlSessionProfile + VERSION 0.1.0 + DESCRIPTION "IEC 61850 smart-control discovery and session parity validation" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +function(ariec61850_control_strict target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endfunction() + +add_executable(ariec61850_control_session_tests + ${ARIEC61850_ROOT}/tests/test_control_session.cpp + ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/mms/data_codec.cpp + ${ARIEC61850_ROOT}/src/mms/services.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp + ${ARIEC61850_ROOT}/src/control/mms_control_structure.cpp + ${ARIEC61850_ROOT}/src/control/command_termination.cpp + ${ARIEC61850_ROOT}/src/control/control_discovery.cpp + ${ARIEC61850_ROOT}/src/control/control_session.cpp) +ariec61850_control_strict(ariec61850_control_session_tests) + +# Production adapter compile gate. Linking is exercised by repository C++ CI +# once these sources are part of ariec61850_core; this target catches API and +# warning regressions without duplicating the complete association link graph. +add_library(ariec61850_control_transport_compile OBJECT + ${ARIEC61850_ROOT}/src/control/mms_association_control_transport.cpp + ${ARIEC61850_ROOT}/src/mms/association_runtime_control_wait.cpp) +ariec61850_control_strict(ariec61850_control_transport_compile) + +enable_testing() +add_test( + NAME ariec61850_control_session_tests + COMMAND ariec61850_control_session_tests) + +message(STATUS "arstack61850 Control session: ctlModel + live GVAA discovery=ON") +message(STATUS "arstack61850 Control session: Direct/SBO/SBOw/Oper/Cancel orchestration=ON") +message(STATUS "arstack61850 Control session: subscribe/drain before command Write=ON") +message(STATUS "arstack61850 Control session: enhanced CommandTermination completion=ON") +message(STATUS "arstack61850 Control session: command retry=OFF") +message(STATUS "arstack61850 Control session: default authorization=DENY") From d460eaa459691078260f552fd5bcca37fae42822 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:56:22 +0700 Subject: [PATCH 09/14] Gate smart-control session parity on GCC and Clang --- .../workflows/control-session-profile-ci.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/control-session-profile-ci.yml diff --git a/.github/workflows/control-session-profile-ci.yml b/.github/workflows/control-session-profile-ci.yml new file mode 100644 index 000000000..b1936b143 --- /dev/null +++ b/.github/workflows/control-session-profile-ci.yml @@ -0,0 +1,61 @@ +name: Control Session Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/mms/association_runtime.hpp' + - 'src/mms/association_runtime_control_wait.cpp' + - 'tests/test_control_session.cpp' + - 'tests/control_session_profile/**' + - '.github/workflows/control-session-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-session-profile: + name: Control Session / ${{ 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 session profile + run: cmake -S tests/control_session_profile -B build-control-session + + - name: Build control session profile + run: cmake --build build-control-session --parallel + + - name: Run smart-control parity tests + run: >- + ctest --test-dir build-control-session + --output-on-failure + --output-junit control-session-ctest.xml + + - name: Upload control session evidence + uses: actions/upload-artifact@v4 + with: + name: control-session-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-control-session/control-session-ctest.xml From 8f31f45f1c44226df07b339863051cad380d5e4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:00:35 +0700 Subject: [PATCH 10/14] Link MMS PDU dependencies in control session profile --- tests/control_session_profile/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/control_session_profile/CMakeLists.txt b/tests/control_session_profile/CMakeLists.txt index 13b19f178..eed426ee3 100644 --- a/tests/control_session_profile/CMakeLists.txt +++ b/tests/control_session_profile/CMakeLists.txt @@ -21,9 +21,11 @@ endfunction() add_executable(ariec61850_control_session_tests ${ARIEC61850_ROOT}/tests/test_control_session.cpp ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/osi/presentation.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/mms/data_value.cpp ${ARIEC61850_ROOT}/src/mms/data_codec.cpp + ${ARIEC61850_ROOT}/src/mms/pdu.cpp ${ARIEC61850_ROOT}/src/mms/services.cpp ${ARIEC61850_ROOT}/src/control/guarded_control.cpp ${ARIEC61850_ROOT}/src/control/mms_control_structure.cpp From 6dbdade7e37f088817565e8ecd890b62f94ce0d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:03:55 +0700 Subject: [PATCH 11/14] Fix non-default MMS value fixtures in control session tests --- tests/test_control_session.cpp | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test_control_session.cpp b/tests/test_control_session.cpp index dbd6e40e2..94fd10486 100644 --- a/tests/test_control_session.cpp +++ b/tests/test_control_session.cpp @@ -257,9 +257,12 @@ ControlRequest request(const DoublePointValue value = DoublePointValue::on) { void discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CF$Pos$ctlModel"] = MmsDataValue::integer(2); - transport.reads["LD0/CSWI1$CF$Pos$sboTimeout"] = MmsDataValue::unsigned_integer(1'500U); - transport.reads["LD0/CSWI1$CF$Pos$operTimeout"] = MmsDataValue::integer(2'500); + transport.reads.insert_or_assign( + "LD0/CSWI1$CF$Pos$ctlModel", MmsDataValue::integer(2)); + transport.reads.insert_or_assign( + "LD0/CSWI1$CF$Pos$sboTimeout", MmsDataValue::unsigned_integer(1'500U)); + transport.reads.insert_or_assign( + "LD0/CSWI1$CF$Pos$operTimeout", MmsDataValue::integer(2'500)); transport.specifications["LD0/CSWI1$CO$Pos$Oper"] = oper_spec(); transport.specifications["LD0/CSWI1$CO$Pos$Cancel"] = cancel_spec(); transport.names["LD0"] = { @@ -279,16 +282,15 @@ void discovery_matches_csharp_ctlmodel_gvaa_and_timeout_rules() { CHECK(found.status_functional_constraint == "ST"); CHECK(found.operationally_ready()); - // Cancel is mandatory for a model that requires Select. transport.specifications.erase("LD0/CSWI1$CO$Pos$Cancel"); - bool rejected = false; + bool rejected_discovery = false; try { static_cast(ControlDescriptorDiscovery::discover( transport, "LD0/CSWI1.Pos")); } catch (const std::runtime_error&) { - rejected = true; + rejected_discovery = true; } - CHECK(rejected); + CHECK(rejected_discovery); } void direct_normal_is_exactly_one_oper_write() { @@ -313,15 +315,15 @@ void direct_normal_is_exactly_one_oper_write() { void sbo_normal_select_oper_and_cancel_are_immutable() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CO$Pos$SBO"] = - MmsDataValue::visible_string("LD0/CSWI1.Pos"); + transport.reads.insert_or_assign( + "LD0/CSWI1$CO$Pos$SBO", MmsDataValue::visible_string("LD0/CSWI1.Pos")); ControlObjectSession session{ transport, descriptor(ControlModel::select_before_operate_normal), options()}; auto selected = session.select(request()); CHECK(selected.success()); CHECK(session.has_active_selection()); - CHECK(transport.writes.empty()); // normal SBO Select is a Read. + CHECK(transport.writes.empty()); const auto operated = session.operate(request()); CHECK(operated.success()); @@ -339,7 +341,8 @@ void sbo_normal_select_oper_and_cancel_are_immutable() { void stale_sbo_sequence_is_cancelled_not_operated() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::boolean(true); + transport.reads.insert_or_assign( + "LD0/CSWI1$CO$Pos$SBO", MmsDataValue::boolean(true)); ControlObjectSession session{ transport, descriptor(ControlModel::select_before_operate_normal), options()}; CHECK(session.select(request(DoublePointValue::on)).success()); @@ -354,7 +357,8 @@ void stale_sbo_sequence_is_cancelled_not_operated() { void auto_select_performs_sbo_read_then_one_oper_write() { ScriptedControlTransport transport; - transport.reads["LD0/CSWI1$CO$Pos$SBO"] = MmsDataValue::visible_string("SEL-TOKEN"); + transport.reads.insert_or_assign( + "LD0/CSWI1$CO$Pos$SBO", MmsDataValue::visible_string("SEL-TOKEN")); ControlObjectSession session{ transport, descriptor(ControlModel::select_before_operate_normal), options()}; const auto result = session.operate(request()); From 878bbb9de4246d50e16cf9543eb6dff7e08403f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:05:19 +0700 Subject: [PATCH 12/14] Link Session codec dependency in control session profile --- tests/control_session_profile/CMakeLists.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/control_session_profile/CMakeLists.txt b/tests/control_session_profile/CMakeLists.txt index eed426ee3..15f9aaf9d 100644 --- a/tests/control_session_profile/CMakeLists.txt +++ b/tests/control_session_profile/CMakeLists.txt @@ -21,6 +21,7 @@ endfunction() add_executable(ariec61850_control_session_tests ${ARIEC61850_ROOT}/tests/test_control_session.cpp ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/osi/session.cpp ${ARIEC61850_ROOT}/src/osi/presentation.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/mms/data_value.cpp @@ -34,9 +35,6 @@ add_executable(ariec61850_control_session_tests ${ARIEC61850_ROOT}/src/control/control_session.cpp) ariec61850_control_strict(ariec61850_control_session_tests) -# Production adapter compile gate. Linking is exercised by repository C++ CI -# once these sources are part of ariec61850_core; this target catches API and -# warning regressions without duplicating the complete association link graph. add_library(ariec61850_control_transport_compile OBJECT ${ARIEC61850_ROOT}/src/control/mms_association_control_transport.cpp ${ARIEC61850_ROOT}/src/mms/association_runtime_control_wait.cpp) From 8109f88a6fbfee5568c9bfef61b723a2e6d364b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:09:08 +0700 Subject: [PATCH 13/14] Integrate guarded control parity into normal core build --- CMakeLists.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 39c5e1a8b..08346d674 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,10 +28,17 @@ add_library(ariec61850_core src/mms/invoke_router.cpp src/mms/reporting.cpp src/mms/association_runtime.cpp + src/mms/association_runtime_control_wait.cpp src/mms/report_subscription_runtime.cpp src/mms/tcp_transport.cpp src/mms/live_discovery.cpp src/mms/rcb_selection.cpp + src/control/guarded_control.cpp + src/control/mms_control_structure.cpp + src/control/command_termination.cpp + src/control/control_discovery.cpp + src/control/control_session.cpp + src/control/mms_association_control_transport.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/quality.cpp @@ -255,6 +262,27 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_control_block_read_tests) add_test(NAME ariec61850_control_block_read_tests COMMAND ariec61850_control_block_read_tests) + add_executable(ariec61850_control_mms_structure_tests tests/test_control_mms_structure.cpp) + target_link_libraries(ariec61850_control_mms_structure_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_control_mms_structure_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_control_mms_structure_tests) + ariec61850_apply_sanitizers(ariec61850_control_mms_structure_tests) + add_test(NAME ariec61850_control_mms_structure_tests COMMAND ariec61850_control_mms_structure_tests) + + add_executable(ariec61850_command_termination_tests tests/test_command_termination.cpp) + target_link_libraries(ariec61850_command_termination_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_command_termination_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_command_termination_tests) + ariec61850_apply_sanitizers(ariec61850_command_termination_tests) + add_test(NAME ariec61850_command_termination_tests COMMAND ariec61850_command_termination_tests) + + add_executable(ariec61850_control_session_tests tests/test_control_session.cpp) + target_link_libraries(ariec61850_control_session_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_control_session_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_control_session_tests) + ariec61850_apply_sanitizers(ariec61850_control_session_tests) + add_test(NAME ariec61850_control_session_tests COMMAND ariec61850_control_session_tests) + add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE From 943f1506f2c527555b44a882e51267407e8dce0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:10:56 +0700 Subject: [PATCH 14/14] Update port status for guarded control parity C1-C4 --- PORT_STATUS.md | 147 +++++++++++++++++++++++++------------------------ 1 file changed, 76 insertions(+), 71 deletions(-) diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 331b05cf5..0c1da50e2 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,9 +2,11 @@ ## Current milestone -**Phase 4D-R3 — bounded BRCB operational semantics, association ownership, MMS replay/purge controls, lifecycle handling, and replay-capable recovery state v2 are implemented for review.** +**Phase 4D-C4 — guarded IEC 61850 control-model software parity is implemented for review: live `ctlModel`/TypeSpecification discovery, Direct/SBO normal and enhanced sequencing, exact `Oper`/`SBOw`/`Cancel` structures, CommandTermination correlation, and LastApplError/AddCause diagnostics.** -The active Phase 4D work is intentionally carried as a stacked draft-PR series on top of the ESP32-P4/SV work. `main` does not yet represent this milestone. +The active Phase 4D work is intentionally carried as a stacked draft-PR series. `main` does not yet represent this milestone. + +Sampled Values / ESP32-P4 process-bus work is developed and validated on a separate branch/thread. Its hardware success is not used as evidence for MMS control behavior. ## Delivered modules @@ -19,78 +21,81 @@ The active Phase 4D work is intentionally carried as a stacked draft-PR series o | Persistent RCB subscription runtime | Merged to `main` | | Built-in Windows/Linux TCP transport | Implemented in Phase 4C stack | | Live read-only MMS discovery/model parity | Implemented in Phase 4C stack | -| ESP32-P4 BRCB raw-partition backend + geometry/latency probe firmware | Implemented; physical measurement pending | | BRCB retained history + independent delivery cursor | Phase 4D-R1 / PR #16 | | Association-aware BRCB reservation/Owner/ResvTms state machine | Phase 4D-R2a / PR #17 | | Per-request association identity propagated through MMS writes | Phase 4D-R2b / PR #18 | | MMS RptEna/EntryID/PurgeBuf/ResvTms/Owner operational object bank | Phase 4D-R2c / PR #20 | | TCP/reset/COTP-DR association-loss lifecycle bridge | Phase 4D-R2d / PR #22 | | Replay-capable BRCB recovery image v2 with v1 restore | Phase 4D-R3 / PR #23 | - -## Phase 4D behavior - -- BRCB delivery no longer destroys retained report history. -- The bounded ring has an independent delivery cursor and explicit retained-history size. -- `EntryID` can resume after a retained report; all-zero `EntryID` rewinds to the oldest retained report. -- `PurgeBuf` clears retained history and replay-gap state without rolling EntryID backward. -- Queue overflow remains bounded and records both dropped-report count and a replay-gap condition. -- Reservation ownership uses an opaque stable Owner identity plus a separate ephemeral MMS association ID; it is not coupled to IP addresses or socket handles. -- A second live association cannot steal an owned BRCB, including one presenting the same stable Owner identity. -- `ResvTms=0` releases on association loss; positive `ResvTms` retains the stable Owner until expiry and permits that Owner to reconnect with a new association ID. -- Association loss always disables `RptEna`. -- TCP EOF/socket/local close, runtime reset, and incoming COTP Disconnect Request use the same exactly-once lifecycle bridge. -- MMS static writes carry caller-owned association context without changing legacy non-contextual object callbacks. -- The operational MMS object bank exposes `RptID`, `RptEna`, `DatSet`, `ConfRev`, `PurgeBuf`, `EntryID`, `ResvTms`, and `Owner`. -- Recovery image v2 persists the full retained replay window, delivery cursor, replay-gap flag, EntryIDs, report PDUs, sequence state, next EntryID, and dropped-report counter. -- Recovery image v2 accepts legacy v1 images. A v1 image restores with its original undelivered-only semantics. -- Reboot recovery deliberately returns reporting disabled and does not restore live association identity or Owner connection state. - -## Validation completed without physical ESP32-P4 flash evidence - -The BRCB hard profile is compiled with GCC and Clang using `-fno-exceptions -fno-rtti`, and its evidence gate currently covers: - -- bounded BRCB capture, EntryID progression and BufOvfl behavior; -- retained replay history and independent delivery cursor; -- multi-client ownership, reservation, reconnect and expiry semantics; -- encoded MMS writes crossing BER decode -> dispatcher -> association-aware BRCB control; -- EntryID resume/rewind, invalid EntryID rejection and PurgeBuf; -- TCP/reset/COTP-DR association-loss lifecycle behavior; -- A/B checkpoint generation, torn-write fallback and corruption fallback; -- recovery state v2 preserving delivered history, cursor and replay gap; -- durable purge with monotonic EntryID after reboot; -- backward restore of a legacy v1 recovery image; -- absence of C++ exception runtime symbols in the hard-profile binaries. - -## Physical non-volatile storage gate - -The ESP32-P4 non-volatile adapter and probe firmware are implemented, but **physical acceptance is not yet claimed**. - -Current evidence status remains: - -`READY_NOT_MEASURED` - -The dedicated ESP32-P4 probe can read the actual partition erase geometry and measure erase/write/read latency using the real `brcb_state` raw partition. Hosted CI cannot replace this evidence. - -Before changing the status to hardware-measured/passed, capture and retain: - -1. actual `esp_partition_t::erase_size` and accepted A/B/probe geometry from the target board; -2. measured erase/write/read latency records from the destructive isolated probe erase unit; -3. the actual flash-device endurance/P-E rating used to convert checkpoint frequency into a wear budget; -4. an accepted checkpoint interval and worst-case lifetime policy based on that rating; -5. controlled physical power-loss evidence demonstrating committed-bank recovery and torn-write fallback on the real board. - -Recovery state v2 can be larger than v1 because delivered replay history is intentionally retained. The checkpoint operation must therefore fail closed if the measured bank geometry cannot hold the configured worst-case retained state; software must not silently discard replay history merely to fit flash. - -## Remaining Phase 4D acceptance gates - -- Complete the repository-wide CI matrix for the final stacked head and resolve any non-BRCB regression if reported. -- Run the ESP32-P4 flash probe on physical hardware and archive UART geometry/latency evidence. -- Bind the actual flash P/E endurance rating into an explicit checkpoint-frequency/wear policy. -- Run controlled physical power-cut/reboot tests against the A/B journal and recovery-v2 image. -- Review and merge the stacked Phase 4D PRs in dependency order once their parent branches are accepted. - -## Deliberately deferred - -Mutable BRCB definition/configuration lifecycle for fields such as `OptFlds`, `BufTm`, `TrgOps`, and `IntgPd` is not mixed into the ownership/recovery work. Those fields require their own configuration/versioning rules. - -IEC 61850 control-model parity (`ctlModel`, Direct/SBO, enhanced security, `Oper`, `SBO/SBOw`, `Cancel`, command termination, `LastApplError`/`AddCause`, interlock/synchrocheck, and no automatic command retry) is the next major functional phase after the Phase 4D hardware acceptance gate is closed. +| Guarded Direct/SBO control safety core | Phase 4D-C1 / PR #25 | +| Live-type Oper/SBOw/Cancel MMS structure builder | Phase 4D-C2 / PR #27 | +| CommandTermination + LastApplError/AddCause correlation | Phase 4D-C3 / PR #28 | +| Live control discovery/session over MmsAssociationRuntime | Phase 4D-C4 / PR #29 | + +## Phase 4D-C control behavior + +- `ctlModel` values 0..4 match the C# oracle: status-only, Direct normal, SBO normal, Direct enhanced, and SBO enhanced. +- Control roots are Data Objects (`LD/LN.DO`); service-leaf references are rejected as roots. +- The control safety planner is association-aware and authorization is **default deny**. +- `ctlNum` is monotonic in the range 1..255 and wraps to 1; zero is never auto-generated. +- SBO retains an immutable selected sequence across `ctlVal`, origin, `ctlNum`, `T`, optional `operTm`, `Test`, synchrocheck, and interlock-check. +- A second association cannot operate or cancel another association's selection. +- Selection timeout, association loss, value/check mismatch, or authorization loss fail closed. +- Direct normal and SBO normal complete at the accepted MMS service boundary. +- Direct enhanced and SBO enhanced remain pending until a correlated CommandTermination arrives. +- Ordinary ST/MX process reports cannot complete an enhanced command. +- LastApplError retains the raw ControlError/AddCause values while mapping the standard ControlError 0..3 and AddCause 0..27 names. +- Positive Oper-only CommandTermination requires an exact `CO/Oper` reference. +- The live TypeSpecification is the source of truth for `ctlVal`, origin, `ctlNum`, `T`, `Test`, `Check`, and optional `operTm`; unknown vendor fields are rejected rather than guessed. +- DPC uses MMS network bit order (`Off=01`, `On=10`). +- `Check` is exactly two bits: bit 0 synchrocheck and bit 1 interlock-check. +- Normal SBO Select is a Read of `SBO`; SBO enhanced SelectWithValue is exactly one `SBOw` Write. +- `Cancel` is built from the exact retained selected sequence. +- Before `SBOw` or `Oper`, stale InformationReports are drained; reports arriving during the confirmed exchange remain available for application-error/termination correlation. +- Missing CommandTermination expires as a control timeout and does not automatically fault an otherwise healthy MMS association. +- **No automatic command retry is performed.** + +## Control validation completed without issuing a physical command + +Dedicated strict profiles currently cover: + +- GCC and Clang `-fno-exceptions -fno-rtti` guarded-control safety semantics; +- default-deny authorization and revoked authorization between Select and Oper; +- second-client takeover rejection, selection expiry, association-loss cleanup, Cancel ownership, and `ctlNum` wrap; +- exact live-TypeSpecification control-value binding and golden MMS Data bytes for Oper; +- DPC network bit order, StepPosition and conservative analogue structures; +- exact `origin`, `ctlNum`, `T`, `Test`, and two-bit `Check` construction; +- LastApplError standard/ctlObj-omitted layouts, unknown raw-code preservation, and object correlation; +- positive/negative enhanced CommandTermination behavior; +- live descriptor-discovery orchestration using `ctlModel`, GVAA, GetNameList, CF timeouts, and ST/MX status-reference priority; +- Direct one-write behavior, SBO Select -> Oper, SBOw asynchronous application-error window, AutoSelect, stale-selection Cancel, and enhanced termination wait; +- production `MmsAssociationControlTransport` strict compile validation; +- integration of C1-C4 sources into the normal `ARIEC61850::core` build graph and normal CTest targets. + +## Remaining control acceptance gates + +The software path is ready for controlled interoperability testing, but **physical IED control acceptance is not yet claimed**. + +Before any production-control or conformance claim, run on an isolated/non-operational laboratory IED or simulator and retain evidence for: + +1. live `ctlModel` and GVAA discovery for a known controllable object; +2. Direct normal `Oper` with exactly one command Write and observed process/status feedback; +3. SBO normal `SBO -> Oper` plus explicit `Cancel` and selection-timeout behavior; +4. Direct enhanced `Oper -> CommandTermination` positive and at least one safe negative LastApplError/AddCause path; +5. SBO enhanced `SBOw -> Oper -> CommandTermination` plus ownership/contention behavior; +6. association loss during selection and while waiting for enhanced termination; +7. interlock/synchrocheck behavior only where the lab setup can exercise it safely; +8. packet capture and deterministic event log proving no automatic command retry. + +## BRCB / non-volatile storage boundary + +BRCB recovery-v2 software and the ESP32-P4 raw-partition adapter/probe remain a separate persistence concern from Sampled Values hardware transmission and from MMS control. + +A board successfully booting/flashing another ESP32-P4 application does not by itself prove the BRCB flash geometry, erase/write/read latency, endurance budget, or controlled power-loss recovery. Those NVM measurements remain an independent hardware gate until their dedicated probe evidence is archived. + +## Safety / claim boundary + +- The C4 branch implements software parity and deterministic validation; it is **not** an IEC 61850 conformance certificate. +- No physical IED command is claimed from hosted CI. +- Live control must remain explicit, authorization-gated, and laboratory-scoped until interoperability evidence is accepted. +- Sampled Values / ESP32-P4 development remains outside this control branch.