From 23c9be6852f9ceb3c16663bdf3ae4041268a3683 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:25:52 +0700 Subject: [PATCH 01/50] Add portable guarded IEC 61850 control model core --- .../ariec61850/control/guarded_control.hpp | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 include/ariec61850/control/guarded_control.hpp diff --git a/include/ariec61850/control/guarded_control.hpp b/include/ariec61850/control/guarded_control.hpp new file mode 100644 index 000000000..6a3bfc51b --- /dev/null +++ b/include/ariec61850/control/guarded_control.hpp @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { + +enum class ControlModel : std::uint8_t { + status_only = 0U, + direct_normal = 1U, + select_before_operate_normal = 2U, + direct_enhanced = 3U, + select_before_operate_enhanced = 4U, + unknown = 0xFFU, +}; + +enum class ControlAction : std::uint8_t { + select, + select_with_value, + operate, + cancel, +}; + +enum class OriginCategory : std::uint8_t { + not_supported = 0U, + bay_control = 1U, + station_control = 2U, + remote_control = 3U, + automatic_bay = 4U, + automatic_station = 5U, + automatic_remote = 6U, + maintenance = 7U, + process = 8U, +}; + +enum class ControlError : std::uint8_t { + no_error = 0U, + unknown = 1U, + timeout_test = 2U, + operator_test = 3U, +}; + +enum class AddCause : std::uint8_t { + unknown = 0U, + not_supported = 1U, + blocked_by_switching_hierarchy = 2U, + select_failed = 3U, + invalid_position = 4U, + position_reached = 5U, + parameter_change_in_execution = 6U, + step_limit = 7U, + blocked_by_mode = 8U, + blocked_by_process = 9U, + blocked_by_interlocking = 10U, + blocked_by_synchrocheck = 11U, + command_already_in_execution = 12U, + blocked_by_health = 13U, + one_of_n_control = 14U, + abortion_by_cancel = 15U, + time_limit_over = 16U, + abortion_by_trip = 17U, + object_not_selected = 18U, + object_already_selected = 19U, + no_access_authority = 20U, + ended_with_overshoot = 21U, + abortion_due_to_deviation = 22U, + abortion_by_communication_loss = 23U, + abortion_by_command = 24U, + none = 25U, + inconsistent_parameters = 26U, + locked_by_other_client = 27U, +}; + +enum class GuardedControlStatus : std::uint8_t { + ok, + accepted_waiting_termination, + positive_termination, + negative_termination, + unsupported, + invalid_identity, + invalid_sequence, + access_denied, + object_not_selected, + object_already_selected, + locked_by_other_client, + selection_expired, + sequence_mismatch, + command_already_in_execution, + no_active_selection, + no_pending_termination, +}; + +struct ControlObjectReference final { + std::string_view domain{}; + std::string_view logical_node{}; + std::string_view data_object_path{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return !domain.empty() && !logical_node.empty() && !data_object_path.empty(); + } +}; + +// Parse the public control-object root used by the C# oracle: LD/LN.DO[.subDO]. +// Service leaves (Oper/SBO/SBOw/Cancel/ctlModel/etc.) are intentionally rejected. +[[nodiscard]] bool try_parse_control_object_reference( + std::string_view reference, + ControlObjectReference& output) noexcept; + +// Build an MMS variable item such as LN$CF$DO$ctlModel or LN$CO$DO$Oper. +// The domain is returned separately by ControlObjectReference::domain. +[[nodiscard]] bool build_control_item( + const ControlObjectReference& object, + std::string_view functional_constraint, + std::string_view service_leaf, + std::span destination, + std::size_t& bytes_written) noexcept; + +struct ControlClientIdentity final { + std::uint64_t association_id{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return association_id != 0U; + } +}; + +// `control_value` is one exact encoded MMS Data value (not an MMS Write PDU). +// The planner fingerprints it at selection/operate boundaries rather than +// allocating/copying an unbounded vendor-specific value. +struct ControlSequenceView final { + std::span control_value{}; + OriginCategory origin_category{OriginCategory::station_control}; + std::span origin_identifier{}; + std::uint8_t control_number{}; // 0 => association-local auto allocation. + std::uint64_t timestamp_token{}; // normalized T identity supplied by caller. + std::uint64_t operate_at_token{}; // 0 => immediate, otherwise normalized operTm. + bool test{}; + bool interlock_check{}; + bool synchro_check{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return !control_value.empty() && origin_identifier.size() <= 64U && + static_cast(origin_category) <= + static_cast(OriginCategory::process); + } +}; + +struct GuardedControlDecision final { + GuardedControlStatus status{GuardedControlStatus::unsupported}; + ControlAction action{ControlAction::operate}; + std::uint8_t control_number{}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + + [[nodiscard]] constexpr bool accepted() const noexcept { + return status == GuardedControlStatus::ok || + status == GuardedControlStatus::accepted_waiting_termination || + status == GuardedControlStatus::positive_termination; + } +}; + +using ControlAuthorizationCallback = bool (*)( + void* context, + ControlAction action, + const ControlObjectReference& object, + const ControlClientIdentity& client, + const ControlSequenceView& sequence) noexcept; + +struct GuardedControlPolicy final { + std::uint64_t sbo_timeout_ms{10'000U}; + void* authorization_context{}; + ControlAuthorizationCallback authorize{}; // nullptr => fail closed. +}; + +struct GuardedControlStateView final { + ControlModel model{ControlModel::unknown}; + bool selected{}; + bool waiting_for_termination{}; + std::uint64_t owner_association_id{}; + std::uint64_t selection_expires_at_ms{}; + std::uint8_t active_control_number{}; +}; + +// Allocation-free safety planner for one controllable Data Object. It models +// the immutable client sequence used by ARIEC61850 and deliberately does not +// perform network I/O. A transport layer executes the returned service action. +class GuardedControlPlanner final { +public: + GuardedControlPlanner( + ControlObjectReference object, + ControlModel model, + GuardedControlPolicy policy = {}) noexcept; + + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] GuardedControlDecision select( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlDecision select_with_value( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlDecision operate( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlDecision cancel( + const ControlClientIdentity& client, + std::uint64_t now_ms) noexcept; + + // Enhanced-security completion boundary. MMS Write acceptance alone leaves + // the planner waiting; only matching CommandTermination completes it. + [[nodiscard]] GuardedControlDecision command_termination( + ControlError error, + AddCause add_cause) noexcept; + + void on_association_closed(std::uint64_t association_id) noexcept; + void tick(std::uint64_t now_ms) noexcept; + [[nodiscard]] GuardedControlStateView state(std::uint64_t now_ms) noexcept; + +private: + struct SequenceFingerprint final { + std::uint64_t value_hash{}; + std::uint64_t origin_hash{}; + std::size_t value_bytes{}; + std::size_t origin_bytes{}; + OriginCategory origin_category{OriginCategory::station_control}; + std::uint8_t control_number{}; + std::uint64_t timestamp_token{}; + std::uint64_t operate_at_token{}; + bool test{}; + bool interlock_check{}; + bool synchro_check{}; + }; + + [[nodiscard]] GuardedControlDecision reject( + ControlAction action, + GuardedControlStatus status, + AddCause add_cause) const noexcept; + [[nodiscard]] bool authorized( + ControlAction action, + const ControlClientIdentity& client, + const ControlSequenceView& sequence) const noexcept; + [[nodiscard]] SequenceFingerprint fingerprint( + const ControlSequenceView& sequence, + std::uint8_t assigned_control_number) const noexcept; + [[nodiscard]] bool sequence_matches( + const ControlSequenceView& sequence, + const SequenceFingerprint& selected) const noexcept; + [[nodiscard]] std::uint8_t assign_control_number( + const ControlSequenceView& sequence) noexcept; + void expire_selection(std::uint64_t now_ms) noexcept; + void clear_active() noexcept; + + ControlObjectReference object_{}; + ControlModel model_{ControlModel::unknown}; + GuardedControlPolicy policy_{}; + SequenceFingerprint active_sequence_{}; + std::uint64_t owner_association_id_{}; + std::uint64_t selection_expires_at_ms_{}; + std::uint8_t next_control_number_{}; + bool selected_{}; + bool waiting_for_termination_{}; +}; + +[[nodiscard]] constexpr bool model_requires_select(const ControlModel model) noexcept { + return model == ControlModel::select_before_operate_normal || + model == ControlModel::select_before_operate_enhanced; +} + +[[nodiscard]] constexpr bool model_is_enhanced(const ControlModel model) noexcept { + return model == ControlModel::direct_enhanced || + model == ControlModel::select_before_operate_enhanced; +} + +} // namespace ar::iec61850::control From 563120ce56b1f03b2c2817528b18421cdd986820 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:26:56 +0700 Subject: [PATCH 02/50] Implement guarded Direct and SBO control planner --- src/control/guarded_control.cpp | 516 ++++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 src/control/guarded_control.cpp diff --git a/src/control/guarded_control.cpp b/src/control/guarded_control.cpp new file mode 100644 index 000000000..3fbb36d35 --- /dev/null +++ b/src/control/guarded_control.cpp @@ -0,0 +1,516 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/guarded_control.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +constexpr std::uint64_t kFnvOffset = 14695981039346656037ULL; +constexpr std::uint64_t kFnvPrime = 1099511628211ULL; + +[[nodiscard]] constexpr char ascii_lower(const char value) noexcept { + return value >= 'A' && value <= 'Z' + ? static_cast(value - 'A' + 'a') + : value; +} + +[[nodiscard]] bool ascii_equal( + const std::string_view left, + const std::string_view right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0U; index < left.size(); ++index) { + if (ascii_lower(left[index]) != ascii_lower(right[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool service_leaf(const std::string_view segment) noexcept { + constexpr std::array leaves{ + "ctlModel", "ctlVal", "ctlNum", "stSeld", "Oper", "SBO", "SBOw", + "Cancel", "origin", "orCat", "orIdent", "T", "Test", "Check", "operTm"}; + return std::any_of(leaves.begin(), leaves.end(), [&](const auto leaf) { + return ascii_equal(segment, leaf); + }); +} + +[[nodiscard]] std::string_view trim(const std::string_view input) noexcept { + std::size_t begin = 0U; + while (begin < input.size() && + (input[begin] == ' ' || input[begin] == '\t' || + input[begin] == '\r' || input[begin] == '\n')) { + ++begin; + } + std::size_t end = input.size(); + while (end > begin && + (input[end - 1U] == ' ' || input[end - 1U] == '\t' || + input[end - 1U] == '\r' || input[end - 1U] == '\n')) { + --end; + } + return input.substr(begin, end - begin); +} + +[[nodiscard]] bool path_separator(const char value) noexcept { + return value == '.' || value == '$'; +} + +[[nodiscard]] std::uint64_t hash_bytes( + const std::span bytes) noexcept { + std::uint64_t hash = kFnvOffset; + for (const auto byte : bytes) { + hash ^= byte; + hash *= kFnvPrime; + } + return hash; +} + +[[nodiscard]] bool same_fingerprint( + const GuardedControlPlanner::SequenceFingerprint& left, + const GuardedControlPlanner::SequenceFingerprint& right) noexcept; + +} // namespace + +bool try_parse_control_object_reference( + const std::string_view reference, + ControlObjectReference& output) noexcept { + output = {}; + const auto normalized = trim(reference); + const auto slash = normalized.find('/'); + if (slash == std::string_view::npos || slash == 0U || + slash + 1U >= normalized.size() || + normalized.find('/', slash + 1U) != std::string_view::npos) { + return false; + } + + const auto domain = trim(normalized.substr(0U, slash)); + const auto path = trim(normalized.substr(slash + 1U)); + if (domain.empty() || path.empty()) { + return false; + } + + std::size_t separator = std::string_view::npos; + for (std::size_t index = 0U; index < path.size(); ++index) { + if (path_separator(path[index])) { + separator = index; + break; + } + } + if (separator == std::string_view::npos || separator == 0U || + separator + 1U >= path.size()) { + return false; + } + + const auto logical_node = path.substr(0U, separator); + const auto data_object_path = path.substr(separator + 1U); + if (logical_node.empty() || data_object_path.empty()) { + return false; + } + + std::size_t segment_begin = 0U; + while (segment_begin < data_object_path.size()) { + auto segment_end = segment_begin; + while (segment_end < data_object_path.size() && + !path_separator(data_object_path[segment_end])) { + if (data_object_path[segment_end] == '/') { + return false; + } + ++segment_end; + } + if (segment_end == segment_begin || + service_leaf(data_object_path.substr(segment_begin, segment_end - segment_begin))) { + return false; + } + if (segment_end == data_object_path.size()) { + break; + } + segment_begin = segment_end + 1U; + if (segment_begin == data_object_path.size()) { + return false; + } + } + + output = {domain, logical_node, data_object_path}; + return output.valid(); +} + +bool build_control_item( + const ControlObjectReference& object, + const std::string_view functional_constraint, + const std::string_view service_leaf_name, + const std::span destination, + std::size_t& bytes_written) noexcept { + bytes_written = 0U; + if (!object.valid() || functional_constraint.empty() || service_leaf_name.empty() || + functional_constraint.find('$') != std::string_view::npos || + service_leaf_name.find('$') != std::string_view::npos || + service_leaf_name.find('.') != std::string_view::npos) { + return false; + } + + const auto required = object.logical_node.size() + 1U + + functional_constraint.size() + 1U + object.data_object_path.size() + + 1U + service_leaf_name.size(); + if (destination.size() < required) { + bytes_written = required; + return false; + } + + std::size_t offset = 0U; + const auto append = [&](const std::string_view text) { + for (const auto character : text) { + destination[offset++] = path_separator(character) ? '$' : character; + } + }; + append(object.logical_node); + destination[offset++] = '$'; + append(functional_constraint); + destination[offset++] = '$'; + append(object.data_object_path); + destination[offset++] = '$'; + append(service_leaf_name); + bytes_written = offset; + return true; +} + +GuardedControlPlanner::GuardedControlPlanner( + const ControlObjectReference object, + const ControlModel model, + const GuardedControlPolicy policy) noexcept + : object_{object}, model_{model}, policy_{policy} {} + +bool GuardedControlPlanner::valid() const noexcept { + return object_.valid() && + model_ != ControlModel::unknown && + static_cast(model_) <= + static_cast(ControlModel::select_before_operate_enhanced); +} + +GuardedControlDecision GuardedControlPlanner::reject( + const ControlAction action, + const GuardedControlStatus status, + const AddCause add_cause) const noexcept { + return {status, action, active_sequence_.control_number, ControlError::no_error, add_cause}; +} + +bool GuardedControlPlanner::authorized( + const ControlAction action, + const ControlClientIdentity& client, + const ControlSequenceView& sequence) const noexcept { + return policy_.authorize != nullptr && + policy_.authorize(policy_.authorization_context, action, object_, client, sequence); +} + +GuardedControlPlanner::SequenceFingerprint GuardedControlPlanner::fingerprint( + const ControlSequenceView& sequence, + const std::uint8_t assigned_control_number) const noexcept { + SequenceFingerprint result; + result.value_hash = hash_bytes(sequence.control_value); + result.origin_hash = hash_bytes(sequence.origin_identifier); + result.value_bytes = sequence.control_value.size(); + result.origin_bytes = sequence.origin_identifier.size(); + result.origin_category = sequence.origin_category; + result.control_number = assigned_control_number; + result.timestamp_token = sequence.timestamp_token; + result.operate_at_token = sequence.operate_at_token; + result.test = sequence.test; + result.interlock_check = sequence.interlock_check; + result.synchro_check = sequence.synchro_check; + return result; +} + +bool GuardedControlPlanner::sequence_matches( + const ControlSequenceView& sequence, + const SequenceFingerprint& selected) const noexcept { + const auto candidate = fingerprint( + sequence, + sequence.control_number == 0U ? selected.control_number : sequence.control_number); + return candidate.value_hash == selected.value_hash && + candidate.origin_hash == selected.origin_hash && + candidate.value_bytes == selected.value_bytes && + candidate.origin_bytes == selected.origin_bytes && + candidate.origin_category == selected.origin_category && + candidate.control_number == selected.control_number && + candidate.timestamp_token == selected.timestamp_token && + candidate.operate_at_token == selected.operate_at_token && + candidate.test == selected.test && + candidate.interlock_check == selected.interlock_check && + candidate.synchro_check == selected.synchro_check; +} + +std::uint8_t GuardedControlPlanner::assign_control_number( + const ControlSequenceView& sequence) noexcept { + if (sequence.control_number != 0U) { + return sequence.control_number; + } + next_control_number_ = next_control_number_ >= 255U + ? 1U + : static_cast(next_control_number_ + 1U); + return next_control_number_; +} + +void GuardedControlPlanner::clear_active() noexcept { + active_sequence_ = {}; + owner_association_id_ = 0U; + selection_expires_at_ms_ = 0U; + selected_ = false; + waiting_for_termination_ = false; +} + +void GuardedControlPlanner::expire_selection(const std::uint64_t now_ms) noexcept { + if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + clear_active(); + } +} + +GuardedControlDecision GuardedControlPlanner::select( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + const std::uint64_t now_ms) noexcept { + if (model_ != ControlModel::select_before_operate_normal) { + return reject(ControlAction::select, GuardedControlStatus::unsupported, AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::select, GuardedControlStatus::invalid_identity, AddCause::no_access_authority); + } + if (!sequence.valid()) { + return reject(ControlAction::select, GuardedControlStatus::invalid_sequence, AddCause::inconsistent_parameters); + } + expire_selection(now_ms); + if (waiting_for_termination_) { + return reject(ControlAction::select, GuardedControlStatus::command_already_in_execution, + AddCause::command_already_in_execution); + } + if (selected_) { + return reject(ControlAction::select, + owner_association_id_ == client.association_id + ? GuardedControlStatus::object_already_selected + : GuardedControlStatus::locked_by_other_client, + owner_association_id_ == client.association_id + ? AddCause::object_already_selected + : AddCause::locked_by_other_client); + } + if (!authorized(ControlAction::select, client, sequence)) { + return reject(ControlAction::select, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + const auto control_number = assign_control_number(sequence); + active_sequence_ = fingerprint(sequence, control_number); + owner_association_id_ = client.association_id; + selection_expires_at_ms_ = policy_.sbo_timeout_ms == 0U + ? 0U + : (now_ms > std::numeric_limits::max() - policy_.sbo_timeout_ms + ? std::numeric_limits::max() + : now_ms + policy_.sbo_timeout_ms); + selected_ = true; + return {GuardedControlStatus::ok, ControlAction::select, control_number, + ControlError::no_error, AddCause::none}; +} + +GuardedControlDecision GuardedControlPlanner::select_with_value( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + const std::uint64_t now_ms) noexcept { + if (model_ != ControlModel::select_before_operate_enhanced) { + return reject(ControlAction::select_with_value, GuardedControlStatus::unsupported, + AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::select_with_value, GuardedControlStatus::invalid_identity, + AddCause::no_access_authority); + } + if (!sequence.valid()) { + return reject(ControlAction::select_with_value, GuardedControlStatus::invalid_sequence, + AddCause::inconsistent_parameters); + } + expire_selection(now_ms); + if (selected_) { + return reject(ControlAction::select_with_value, + owner_association_id_ == client.association_id + ? GuardedControlStatus::object_already_selected + : GuardedControlStatus::locked_by_other_client, + owner_association_id_ == client.association_id + ? AddCause::object_already_selected + : AddCause::locked_by_other_client); + } + if (!authorized(ControlAction::select_with_value, client, sequence)) { + return reject(ControlAction::select_with_value, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + const auto control_number = assign_control_number(sequence); + active_sequence_ = fingerprint(sequence, control_number); + owner_association_id_ = client.association_id; + selection_expires_at_ms_ = policy_.sbo_timeout_ms == 0U + ? 0U + : (now_ms > std::numeric_limits::max() - policy_.sbo_timeout_ms + ? std::numeric_limits::max() + : now_ms + policy_.sbo_timeout_ms); + selected_ = true; + return {GuardedControlStatus::ok, ControlAction::select_with_value, control_number, + ControlError::no_error, AddCause::none}; +} + +GuardedControlDecision GuardedControlPlanner::operate( + const ControlClientIdentity& client, + const ControlSequenceView& sequence, + const std::uint64_t now_ms) noexcept { + if (!valid() || model_ == ControlModel::status_only) { + return reject(ControlAction::operate, GuardedControlStatus::unsupported, + AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::operate, GuardedControlStatus::invalid_identity, + AddCause::no_access_authority); + } + if (!sequence.valid()) { + return reject(ControlAction::operate, GuardedControlStatus::invalid_sequence, + AddCause::inconsistent_parameters); + } + if (waiting_for_termination_) { + return reject(ControlAction::operate, GuardedControlStatus::command_already_in_execution, + AddCause::command_already_in_execution); + } + + if (model_requires_select(model_)) { + if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + clear_active(); + return reject(ControlAction::operate, GuardedControlStatus::selection_expired, + AddCause::time_limit_over); + } + if (!selected_) { + return reject(ControlAction::operate, GuardedControlStatus::object_not_selected, + AddCause::object_not_selected); + } + if (owner_association_id_ != client.association_id) { + return reject(ControlAction::operate, GuardedControlStatus::locked_by_other_client, + AddCause::locked_by_other_client); + } + if (!sequence_matches(sequence, active_sequence_)) { + clear_active(); + return reject(ControlAction::operate, GuardedControlStatus::sequence_mismatch, + AddCause::inconsistent_parameters); + } + } + + if (!authorized(ControlAction::operate, client, sequence)) { + if (model_requires_select(model_)) { + clear_active(); + } + return reject(ControlAction::operate, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + std::uint8_t control_number{}; + if (model_requires_select(model_)) { + control_number = active_sequence_.control_number; + selected_ = false; + selection_expires_at_ms_ = 0U; + } else { + control_number = assign_control_number(sequence); + active_sequence_ = fingerprint(sequence, control_number); + owner_association_id_ = client.association_id; + } + + if (model_is_enhanced(model_)) { + waiting_for_termination_ = true; + return {GuardedControlStatus::accepted_waiting_termination, ControlAction::operate, + control_number, ControlError::no_error, AddCause::none}; + } + + const GuardedControlDecision result{ + GuardedControlStatus::ok, ControlAction::operate, control_number, + ControlError::no_error, AddCause::none}; + clear_active(); + return result; +} + +GuardedControlDecision GuardedControlPlanner::cancel( + const ControlClientIdentity& client, + const std::uint64_t now_ms) noexcept { + if (!model_requires_select(model_)) { + return reject(ControlAction::cancel, GuardedControlStatus::unsupported, + AddCause::not_supported); + } + if (!client.valid()) { + return reject(ControlAction::cancel, GuardedControlStatus::invalid_identity, + AddCause::no_access_authority); + } + if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + clear_active(); + return reject(ControlAction::cancel, GuardedControlStatus::selection_expired, + AddCause::time_limit_over); + } + if (!selected_) { + return reject(ControlAction::cancel, GuardedControlStatus::no_active_selection, + AddCause::object_not_selected); + } + if (owner_association_id_ != client.association_id) { + return reject(ControlAction::cancel, GuardedControlStatus::locked_by_other_client, + AddCause::locked_by_other_client); + } + + // Cancel authorization is evaluated with a minimal view because the exact + // selected sequence is already locked inside the planner. + const ControlSequenceView authorization_sequence{ + std::span{}, OriginCategory::station_control, {}}; + if (policy_.authorize == nullptr || + !policy_.authorize(policy_.authorization_context, ControlAction::cancel, + object_, client, authorization_sequence)) { + return reject(ControlAction::cancel, GuardedControlStatus::access_denied, + AddCause::no_access_authority); + } + + const auto control_number = active_sequence_.control_number; + clear_active(); + return {GuardedControlStatus::ok, ControlAction::cancel, control_number, + ControlError::no_error, AddCause::abortion_by_cancel}; +} + +GuardedControlDecision GuardedControlPlanner::command_termination( + const ControlError error, + const AddCause add_cause) noexcept { + if (!waiting_for_termination_) { + return reject(ControlAction::operate, GuardedControlStatus::no_pending_termination, + AddCause::unknown); + } + const auto control_number = active_sequence_.control_number; + const bool positive = error == ControlError::no_error && + (add_cause == AddCause::unknown || add_cause == AddCause::none); + const GuardedControlDecision result{ + positive ? GuardedControlStatus::positive_termination + : GuardedControlStatus::negative_termination, + ControlAction::operate, + control_number, + error, + add_cause}; + clear_active(); + return result; +} + +void GuardedControlPlanner::on_association_closed( + const std::uint64_t association_id) noexcept { + if (association_id != 0U && owner_association_id_ == association_id) { + clear_active(); + } +} + +void GuardedControlPlanner::tick(const std::uint64_t now_ms) noexcept { + expire_selection(now_ms); +} + +GuardedControlStateView GuardedControlPlanner::state(const std::uint64_t now_ms) noexcept { + expire_selection(now_ms); + return {model_, selected_, waiting_for_termination_, owner_association_id_, + selection_expires_at_ms_, active_sequence_.control_number}; +} + +} // namespace ar::iec61850::control From 456ad8b5b3b17ea4c0d1505ee67663476b510f25 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:29:16 +0700 Subject: [PATCH 03/50] Harden guarded control pending and expiry semantics --- src/control/guarded_control.cpp | 39 ++++++++++++++++----------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/control/guarded_control.cpp b/src/control/guarded_control.cpp index 3fbb36d35..662e072d9 100644 --- a/src/control/guarded_control.cpp +++ b/src/control/guarded_control.cpp @@ -4,7 +4,6 @@ #include #include -#include #include namespace ar::iec61850::control { @@ -72,10 +71,6 @@ constexpr std::uint64_t kFnvPrime = 1099511628211ULL; return hash; } -[[nodiscard]] bool same_fingerprint( - const GuardedControlPlanner::SequenceFingerprint& left, - const GuardedControlPlanner::SequenceFingerprint& right) noexcept; - } // namespace bool try_parse_control_object_reference( @@ -332,6 +327,11 @@ GuardedControlDecision GuardedControlPlanner::select_with_value( AddCause::inconsistent_parameters); } expire_selection(now_ms); + if (waiting_for_termination_) { + return reject(ControlAction::select_with_value, + GuardedControlStatus::command_already_in_execution, + AddCause::command_already_in_execution); + } if (selected_) { return reject(ControlAction::select_with_value, owner_association_id_ == client.association_id @@ -382,9 +382,10 @@ GuardedControlDecision GuardedControlPlanner::operate( if (model_requires_select(model_)) { if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + const auto control_number = active_sequence_.control_number; clear_active(); - return reject(ControlAction::operate, GuardedControlStatus::selection_expired, - AddCause::time_limit_over); + return {GuardedControlStatus::selection_expired, ControlAction::operate, + control_number, ControlError::no_error, AddCause::time_limit_over}; } if (!selected_) { return reject(ControlAction::operate, GuardedControlStatus::object_not_selected, @@ -395,18 +396,20 @@ GuardedControlDecision GuardedControlPlanner::operate( AddCause::locked_by_other_client); } if (!sequence_matches(sequence, active_sequence_)) { + const auto control_number = active_sequence_.control_number; clear_active(); - return reject(ControlAction::operate, GuardedControlStatus::sequence_mismatch, - AddCause::inconsistent_parameters); + return {GuardedControlStatus::sequence_mismatch, ControlAction::operate, + control_number, ControlError::no_error, AddCause::inconsistent_parameters}; } } if (!authorized(ControlAction::operate, client, sequence)) { + const auto control_number = active_sequence_.control_number; if (model_requires_select(model_)) { clear_active(); } - return reject(ControlAction::operate, GuardedControlStatus::access_denied, - AddCause::no_access_authority); + return {GuardedControlStatus::access_denied, ControlAction::operate, + control_number, ControlError::no_error, AddCause::no_access_authority}; } std::uint8_t control_number{}; @@ -445,9 +448,10 @@ GuardedControlDecision GuardedControlPlanner::cancel( AddCause::no_access_authority); } if (selected_ && selection_expires_at_ms_ != 0U && now_ms >= selection_expires_at_ms_) { + const auto control_number = active_sequence_.control_number; clear_active(); - return reject(ControlAction::cancel, GuardedControlStatus::selection_expired, - AddCause::time_limit_over); + return {GuardedControlStatus::selection_expired, ControlAction::cancel, + control_number, ControlError::no_error, AddCause::time_limit_over}; } if (!selected_) { return reject(ControlAction::cancel, GuardedControlStatus::no_active_selection, @@ -458,13 +462,8 @@ GuardedControlDecision GuardedControlPlanner::cancel( AddCause::locked_by_other_client); } - // Cancel authorization is evaluated with a minimal view because the exact - // selected sequence is already locked inside the planner. - const ControlSequenceView authorization_sequence{ - std::span{}, OriginCategory::station_control, {}}; - if (policy_.authorize == nullptr || - !policy_.authorize(policy_.authorization_context, ControlAction::cancel, - object_, client, authorization_sequence)) { + const ControlSequenceView authorization_sequence{}; + if (!authorized(ControlAction::cancel, client, authorization_sequence)) { return reject(ControlAction::cancel, GuardedControlStatus::access_denied, AddCause::no_access_authority); } From 1d9ad4c71a362a4c49b47885e53ad1cfa6bf84c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:02 +0700 Subject: [PATCH 04/50] Add guarded control hard-profile oracle --- .../control_guarded_hard_profile_smoke.cpp | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 embedded/control_guarded_hard_profile_smoke.cpp diff --git a/embedded/control_guarded_hard_profile_smoke.cpp b/embedded/control_guarded_hard_profile_smoke.cpp new file mode 100644 index 000000000..f7272cb95 --- /dev/null +++ b/embedded/control_guarded_hard_profile_smoke.cpp @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/guarded_control.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::control; + +bool allow_all( + void*, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + return true; +} + +struct AuthState final { + bool allow{true}; + std::uint32_t calls{}; +}; + +bool controlled_authorize( + void* context, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + auto* state = static_cast(context); + if (state == nullptr) { + return false; + } + ++state->calls; + return state->allow; +} + +ControlSequenceView sequence( + const std::span value, + const std::span origin, + const std::uint8_t control_number = 0U, + const bool synchro = true, + const bool interlock = true) noexcept { + ControlSequenceView result; + result.control_value = value; + result.origin_category = OriginCategory::station_control; + result.origin_identifier = origin; + result.control_number = control_number; + result.timestamp_token = 0x1122334455667788ULL; + result.operate_at_token = 0U; + result.test = false; + result.interlock_check = interlock; + result.synchro_check = synchro; + return result; +} + +} // namespace + +int main() { + ControlObjectReference object; + if (!try_parse_control_object_reference(" LD0/CSWI1.Pos ", object) || + object.domain != "LD0" || object.logical_node != "CSWI1" || + object.data_object_path != "Pos") { + return 1; + } + ControlObjectReference rejected; + if (try_parse_control_object_reference("LD0/CSWI1.Pos.Oper", rejected) || + try_parse_control_object_reference("LD0/CSWI1.Pos.Cancel", rejected) || + try_parse_control_object_reference("LD0/CSWI1", rejected)) { + return 2; + } + + std::array item{}; + std::size_t item_bytes{}; + if (!build_control_item(object, "CF", "ctlModel", item, item_bytes) || + std::string_view{item.data(), item_bytes} != "CSWI1$CF$Pos$ctlModel" || + !build_control_item(object, "CO", "Oper", item, item_bytes) || + std::string_view{item.data(), item_bytes} != "CSWI1$CO$Pos$Oper") { + return 3; + } + std::array tiny{}; + if (build_control_item(object, "CO", "Oper", tiny, item_bytes) || + item_bytes <= tiny.size()) { + return 4; + } + + constexpr std::array on_value{0x83U, 0x01U, 0xFFU}; + constexpr std::array off_value{0x83U, 0x01U, 0x00U}; + constexpr std::array origin_a{'H', 'M', 'I'}; + constexpr std::array origin_b{'E', 'N', 'G'}; + constexpr ControlClientIdentity client_a{0xA1U}; + constexpr ControlClientIdentity client_b{0xB2U}; + + // Default-deny is intentional: a usable control object still refuses an + // Oper unless an application supplies an explicit authorization callback. + GuardedControlPlanner denied{object, ControlModel::direct_normal}; + auto result = denied.operate(client_a, sequence(on_value, origin_a), 100U); + if (result.status != GuardedControlStatus::access_denied || + result.add_cause != AddCause::no_access_authority) { + return 5; + } + + GuardedControlPolicy allow_policy; + allow_policy.sbo_timeout_ms = 1'000U; + allow_policy.authorize = allow_all; + + // Direct-normal completion boundary is MMS service acceptance. ctlNum is + // association-local monotonic 1..255 and wraps without using zero. + GuardedControlPlanner direct{object, ControlModel::direct_normal, allow_policy}; + result = direct.operate(client_a, sequence(on_value, origin_a), 100U); + if (result.status != GuardedControlStatus::ok || result.control_number != 1U || + direct.state(100U).owner_association_id != 0U) { + return 6; + } + result = direct.operate(client_a, sequence(off_value, origin_a), 110U); + if (result.status != GuardedControlStatus::ok || result.control_number != 2U) { + return 7; + } + std::uint8_t last_ctl_num = result.control_number; + for (std::uint16_t index = 0U; index < 254U; ++index) { + result = direct.operate(client_a, sequence(on_value, origin_a), 120U + index); + if (!result.accepted()) { + return 8; + } + last_ctl_num = result.control_number; + } + if (last_ctl_num != 1U) { + return 9; + } + + // Direct-enhanced stays busy after Write acceptance until an explicit + // matching CommandTermination boundary is supplied by the upper layer. + GuardedControlPlanner direct_enhanced{ + object, ControlModel::direct_enhanced, allow_policy}; + result = direct_enhanced.operate(client_a, sequence(on_value, origin_a), 1'000U); + if (result.status != GuardedControlStatus::accepted_waiting_termination || + !direct_enhanced.state(1'000U).waiting_for_termination) { + return 10; + } + const auto busy = direct_enhanced.operate( + client_a, sequence(off_value, origin_a), 1'001U); + if (busy.status != GuardedControlStatus::command_already_in_execution || + busy.add_cause != AddCause::command_already_in_execution) { + return 11; + } + result = direct_enhanced.command_termination(ControlError::no_error, AddCause::none); + if (result.status != GuardedControlStatus::positive_termination || + direct_enhanced.state(1'002U).waiting_for_termination) { + return 12; + } + result = direct_enhanced.operate(client_a, sequence(on_value, origin_a), 1'003U); + if (result.status != GuardedControlStatus::accepted_waiting_termination) { + return 13; + } + result = direct_enhanced.command_termination( + ControlError::no_error, AddCause::blocked_by_interlocking); + if (result.status != GuardedControlStatus::negative_termination || + result.add_cause != AddCause::blocked_by_interlocking) { + return 14; + } + + // SBO normal: selection is per-object/per-association and the exact + // ctlVal/origin/ctlNum/T/Test/Check sequence is immutable until Oper/Cancel. + GuardedControlPlanner sbo{ + object, ControlModel::select_before_operate_normal, allow_policy}; + result = sbo.select(client_a, sequence(on_value, origin_a), 2'000U); + if (result.status != GuardedControlStatus::ok || result.control_number != 1U || + !sbo.state(2'000U).selected) { + return 15; + } + const auto selected_ctl_num = result.control_number; + result = sbo.select(client_b, sequence(on_value, origin_b), 2'001U); + if (result.status != GuardedControlStatus::locked_by_other_client || + result.add_cause != AddCause::locked_by_other_client) { + return 16; + } + result = sbo.operate(client_b, sequence(on_value, origin_a), 2'002U); + if (result.status != GuardedControlStatus::locked_by_other_client) { + return 17; + } + result = sbo.operate( + client_a, sequence(off_value, origin_a, selected_ctl_num), 2'003U); + if (result.status != GuardedControlStatus::sequence_mismatch || + result.control_number != selected_ctl_num || sbo.state(2'003U).selected) { + return 18; + } + + result = sbo.select(client_a, sequence(on_value, origin_a), 2'100U); + if (!result.accepted()) { + return 19; + } + result = sbo.operate(client_a, sequence(on_value, origin_a), 2'101U); + if (result.status != GuardedControlStatus::ok || result.control_number == 0U || + sbo.state(2'101U).selected) { + return 20; + } + + // Check bits are sequence identity. Changing synchro/interlock after select + // is rejected instead of silently operating with a stale selection. + result = sbo.select(client_a, sequence(on_value, origin_a), 2'200U); + if (!result.accepted()) { + return 21; + } + result = sbo.operate(client_a, sequence(on_value, origin_a, 0U, false, true), 2'201U); + if (result.status != GuardedControlStatus::sequence_mismatch) { + return 22; + } + + // Selection timeout is a hard boundary and maps to the C# oracle's + // time-limit-over semantics. + result = sbo.select(client_a, sequence(on_value, origin_a), 3'000U); + if (!result.accepted()) { + return 23; + } + result = sbo.operate(client_a, sequence(on_value, origin_a), 4'000U); + if (result.status != GuardedControlStatus::selection_expired || + result.add_cause != AddCause::time_limit_over) { + return 24; + } + + // Cancel is owner-only and releases the selection locally after service + // acceptance; the transport layer will encode the retained selected values. + result = sbo.select(client_a, sequence(on_value, origin_a), 5'000U); + if (!result.accepted()) { + return 25; + } + result = sbo.cancel(client_b, 5'001U); + if (result.status != GuardedControlStatus::locked_by_other_client) { + return 26; + } + result = sbo.cancel(client_a, 5'002U); + if (result.status != GuardedControlStatus::ok || + result.add_cause != AddCause::abortion_by_cancel || + sbo.state(5'002U).selected) { + return 27; + } + + // SBO enhanced uses SelectWithValue, then Oper, then CommandTermination. + GuardedControlPlanner sbo_enhanced{ + object, ControlModel::select_before_operate_enhanced, allow_policy}; + result = sbo_enhanced.select_with_value( + client_a, sequence(on_value, origin_a), 6'000U); + if (result.status != GuardedControlStatus::ok) { + return 28; + } + result = sbo_enhanced.operate(client_a, sequence(on_value, origin_a), 6'001U); + if (result.status != GuardedControlStatus::accepted_waiting_termination || + !sbo_enhanced.state(6'001U).waiting_for_termination) { + return 29; + } + result = sbo_enhanced.select_with_value( + client_a, sequence(off_value, origin_a), 6'002U); + if (result.status != GuardedControlStatus::command_already_in_execution) { + return 30; + } + sbo_enhanced.on_association_closed(client_a.association_id); + if (sbo_enhanced.state(6'003U).waiting_for_termination || + sbo_enhanced.state(6'003U).owner_association_id != 0U) { + return 31; + } + + // Authorization is rechecked at Oper. Losing permission between Select and + // Oper cancels local ownership rather than executing a previously approved command. + AuthState auth{}; + GuardedControlPolicy changing_policy; + changing_policy.sbo_timeout_ms = 1'000U; + changing_policy.authorization_context = &auth; + changing_policy.authorize = controlled_authorize; + GuardedControlPlanner guarded{ + object, ControlModel::select_before_operate_normal, changing_policy}; + result = guarded.select(client_a, sequence(on_value, origin_a), 7'000U); + if (!result.accepted() || auth.calls != 1U) { + return 32; + } + auth.allow = false; + result = guarded.operate(client_a, sequence(on_value, origin_a), 7'001U); + if (result.status != GuardedControlStatus::access_denied || + guarded.state(7'001U).selected || auth.calls != 2U) { + return 33; + } + + GuardedControlPlanner status_only{object, ControlModel::status_only, allow_policy}; + result = status_only.operate(client_a, sequence(on_value, origin_a), 8'000U); + if (result.status != GuardedControlStatus::unsupported || + result.add_cause != AddCause::not_supported) { + return 34; + } + + return 0; +} From 8ec399d81cd8f6b94bd78a61576f9836d14d1285 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:19 +0700 Subject: [PATCH 05/50] Add no-exception control hard profile --- embedded/control_hard_profile/CMakeLists.txt | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 embedded/control_hard_profile/CMakeLists.txt diff --git a/embedded/control_hard_profile/CMakeLists.txt b/embedded/control_hard_profile/CMakeLists.txt new file mode 100644 index 000000000..d31ca61c1 --- /dev/null +++ b/embedded/control_hard_profile/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlHardProfile + VERSION 0.1.0 + DESCRIPTION "No-exception guarded IEC 61850 control model profile" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "Control hard profile is validated with GCC/Clang -fno-exceptions.") +endif() + +add_executable(ariec61850_hard_guarded_control_smoke + ${CMAKE_CURRENT_LIST_DIR}/../control_guarded_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp) + +target_compile_features(ariec61850_hard_guarded_control_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_hard_guarded_control_smoke PRIVATE + ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_hard_guarded_control_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) +target_compile_options(ariec61850_hard_guarded_control_smoke PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) + +enable_testing() +add_test( + NAME ariec61850_hard_guarded_control_smoke + COMMAND ariec61850_hard_guarded_control_smoke) + +message(STATUS "arstack61850 Control hard profile: ctlModel Direct/SBO semantics=ON") +message(STATUS "arstack61850 Control hard profile: immutable selection sequence=ON") +message(STATUS "arstack61850 Control hard profile: enhanced CommandTermination boundary=ON") +message(STATUS "arstack61850 Control hard profile: authorization default-deny=ON") +message(STATUS "arstack61850 Control hard profile: automatic command retry=OFF") +message(STATUS "arstack61850 Control hard profile: exceptions/RTTI disabled=ON") From 0a4b4b5ae1d63c86d2797743cd19bb153b52d6a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:30:42 +0700 Subject: [PATCH 06/50] Gate guarded control semantics in GCC and Clang --- .github/workflows/control-hard-profile-ci.yml | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/control-hard-profile-ci.yml diff --git a/.github/workflows/control-hard-profile-ci.yml b/.github/workflows/control-hard-profile-ci.yml new file mode 100644 index 000000000..b3894e862 --- /dev/null +++ b/.github/workflows/control-hard-profile-ci.yml @@ -0,0 +1,81 @@ +name: Control Hard Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'embedded/control_guarded_hard_profile_smoke.cpp' + - 'embedded/control_hard_profile/**' + - '.github/workflows/control-hard-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-hard-profile: + name: Control Hard / ${{ 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 hard profile + run: cmake -S embedded/control_hard_profile -B build-control-hard + + - name: Build control hard profile + run: cmake --build build-control-hard --parallel + + - name: Run control smoke + run: >- + ctest --test-dir build-control-hard + --output-on-failure + --output-junit control-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + binary=build-control-hard/ariec61850_hard_guarded_control_smoke + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + size build-control-hard/ariec61850_hard_guarded_control_smoke + } | tee build-control-hard/control-footprint.txt + + - name: Upload control evidence + uses: actions/upload-artifact@v4 + with: + name: control-hard-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-control-hard/control-ctest.xml + build-control-hard/control-footprint.txt From 3f5766725b1bbf69f79b5e618a0a51f429da1381 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:34:09 +0700 Subject: [PATCH 07/50] Add live-type MMS control structure builder API --- .../control/mms_control_structure.hpp | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 include/ariec61850/control/mms_control_structure.hpp diff --git a/include/ariec61850/control/mms_control_structure.hpp b/include/ariec61850/control/mms_control_structure.hpp new file mode 100644 index 000000000..b667a7bbf --- /dev/null +++ b/include/ariec61850/control/mms_control_structure.hpp @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/control/guarded_control.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { + +enum class DoublePointValue : std::uint8_t { + intermediate = 0U, + off = 1U, + on = 2U, + bad = 3U, +}; + +struct StepPosition final { + std::int64_t position{}; + bool transient{}; +}; + +enum class ControlValueKind : std::uint8_t { + boolean, + double_point, + integer, + unsigned_integer, + floating_point, + step_position, + raw_mms, +}; + +class ControlValue final { +public: + using Value = std::variant< + bool, + DoublePointValue, + std::int64_t, + std::uint64_t, + double, + StepPosition, + mms::MmsDataValue>; + + [[nodiscard]] static ControlValue boolean(bool value); + [[nodiscard]] static ControlValue double_point(DoublePointValue value); + [[nodiscard]] static ControlValue integer(std::int64_t value); + [[nodiscard]] static ControlValue unsigned_integer(std::uint64_t value); + [[nodiscard]] static ControlValue floating_point(double value); + [[nodiscard]] static ControlValue step_position(StepPosition value); + [[nodiscard]] static ControlValue raw_mms(mms::MmsDataValue value); + + [[nodiscard]] ControlValueKind kind() const noexcept { return kind_; } + [[nodiscard]] const Value& value() const noexcept { return value_; } + +private: + ControlValue(ControlValueKind kind, Value value) + : kind_{kind}, value_{std::move(value)} {} + + ControlValueKind kind_; + Value value_; +}; + +enum class MmsControlService : std::uint8_t { + operate, + select_with_value, + cancel, +}; + +enum class MmsControlBuildStatus : std::uint8_t { + ok, + invalid_service_specification, + unsupported_component, + missing_required_component, + type_mismatch, + shape_mismatch, + value_out_of_range, + timestamp_missing, + unsupported_control_value, +}; + +struct MmsControlBuildResult final { + MmsControlBuildStatus status{MmsControlBuildStatus::invalid_service_specification}; + std::optional value; + std::string path; + + [[nodiscard]] bool success() const noexcept { + return status == MmsControlBuildStatus::ok && value.has_value(); + } +}; + +struct MmsControlSequenceContext final { + ControlValue control_value{ControlValue::boolean(false)}; + OriginCategory origin_category{OriginCategory::station_control}; + std::vector origin_identifier; + std::uint8_t control_number{}; + std::optional timestamp; + std::optional operate_at; + bool test{}; + bool interlock_check{}; + bool synchro_check{}; +}; + +// Hosted/live-control builder. It mirrors the C# oracle's conservative policy: +// consume the live MMS TypeSpecification, preserve its component order, and +// reject unknown/vendor-specific command fields rather than guessing them. +class MmsControlStructureBuilder final { +public: + [[nodiscard]] static MmsControlBuildResult bind_control_value( + const ControlValue& value, + const mms::MmsTypeSpecification& specification); + + [[nodiscard]] static MmsControlBuildResult validate_value( + const mms::MmsDataValue& value, + const mms::MmsTypeSpecification& specification, + std::string path = "ctlVal"); + + [[nodiscard]] static MmsControlBuildResult build_operate( + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields = true); + + [[nodiscard]] static MmsControlBuildResult build_select_with_value( + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields = true); + + [[nodiscard]] static MmsControlBuildResult build_cancel( + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields = true); + +private: + [[nodiscard]] static MmsControlBuildResult build_service( + MmsControlService service, + const MmsControlSequenceContext& context, + const mms::MmsTypeSpecification& specification, + bool require_exact_named_fields); +}; + +} // namespace ar::iec61850::control From b5bba0e12f3a718e273a0a8af3f306399fe7712b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:35:37 +0700 Subject: [PATCH 08/50] Implement live-type Oper SBOw and Cancel builder --- src/control/mms_control_structure.cpp | 742 ++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 src/control/mms_control_structure.cpp diff --git a/src/control/mms_control_structure.cpp b/src/control/mms_control_structure.cpp new file mode 100644 index 000000000..128dd7d4c --- /dev/null +++ b/src/control/mms_control_structure.cpp @@ -0,0 +1,742 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/mms_control_structure.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +using mms::MmsDataKind; +using mms::MmsDataValue; +using mms::MmsTypeKind; +using mms::MmsTypeSpecification; + +[[nodiscard]] MmsControlBuildResult failure( + const MmsControlBuildStatus status, + std::string path) { + return {status, std::nullopt, std::move(path)}; +} + +[[nodiscard]] MmsControlBuildResult success(MmsDataValue value) { + return {MmsControlBuildStatus::ok, std::move(value), {}}; +} + +[[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]] bool numeric_kind(const MmsTypeKind kind) noexcept { + return kind == MmsTypeKind::integer || + kind == MmsTypeKind::unsigned_integer || + kind == MmsTypeKind::floating_point || + kind == MmsTypeKind::bcd; +} + +[[nodiscard]] bool compatible_kind( + const MmsDataKind actual, + const MmsTypeKind expected) noexcept { + switch (expected) { + case MmsTypeKind::array: return actual == MmsDataKind::array; + case MmsTypeKind::structure: return actual == MmsDataKind::structure; + case MmsTypeKind::boolean: return actual == MmsDataKind::boolean; + case MmsTypeKind::bit_string: return actual == MmsDataKind::bit_string; + case MmsTypeKind::integer: return actual == MmsDataKind::integer; + case MmsTypeKind::unsigned_integer: return actual == MmsDataKind::unsigned_integer; + case MmsTypeKind::floating_point: return actual == MmsDataKind::floating_point; + case MmsTypeKind::octet_string: return actual == MmsDataKind::octet_string; + case MmsTypeKind::visible_string: return actual == MmsDataKind::visible_string; + case MmsTypeKind::binary_time: return actual == MmsDataKind::binary_time; + case MmsTypeKind::bcd: return actual == MmsDataKind::integer; + case MmsTypeKind::boolean_array: return actual == MmsDataKind::boolean_array; + case MmsTypeKind::object_id: return actual == MmsDataKind::object_id; + case MmsTypeKind::mms_string: return actual == MmsDataKind::mms_string; + case MmsTypeKind::utc_time: return actual == MmsDataKind::utc_time; + case MmsTypeKind::unknown: return false; + } + return false; +} + +[[nodiscard]] MmsControlBuildResult validate_recursive( + const MmsDataValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (!compatible_kind(value.kind(), specification.kind)) { + return failure(MmsControlBuildStatus::type_mismatch, path); + } + + if (specification.kind == MmsTypeKind::bit_string) { + const auto& raw = value.raw_value(); + if (raw.size() <= 1U || raw.front() > 7U) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + const auto total_bits = (raw.size() - 1U) * 8U; + const auto unused_bits = static_cast(raw.front()); + if (unused_bits > total_bits) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + const auto actual_bits = total_bits - unused_bits; + if (specification.size.has_value() && specification.size.value() != 0U && + actual_bits != static_cast(specification.size.value())) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + } + + if (specification.kind == MmsTypeKind::octet_string && + specification.size.has_value() && specification.size.value() != 0U && + value.raw_value().size() > static_cast(specification.size.value())) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + + if ((specification.kind == MmsTypeKind::visible_string || + specification.kind == MmsTypeKind::mms_string) && + specification.size.has_value() && specification.size.value() != 0U) { + const auto* text = std::get_if(&value.value()); + if (text == nullptr || + text->size() > static_cast(specification.size.value())) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + } + + if (specification.kind == MmsTypeKind::structure || + specification.kind == MmsTypeKind::array) { + const auto& children = value.children(); + if (!specification.children.empty() && children.size() != specification.children.size()) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + const auto count = std::min(children.size(), specification.children.size()); + for (std::size_t index = 0U; index < count; ++index) { + const auto child_name = specification.children[index].name.empty() + ? "[" + std::to_string(index) + "]" + : specification.children[index].name; + auto child = validate_recursive( + children[index], specification.children[index], path + "." + child_name); + if (!child.success()) { + return child; + } + } + } + + return success(value); +} + +[[nodiscard]] MmsControlBuildResult default_value( + const MmsTypeSpecification& specification, + const std::string& path); + +[[nodiscard]] MmsControlBuildResult bind_value( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path); + +[[nodiscard]] MmsControlBuildResult zero_bit_string( + const MmsTypeSpecification& specification, + const std::string& path) { + auto bit_count = specification.size.value_or(1U); + if (bit_count == 0U || bit_count > 1024U) { + bit_count = 1U; + } + const auto byte_count = static_cast((bit_count + 7U) / 8U); + std::vector bytes(byte_count, 0U); + const auto unused = static_cast(byte_count * 8U - bit_count); + auto result = MmsDataValue::bit_string(unused, bytes); + return validate_recursive(result, specification, path); +} + +[[nodiscard]] MmsControlBuildResult default_value( + const MmsTypeSpecification& specification, + const std::string& path) { + switch (specification.kind) { + case MmsTypeKind::boolean: + return success(MmsDataValue::boolean(false)); + case MmsTypeKind::bit_string: + return zero_bit_string(specification, path); + case MmsTypeKind::integer: + case MmsTypeKind::bcd: + return success(MmsDataValue::integer(0)); + case MmsTypeKind::unsigned_integer: + return success(MmsDataValue::unsigned_integer(0U)); + case MmsTypeKind::floating_point: + return success(MmsDataValue::floating_point(0.0)); + case MmsTypeKind::octet_string: + return success(MmsDataValue::octet_string({})); + case MmsTypeKind::visible_string: + return success(MmsDataValue::visible_string({})); + case MmsTypeKind::mms_string: + return success(MmsDataValue::mms_string({})); + case MmsTypeKind::utc_time: + return success(MmsDataValue::utc_time(mms::Iec61850UtcTime{})); + case MmsTypeKind::binary_time: { + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + std::array zero{}; + return success(MmsDataValue::binary_time( + std::span{zero}.first(size))); + } + case MmsTypeKind::structure: { + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + auto child = default_value( + child_specification, + path + "." + child_specification.name); + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + return success(MmsDataValue::structure(std::move(children))); + } + default: + return failure(MmsControlBuildStatus::unsupported_component, path); + } +} + +[[nodiscard]] MmsControlBuildResult bind_boolean( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + bool result{}; + switch (value.kind()) { + case ControlValueKind::boolean: + result = std::get(value.value()); + break; + case ControlValueKind::integer: + result = std::get(value.value()) != 0; + break; + case ControlValueKind::unsigned_integer: + result = std::get(value.value()) != 0U; + break; + default: + return failure(MmsControlBuildStatus::type_mismatch, path); + } + auto encoded = MmsDataValue::boolean(result); + return validate_recursive(encoded, specification, path); +} + +[[nodiscard]] bool try_signed( + const ControlValue& value, + std::int64_t& result) noexcept { + result = 0; + switch (value.kind()) { + case ControlValueKind::integer: + result = std::get(value.value()); + return true; + case ControlValueKind::unsigned_integer: { + const auto input = std::get(value.value()); + if (input > static_cast(std::numeric_limits::max())) { + return false; + } + result = static_cast(input); + return true; + } + case ControlValueKind::double_point: + result = static_cast(std::get(value.value())); + return true; + case ControlValueKind::floating_point: { + const auto input = std::get(value.value()); + if (!std::isfinite(input) || + input < static_cast(std::numeric_limits::min()) || + input > static_cast(std::numeric_limits::max())) { + return false; + } + result = static_cast(input); + return true; + } + default: + return false; + } +} + +[[nodiscard]] bool try_unsigned( + const ControlValue& value, + std::uint64_t& result) noexcept { + result = 0U; + switch (value.kind()) { + case ControlValueKind::unsigned_integer: + result = std::get(value.value()); + return true; + case ControlValueKind::integer: { + const auto input = std::get(value.value()); + if (input < 0) { + return false; + } + result = static_cast(input); + return true; + } + case ControlValueKind::double_point: + result = static_cast(std::get(value.value())); + return true; + default: + return false; + } +} + +[[nodiscard]] MmsControlBuildResult bind_bit_string( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + std::uint64_t numeric{}; + if (!try_unsigned(value, numeric)) { + return failure(MmsControlBuildStatus::type_mismatch, path); + } + + auto bit_count = specification.size.value_or(2U); + if (bit_count == 0U || bit_count > 32U) { + bit_count = 2U; + } + const auto limit = std::uint64_t{1U} << bit_count; + if (numeric >= limit) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + + const auto byte_count = static_cast((bit_count + 7U) / 8U); + std::vector bytes(byte_count, 0U); + for (std::uint32_t encoded_bit = 0U; encoded_bit < bit_count; ++encoded_bit) { + const auto numeric_bit = bit_count - 1U - encoded_bit; + if ((numeric & (std::uint64_t{1U} << numeric_bit)) != 0U) { + const auto byte_index = static_cast(encoded_bit / 8U); + const auto bit_in_byte = encoded_bit % 8U; + bytes[byte_index] = static_cast( + bytes[byte_index] | static_cast(0x80U >> bit_in_byte)); + } + } + const auto unused = static_cast(byte_count * 8U - bit_count); + auto encoded = MmsDataValue::bit_string(unused, bytes); + return validate_recursive(encoded, specification, path); +} + +[[nodiscard]] MmsControlBuildResult bind_structure( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.children.empty()) { + return failure(MmsControlBuildStatus::shape_mismatch, path); + } + + if (value.kind() == ControlValueKind::step_position) { + const auto step = std::get(value.value()); + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + const auto name = normalize_name(child_specification.name); + MmsControlBuildResult child; + if (name == "posval" || name == "position" || name == "i") { + child = bind_value( + ControlValue::integer(step.position), child_specification, + path + "." + child_specification.name); + } else if (name == "transind" || name == "transient") { + child = bind_value( + ControlValue::boolean(step.transient), child_specification, + path + "." + child_specification.name); + } else { + return failure( + MmsControlBuildStatus::unsupported_component, + path + "." + child_specification.name); + } + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, path); + } + + const bool scalar_numeric = value.kind() == ControlValueKind::floating_point || + value.kind() == ControlValueKind::integer || + value.kind() == ControlValueKind::unsigned_integer; + if (scalar_numeric) { + std::vector numeric_indices; + for (std::size_t index = 0U; index < specification.children.size(); ++index) { + if (numeric_kind(specification.children[index].kind)) { + numeric_indices.push_back(index); + } + } + + std::optional selected_index; + if (numeric_indices.size() == 1U) { + selected_index = numeric_indices.front(); + } else { + const auto desired_name = value.kind() == ControlValueKind::floating_point ? "f" : "i"; + for (std::size_t index = 0U; index < specification.children.size(); ++index) { + if (normalize_name(specification.children[index].name) == desired_name) { + selected_index = index; + break; + } + } + } + + if (selected_index.has_value()) { + std::vector children; + children.reserve(specification.children.size()); + for (std::size_t index = 0U; index < specification.children.size(); ++index) { + MmsControlBuildResult child = index == selected_index.value() + ? bind_value(value, specification.children[index], + path + "." + specification.children[index].name) + : default_value(specification.children[index], + path + "." + specification.children[index].name); + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, path); + } + } + + if (specification.children.size() == 1U) { + auto child = bind_value( + value, specification.children.front(), + path + "." + specification.children.front().name); + if (!child.success()) { + return child; + } + std::vector children; + children.push_back(std::move(child.value.value())); + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, path); + } + + return failure(MmsControlBuildStatus::unsupported_control_value, path); +} + +[[nodiscard]] MmsControlBuildResult bind_value( + const ControlValue& value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (value.kind() == ControlValueKind::raw_mms) { + return validate_recursive( + std::get(value.value()), specification, path); + } + + switch (specification.kind) { + case MmsTypeKind::boolean: + return bind_boolean(value, specification, path); + case MmsTypeKind::bit_string: + return bind_bit_string(value, specification, path); + case MmsTypeKind::integer: + case MmsTypeKind::bcd: { + std::int64_t numeric{}; + if (!try_signed(value, numeric)) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + auto encoded = MmsDataValue::integer(numeric); + return validate_recursive(encoded, specification, path); + } + case MmsTypeKind::unsigned_integer: { + std::uint64_t numeric{}; + if (!try_unsigned(value, numeric)) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + auto encoded = MmsDataValue::unsigned_integer(numeric); + return validate_recursive(encoded, specification, path); + } + case MmsTypeKind::floating_point: { + double numeric{}; + switch (value.kind()) { + case ControlValueKind::floating_point: + numeric = std::get(value.value()); + break; + case ControlValueKind::integer: + numeric = static_cast(std::get(value.value())); + break; + case ControlValueKind::unsigned_integer: + numeric = static_cast(std::get(value.value())); + break; + default: + return failure(MmsControlBuildStatus::type_mismatch, path); + } + auto encoded = MmsDataValue::floating_point(numeric); + return validate_recursive(encoded, specification, path); + } + case MmsTypeKind::structure: + return bind_structure(value, specification, path); + default: + return failure(MmsControlBuildStatus::unsupported_control_value, path); + } +} + +[[nodiscard]] MmsControlBuildResult build_integer_like( + const std::int64_t value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.kind == MmsTypeKind::integer || specification.kind == MmsTypeKind::bcd) { + auto encoded = MmsDataValue::integer(value); + return validate_recursive(encoded, specification, path); + } + if (specification.kind == MmsTypeKind::unsigned_integer) { + if (value < 0) { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + auto encoded = MmsDataValue::unsigned_integer(static_cast(value)); + return validate_recursive(encoded, specification, path); + } + return failure(MmsControlBuildStatus::type_mismatch, path); +} + +[[nodiscard]] MmsControlBuildResult build_origin( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification) { + if (specification.kind != MmsTypeKind::structure || specification.children.empty()) { + return failure(MmsControlBuildStatus::type_mismatch, "origin"); + } + + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + const auto name = normalize_name(child_specification.name); + MmsControlBuildResult child; + if (name == "orcat") { + child = build_integer_like( + static_cast(context.origin_category), + child_specification, + "origin.orCat"); + } else if (name == "orident") { + if (child_specification.kind == MmsTypeKind::octet_string) { + auto encoded = MmsDataValue::octet_string(context.origin_identifier); + child = validate_recursive(encoded, child_specification, "origin.orIdent"); + } else if (child_specification.kind == MmsTypeKind::visible_string) { + std::string text; + text.reserve(context.origin_identifier.size()); + for (const auto byte : context.origin_identifier) { + text.push_back(byte <= 0x7FU ? static_cast(byte) : '?'); + } + auto encoded = MmsDataValue::visible_string(std::move(text)); + child = validate_recursive(encoded, child_specification, "origin.orIdent"); + } else { + return failure(MmsControlBuildStatus::type_mismatch, "origin.orIdent"); + } + } else { + return failure( + MmsControlBuildStatus::unsupported_component, + "origin." + child_specification.name); + } + if (!child.success()) { + return child; + } + children.push_back(std::move(child.value.value())); + } + + auto encoded = MmsDataValue::structure(std::move(children)); + return validate_recursive(encoded, specification, "origin"); +} + +[[nodiscard]] MmsControlBuildResult build_boolean_field( + const bool value, + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.kind != MmsTypeKind::boolean) { + return failure(MmsControlBuildStatus::type_mismatch, path); + } + auto encoded = MmsDataValue::boolean(value); + return validate_recursive(encoded, specification, path); +} + +[[nodiscard]] MmsControlBuildResult build_check( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification) { + if (specification.kind != MmsTypeKind::bit_string) { + return failure(MmsControlBuildStatus::type_mismatch, "Check"); + } + std::uint8_t bits = 0U; + if (context.synchro_check) { + bits = static_cast(bits | 0x80U); + } + if (context.interlock_check) { + bits = static_cast(bits | 0x40U); + } + const std::array data{bits}; + auto encoded = MmsDataValue::bit_string(6U, data); + return validate_recursive(encoded, specification, "Check"); +} + +[[nodiscard]] MmsControlBuildResult build_default_timestamp( + const MmsTypeSpecification& specification, + const std::string& path) { + if (specification.kind == MmsTypeKind::utc_time) { + auto encoded = MmsDataValue::utc_time(mms::Iec61850UtcTime{}); + return validate_recursive(encoded, specification, path); + } + if (specification.kind == MmsTypeKind::binary_time) { + const auto size = specification.size.value_or(6U) == 4U ? 4U : 6U; + std::array zero{}; + auto encoded = MmsDataValue::binary_time( + std::span{zero}.first(size)); + return validate_recursive(encoded, specification, path); + } + return failure(MmsControlBuildStatus::type_mismatch, path); +} + +[[nodiscard]] bool required_field( + const MmsControlService service, + const std::string& name) noexcept { + if (name == "ctlval" || name == "origin" || name == "ctlnum" || + name == "t" || name == "test") { + return true; + } + return service != MmsControlService::cancel && name == "check"; +} + +} // namespace + +ControlValue ControlValue::boolean(const bool value) { + return {ControlValueKind::boolean, value}; +} + +ControlValue ControlValue::double_point(const DoublePointValue value) { + return {ControlValueKind::double_point, value}; +} + +ControlValue ControlValue::integer(const std::int64_t value) { + return {ControlValueKind::integer, value}; +} + +ControlValue ControlValue::unsigned_integer(const std::uint64_t value) { + return {ControlValueKind::unsigned_integer, value}; +} + +ControlValue ControlValue::floating_point(const double value) { + return {ControlValueKind::floating_point, value}; +} + +ControlValue ControlValue::step_position(const StepPosition value) { + return {ControlValueKind::step_position, value}; +} + +ControlValue ControlValue::raw_mms(MmsDataValue value) { + return {ControlValueKind::raw_mms, std::move(value)}; +} + +MmsControlBuildResult MmsControlStructureBuilder::bind_control_value( + const ControlValue& value, + const MmsTypeSpecification& specification) { + return bind_value(value, specification, "ctlVal"); +} + +MmsControlBuildResult MmsControlStructureBuilder::validate_value( + const MmsDataValue& value, + const MmsTypeSpecification& specification, + std::string path) { + return validate_recursive(value, specification, path); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_operate( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + return build_service( + MmsControlService::operate, context, specification, require_exact_named_fields); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_select_with_value( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + return build_service( + MmsControlService::select_with_value, context, specification, + require_exact_named_fields); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_cancel( + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + return build_service( + MmsControlService::cancel, context, specification, require_exact_named_fields); +} + +MmsControlBuildResult MmsControlStructureBuilder::build_service( + const MmsControlService service, + const MmsControlSequenceContext& context, + const MmsTypeSpecification& specification, + const bool require_exact_named_fields) { + if (specification.kind != MmsTypeKind::structure || specification.children.empty()) { + return failure(MmsControlBuildStatus::invalid_service_specification, "service"); + } + + std::array found{}; + const auto mark = [&](const std::string& name) { + if (name == "ctlval") found[0] = true; + else if (name == "origin") found[1] = true; + else if (name == "ctlnum") found[2] = true; + else if (name == "t") found[3] = true; + else if (name == "test") found[4] = true; + else if (name == "check") found[5] = true; + }; + + std::vector children; + children.reserve(specification.children.size()); + for (const auto& child_specification : specification.children) { + const auto name = normalize_name(child_specification.name); + MmsControlBuildResult child; + if (name == "ctlval") { + child = bind_value(context.control_value, child_specification, "ctlVal"); + } else if (name == "opertm" && service != MmsControlService::cancel) { + child = context.operate_at.has_value() + ? validate_recursive(context.operate_at.value(), child_specification, "operTm") + : build_default_timestamp(child_specification, "operTm"); + } else if (name == "origin") { + child = build_origin(context, child_specification); + } else if (name == "ctlnum") { + child = build_integer_like( + static_cast(context.control_number), + child_specification, + "ctlNum"); + } else if (name == "t") { + if (!context.timestamp.has_value()) { + return failure(MmsControlBuildStatus::timestamp_missing, "T"); + } + child = validate_recursive(context.timestamp.value(), child_specification, "T"); + } else if (name == "test") { + child = build_boolean_field(context.test, child_specification, "Test"); + } else if (name == "check") { + child = build_check(context, child_specification); + } else { + return failure( + MmsControlBuildStatus::unsupported_component, + child_specification.name); + } + + if (!child.success()) { + return child; + } + mark(name); + children.push_back(std::move(child.value.value())); + } + + if (require_exact_named_fields) { + constexpr std::array names{ + "ctlval", "origin", "ctlnum", "t", "test", "check"}; + for (std::size_t index = 0U; index < names.size(); ++index) { + if (required_field(service, std::string{names[index]}) && !found[index]) { + return failure( + MmsControlBuildStatus::missing_required_component, + std::string{names[index]}); + } + } + } + + auto encoded = MmsDataValue::structure(std::move(children)); + const auto service_name = service == MmsControlService::operate + ? "Oper" + : (service == MmsControlService::select_with_value ? "SBOw" : "Cancel"); + return validate_recursive(encoded, specification, service_name); +} + +} // namespace ar::iec61850::control From 8e05c1b42437fee5c69856bd031fead1d544184d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:36:17 +0700 Subject: [PATCH 09/50] Fix control structure header utility dependency --- include/ariec61850/control/mms_control_structure.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ariec61850/control/mms_control_structure.hpp b/include/ariec61850/control/mms_control_structure.hpp index b667a7bbf..dc3a892e7 100644 --- a/include/ariec61850/control/mms_control_structure.hpp +++ b/include/ariec61850/control/mms_control_structure.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include From ca0fcdaf4cb3a20a3db88c06914d0a150712d384 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:37:59 +0700 Subject: [PATCH 10/50] Add MMS control structure golden-wire tests --- tests/test_control_mms_structure.cpp | 267 +++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 tests/test_control_mms_structure.cpp diff --git a/tests/test_control_mms_structure.cpp b/tests/test_control_mms_structure.cpp new file mode 100644 index 000000000..17e157950 --- /dev/null +++ b/tests/test_control_mms_structure.cpp @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/mms_control_structure.hpp" +#include "ariec61850/mms/data_codec.hpp" + +#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) + +std::string to_hex(const std::vector& bytes) { + static constexpr char digits[] = "0123456789ABCDEF"; + std::string result; + result.reserve(bytes.size() * 2U); + for (const auto byte : bytes) { + result.push_back(digits[(byte >> 4U) & 0x0FU]); + result.push_back(digits[byte & 0x0FU]); + } + return result; +} + +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() { + // Deliberately put operTm before origin to prove that live component order, + // not a locally invented fixed order, drives the resulting MMS structure. + return structure("Oper", { + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + scalar(MmsTypeKind::utc_time, "operTm"), + origin_spec(), + scalar(MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(MmsTypeKind::utc_time, "T"), + scalar(MmsTypeKind::boolean, "Test"), + scalar(MmsTypeKind::bit_string, "Check", 2U), + }); +} + +MmsTypeSpecification cancel_spec(const bool include_check) { + std::vector children{ + scalar(MmsTypeKind::bit_string, "ctlVal", 2U), + origin_spec(), + scalar(MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(MmsTypeKind::utc_time, "T"), + scalar(MmsTypeKind::boolean, "Test"), + }; + if (include_check) { + children.push_back(scalar(MmsTypeKind::bit_string, "Check", 2U)); + } + return structure("Cancel", std::move(children)); +} + +MmsControlSequenceContext context() { + MmsControlSequenceContext result; + result.control_value = ControlValue::double_point(DoublePointValue::on); + result.origin_category = OriginCategory::station_control; + result.origin_identifier = {'H', 'M', 'I'}; + result.control_number = 7U; + result.timestamp = MmsDataValue::utc_time(Iec61850UtcTime{}); + result.test = false; + result.synchro_check = true; + result.interlock_check = true; + return result; +} + +void dpc_binding_matches_network_bit_order() { + const auto specification = scalar(MmsTypeKind::bit_string, "ctlVal", 2U); + auto result = MmsControlStructureBuilder::bind_control_value( + ControlValue::double_point(DoublePointValue::on), specification); + CHECK(result.success()); + CHECK(result.value->kind() == MmsDataKind::bit_string); + CHECK(result.value->raw_value().size() == 2U); + CHECK(result.value->raw_value()[0] == 6U); + CHECK(result.value->raw_value()[1] == 0x80U); // DPC On == binary 10. + + result = MmsControlStructureBuilder::bind_control_value( + ControlValue::double_point(DoublePointValue::off), specification); + CHECK(result.success()); + CHECK(result.value->raw_value()[1] == 0x40U); // DPC Off == binary 01. +} + +void operate_matches_csharp_structure_contract_and_golden_wire() { + const auto specification = oper_spec(); + auto build = MmsControlStructureBuilder::build_operate(context(), specification); + CHECK(build.success()); + CHECK(build.value->kind() == MmsDataKind::structure); + CHECK(build.value->children().size() == 7U); + + const auto& children = build.value->children(); + CHECK(children[0].kind() == MmsDataKind::bit_string); + CHECK(children[0].raw_value() == std::vector({0x06U, 0x80U})); + CHECK(children[1].kind() == MmsDataKind::utc_time); // default operTm epoch. + CHECK(children[2].kind() == MmsDataKind::structure); + CHECK(std::get(children[2].children()[0].value()) == 2U); + CHECK(children[2].children()[1].raw_value() == + std::vector({'H', 'M', 'I'})); + CHECK(std::get(children[3].value()) == 7U); + CHECK(children[4].kind() == MmsDataKind::utc_time); + CHECK(!std::get(children[5].value())); + CHECK(children[6].raw_value() == std::vector({0x06U, 0xC0U})); + + const auto encoded = MmsDataCodec::encode(build.value.value()); + CHECK(to_hex(encoded) == + "A22C8402068091080000000000000000A2088601028903484D4986010791080000000000000000830100840206C0"); + + const auto decoded = MmsDataCodec::decode_all(encoded); + CHECK(decoded.size() == 1U); + CHECK(decoded.front().kind() == MmsDataKind::structure); + CHECK(decoded.front().children().size() == 7U); + CHECK(decoded.front().children()[6].raw_value() == + std::vector({0x06U, 0xC0U})); +} + +void sbow_uses_same_exact_sequence_contract() { + auto specification = oper_spec(); + specification.name = "SBOw"; + auto input = context(); + input.operate_at = MmsDataValue::utc_time(Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{10}}, 0U}); + const auto build = MmsControlStructureBuilder::build_select_with_value( + input, specification); + CHECK(build.success()); + CHECK(build.value->children().size() == 7U); + CHECK(build.value->children()[1].kind() == MmsDataKind::utc_time); +} + +void cancel_allows_optional_check_but_not_vendor_guessing() { + auto build = MmsControlStructureBuilder::build_cancel(context(), cancel_spec(false)); + CHECK(build.success()); + CHECK(build.value->children().size() == 5U); + + build = MmsControlStructureBuilder::build_cancel(context(), cancel_spec(true)); + CHECK(build.success()); + CHECK(build.value->children().size() == 6U); + CHECK(build.value->children().back().raw_value() == + std::vector({0x06U, 0xC0U})); + + auto vendor = cancel_spec(false); + vendor.children.push_back(scalar(MmsTypeKind::boolean, "vendorMagic")); + build = MmsControlStructureBuilder::build_cancel(context(), vendor); + CHECK(build.status == MmsControlBuildStatus::unsupported_component); + CHECK(build.path == "vendorMagic"); +} + +void exact_required_fields_and_check_shape_fail_closed() { + auto missing_check = oper_spec(); + missing_check.children.pop_back(); + auto build = MmsControlStructureBuilder::build_operate(context(), missing_check); + CHECK(build.status == MmsControlBuildStatus::missing_required_component); + CHECK(build.path == "check"); + + auto wrong_check = oper_spec(); + wrong_check.children.back().size = 3U; + build = MmsControlStructureBuilder::build_operate(context(), wrong_check); + CHECK(build.status == MmsControlBuildStatus::shape_mismatch); + CHECK(build.path == "Check"); + + auto missing_timestamp = context(); + missing_timestamp.timestamp.reset(); + build = MmsControlStructureBuilder::build_operate(missing_timestamp, oper_spec()); + CHECK(build.status == MmsControlBuildStatus::timestamp_missing); +} + +void origin_ctl_num_and_raw_values_are_live_type_checked() { + auto bad_origin = oper_spec(); + bad_origin.children[2].children[0].kind = MmsTypeKind::boolean; + auto build = MmsControlStructureBuilder::build_operate(context(), bad_origin); + CHECK(build.status == MmsControlBuildStatus::type_mismatch); + CHECK(build.path == "origin.orCat"); + + auto bad_ctl_num = oper_spec(); + bad_ctl_num.children[3].kind = MmsTypeKind::visible_string; + build = MmsControlStructureBuilder::build_operate(context(), bad_ctl_num); + CHECK(build.status == MmsControlBuildStatus::type_mismatch); + CHECK(build.path == "ctlNum"); + + const auto raw_good = ControlValue::raw_mms(MmsDataValue::boolean(true)); + auto raw_result = MmsControlStructureBuilder::bind_control_value( + raw_good, scalar(MmsTypeKind::boolean, "ctlVal")); + CHECK(raw_result.success()); + raw_result = MmsControlStructureBuilder::bind_control_value( + raw_good, scalar(MmsTypeKind::integer, "ctlVal")); + CHECK(raw_result.status == MmsControlBuildStatus::type_mismatch); +} + +void step_position_and_analogue_structures_follow_live_shape() { + const auto step_specification = structure("ctlVal", { + scalar(MmsTypeKind::integer, "posVal"), + scalar(MmsTypeKind::boolean, "transInd"), + }); + auto result = MmsControlStructureBuilder::bind_control_value( + ControlValue::step_position({-3, true}), step_specification); + CHECK(result.success()); + CHECK(std::get(result.value->children()[0].value()) == -3); + CHECK(std::get(result.value->children()[1].value())); + + const auto analogue_specification = structure("ctlVal", { + scalar(MmsTypeKind::floating_point, "f"), + scalar(MmsTypeKind::boolean, "qualityHint"), + }); + result = MmsControlStructureBuilder::bind_control_value( + ControlValue::floating_point(12.5), analogue_specification); + CHECK(result.success()); + CHECK(std::get(result.value->children()[0].value()) == 12.5); + CHECK(!std::get(result.value->children()[1].value())); +} + +} // namespace + +int main() { + try { + dpc_binding_matches_network_bit_order(); + operate_matches_csharp_structure_contract_and_golden_wire(); + sbow_uses_same_exact_sequence_contract(); + cancel_allows_optional_check_but_not_vendor_guessing(); + exact_required_fields_and_check_shape_fail_closed(); + origin_ctl_num_and_raw_values_are_live_type_checked(); + step_position_and_analogue_structures_follow_live_shape(); + std::cout << "Control MMS structure tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From 67f0764c7cdfeb119c5a3fafc1209528e27a57ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:38:21 +0700 Subject: [PATCH 11/50] Add strict control MMS structure validation profile --- tests/control_mms_profile/CMakeLists.txt | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/control_mms_profile/CMakeLists.txt diff --git a/tests/control_mms_profile/CMakeLists.txt b/tests/control_mms_profile/CMakeLists.txt new file mode 100644 index 000000000..827736de3 --- /dev/null +++ b/tests/control_mms_profile/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlMmsProfile + VERSION 0.1.0 + DESCRIPTION "Live-type IEC 61850 Oper/SBOw/Cancel MMS structure validation" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +add_executable(ariec61850_control_mms_structure_tests + ${ARIEC61850_ROOT}/tests/test_control_mms_structure.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/control/guarded_control.cpp + ${ARIEC61850_ROOT}/src/control/mms_control_structure.cpp) + +target_compile_features(ariec61850_control_mms_structure_tests PRIVATE cxx_std_20) +target_include_directories(ariec61850_control_mms_structure_tests PRIVATE + ${ARIEC61850_ROOT}/include) + +if(MSVC) + target_compile_options(ariec61850_control_mms_structure_tests PRIVATE + /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_control_mms_structure_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_control_mms_structure_tests + COMMAND ariec61850_control_mms_structure_tests) + +message(STATUS "arstack61850 Control MMS profile: live TypeSpecification binding=ON") +message(STATUS "arstack61850 Control MMS profile: Oper/SBOw/Cancel exact fields=ON") +message(STATUS "arstack61850 Control MMS profile: DPC network-bit-order oracle=ON") +message(STATUS "arstack61850 Control MMS profile: Check 2-bit oracle=ON") +message(STATUS "arstack61850 Control MMS profile: vendor-specific field guessing=OFF") From e53aa25175c33685f0107756d782d1842276f9b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:38:35 +0700 Subject: [PATCH 12/50] Gate control MMS structures on GCC and Clang --- .github/workflows/control-mms-profile-ci.yml | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/control-mms-profile-ci.yml diff --git a/.github/workflows/control-mms-profile-ci.yml b/.github/workflows/control-mms-profile-ci.yml new file mode 100644 index 000000000..86ef42d47 --- /dev/null +++ b/.github/workflows/control-mms-profile-ci.yml @@ -0,0 +1,64 @@ +name: Control MMS Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_control_mms_structure.cpp' + - 'tests/control_mms_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/services.hpp' + - 'src/mms/data_value.cpp' + - 'src/mms/data_codec.cpp' + - 'src/mms/utc_time.cpp' + - '.github/workflows/control-mms-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-mms-profile: + name: Control MMS / ${{ 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 MMS profile + run: cmake -S tests/control_mms_profile -B build-control-mms + + - name: Build control MMS profile + run: cmake --build build-control-mms --parallel + + - name: Run control MMS golden-wire tests + run: >- + ctest --test-dir build-control-mms + --output-on-failure + --output-junit control-mms-ctest.xml + + - name: Upload control MMS evidence + uses: actions/upload-artifact@v4 + with: + name: control-mms-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-control-mms/control-mms-ctest.xml From e26c73226a5ce3fbe8d2bc7836aa8016af508050 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:40:25 +0700 Subject: [PATCH 13/50] Add CommandTermination and LastApplError decoder API --- .../control/command_termination.hpp | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 include/ariec61850/control/command_termination.hpp diff --git a/include/ariec61850/control/command_termination.hpp b/include/ariec61850/control/command_termination.hpp new file mode 100644 index 000000000..9e493d2b6 --- /dev/null +++ b/include/ariec61850/control/command_termination.hpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/control/guarded_control.hpp" +#include "ariec61850/mms/reporting.hpp" + +#include +#include +#include + +namespace ar::iec61850::control { + +struct LastApplError final { + std::string control_object; + std::int64_t raw_control_error{}; + std::int64_t raw_add_cause{25}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + std::string control_error_name; + std::string add_cause_name; +}; + +struct CommandTermination final { + bool is_for_control_object{}; + bool is_termination{}; + bool positive{}; + ControlError control_error{ControlError::no_error}; + AddCause add_cause{AddCause::none}; + std::int64_t raw_control_error{}; + std::int64_t raw_add_cause{25}; + std::string control_error_name; + std::string add_cause_name; + std::optional last_appl_error; +}; + +class CommandTerminationDecoder final { +public: + [[nodiscard]] static CommandTermination decode( + const mms::MmsInformationReport& report, + const ControlObjectReference& object); + + [[nodiscard]] static std::optional try_decode_last_appl_error( + const mms::MmsDataValue& value); + + [[nodiscard]] static bool matches_operate_reference( + const ControlObjectReference& object, + const std::string& reference); + + [[nodiscard]] static bool matches_reported_reference( + const ControlObjectReference& object, + const std::string& reference); + + [[nodiscard]] static std::string control_error_name(std::int64_t value); + [[nodiscard]] static std::string add_cause_name(std::int64_t value); +}; + +} // namespace ar::iec61850::control From 81d1cf294efc42848ce1c45dc9fc7659e0f61f6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:41:12 +0700 Subject: [PATCH 14/50] Implement correlated CommandTermination decoding --- src/control/command_termination.cpp | 366 ++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 src/control/command_termination.cpp diff --git a/src/control/command_termination.cpp b/src/control/command_termination.cpp new file mode 100644 index 000000000..b415c42be --- /dev/null +++ b/src/control/command_termination.cpp @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/command_termination.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::control { +namespace { + +[[nodiscard]] char ascii_lower(const char value) noexcept { + return value >= 'A' && value <= 'Z' + ? static_cast(value - 'A' + 'a') + : value; +} + +[[nodiscard]] bool ascii_equal( + const std::string& left, + const std::string& right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0U; index < left.size(); ++index) { + if (ascii_lower(left[index]) != ascii_lower(right[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_ends_with( + const std::string& value, + const std::string& suffix) noexcept { + if (suffix.size() > value.size()) { + return false; + } + const auto offset = value.size() - suffix.size(); + for (std::size_t index = 0U; index < suffix.size(); ++index) { + if (ascii_lower(value[offset + index]) != ascii_lower(suffix[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_starts_with( + const std::string& value, + const std::string& prefix) noexcept { + if (prefix.size() > value.size()) { + return false; + } + for (std::size_t index = 0U; index < prefix.size(); ++index) { + if (ascii_lower(value[index]) != ascii_lower(prefix[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ascii_contains( + const std::string& value, + const std::string& needle) noexcept { + if (needle.empty()) { + return true; + } + if (needle.size() > value.size()) { + return false; + } + for (std::size_t offset = 0U; offset + needle.size() <= value.size(); ++offset) { + bool match = true; + for (std::size_t index = 0U; index < needle.size(); ++index) { + if (ascii_lower(value[offset + index]) != ascii_lower(needle[index])) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; +} + +[[nodiscard]] std::string trim_delimiter(std::string value, const char delimiter) { + while (!value.empty() && value.front() == delimiter) { + value.erase(value.begin()); + } + while (!value.empty() && value.back() == delimiter) { + value.pop_back(); + } + return value; +} + +[[nodiscard]] std::string object_path_dollars(const ControlObjectReference& object) { + std::string result{object.data_object_path}; + std::replace(result.begin(), result.end(), '.', '$'); + return result; +} + +[[nodiscard]] std::string object_reference(const ControlObjectReference& object) { + return std::string{object.domain} + "/" + std::string{object.logical_node} + "." + + std::string{object.data_object_path}; +} + +[[nodiscard]] std::string mms_reference(const mms::MmsObjectName& name) { + switch (name.kind) { + case mms::MmsObjectNameKind::domain_specific: + return name.domain + "/" + name.item; + case mms::MmsObjectNameKind::vmd_specific: + case mms::MmsObjectNameKind::aa_specific: + return name.item; + } + return {}; +} + +[[nodiscard]] std::string read_text(const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::visible_string && + value.kind() != mms::MmsDataKind::mms_string) { + return {}; + } + const auto* text = std::get_if(&value.value()); + return text == nullptr ? std::string{} : *text; +} + +[[nodiscard]] std::optional read_number( + const mms::MmsDataValue& value) noexcept { + if (value.kind() == mms::MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) { + return *number; + } + return std::nullopt; + } + if (value.kind() == mms::MmsDataKind::unsigned_integer) { + if (const auto* number = std::get_if(&value.value())) { + if (*number <= static_cast( + std::numeric_limits::max())) { + return static_cast(*number); + } + } + } + return std::nullopt; +} + +[[nodiscard]] ControlError map_control_error(const std::int64_t value) noexcept { + switch (value) { + case 0: return ControlError::no_error; + case 1: return ControlError::unknown; + case 2: return ControlError::timeout_test; + case 3: return ControlError::operator_test; + default: return ControlError::unknown; + } +} + +[[nodiscard]] AddCause map_add_cause(const std::int64_t value) noexcept { + if (value >= 0 && value <= 27) { + return static_cast(static_cast(value)); + } + return AddCause::unknown; +} + +[[nodiscard]] bool positive_termination( + const std::int64_t error, + const std::int64_t add_cause) noexcept { + return error == 0 && (add_cause == 0 || add_cause == 25); +} + +} // namespace + +std::string CommandTerminationDecoder::control_error_name(const std::int64_t value) { + switch (value) { + case 0: return "no-error"; + case 1: return "unknown"; + case 2: return "timeout-test"; + case 3: return "operator-test"; + default: return "control-error-" + std::to_string(value); + } +} + +std::string CommandTerminationDecoder::add_cause_name(const std::int64_t value) { + switch (value) { + case 0: return "unknown"; + case 1: return "not-supported"; + case 2: return "blocked-by-switching-hierarchy"; + case 3: return "select-failed"; + case 4: return "invalid-position"; + case 5: return "position-reached"; + case 6: return "parameter-change-in-execution"; + case 7: return "step-limit"; + case 8: return "blocked-by-mode"; + case 9: return "blocked-by-process"; + case 10: return "blocked-by-interlocking"; + case 11: return "blocked-by-synchrocheck"; + case 12: return "command-already-in-execution"; + case 13: return "blocked-by-health"; + case 14: return "one-of-n-control"; + case 15: return "abortion-by-cancel"; + case 16: return "time-limit-over"; + case 17: return "abortion-by-trip"; + case 18: return "object-not-selected"; + case 19: return "object-already-selected"; + case 20: return "no-access-authority"; + case 21: return "ended-with-overshoot"; + case 22: return "abortion-due-to-deviation"; + case 23: return "abortion-by-communication-loss"; + case 24: return "abortion-by-command"; + case 25: return "none"; + case 26: return "inconsistent-parameters"; + case 27: return "locked-by-other-client"; + default: return "add-cause-" + std::to_string(value); + } +} + +bool CommandTerminationDecoder::matches_operate_reference( + const ControlObjectReference& object, + const std::string& reference) { + if (!object.valid() || reference.empty()) { + return false; + } + std::string normalized = reference; + std::replace(normalized.begin(), normalized.end(), '.', '$'); + std::replace(normalized.begin(), normalized.end(), '/', '$'); + normalized = trim_delimiter(std::move(normalized), '$'); + + const auto data_path = object_path_dollars(object); + const auto expected = std::string{object.domain} + "$" + + std::string{object.logical_node} + "$CO$" + data_path + "$Oper"; + const auto suffix = std::string{object.logical_node} + "$CO$" + + data_path + "$Oper"; + return ascii_equal(normalized, expected) || ascii_ends_with(normalized, suffix); +} + +bool CommandTerminationDecoder::matches_reported_reference( + const ControlObjectReference& object, + const std::string& reference) { + if (!object.valid() || reference.empty()) { + return false; + } + std::string normalized = reference; + std::replace(normalized.begin(), normalized.end(), '$', '.'); + const auto expected = object_reference(object); + const auto logical_suffix = "/" + std::string{object.logical_node} + "." + + std::string{object.data_object_path}; + const auto co_fragment = "/" + std::string{object.logical_node} + ".CO." + + std::string{object.data_object_path} + "."; + return ascii_equal(normalized, expected) || + ascii_starts_with(normalized, expected + ".") || + ascii_ends_with(normalized, logical_suffix) || + ascii_contains(normalized, co_fragment); +} + +std::optional CommandTerminationDecoder::try_decode_last_appl_error( + const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::structure) { + return std::nullopt; + } + const auto& children = value.children(); + if (children.size() < 2U) { + return std::nullopt; + } + + std::vector> numeric; + numeric.reserve(children.size()); + for (std::size_t index = 0U; index < children.size(); ++index) { + if (const auto number = read_number(children[index]); number.has_value()) { + numeric.emplace_back(index, number.value()); + } + } + if (numeric.size() < 2U) { + return std::nullopt; + } + + std::optional error; + for (const auto& [index, number] : numeric) { + if (index == 0U || index == 1U) { + error = number; + break; + } + } + if (!error.has_value()) { + return std::nullopt; + } + + const auto add_cause = numeric.back().second; + LastApplError result; + result.control_object = read_text(children.front()); + result.raw_control_error = error.value(); + result.raw_add_cause = add_cause; + result.control_error = map_control_error(error.value()); + result.add_cause = map_add_cause(add_cause); + result.control_error_name = control_error_name(error.value()); + result.add_cause_name = add_cause_name(add_cause); + return result; +} + +CommandTermination CommandTerminationDecoder::decode( + const mms::MmsInformationReport& report, + const ControlObjectReference& object) { + CommandTermination result; + if (!object.valid()) { + return result; + } + + bool matching_object = false; + bool matching_operate = false; + for (const auto& reference : report.variable_references) { + const auto text = mms_reference(reference); + matching_object = matching_object || matches_reported_reference(object, text); + matching_operate = matching_operate || matches_operate_reference(object, text); + } + + for (const auto& item : report.items) { + if (!item.value.has_value() || + item.value->kind() != mms::MmsDataKind::structure) { + continue; + } + const auto last_error = try_decode_last_appl_error(item.value.value()); + if (!last_error.has_value()) { + continue; + } + + const bool embedded_match = !last_error->control_object.empty() && + (matches_reported_reference(object, last_error->control_object) || + matches_operate_reference(object, last_error->control_object)); + if (!matching_object && !matching_operate && !embedded_match) { + continue; + } + + result.is_for_control_object = true; + result.is_termination = true; + result.positive = positive_termination( + last_error->raw_control_error, last_error->raw_add_cause); + result.control_error = last_error->control_error; + result.add_cause = last_error->add_cause; + result.raw_control_error = last_error->raw_control_error; + result.raw_add_cause = last_error->raw_add_cause; + result.control_error_name = last_error->control_error_name; + result.add_cause_name = last_error->add_cause_name; + result.last_appl_error = last_error; + return result; + } + + // Mirror the C# oracle: a positive enhanced-security termination may carry + // only the exact CO/Oper variable. Ordinary ST/MX process reports must not + // complete the pending command. + if (!matching_operate) { + return result; + } + + result.is_for_control_object = true; + result.is_termination = true; + result.positive = true; + result.control_error = ControlError::no_error; + result.add_cause = AddCause::none; + result.raw_control_error = 0; + result.raw_add_cause = 25; + result.control_error_name = "no-error"; + result.add_cause_name = "none"; + return result; +} + +} // namespace ar::iec61850::control From 68f1eb22086f71fd91a35ded56a5e7919d82315b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:02 +0700 Subject: [PATCH 15/50] Add LastApplError and CommandTermination parity tests --- tests/test_command_termination.cpp | 229 +++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/test_command_termination.cpp diff --git a/tests/test_command_termination.cpp b/tests/test_command_termination.cpp new file mode 100644 index 000000000..a5b61a293 --- /dev/null +++ b/tests/test_command_termination.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/command_termination.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +ControlObjectReference object() { + ControlObjectReference result; + CHECK(try_parse_control_object_reference("LD0/CSWI1.Pos", result)); + return result; +} + +MmsObjectName reference(std::string item) { + MmsObjectName result; + result.kind = MmsObjectNameKind::domain_specific; + result.domain = "LD0"; + result.item = std::move(item); + return result; +} + +MmsInformationReportItem value_item(MmsDataValue value, const std::size_t index = 0U) { + MmsInformationReportItem item; + item.index = index; + item.value = std::move(value); + return item; +} + +MmsDataValue last_appl_error( + std::string control_object, + const std::int64_t error, + const std::int64_t add_cause) { + return MmsDataValue::structure({ + MmsDataValue::visible_string(std::move(control_object)), + MmsDataValue::integer(error), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(add_cause), + }); +} + +bool allow_all( + void*, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + return true; +} + +void reference_matching_follows_csharp_oracle() { + const auto target = object(); + CHECK(CommandTerminationDecoder::matches_operate_reference( + target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(CommandTerminationDecoder::matches_operate_reference( + target, "CSWI1.CO.Pos.Oper")); + CHECK(!CommandTerminationDecoder::matches_operate_reference( + target, "LD0/CSWI1$ST$Pos$stVal")); + + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1.Pos")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1.Pos.stVal")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(!CommandTerminationDecoder::matches_reported_reference( + target, "LD0/XCBR1.Pos.stVal")); +} + +void positive_termination_requires_exact_oper_reference() { + MmsInformationReport report; + report.variable_references.push_back(reference("CSWI1$CO$Pos$Oper")); + auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(result.positive); + CHECK(result.control_error == ControlError::no_error); + CHECK(result.add_cause == AddCause::none); + CHECK(result.raw_add_cause == 25); + + MmsInformationReport ordinary; + ordinary.variable_references.push_back(reference("CSWI1$ST$Pos$stVal")); + ordinary.items.push_back(value_item(MmsDataValue::boolean(true))); + result = CommandTerminationDecoder::decode(ordinary, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + +void negative_last_appl_error_is_correlated_and_mapped() { + MmsInformationReport report; + // Embedded ctlObj is sufficient even if the report variable reference itself + // is not the target object. + report.variable_references.push_back(reference("LLN0$ST$Mod$stVal")); + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 0, 10))); + + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.control_error == ControlError::no_error); + CHECK(result.add_cause == AddCause::blocked_by_interlocking); + CHECK(result.control_error_name == "no-error"); + CHECK(result.add_cause_name == "blocked-by-interlocking"); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object == "LD0/CSWI1.Pos"); +} + +void omitted_ctl_obj_layout_is_supported_when_report_correlates() { + MmsInformationReport report; + report.variable_references.push_back(reference("CSWI1$CO$Pos$Oper")); + report.items.push_back(value_item(MmsDataValue::structure({ + MmsDataValue::integer(0), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(2U), + MmsDataValue::octet_string(std::vector{'H', 'M', 'I'}), + }), + MmsDataValue::unsigned_integer(7U), + MmsDataValue::integer(25), + }))); + + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_termination); + CHECK(result.positive); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object.empty()); + CHECK(result.add_cause == AddCause::none); +} + +void uncorrelated_last_appl_error_cannot_complete_command() { + MmsInformationReport report; + report.variable_references.push_back(reference("XCBR1$ST$Pos$stVal")); + report.items.push_back(value_item(last_appl_error( + "LD0/XCBR1.Pos", 0, 11))); + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + +void unknown_numeric_codes_are_preserved_without_false_positive() { + MmsInformationReport report; + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 99, 88))); + const auto result = CommandTerminationDecoder::decode(report, object()); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.raw_control_error == 99); + CHECK(result.raw_add_cause == 88); + CHECK(result.control_error == ControlError::unknown); + CHECK(result.add_cause == AddCause::unknown); + CHECK(result.control_error_name == "control-error-99"); + CHECK(result.add_cause_name == "add-cause-88"); +} + +void decoder_output_drives_enhanced_planner_completion() { + GuardedControlPolicy policy; + policy.authorize = allow_all; + const auto target = object(); + GuardedControlPlanner planner{target, ControlModel::direct_enhanced, policy}; + constexpr std::uint8_t encoded_boolean[] = {0x83U, 0x01U, 0xFFU}; + constexpr std::uint8_t origin[] = {'H', 'M', 'I'}; + ControlSequenceView sequence; + sequence.control_value = encoded_boolean; + sequence.origin_identifier = origin; + sequence.timestamp_token = 1U; + const ControlClientIdentity client{0x1234U}; + auto decision = planner.operate(client, sequence, 100U); + CHECK(decision.status == GuardedControlStatus::accepted_waiting_termination); + + MmsInformationReport report; + report.items.push_back(value_item(last_appl_error( + "LD0/CSWI1.Pos", 0, 11))); + const auto termination = CommandTerminationDecoder::decode(report, target); + CHECK(termination.is_termination); + CHECK(!termination.positive); + + decision = planner.command_termination( + termination.control_error, termination.add_cause); + CHECK(decision.status == GuardedControlStatus::negative_termination); + CHECK(decision.add_cause == AddCause::blocked_by_synchrocheck); + CHECK(!planner.state(101U).waiting_for_termination); +} + +void last_appl_error_needs_two_numeric_fields() { + const auto invalid = MmsDataValue::structure({ + MmsDataValue::visible_string("LD0/CSWI1.Pos"), + MmsDataValue::integer(0), + }); + CHECK(!CommandTerminationDecoder::try_decode_last_appl_error(invalid).has_value()); +} + +} // namespace + +int main() { + try { + reference_matching_follows_csharp_oracle(); + positive_termination_requires_exact_oper_reference(); + negative_last_appl_error_is_correlated_and_mapped(); + omitted_ctl_obj_layout_is_supported_when_report_correlates(); + uncorrelated_last_appl_error_cannot_complete_command(); + unknown_numeric_codes_are_preserved_without_false_positive(); + decoder_output_drives_enhanced_planner_completion(); + last_appl_error_needs_two_numeric_fields(); + std::cout << "CommandTermination tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} From 64581836000db2d4621eeabe372762df73e4fd5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:22 +0700 Subject: [PATCH 16/50] Add strict CommandTermination validation profile --- .../CMakeLists.txt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/control_termination_profile/CMakeLists.txt diff --git a/tests/control_termination_profile/CMakeLists.txt b/tests/control_termination_profile/CMakeLists.txt new file mode 100644 index 000000000..d55bc0b73 --- /dev/null +++ b/tests/control_termination_profile/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlTerminationProfile + VERSION 0.1.0 + DESCRIPTION "IEC 61850 CommandTermination and LastApplError parity validation" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +add_executable(ariec61850_command_termination_tests + ${ARIEC61850_ROOT}/tests/test_command_termination.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/control/guarded_control.cpp + ${ARIEC61850_ROOT}/src/control/command_termination.cpp) + +target_compile_features(ariec61850_command_termination_tests PRIVATE cxx_std_20) +target_include_directories(ariec61850_command_termination_tests PRIVATE + ${ARIEC61850_ROOT}/include) + +if(MSVC) + target_compile_options(ariec61850_command_termination_tests PRIVATE + /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_command_termination_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_command_termination_tests + COMMAND ariec61850_command_termination_tests) + +message(STATUS "arstack61850 Control termination profile: LastApplError mapping=ON") +message(STATUS "arstack61850 Control termination profile: exact CO/Oper positive boundary=ON") +message(STATUS "arstack61850 Control termination profile: ordinary ST/MX completion=DENIED") +message(STATUS "arstack61850 Control termination profile: unknown numeric diagnostics preserved=ON") From 6d8c2bdf60934089b68af7a61b3e3eccc6fa59f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:42:36 +0700 Subject: [PATCH 17/50] Gate CommandTermination parity on GCC and Clang --- .../control-termination-profile-ci.yml | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/control-termination-profile-ci.yml diff --git a/.github/workflows/control-termination-profile-ci.yml b/.github/workflows/control-termination-profile-ci.yml new file mode 100644 index 000000000..56e0730a2 --- /dev/null +++ b/.github/workflows/control-termination-profile-ci.yml @@ -0,0 +1,62 @@ +name: Control Termination Profile CI + +on: + push: + branches: + - main + pull_request: + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_command_termination.cpp' + - 'tests/control_termination_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/reporting.hpp' + - 'src/mms/data_value.cpp' + - '.github/workflows/control-termination-profile-ci.yml' + +permissions: + contents: read + +jobs: + control-termination-profile: + name: Control Termination / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure control termination profile + run: cmake -S tests/control_termination_profile -B build-control-termination + + - name: Build control termination profile + run: cmake --build build-control-termination --parallel + + - name: Run CommandTermination parity tests + run: >- + ctest --test-dir build-control-termination + --output-on-failure + --output-junit control-termination-ctest.xml + + - name: Upload termination evidence + uses: actions/upload-artifact@v4 + with: + name: control-termination-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: build-control-termination/control-termination-ctest.xml From c14316189e3780b2c176e42b447baf6ab8410722 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:48:37 +0700 Subject: [PATCH 18/50] 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 8b664a834fba78a2825844f9ddf45b31e2259577 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:49:07 +0700 Subject: [PATCH 19/50] 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 9ce8a9389fef0f50783446a7b9f05d7aae225249 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:50:01 +0700 Subject: [PATCH 20/50] 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 6cc7bf2c30a2803467012bea8528401cef43a8f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:50:37 +0700 Subject: [PATCH 21/50] 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 dd57b97055b092c1da5da443d7902722e706b926 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:51:44 +0700 Subject: [PATCH 22/50] 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 c58635a27dc2c2d9d660fe499eb82b6b3529d6d2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:53:49 +0700 Subject: [PATCH 23/50] 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 f5923f934b3976c8c10d3d89635826bf76e7a5db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:55:36 +0700 Subject: [PATCH 24/50] 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 cbd1e762f60b10d1c63c144090aa47cf4f77de23 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:56:03 +0700 Subject: [PATCH 25/50] 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 2f49987ef5dcedeeb9b0926899dcf38b920c47f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 19:56:22 +0700 Subject: [PATCH 26/50] 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 1367089e2406af138e0615a51b6a5aba3fcb2389 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:00:35 +0700 Subject: [PATCH 27/50] 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 a65eb9633d2c0e8cbd54d122bbc6b56ca53eb75d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:03:55 +0700 Subject: [PATCH 28/50] 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 20dcc18bc6b57fb21ea3bbebda49c5b717911d58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:05:19 +0700 Subject: [PATCH 29/50] 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 ea0a0963456952d07fab2e18d2dfa6ee84791c7d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:09:08 +0700 Subject: [PATCH 30/50] 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 559815fc2..02c4d2719 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,11 +31,18 @@ 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/mms/rcb_failover.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 @@ -282,6 +289,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 f2c4e68835e37b145afcf7abfd444f9537e33899 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 20:10:56 +0700 Subject: [PATCH 31/50] Update port status for guarded control parity C1-C4 --- PORT_STATUS.md | 121 +++++++++++++++++++++++++++++-------------------- 1 file changed, 73 insertions(+), 48 deletions(-) diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 6de67e1c4..1bef3f79e 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,7 +2,9 @@ ## Current milestone -**Phase 4C.1 — built-in non-blocking TCP, bounded read-only discovery, C#-compatible live-model parity, and physical interoperability evidence tooling are implemented for review.** +**Phase 4D-C5 — guarded IEC 61850 Smart Control is integrated on the current branch for review and controlled laboratory interoperability testing.** + +The implementation preserves the existing Phase 4C live discovery and Phase 4D Dynamic DataSet/reporting work from `main`. Buffered-report recovery and ESP32 process-bus work remain separate concerns and are not used as evidence for MMS control behavior. ## Delivered modules @@ -12,50 +14,73 @@ | SCL and COMTRADE engineering formats | Merged to `main` | | TPKT/COTP and Session/Presentation/ACSE | Merged to `main` | | MMS Initiate and confirmed services | Merged to `main` | -| Offline DataSet/RCB/report monitoring | Merged to `main` | -| Transport-injected MMS association/runtime | Merged to `main` | -| Persistent RCB subscription runtime | Merged to `main` | -| Built-in Windows/Linux TCP transport | Implemented in PR #14 | -| Live read-only MMS discovery | Implemented in PR #14 | -| `live-ied-model-v1` hierarchy/parity mapper | Implemented in PR #14 | -| Physical read-only evidence runner | Implemented in PR #14 | -| Physical IED acceptance evidence | Pending lab execution | - -## Phase 4C.1 behavior - -- Non-blocking Winsock/POSIX TCP honors deadlines and cancellation. -- Discovery is bounded and sends only GetNameList, GetVariableAccessAttributes, - GetNamedVariableListAttributes, and Read. -- MMS references are mapped to Logical Device, Logical Node, Data Object, and Data Attribute. -- Functional constraints, MMS/SCL type evidence, DataSets, and report controls are retained. -- Direct leaf and nested TypeSpecification evidence are resolved into the model. -- IED identity and CDC are inferred conservatively with explicit confidence. -- Canonical manifests and fingerprints make unchanged-model runs comparable. -- C# and C++ `live-ied-model-v1` JSON can be compared by the parity script. -- The lab runner performs repeated reconnect/discovery cycles and records acceptance evidence. - -## Validation completed without a physical IED - -- Phase 4B baseline GCC, Clang, MSVC, sanitizer, CTest, and fuzz evidence remains valid. -- Phase 4C TCP loopback, cancellation, strict GCC/Clang warning, and sanitizer checks passed locally. -- Live-model hierarchy, deterministic fingerprint, parity, and multi-translation-unit header - validation passed locally with GCC and Clang; ASan/UBSan smoke passed. -- Python parity and interoperability scripts pass syntax checks and a deterministic simulated - three-cycle acceptance run. -- The discovery regression decodes all emitted requests and rejects MMS Write. - -## Remaining acceptance gates - -- Run repository GCC/Clang/MSVC and Security/Evidence workflows through `workflow_dispatch`. -- Run the physical evidence runner against a reachable IED or vendor simulator. -- Record a primary-vendor ten-cycle run, timeout/reconnect evidence, and large-model pagination - where available. -- Export the C# oracle model and accept C#↔C++ parity against the same IED configuration. -- Review warning policy and packet-capture references before merging PR #14. - -## Safety boundary - -The live discovery surface is read-only and does not construct Write, control, GI, RCB -reservation/enable, dynamic DataSet mutation, or file-service requests. The generic TCP transport -can be used by other explicit runtime surfaces, but those surfaces retain their own authorization -and physical-laboratory gates. +| Built-in Windows/Linux TCP transport | Merged to `main` | +| Live read-only MMS discovery/model parity | Merged to `main` | +| Dynamic DataSet lifecycle | Merged to `main` | +| Guarded Dynamic RCB live-trial planner | Merged to `main` | +| Guarded Direct/SBO control safety core | Integrated from Phase 4D-C1 / PR #25 | +| Live-type Oper/SBOw/Cancel MMS structure builder | Integrated from Phase 4D-C2 / PR #27 | +| CommandTermination and LastApplError/AddCause correlation | Integrated from Phase 4D-C3 / PR #28 | +| Live control discovery/session over `MmsAssociationRuntime` | Integrated from Phase 4D-C4 / PR #29 | +| Guarded physical interoperability harness and read-only inventory probes | Integrated from Phase 4D-C5 / PR #30 | + +## Smart 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.** + +## Deterministic validation + +Dedicated strict profiles 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 layouts, unknown raw-code preservation, and object correlation; +- positive and negative enhanced CommandTermination behavior; +- live descriptor discovery using `ctlModel`, GVAA, GetNameList, CF timeouts, and ST/MX status-reference priority; +- Direct one-write behavior, SBO Select to Operate, SBOw asynchronous application-error handling, AutoSelect, stale-selection Cancel, and enhanced termination wait; +- production `MmsAssociationControlTransport` strict compile validation; +- integration of C1-C5 sources into the normal `ARIEC61850::core` build graph and CTest targets. + +## Physical interoperability acceptance + +Physical IED control acceptance is not claimed until evidence is retained from an isolated, non-operational laboratory IED or simulator for: + +1. read-only 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 a 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. + +## Safety and claim boundary + +- Live inventory and raw `ctlModel` probes are read-only. +- Mutating control requires explicit command intent, an exact object root, live type discovery, and the harness authorization gate. +- The implementation and deterministic tests are not an IEC 61850 conformance certificate. +- No hosted CI result is treated as proof of a physical IED command. +- Sampled Values, ESP32 hardware transmission, and BRCB persistence remain outside Smart Control acceptance. From 9ffc0c30cdb4c7502935fa3d0036cb5f583f0029 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:23:34 +0700 Subject: [PATCH 32/50] Add guarded live control interoperability harness --- tools/control_interop_probe.cpp | 729 ++++++++++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 tools/control_interop_probe.cpp diff --git a/tools/control_interop_probe.cpp b/tools/control_interop_probe.cpp new file mode 100644 index 000000000..d0b7a6983 --- /dev/null +++ b/tools/control_interop_probe.cpp @@ -0,0 +1,729 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#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; + +constexpr std::string_view kArmToken{"IEC61850-LAB-CONTROL"}; + +struct Options final { + MmsEndpoint endpoint{}; + std::string object_reference; + std::string action{"discover"}; + std::string value; + std::string value_kind; + std::string origin_identifier{"ARSTACK-C5"}; + OriginCategory origin_category{OriginCategory::station_control}; + std::chrono::milliseconds timeout{5'000}; + std::optional termination_timeout; + bool test{}; + bool interlock_check{true}; + bool synchro_check{true}; + bool auto_select{true}; + bool armed{}; + std::string evidence_path; + bool self_test{}; +}; + +struct LabAuthorization final { + bool armed{}; +}; + +[[nodiscard]] bool authorize_lab_control( + void* context, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + const auto* authorization = static_cast(context); + return authorization != nullptr && authorization->armed; +} + +[[nodiscard]] std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char ch) { + return static_cast(std::tolower(static_cast(ch))); + }); + return value; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::chrono::milliseconds parse_timeout( + const std::string& option, + const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive millisecond value."); + } + return std::chrono::milliseconds{static_cast(parsed)}; +} + +[[nodiscard]] bool parse_switch(const std::string& option, const std::string& value) { + const auto normalized = lower(value); + if (normalized == "on" || normalized == "true" || normalized == "1") { + return true; + } + if (normalized == "off" || normalized == "false" || normalized == "0") { + return false; + } + throw std::invalid_argument(option + " requires on/off."); +} + +[[nodiscard]] OriginCategory parse_origin_category(const std::string& value) { + const auto normalized = lower(value); + if (normalized == "bay") return OriginCategory::bay_control; + if (normalized == "station") return OriginCategory::station_control; + if (normalized == "remote") return OriginCategory::remote_control; + if (normalized == "automatic-bay") return OriginCategory::automatic_bay; + if (normalized == "automatic-station") return OriginCategory::automatic_station; + if (normalized == "automatic-remote") return OriginCategory::automatic_remote; + if (normalized == "maintenance") return OriginCategory::maintenance; + if (normalized == "process") return OriginCategory::process; + throw std::invalid_argument( + "--origin-category requires bay, station, remote, automatic-bay, " + "automatic-station, automatic-remote, maintenance, or process."); +} + +[[nodiscard]] const char* model_name(const ControlModel model) noexcept { + switch (model) { + case ControlModel::status_only: return "status-only"; + case ControlModel::direct_normal: return "direct-normal"; + case ControlModel::select_before_operate_normal: return "sbo-normal"; + case ControlModel::direct_enhanced: return "direct-enhanced"; + case ControlModel::select_before_operate_enhanced: return "sbo-enhanced"; + case ControlModel::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] const char* completion_name(const ControlCompletionState state) noexcept { + switch (state) { + case ControlCompletionState::accepted: return "accepted"; + case ControlCompletionState::positive_termination: return "positive-termination"; + case ControlCompletionState::negative_termination: return "negative-termination"; + case ControlCompletionState::rejected: return "rejected"; + case ControlCompletionState::unsupported: return "unsupported"; + case ControlCompletionState::timed_out: return "timed-out"; + case ControlCompletionState::association_lost: return "association-lost"; + case ControlCompletionState::cancelled: return "cancelled"; + } + return "unknown"; +} + +[[nodiscard]] const char* action_name(const ControlAction action) noexcept { + switch (action) { + case ControlAction::select: return "select"; + case ControlAction::select_with_value: return "select-with-value"; + case ControlAction::operate: return "operate"; + case ControlAction::cancel: return "cancel"; + } + return "unknown"; +} + +[[nodiscard]] const char* add_cause_name(const AddCause cause) noexcept { + switch (cause) { + case AddCause::unknown: return "unknown"; + case AddCause::not_supported: return "not-supported"; + case AddCause::blocked_by_switching_hierarchy: return "blocked-by-switching-hierarchy"; + case AddCause::select_failed: return "select-failed"; + case AddCause::invalid_position: return "invalid-position"; + case AddCause::position_reached: return "position-reached"; + case AddCause::parameter_change_in_execution: return "parameter-change-in-execution"; + case AddCause::step_limit: return "step-limit"; + case AddCause::blocked_by_mode: return "blocked-by-mode"; + case AddCause::blocked_by_process: return "blocked-by-process"; + case AddCause::blocked_by_interlocking: return "blocked-by-interlocking"; + case AddCause::blocked_by_synchrocheck: return "blocked-by-synchrocheck"; + case AddCause::command_already_in_execution: return "command-already-in-execution"; + case AddCause::blocked_by_health: return "blocked-by-health"; + case AddCause::one_of_n_control: return "one-of-n-control"; + case AddCause::abortion_by_cancel: return "abortion-by-cancel"; + case AddCause::time_limit_over: return "time-limit-over"; + case AddCause::abortion_by_trip: return "abortion-by-trip"; + case AddCause::object_not_selected: return "object-not-selected"; + case AddCause::object_already_selected: return "object-already-selected"; + case AddCause::no_access_authority: return "no-access-authority"; + case AddCause::ended_with_overshoot: return "ended-with-overshoot"; + case AddCause::abortion_due_to_deviation: return "abortion-due-to-deviation"; + case AddCause::abortion_by_communication_loss: return "abortion-by-communication-loss"; + case AddCause::abortion_by_command: return "abortion-by-command"; + case AddCause::none: return "none"; + case AddCause::inconsistent_parameters: return "inconsistent-parameters"; + case AddCause::locked_by_other_client: return "locked-by-other-client"; + } + return "unknown"; +} + +[[nodiscard]] std::string json_escape(const std::string& value) { + std::ostringstream output; + for (const auto ch : value) { + switch (ch) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(ch) < 0x20U) { + output << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(static_cast(ch)) + << std::dec; + } else { + output << ch; + } + } + } + return output.str(); +} + +[[nodiscard]] std::string hex_bytes(const std::vector& bytes) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] std::string value_text(const MmsDataValue& value) { + switch (value.kind()) { + case MmsDataKind::boolean: + return std::get(value.value()) ? "true" : "false"; + case MmsDataKind::integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::unsigned_integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::floating_point: { + std::ostringstream output; + if (const auto* float_value = std::get_if(&value.value())) { + output << *float_value; + } else if (const auto* double_value = std::get_if(&value.value())) { + output << *double_value; + } + return output.str(); + } + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: + return std::get(value.value()); + case MmsDataKind::array: + case MmsDataKind::structure: { + std::ostringstream output; + output << '['; + for (std::size_t index = 0U; index < value.children().size(); ++index) { + if (index != 0U) output << ','; + output << value_text(value.children()[index]); + } + output << ']'; + return output.str(); + } + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + case MmsDataKind::bcd: + case MmsDataKind::boolean_array: + case MmsDataKind::object_id: + case MmsDataKind::unknown: + return "0x" + hex_bytes(value.raw_value()); + case MmsDataKind::utc_time: + return ""; + } + return ""; +} + +class RecordingControlTransport final : public ControlTransport { +public: + explicit RecordingControlTransport(ControlTransport& inner) noexcept : inner_{inner} {} + + [[nodiscard]] bool associated() const noexcept override { return inner_.associated(); } + [[nodiscard]] std::uint64_t association_id() const noexcept override { + return inner_.association_id(); + } + + [[nodiscard]] std::optional read( + const MmsObjectName& object, + std::stop_token stop_token = {}) override { + reads.push_back(object.reference()); + return inner_.read(object, stop_token); + } + + [[nodiscard]] std::optional variable_specification( + const MmsObjectName& object, + std::stop_token stop_token = {}) override { + specifications.push_back(object.reference()); + return inner_.variable_specification(object, stop_token); + } + + [[nodiscard]] std::vector domain_variable_names( + const std::string& domain, + std::stop_token stop_token = {}) override { + name_lists.push_back(domain); + return inner_.domain_variable_names(domain, stop_token); + } + + [[nodiscard]] ControlTransportWriteResult write( + const MmsObjectName& object, + MmsDataValue value, + std::stop_token stop_token = {}) override { + writes.push_back(object.reference()); + return inner_.write(object, std::move(value), stop_token); + } + + void clear_information_reports() override { + ++report_drains; + inner_.clear_information_reports(); + } + + [[nodiscard]] bool wait_information_report( + const std::chrono::milliseconds timeout, + MmsInformationReport& report, + std::stop_token stop_token = {}) override { + ++report_waits; + const auto received = inner_.wait_information_report(timeout, report, stop_token); + if (received) ++reports_received; + return received; + } + + std::vector reads; + std::vector specifications; + std::vector name_lists; + std::vector writes; + std::size_t report_drains{}; + std::size_t report_waits{}; + std::size_t reports_received{}; + +private: + ControlTransport& inner_; +}; + +[[nodiscard]] ControlValue parse_control_value( + const std::string& raw_kind, + const std::string& raw_value, + const std::string& cdc) { + auto kind = lower(raw_kind); + if (kind.empty()) { + const auto normalized_cdc = lower(cdc); + if (normalized_cdc == "spc") kind = "bool"; + else if (normalized_cdc == "dpc") kind = "dpc"; + else if (normalized_cdc == "apc") kind = "float"; + else if (normalized_cdc == "bsc") kind = "step"; + else if (normalized_cdc == "inc/isc") kind = "int"; + else throw std::invalid_argument( + "Cannot infer --value-kind for vendor-specific CDC; specify it explicitly."); + } + + if (kind == "bool" || kind == "boolean") { + return ControlValue::boolean(parse_switch("--value", raw_value)); + } + if (kind == "dpc" || kind == "double-point") { + const auto value = lower(raw_value); + if (value == "off" || value == "1") return ControlValue::double_point(DoublePointValue::off); + if (value == "on" || value == "2") return ControlValue::double_point(DoublePointValue::on); + if (value == "intermediate" || value == "0") return ControlValue::double_point(DoublePointValue::intermediate); + if (value == "bad" || value == "3") return ControlValue::double_point(DoublePointValue::bad); + throw std::invalid_argument("DPC --value requires off, on, intermediate, or bad."); + } + if (kind == "int" || kind == "integer") { + std::size_t consumed{}; + const auto parsed = std::stoll(raw_value, &consumed, 0); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid signed integer --value."); + return ControlValue::integer(static_cast(parsed)); + } + if (kind == "uint" || kind == "unsigned") { + std::size_t consumed{}; + const auto parsed = std::stoull(raw_value, &consumed, 0); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid unsigned integer --value."); + return ControlValue::unsigned_integer(static_cast(parsed)); + } + if (kind == "float" || kind == "double") { + std::size_t consumed{}; + const auto parsed = std::stod(raw_value, &consumed); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid floating-point --value."); + return ControlValue::floating_point(parsed); + } + if (kind == "step") { + const auto colon = raw_value.find(':'); + const auto position_text = raw_value.substr(0U, colon); + std::size_t consumed{}; + const auto parsed = std::stoll(position_text, &consumed, 0); + if (consumed != position_text.size()) throw std::invalid_argument("Invalid step position --value."); + bool transient{}; + if (colon != std::string::npos) { + transient = parse_switch("step transient", raw_value.substr(colon + 1U)); + } + return ControlValue::step_position({static_cast(parsed), transient}); + } + throw std::invalid_argument( + "--value-kind requires bool, dpc, int, uint, float, or step."); +} + +[[nodiscard]] ControlActionResult execute_action( + const Options& options, + const ControlObjectDescriptor& descriptor, + ControlObjectSession& session, + const ControlRequest& request) { + if (options.action == "operate") { + return session.operate(request); + } + + if (options.action == "select-operate" || options.action == "select-cancel") { + if (!descriptor.requires_select()) { + throw std::invalid_argument( + options.action + " requires an SBO control model discovered from the IED."); + } + ControlActionResult selected; + if (descriptor.model == ControlModel::select_before_operate_enhanced) { + selected = session.select_with_value(request); + } else { + selected = session.select(request); + } + if (!selected.success()) return selected; + if (options.action == "select-cancel") return session.cancel(); + + auto operate_request = request; + operate_request.auto_select = false; + return session.operate(operate_request); + } + + throw std::invalid_argument( + "--action requires discover, operate, select-operate, or select-cancel."); +} + +void print_descriptor(const ControlObjectDescriptor& descriptor) { + std::cout << "CONTROL_DESCRIPTOR object=" << descriptor.object.domain << '/' + << descriptor.object.logical_node << '.' << descriptor.object.data_object_path + << " ctlModel=" << model_name(descriptor.model) + << " cdc=" << descriptor.cdc + << " requiresSelect=" << (descriptor.requires_select() ? "true" : "false") + << " enhanced=" << (descriptor.enhanced() ? "true" : "false") + << " sboTimeoutMs=" << descriptor.sbo_timeout.count() + << " operTimeoutMs=" << descriptor.operate_timeout.count() + << " commandTermination=" + << (descriptor.supports_command_termination ? "true" : "false") + << '\n'; + if (descriptor.status_object) { + std::cout << "STATUS_OBJECT " << descriptor.status_object->reference() + << " fc=" << descriptor.status_functional_constraint << '\n'; + } + for (const auto& evidence : descriptor.discovery_evidence) { + std::cout << "DISCOVERY_EVIDENCE " << evidence << '\n'; + } +} + +void write_evidence( + const Options& options, + const ControlObjectDescriptor& descriptor, + const RecordingControlTransport& transport, + const std::optional& status_before, + const std::optional& status_after, + const std::optional& result, + const MmsAssociationRuntime& association) { + if (options.evidence_path.empty()) return; + + std::ofstream output{options.evidence_path, std::ios::binary | std::ios::trunc}; + if (!output) { + throw std::runtime_error("Cannot open evidence file: " + options.evidence_path); + } + + output << "{\n" + << " \"schema\": \"arstack61850-control-interop-v1\",\n" + << " \"endpoint\": \"" << json_escape(options.endpoint.host) << ':' + << options.endpoint.port << "\",\n" + << " \"object\": \"" << json_escape(options.object_reference) << "\",\n" + << " \"action\": \"" << json_escape(options.action) << "\",\n" + << " \"armed\": " << (options.armed ? "true" : "false") << ",\n" + << " \"ctlModel\": \"" << model_name(descriptor.model) << "\",\n" + << " \"cdc\": \"" << json_escape(descriptor.cdc) << "\",\n" + << " \"associationProfile\": \"" + << json_escape(association.active_association_profile()) << "\",\n" + << " \"statusBefore\": "; + if (status_before) output << '"' << json_escape(*status_before) << '"'; + else output << "null"; + output << ",\n \"statusAfter\": "; + if (status_after) output << '"' << json_escape(*status_after) << '"'; + else output << "null"; + output << ",\n \"mmsControlWriteCount\": " << transport.writes.size() << ",\n" + << " \"mmsControlWrites\": ["; + for (std::size_t index = 0U; index < transport.writes.size(); ++index) { + if (index != 0U) output << ','; + output << "\"" << json_escape(transport.writes[index]) << "\""; + } + output << "],\n" + << " \"readCount\": " << transport.reads.size() << ",\n" + << " \"gvaaCount\": " << transport.specifications.size() << ",\n" + << " \"reportDrainCount\": " << transport.report_drains << ",\n" + << " \"reportWaitCount\": " << transport.report_waits << ",\n" + << " \"informationReportsReceived\": " << transport.reports_received << ",\n" + << " \"associationEventCount\": " << association.events().size(); + if (result) { + output << ",\n \"result\": {\n" + << " \"action\": \"" << action_name(result->action) << "\",\n" + << " \"completion\": \"" << completion_name(result->completion) << "\",\n" + << " \"requestAccepted\": " + << (result->request_accepted ? "true" : "false") << ",\n" + << " \"commandTerminationReceived\": " + << (result->command_termination_received ? "true" : "false") << ",\n" + << " \"positiveTermination\": " + << (result->positive_termination ? "true" : "false") << ",\n" + << " \"controlNumber\": " << static_cast(result->control_number) << ",\n" + << " \"rawControlError\": " << result->raw_control_error << ",\n" + << " \"rawAddCause\": " << result->raw_add_cause << ",\n" + << " \"addCause\": \"" << add_cause_name(result->add_cause) << "\",\n" + << " \"message\": \"" << json_escape(result->message) << "\"\n" + << " }"; + } + output << "\n}\n"; +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_interop_probe [port] --object LD/LN.DO [options]\n\n" + << "Read-only default:\n" + << " No --action means discover only. Discovery reads ctlModel, exact live GVAA,\n" + << " CF timeouts when exposed, and the best matching ST/MX status value.\n\n" + << "Write actions (LAB ONLY):\n" + << " --action operate|select-operate|select-cancel\n" + << " --value VALUE Required for a write action.\n" + << " --value-kind KIND bool|dpc|int|uint|float|step; inferred from CDC when possible.\n" + << " --arm IEC61850-LAB-CONTROL Mandatory exact token before any control Write.\n\n" + << "Control sequence options:\n" + << " --origin TEXT Origin identifier (default ARSTACK-C5, max 64 bytes).\n" + << " --origin-category CAT bay|station|remote|automatic-bay|automatic-station|\n" + << " automatic-remote|maintenance|process.\n" + << " --test on|off IEC 61850 Test flag (default off).\n" + << " --interlock-check on|off Check bit 1 (default on).\n" + << " --synchro-check on|off Check bit 0 (default on).\n" + << " --auto-select on|off Operate may select automatically (default on).\n" + << " --termination-timeout-ms N Override enhanced CommandTermination wait.\n" + << " --timeout-ms N MMS connect/request timeout (default 5000).\n" + << " --evidence FILE Write machine-readable JSON evidence.\n" + << " --self-test Offline harness safety self-test; no network.\n\n" + << "Safety: this tool never retries a command automatically. For a live write, use an\n" + << "isolated/non-operational laboratory IED and capture tcp.port==102 in Wireshark.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + options.endpoint.port = 102U; + + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + options.self_test = true; + return options; + } + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + return options; + } + + options.endpoint.host = argv[1]; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + options.endpoint.port = parse_port(argv[argument]); + ++argument; + } + + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") return Options{}; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--object") options.object_reference = value; + else if (option == "--action") options.action = lower(value); + else if (option == "--value") options.value = value; + else if (option == "--value-kind") options.value_kind = lower(value); + else if (option == "--origin") options.origin_identifier = value; + else if (option == "--origin-category") options.origin_category = parse_origin_category(value); + else if (option == "--timeout-ms") options.timeout = parse_timeout(option, value); + else if (option == "--termination-timeout-ms") { + options.termination_timeout = parse_timeout(option, value); + } else if (option == "--test") options.test = parse_switch(option, value); + else if (option == "--interlock-check") options.interlock_check = parse_switch(option, value); + else if (option == "--synchro-check") options.synchro_check = parse_switch(option, value); + else if (option == "--auto-select") options.auto_select = parse_switch(option, value); + else if (option == "--arm") options.armed = value == kArmToken; + else if (option == "--evidence") options.evidence_path = value; + else throw std::invalid_argument("Unknown option: " + option); + } + return options; +} + +void validate_options(const Options& options) { + if (options.endpoint.host.empty()) throw std::invalid_argument("Missing host."); + if (options.object_reference.empty()) throw std::invalid_argument("--object is required."); + if (options.origin_identifier.size() > 64U) { + throw std::invalid_argument("--origin must not exceed 64 bytes."); + } + if (options.action != "discover" && options.action != "operate" && + options.action != "select-operate" && options.action != "select-cancel") { + throw std::invalid_argument( + "--action requires discover, operate, select-operate, or select-cancel."); + } + if (options.action != "discover") { + if (!options.armed) { + throw std::invalid_argument( + "Control Write is locked. Add exact --arm IEC61850-LAB-CONTROL only for an isolated lab IED."); + } + if (options.value.empty()) throw std::invalid_argument("--value is required for a control action."); + } +} + +int run_self_test() { + Options read_only; + read_only.endpoint.host = "127.0.0.1"; + read_only.object_reference = "LD0/CSWI1.Pos"; + validate_options(read_only); + + auto blocked = read_only; + blocked.action = "operate"; + blocked.value = "on"; + bool rejected{}; + try { + validate_options(blocked); + } catch (const std::invalid_argument&) { + rejected = true; + } + if (!rejected) { + std::cerr << "SELF_TEST FAIL: unarmed control action was not rejected.\n"; + return 1; + } + + blocked.armed = true; + validate_options(blocked); + const auto dpc = parse_control_value("dpc", "on", "DPC"); + if (dpc.kind() != ControlValueKind::double_point) { + std::cerr << "SELF_TEST FAIL: DPC parsing mismatch.\n"; + return 1; + } + + std::cout << "CONTROL_INTEROP_SELF_TEST PASS readOnlyDefault=true armGate=true noAutoRetry=true\n"; + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.self_test) return run_self_test(); + if (options.endpoint.host.empty()) { + print_usage(); + return argc > 1 ? 0 : 2; + } + validate_options(options); + + MmsAssociationOptions association_options; + association_options.connect_timeout = options.timeout; + association_options.request_timeout = options.timeout; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(options.endpoint); + + MmsAssociationControlTransport association_transport{live_session.association()}; + RecordingControlTransport transport{association_transport}; + const auto descriptor = ControlDescriptorDiscovery::discover( + transport, options.object_reference); + print_descriptor(descriptor); + + std::optional status_before; + if (descriptor.status_object) { + if (const auto value = transport.read(*descriptor.status_object)) { + status_before = value_text(*value); + std::cout << "STATUS_BEFORE " << *status_before << '\n'; + } + } + + if (options.action == "discover") { + std::cout << "CONTROL_INTEROP_RESULT mode=read-only writes=0 status=DISCOVERY_PASS\n"; + write_evidence( + options, descriptor, transport, status_before, std::nullopt, + std::nullopt, live_session.association()); + live_session.disconnect(); + return 0; + } + + LabAuthorization authorization{options.armed}; + ControlSessionOptions session_options; + session_options.guarded_policy.authorization_context = &authorization; + session_options.guarded_policy.authorize = authorize_lab_control; + ControlObjectSession control_session{transport, descriptor, session_options}; + + ControlRequest request; + request.control_value = parse_control_value( + options.value_kind, options.value, descriptor.cdc); + request.origin_category = options.origin_category; + request.origin_identifier.assign( + options.origin_identifier.begin(), options.origin_identifier.end()); + request.test = options.test; + request.interlock_check = options.interlock_check; + request.synchro_check = options.synchro_check; + request.auto_select = options.auto_select; + request.command_termination_timeout = options.termination_timeout; + + const auto result = execute_action(options, descriptor, control_session, request); + + std::optional status_after; + if (descriptor.status_object && transport.associated()) { + if (const auto value = transport.read(*descriptor.status_object)) { + status_after = value_text(*value); + std::cout << "STATUS_AFTER " << *status_after << '\n'; + } + } + + std::cout << "CONTROL_RESULT action=" << action_name(result.action) + << " completion=" << completion_name(result.completion) + << " accepted=" << (result.request_accepted ? "true" : "false") + << " termination=" + << (result.command_termination_received ? "true" : "false") + << " ctlNum=" << static_cast(result.control_number) + << " addCause=" << add_cause_name(result.add_cause) + << " rawAddCause=" << result.raw_add_cause + << " message=\"" << result.message << "\"\n"; + + std::cout << "NO_RETRY_EVIDENCE controlWrites=" << transport.writes.size(); + for (const auto& write : transport.writes) { + std::cout << " write=" << write; + } + std::cout << '\n'; + std::cout << "PCAP_HINT filter=\"tcp.port == " << options.endpoint.port + << " && ip.addr == " << options.endpoint.host << "\"\n"; + + write_evidence( + options, descriptor, transport, status_before, status_after, + result, live_session.association()); + live_session.disconnect(); + return result.success() ? 0 : 4; + } catch (const std::exception& exception) { + std::cerr << "Control interoperability probe failed: " << exception.what() << '\n'; + return 1; + } +} From 60c5426ea997cb9140d51a27f8e1785cc70a6aa0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:24:12 +0700 Subject: [PATCH 33/50] Add standalone C5 control interop build profile --- tools/control_interop_probe/CMakeLists.txt | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tools/control_interop_probe/CMakeLists.txt diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt new file mode 100644 index 000000000..9206accf6 --- /dev/null +++ b/tools/control_interop_probe/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlInteropProbe + VERSION 0.1.0 + DESCRIPTION "Guarded IEC 61850 live control interoperability harness" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) + +add_subdirectory(${ARIEC61850_ROOT} ariec61850-core) + +add_executable(ariec61850_control_interop_probe + ${ARIEC61850_ROOT}/tools/control_interop_probe.cpp) +target_link_libraries(ariec61850_control_interop_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_interop_probe PRIVATE cxx_std_20) + +if(MSVC) + target_compile_options(ariec61850_control_interop_probe PRIVATE /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_control_interop_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_control_interop_self_test + COMMAND ariec61850_control_interop_probe --self-test) +add_test( + NAME ariec61850_control_interop_help + COMMAND ariec61850_control_interop_probe --help) + +message(STATUS "arstack61850 C5 interop harness: read-only default=ON") +message(STATUS "arstack61850 C5 interop harness: explicit arm token required for Write=ON") +message(STATUS "arstack61850 C5 interop harness: automatic command retry=OFF") From 022659b2f5e91b23e38a66b1ad47f7ebf19cb4ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:24:28 +0700 Subject: [PATCH 34/50] Add C5 control interop harness CI --- .../workflows/control-interop-harness-ci.yml | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/control-interop-harness-ci.yml diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml new file mode 100644 index 000000000..2c31f1986 --- /dev/null +++ b/.github/workflows/control-interop-harness-ci.yml @@ -0,0 +1,71 @@ +name: Control Interop Harness CI + +on: + push: + branches: + - "agent/phase-4d-c5-control-interoperability" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + control-interop: + name: Control Interop Harness / ${{ matrix.compiler }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + compiler: gcc + - os: ubuntu-latest + compiler: clang + - os: windows-latest + compiler: msvc + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + if: runner.os == 'Linux' + 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 C5 harness + shell: bash + run: cmake -S tools/control_interop_probe -B build-control-interop + + - name: Build C5 harness + shell: bash + run: cmake --build build-control-interop --config Release --parallel + + - name: Run offline safety self-test + shell: bash + run: ctest --test-dir build-control-interop -C Release --output-on-failure + + - name: Capture C5 harness contract + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p evidence/control-interop + ./build-control-interop/ariec61850_control_interop_probe --self-test \ + | tee evidence/control-interop/self-test-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_interop_probe --help \ + > evidence/control-interop/help-${{ matrix.compiler }}.txt + + - name: Upload C5 harness evidence + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: control-interop-harness-${{ matrix.compiler }} + path: evidence/control-interop + if-no-files-found: error From f5f630273d62e2687002e825a9e14137f7cededc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:25:29 +0700 Subject: [PATCH 35/50] Document C5 physical control interoperability procedure --- docs/CONTROL_INTEROP_RUNBOOK.md | 297 ++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/CONTROL_INTEROP_RUNBOOK.md diff --git a/docs/CONTROL_INTEROP_RUNBOOK.md b/docs/CONTROL_INTEROP_RUNBOOK.md new file mode 100644 index 000000000..e1c9d06dc --- /dev/null +++ b/docs/CONTROL_INTEROP_RUNBOOK.md @@ -0,0 +1,297 @@ +# IEC 61850 Control Interoperability Runbook + +## Purpose + +This runbook is the physical/laboratory acceptance procedure for Phase 4D-C5. +It validates the guarded C++ control path against a real IED or a vendor simulator. +It is not an IEC 61850 conformance certificate. + +Sampled Values / ESP32-P4 testing is deliberately out of scope here. + +## Safety boundary + +Use only an isolated, non-operational laboratory IED or simulator whose controlled +output cannot operate primary plant. Verify the physical/control circuit is safe +before arming any Write. + +`ariec61850_control_interop_probe` is read-only by default. A control Write is +blocked unless the exact token below is supplied: + +```text +--arm IEC61850-LAB-CONTROL +``` + +The harness never retries a command automatically. + +## Build + +### Windows / MSVC + +```powershell +cmake -S tools/control_interop_probe -B build-control-interop +cmake --build build-control-interop --config Release --parallel +ctest --test-dir build-control-interop -C Release --output-on-failure +``` + +Executable: + +```text +build-control-interop\Release\ariec61850_control_interop_probe.exe +``` + +### Linux + +```bash +cmake -S tools/control_interop_probe -B build-control-interop +cmake --build build-control-interop --parallel +ctest --test-dir build-control-interop --output-on-failure +``` + +Executable: + +```text +build-control-interop/ariec61850_control_interop_probe +``` + +## 0. Offline safety gate + +Run this before connecting to an IED: + +```bash +ariec61850_control_interop_probe --self-test +``` + +Expected: + +```text +CONTROL_INTEROP_SELF_TEST PASS readOnlyDefault=true armGate=true noAutoRetry=true +``` + +## 1. Start packet capture + +Start Wireshark before connecting. + +Recommended display filter after the run: + +```text +tcp.port == 102 && ip.addr == +``` + +Retain the original PCAP/PCAPNG together with the JSON evidence produced by the +harness. Keep capture timestamps synchronized with the engineering workstation. + +## 2. Read-only discovery gate + +Example: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --evidence c5-discovery.json +``` + +This may perform MMS Read, GVAA and GetNameList, but no Write. + +Accept when the output proves: + +- the object root resolves as `LD/LN.DO`; +- `ctlModel` is one of Direct normal, SBO normal, Direct enhanced or SBO enhanced; +- exact live `Oper` TypeSpecification is available; +- `SBOw` TypeSpecification is available for SBO enhanced; +- `Cancel` TypeSpecification is available for an SBO model; +- the inferred CDC/control value type is credible; +- `sboTimeout` / `operTimeout` are read when exposed, otherwise bounded fallbacks are explicit; +- the best matching ST/MX status object is identified when exposed; +- evidence JSON reports `mmsControlWriteCount: 0`. + +Do not proceed to Write if discovery is incomplete or vendor-specific command +fields cannot be bound conservatively. + +## 3. Direct normal acceptance + +Example DPC command: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action operate \ + --value on \ + --value-kind dpc \ + --origin ARSTACK-C5 \ + --origin-category station \ + --interlock-check on \ + --synchro-check on \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-direct-normal-on.json +``` + +Acceptance: + +- discovered model is `direct-normal`; +- exactly one `CO/.../Oper` Write is recorded; +- no second command Write appears in the PCAP; +- MMS service completion is accepted; +- status feedback is recorded when the IED exposes a readable ST/MX value. + +Repeat only as a new deliberate operator action if an opposite command is needed. +A second manual action is not an automatic retry. + +## 4. SBO normal acceptance + +Use explicit sequencing so Select and Oper are visible as separate phases: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-operate \ + --value on \ + --value-kind dpc \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-normal-operate.json +``` + +Acceptance: + +- discovered model is `sbo-normal`; +- Select is an MMS Read of `SBO`; +- selection response identifies the selected object or an accepted opaque token; +- Oper uses the immutable selected sequence; +- exactly one command Write is `Oper`; +- no automatic retry appears. + +Explicit Cancel test: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-cancel \ + --value on \ + --value-kind dpc \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-normal-cancel.json +``` + +Acceptance: + +- SBO Select succeeds; +- exactly one `Cancel` Write releases the selection; +- no Oper Write is sent. + +Selection timeout should be tested only with a deliberate bounded lab procedure. +Do not automate repeated Select attempts. + +## 5. Direct enhanced acceptance + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action operate \ + --value on \ + --value-kind dpc \ + --termination-timeout-ms 10000 \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-direct-enhanced.json +``` + +Acceptance: + +- exactly one `Oper` Write is sent; +- MMS Write acceptance alone does not complete the command; +- ordinary ST/MX reports are ignored as completion; +- a correlated `CommandTermination` completes the command; +- positive termination is recorded as `positive-termination`, or a negative + LastApplError path records ControlError/AddCause without a retry; +- lack of termination becomes a control timeout without automatically faulting a + healthy MMS association. + +## 6. SBO enhanced acceptance + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-operate \ + --value on \ + --value-kind dpc \ + --termination-timeout-ms 10000 \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-enhanced.json +``` + +Acceptance: + +- one `SBOw` Write establishes the value-bearing selection; +- the asynchronous application-error grace window is observed; +- one `Oper` Write follows only after successful selection; +- correlated CommandTermination is required for completion; +- JSON/PCAP therefore show two deliberate command-service Writes (`SBOw`, `Oper`), + not duplicate retries of either service. + +Explicit Cancel test uses `--action select-cancel`. For SBO enhanced the expected +write sequence is `SBOw`, then `Cancel`. + +## 7. Negative-path evidence + +Capture at least one negative enhanced-security result when it can be produced +safely by the lab IED/simulator. Preferred cases are a simulator-configured +interlock or synchrocheck rejection. + +Acceptance evidence should preserve: + +- raw ControlError; +- raw AddCause; +- mapped AddCause text such as `blocked-by-interlocking` or + `blocked-by-synchrocheck`; +- the exact control object; +- the number and identity of Write services; +- proof that no retry follows the rejection. + +Do not deliberately defeat a real interlock merely to create a negative test. + +## 8. Association-loss evidence + +Use a simulator or otherwise safe lab setup. Establish an SBO selection, then +cause the MMS association to disappear before Oper. Verify the client fails +closed and the next command requires a new explicit action/selection. + +For enhanced control, separately exercise association loss while waiting for +CommandTermination where possible. + +## Evidence bundle + +For each accepted case retain: + +```text +C5___/ + evidence.json + capture.pcapng + notes.txt + optional_ied_event_log.txt +``` + +Minimum `notes.txt` content: + +```text +IED vendor/model/firmware: +IED IP: +Control object: +ctlModel: +Test case: +Expected safe plant/simulator response: +Observed response: +PC clock/time source: +Operator: +Date/time: +``` + +The JSON field `mmsControlWriteCount` and `mmsControlWrites` must agree with the +PCAP. A mismatch is a failed evidence gate and must be investigated before any +production-control claim. + +## Acceptance state + +Hosted CI can mark the harness `SOFTWARE_READY_FOR_LAB`. + +Only retained physical/simulator evidence for the required model(s) can move the +specific tested IED profile to `LAB_INTEROP_PASSED`. + +Neither state means IEC 61850 conformance certification. From 47b524556e62368347ba2b57ff6a6a1a902e7d5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:27:45 +0700 Subject: [PATCH 36/50] Add one-click Windows build for C5 control harness --- scripts/build_control_interop.ps1 | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 scripts/build_control_interop.ps1 diff --git a/scripts/build_control_interop.ps1 b/scripts/build_control_interop.ps1 new file mode 100644 index 000000000..38b466197 --- /dev/null +++ b/scripts/build_control_interop.ps1 @@ -0,0 +1,41 @@ +param( + [ValidateSet('Release','Debug')] + [string]$Configuration = 'Release', + [switch]$Clean +) + +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSScriptRoot +$Build = Join-Path $Root 'build-control-interop' + +if ($Clean -and (Test-Path $Build)) { + Remove-Item -Recurse -Force $Build +} + +Write-Host "[C5] Configuring IEC 61850 Control Interop harness..." +cmake -S (Join-Path $Root 'tools/control_interop_probe') -B $Build +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "[C5] Building $Configuration..." +cmake --build $Build --config $Configuration --parallel +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "[C5] Running offline safety tests..." +ctest --test-dir $Build -C $Configuration --output-on-failure +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$CandidateA = Join-Path $Build "$Configuration\ariec61850_control_interop_probe.exe" +$CandidateB = Join-Path $Build 'ariec61850_control_interop_probe.exe' +$Exe = if (Test-Path $CandidateA) { $CandidateA } else { $CandidateB } + +if (-not (Test-Path $Exe)) { + throw "Build succeeded but control interop executable was not found." +} + +Write-Host "" +Write-Host "[C5] READY: $Exe" +Write-Host "[C5] Safe first command (read-only):" +Write-Host " `"$Exe`" 102 --object LD0/CSWI1.Pos --evidence c5-discovery.json" +Write-Host "" +Write-Host "Live Write remains locked unless the executable receives:" +Write-Host " --arm IEC61850-LAB-CONTROL" From 2c9abcbaecfcd60a47689987a73b8c2c9e837265 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:29:27 +0700 Subject: [PATCH 37/50] Publish C5 harness binaries with CI evidence --- .../workflows/control-interop-harness-ci.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index 2c31f1986..12e355c86 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -52,7 +52,7 @@ jobs: shell: bash run: ctest --test-dir build-control-interop -C Release --output-on-failure - - name: Capture C5 harness contract + - name: Stage Linux harness and contract if: runner.os == 'Linux' shell: bash run: | @@ -61,9 +61,20 @@ jobs: | tee evidence/control-interop/self-test-${{ matrix.compiler }}.txt ./build-control-interop/ariec61850_control_interop_probe --help \ > evidence/control-interop/help-${{ matrix.compiler }}.txt + cp build-control-interop/ariec61850_control_interop_probe \ + evidence/control-interop/ariec61850_control_interop_probe-${{ matrix.compiler }} - - name: Upload C5 harness evidence - if: runner.os == 'Linux' + - name: Stage Windows harness and contract + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path evidence/control-interop | Out-Null + $exe = 'build-control-interop/Release/ariec61850_control_interop_probe.exe' + & $exe --self-test | Tee-Object -FilePath evidence/control-interop/self-test-msvc.txt + & $exe --help | Out-File -Encoding utf8 evidence/control-interop/help-msvc.txt + Copy-Item $exe evidence/control-interop/ariec61850_control_interop_probe.exe + + - name: Upload C5 harness evidence and binary uses: actions/upload-artifact@v4 with: name: control-interop-harness-${{ matrix.compiler }} From a9f96ad64dcbbde4ad1be50c3b2909b850d234da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:31:59 +0700 Subject: [PATCH 38/50] Sync migration checklist through Control C5 --- MIGRATION_CHECKLIST.md | 66 ++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 483f585ee..7f5cc5538 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -109,19 +109,19 @@ behavioral oracle until laboratory interoperability is complete. - [x] URCB reservation, RptEna enable/disable, optional GI, and touched-state cleanup. - [x] Persistent report polling and Phase 4A monitor ingestion. - [x] Cleanup-required evidence and retry path after association loss. -- [ ] BRCB EntryID resume, purge decisioning, and buffer-overflow recovery. +- [x] BRCB retained-history replay, EntryID resume/rewind, PurgeBuf, replay-gap/overflow recovery, association ownership, ResvTms/Owner lifecycle, and recovery image v2 with legacy v1 restore. ### Phase 4C — built-in TCP and live read-only discovery -- [~] Non-blocking Windows Winsock and POSIX TCP `MmsByteTransport` implementation. -- [~] IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial-send, and peer-close handling. -- [~] Deadline and cancellation-aware connect/send/receive readiness waits. -- [~] Bounded live GetNameList pagination for domains, variables, and DataSets. -- [~] Optional GetVariableAccessAttributes type probes. -- [~] Optional GetNamedVariableListAttributes DataSet directory reads. -- [~] Optional read-only RCB attribute inventory/state probes. -- [~] Read-only `ariec61850_live_discover` human/JSON CLI. -- [~] Discovery service allowlist regression proving no MMS Write request is emitted. +- [x] Non-blocking Windows Winsock and POSIX TCP `MmsByteTransport` implementation. +- [x] IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial-send, and peer-close handling. +- [x] Deadline and cancellation-aware connect/send/receive readiness waits. +- [x] Bounded live GetNameList pagination for domains, variables, and DataSets. +- [x] Optional GetVariableAccessAttributes type probes. +- [x] Optional GetNamedVariableListAttributes DataSet directory reads. +- [x] Optional read-only RCB attribute inventory/state probes. +- [x] Read-only `ariec61850_live_discover` human/JSON CLI. +- [x] Discovery service allowlist regression proving no MMS Write request is emitted. ### Phase 4C.1 — live-model parity and physical read-only interoperability @@ -146,10 +146,36 @@ behavioral oracle until laboratory interoperability is complete. - [x] Controlled OCR7SR12 timeout/recovery evidence accepted: healthy baseline, post-association response withholding, client request timeout observed, fresh direct recovery, and identical `934b555dff76a46f` structural fingerprint before/after recovery. - [ ] Pagination continuation evidence accepted on a target that requires more than one page. +### Phase 4D-R — BRCB operational semantics + +- [x] Retained bounded history with independent delivery cursor. +- [x] EntryID replay/resume and rewind-to-oldest semantics. +- [x] PurgeBuf and explicit replay-gap handling. +- [x] Association-aware reservation/ownership with ResvTms reconnect/expiry behavior. +- [x] MMS BRCB operational object exposure and association-context propagation. +- [x] TCP/reset/COTP-DR association-loss lifecycle bridge. +- [x] Recovery image v2 preserves retained replay history/cursor/gap and restores legacy v1. +- [~] Physical ESP32-P4 BRCB raw-partition geometry/latency/endurance/power-loss evidence remains a separate NVM gate. + +### Phase 4D-C — guarded IEC 61850 Control Model parity + +- [x] C1 association-aware, default-deny guarded-control safety state machine. +- [x] C1 Direct normal, SBO normal, Direct enhanced, and SBO enhanced lifecycle semantics. +- [x] C1 immutable selected sequence, ownership, timeout, Cancel, association-loss cleanup, and ctlNum 1..255 wrap. +- [x] C2 exact live-TypeSpecification `Oper`, `SBOw`, and `Cancel` structure builder. +- [x] C2 typed SPC/DPC/integer/unsigned/floating/step control values and conservative vendor-field rejection. +- [x] C2 exact origin, ctlNum, T, optional operTm, Test, and two-bit Check construction with golden MMS Data evidence. +- [x] C3 enhanced-security CommandTermination and LastApplError object correlation. +- [x] C3 standard ControlError and AddCause 0..27 mapping with raw unknown-code preservation. +- [x] C4 live ctlModel/GVAA/GetNameList/CF-timeout discovery over the production MMS association runtime. +- [x] C4 Direct/SBO/SBOw/Oper/Cancel orchestration, asynchronous application-error grace window, non-fatal enhanced termination timeout, and no automatic command retry. +- [x] C1-C4 integrated into the normal `ARIEC61850::core` build and GCC/Clang/MSVC CTest matrix. +- [x] C5 guarded live-interoperability harness implemented with read-only default, explicit arm token, typed values, status-before/after reads, deterministic control-Write counting, JSON evidence, Wireshark filter hint, and offline safety self-test. +- [x] C5 dedicated GCC/Clang/MSVC harness CI and build artifact workflow implemented. +- [~] Physical/simulator C5 acceptance for Direct normal, SBO normal, Direct enhanced, SBO enhanced, negative LastApplError/AddCause, and association-loss cases is pending retained laboratory evidence. + ### Later Phase 4 work -- [ ] Direct and SBO control state machines. -- [ ] Enhanced-security termination/error handling. - [x] MMS FileDirectory `[77]` codec with root/nested paths, attributes, `moreFollows`, and `continueAfter`. - [x] Bounded FileDirectory pagination with deterministic order-preserving deduplication and no-progress detection. - [x] MMS FileOpen/FileRead/FileClose `[72-74]` codecs with signed Integer32 FRSM parity. @@ -161,17 +187,21 @@ behavioral oracle until laboratory interoperability is complete. - [x] Controlled live forced block-limit failure with zero local bytes and successful FileClose cleanup. - [ ] Multi-page physical FileDirectory continuation evidence. - [ ] Multi-vendor file-service acceptance and ESP32 device validation. +- [ ] Dynamic DataSet define/delete parity and full dynamic RCB lifecycle parity where still missing from the C# surface. ## Phase 5 — simulator and applications -- [ ] Deterministic IED simulation and read-only MMS server. -- [ ] CLI parity and Windows Npcap abstraction. -- [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI. +- [ ] Deterministic IED simulation and read-only MMS server integration parity. +- [ ] CLI/application parity and broader Windows Npcap abstraction where still required. +- [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI parity where product scope still requires it. ## Safety gate -SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 live discovery surface are read-only. MMS file +SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 discovery surface remain read-only. MMS file download is also remote-content read-only but has an active FileOpen/FileRead/FileClose resource lifecycle, so the host CLI requires an explicit remote path/destination and controlled-lab -authorization. RCB mutation, control, dynamic DataSet mutation, remote file mutation, Npcap -transmission, and real-time SV scheduling retain separate explicit safety gates. +authorization. Phase 4D-C live control is an explicit, authorization-gated API and the C5 +interoperability harness is read-only by default; it refuses a control Write without the exact +laboratory arm token. RCB mutation, control, dynamic DataSet mutation, remote file mutation, +Npcap transmission, and real-time process-bus scheduling retain separate explicit safety gates. +Hosted CI does not constitute physical IED interoperability or IEC 61850 conformance certification. From 2da72e0e0e249f0aec0008cb8a591cb241efdbf9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:32:52 +0700 Subject: [PATCH 39/50] Advance port status to Control C5 lab readiness --- PORT_STATUS.md | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 1bef3f79e..794e4d413 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,9 +2,9 @@ ## Current milestone -**Phase 4D-C5 — guarded IEC 61850 Smart Control is integrated on the current branch for review and controlled laboratory interoperability testing.** +**Phase 4D-C5 — guarded IEC 61850 Smart Control is integrated for controlled laboratory interoperability: live `ctlModel`/TypeSpecification discovery, Direct/SBO normal and enhanced sequencing, exact `Oper`/`SBOw`/`Cancel` structures, CommandTermination correlation, LastApplError/AddCause diagnostics, and a read-only-by-default evidence harness.** -The implementation preserves the existing Phase 4C live discovery and Phase 4D Dynamic DataSet/reporting work from `main`. Buffered-report recovery and ESP32 process-bus work remain separate concerns and are not used as evidence for MMS control behavior. +The integration preserves Phase 4C live discovery and the Dynamic DataSet/reporting work already merged to `main`. Buffered-report recovery and ESP32 process-bus work remain separate concerns and are not used as evidence for MMS control behavior. ## Delivered modules @@ -36,17 +36,36 @@ The implementation preserves the existing Phase 4C live discovery and Phase 4D D - 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. +- LastApplError retains 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. +- Live TypeSpecification is authoritative 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. +- Stale InformationReports are drained before `SBOw` or `Oper`; 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.** +## C5 interoperability harness + +`ariec61850_control_interop_probe` is the controlled live-test harness. + +- Read-only discovery is the default. +- A control Write is rejected unless the exact `--arm IEC61850-LAB-CONTROL` token is supplied. +- The operator must provide an explicit object, action, and typed value. +- Supported actions are `operate`, `select-operate`, and `select-cancel`. +- SPC, DPC, integer, unsigned, floating-point, and step-position values are bound conservatively from live type evidence. +- Origin category/identifier, Test, synchrocheck, and interlock-check are explicit. +- Live status is read before and after the action when the IED exposes a usable ST/MX reference. +- Every control Write reference is counted and retained in JSON evidence. +- JSON evidence includes `ctlModel`, CDC, status, Write count/list, report-observation counts, completion, raw ControlError/AddCause, and mapped AddCause. +- The tool prints a Wireshark MMS/TCP filter for JSON-to-PCAP reconciliation. +- Offline `--self-test` verifies the read-only default and arm gate without network access. +- A standalone CMake profile, Windows build helper, and cross-platform CI workflow are provided. + +The laboratory procedure and evidence bundle are defined in `docs/CONTROL_INTEROP_RUNBOOK.md`. + ## Deterministic validation Dedicated strict profiles cover: @@ -62,25 +81,28 @@ Dedicated strict profiles cover: - live descriptor discovery using `ctlModel`, GVAA, GetNameList, CF timeouts, and ST/MX status-reference priority; - Direct one-write behavior, SBO Select to Operate, SBOw asynchronous application-error handling, AutoSelect, stale-selection Cancel, and enhanced termination wait; - production `MmsAssociationControlTransport` strict compile validation; -- integration of C1-C5 sources into the normal `ARIEC61850::core` build graph and CTest targets. +- integration of C1-C4 sources into the normal `ARIEC61850::core` build graph and CTest targets; +- C5 harness offline safety gate and cross-platform build profile. ## Physical interoperability acceptance -Physical IED control acceptance is not claimed until evidence is retained from an isolated, non-operational laboratory IED or simulator for: +The software path and live harness are ready for controlled interoperability testing, but **physical IED control acceptance is not claimed** until retained evidence exists for: -1. read-only live `ctlModel` and GVAA discovery for a known controllable object; +1. read-only live `ctlModel` and GVAA discovery for a known controllable object with `mmsControlWriteCount=0`; 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 a 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. +8. packet capture and JSON/event evidence proving no automatic command retry. + +An individual profile may move from `SOFTWARE_READY_FOR_LAB` to `LAB_INTEROP_PASSED` only when its retained JSON and PCAP evidence agree. ## Safety and claim boundary - Live inventory and raw `ctlModel` probes are read-only. -- Mutating control requires explicit command intent, an exact object root, live type discovery, and the harness authorization gate. +- Mutating control requires explicit intent, an exact object root, live type discovery, and the harness authorization gate. - The implementation and deterministic tests are not an IEC 61850 conformance certificate. - No hosted CI result is treated as proof of a physical IED command. - Sampled Values, ESP32 hardware transmission, and BRCB persistence remain outside Smart Control acceptance. From d7ea8c563c18749470c7be28a7fde843c01e409e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:35:29 +0700 Subject: [PATCH 40/50] Advance migration plan through Control C5 --- MIGRATION_PLAN.md | 136 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 40 deletions(-) diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index 57872a991..bb97248ad 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -1,64 +1,120 @@ # ARIEC61850 C++ Migration Plan The migration follows deterministic protocol foundations first, then engineering formats, -association/runtime behavior, live read-only interoperability, and finally active services and -applications. The C# repository remains the behavioral oracle until same-target laboratory -comparison is accepted. +association/runtime behavior, live read-only interoperability, bounded active-service semantics, +and finally physical interoperability plus application parity. The C# repository remains the +behavioral oracle until same-target laboratory comparison is accepted. ## Completed foundation - Phase 0: C++20/CMake, strict warnings, BER, Ethernet/VLAN, PCAP. - Phase 1: MMS Data/UTC, GOOSE, Sampled Values, evidence and fuzzing. -- Phase 2: read-only SCL and COMTRADE. +- Phase 2: read-only SCL and COMTRADE foundations. - Phase 3: TPKT/COTP, Session, Presentation, ACSE, MMS Initiate and confirmed services. - Phase 4A: DataSet/RCB inventory, InformationReport decoding, continuity monitoring. - Phase 4B: transport-injected association and persistent report-subscription runtime. +- Phase 4C/4C.1: built-in Windows/Linux TCP, live read-only discovery/model projection, + C#↔C++ structural/type parity, repeated physical read-only interoperability, timeout/recovery, + RCB/control-block deep-read evidence, and large-model GetNameList pagination. -## Current delivery — Phase 4C and 4C.1 +## Completed software tranche — Phase 4D-R -### Phase 4C +BRCB operational semantics are implemented and regression-tested: -- built-in non-blocking Windows/Linux TCP transport; -- deadline/cancellation-aware connect, send, and receive; -- bounded live domain, variable, DataSet, type, and RCB discovery; -- strict read-only service allowlist. +- bounded retained report history with an independent delivery cursor; +- EntryID replay/resume and rewind-to-oldest; +- PurgeBuf and replay-gap/overflow handling; +- association-aware reservation/Owner/ResvTms lifecycle; +- association identity propagation through MMS writes; +- TCP/reset/COTP-DR association-loss cleanup; +- replay-capable recovery image v2 with legacy-v1 restore. -### Phase 4C.1 +Physical BRCB raw-partition geometry, latency, endurance budgeting and controlled power-loss +acceptance remain a separate ESP32-P4 NVM evidence gate. -- C#-compatible `live-ied-model-v1` hierarchy; -- IED/LD/LN/DO/DA references, FC, type, DataSet, and RCB evidence; -- conservative identity and CDC inference; -- deterministic manifest/fingerprint; -- C#↔C++ parity checker; -- repeated physical read-only interoperability runner and evidence format. +## Current delivery — Phase 4D-C5 guarded control interoperability -### Acceptance work still required +### C1 — safety state machine -- GCC/Clang/MSVC and Security/Evidence workflow run on PR #14; -- full branch CTest; -- reachable IED/vendor-simulator run; -- primary-vendor ten-cycle and timeout/reconnect evidence; -- same-target C# and C++ parity review; -- large-model pagination evidence when available. +- `ctlModel` 0..4 parity with the C# oracle; +- Direct normal, SBO normal, Direct enhanced and SBO enhanced lifecycle semantics; +- association-aware ownership and default-deny authorization; +- immutable selected sequence across ctlVal/origin/ctlNum/T/operTm/Test/Check; +- selection timeout, association-loss cleanup and explicit Cancel; +- ctlNum 1..255 wrap with zero excluded; +- no automatic command retry. -## Next implementation priority after Phase 4C.1 acceptance +### C2 — exact MMS control structures -### Phase 4D — guarded control foundation +- live TypeSpecification-driven `Oper`, `SBOw` and `Cancel` construction; +- conservative SPC/DPC/integer/unsigned/floating/step-position binding; +- exact origin, ctlNum, T, optional operTm, Test and two-bit Check fields; +- DPC network-bit-order parity and golden MMS Data vectors; +- unknown vendor-specific fields are rejected instead of guessed. -- Direct Operate and Select-Before-Operate state machines; -- normal and enhanced security; -- select, operate, cancel, ownership, timeout, and command termination; -- LastApplError and AddCause; -- default-deny authorization, operator confirmation, and full audit trail; -- no automatic command retry. +### C3 — enhanced-security completion and diagnostics + +- correlated CommandTermination handling; +- LastApplError decoding with standard ControlError and AddCause 0..27 mapping; +- raw unknown-code preservation; +- exact controlled-object correlation; +- ordinary ST/MX process reports cannot complete an enhanced command. + +### C4 — live guarded control session + +- live ctlModel Read and exact Oper/SBOw/Cancel GVAA discovery; +- domain variable discovery and CF sboTimeout/operTimeout reads; +- Direct Operate, SBO Read -> Oper, SBOw -> Oper and explicit Cancel orchestration; +- asynchronous application-error grace window; +- enhanced commands remain pending until correlated CommandTermination; +- missing termination is a control timeout, not an automatic association fault; +- C1-C4 are integrated into `ARIEC61850::core` and the normal GCC/Clang/MSVC CTest matrix. + +### C5 — controlled live interoperability harness + +- `ariec61850_control_interop_probe` is read-only by default; +- any live control Write requires the exact `--arm IEC61850-LAB-CONTROL` token plus an explicit + object, action and value; +- supported deliberate lab actions are `operate`, `select-operate`, and `select-cancel`; +- status-before/status-after reads are captured when a suitable ST/MX object exists; +- all control Write references are counted for deterministic no-retry evidence; +- optional JSON evidence records ctlModel, CDC, status, Write list/count, report observations, + completion, raw ControlError/AddCause and mapped AddCause; +- Wireshark filter guidance lets JSON evidence be reconciled with PCAP/PCAPNG; +- a standalone CMake profile, Windows PowerShell build helper and GCC/Clang/MSVC CI artifacts + make the harness directly usable in a laboratory. + +Current software state: + +`SOFTWARE_READY_FOR_LAB` + +Physical/simulator evidence is still required before changing a tested profile to: + +`LAB_INTEROP_PASSED` + +The required procedure is documented in `docs/CONTROL_INTEROP_RUNBOOK.md`. + +## C5 laboratory acceptance order + +1. Read-only live ctlModel/GVAA/status discovery with zero control Writes. +2. Direct normal: exactly one Oper Write plus status/process feedback. +3. SBO normal: SBO Read -> exactly one Oper Write; separately Select -> Cancel. +4. Direct enhanced: exactly one Oper Write -> correlated CommandTermination. +5. SBO enhanced: one SBOw Write -> one Oper Write -> correlated CommandTermination. +6. Safe negative LastApplError/AddCause case where the simulator/lab IED can provide it. +7. Association loss during SBO selection and enhanced-termination wait. +8. Reconcile JSON Write counts/references with retained PCAP and prove no automatic retry. + +Physical command testing must remain on an isolated/non-operational laboratory IED or simulator. +Hosted CI does not issue a physical command and is not IEC 61850 conformance certification. -Phase 4D must not begin physical command testing until Phase 4C.1 transport/model identity and -same-target read-only parity are accepted. +## Next implementation priorities after accepted C5 evidence -### Later work +1. MMS file and fault-record transfer parity. +2. Dynamic DataSet define/delete and the remaining dynamic RCB lifecycle parity. +3. Mutable SCL workspace/export and live-discovery-to-SCL reconstruction parity. +4. Deterministic IED simulator/server integration needed for repeatable multi-client regression. +5. CLI/application and native engineering-tool parity where product scope requires it. -- BRCB EntryID resume, replay, purge, and overflow recovery; -- dynamic DataSet lifecycle; -- MMS file and fault-record transfer; -- deterministic IED simulator/read-only server; -- CLI parity, Windows Npcap abstraction, and native applications. +Sampled Values / ESP32-P4 process-bus development is intentionally maintained separately from +this MMS-control tranche. From e7aa282e75f28c036e2f7338e88c0cb623379886 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 21:36:28 +0700 Subject: [PATCH 41/50] Sync feature matrix through Control C5 --- docs/FEATURE_MATRIX.md | 146 ++++++++++++++++++++--------------------- 1 file changed, 71 insertions(+), 75 deletions(-) diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index a13b76d2d..616803dcd 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -2,95 +2,91 @@ Status terms are intentionally conservative: -- **Live-proven** — exercised against a reachable IED/vendor simulator endpoint. -- **Offline-tested** — deterministic unit/integration/fuzz evidence exists, but no - current live-device claim is made. -- **Partial** — useful implementation exists but important parity/interop gaps remain. +- **Live-proven** — exercised against a reachable IED or vendor simulator. +- **Offline-tested** — deterministic unit/integration/fuzz/CI evidence exists, but no current live-device claim is made. +- **Software-ready for lab** — the production path and guarded live-test harness are implemented; retained physical/simulator evidence is still required. +- **Partial** — useful implementation exists but important parity or interoperability gaps remain. - **Planned** — not implemented as a usable public feature yet. | Area | Feature | Status | Current evidence / boundary | |---|---|---|---| -| Transport | TCP client transport | Live-proven | Windows/POSIX transport used by live MMS discovery. | -| OSI | TPKT / COTP | Live-proven | Live association path reaches MMS endpoint. | -| OSI | Session / Presentation / ACSE | Live-proven | Current association profile + tolerant C#-compatible acceptance path interoperates with current test endpoint. | -| MMS client | Initiate / association lifecycle | Live-proven | Read-only discovery session associates successfully. Multi-profile reconnect retry is still incomplete. | -| MMS client | GetNameList domains | Live-proven | Current OCR7SR12 evidence discovers 4 domains. | -| MMS client | GetNameList variables | Live-proven | Thousands of MMS variables discovered live; inventory can vary with exposed runtime/configuration state. | -| MMS client | GetVariableAccessAttributes | Live-proven | C++ LN-root planner completed 123/123 probes and projected exact MMS types across 6990 canonical DA on the current endpoint. | -| MMS client | Read | Live-proven | Full current OCR7SR12 RCB inventory was probed read-only: 286/286 represented, 0 NotRead and 0 ReadFailed in the supplied full-probe snapshot. | -| MMS client | GetNamedVariableListAttributes | Live-proven/optional | Used when DataSet directory reads are enabled; may be deliberately skipped for structural-only runs. | -| MMS client | GO/SV/SG/LG exact-attribute deep Read | SGCB live-proven / integrated path offline-tested | Standalone bounded reader physically read `OCR7SR12PROT/LLN0.SP.SGCB` 5/5 (`ActSG`, `CnfEdit`, `EditSG`, `LActTm`, `NumOfSG`). The new `live_discover --control-block-values` overlay is CI-tested and awaits one integrated physical regression. | -| Live model | LD/LN/DO/DA projection | Live-proven | Same-IED OCR7SR12 evidence: 4 LD, 123 LN, 1186 DO, 6990 DA; C#↔C++ structural comparison has blocking=0. | -| Live model | IED identity resolver | Live-proven | OCR7SR12 inferred from logical-device domain suffix evidence. | -| Live model | CDC inference | Live-proven + offline-tested | C# registry/pattern port used by live model; exact CDC still depends on available evidence. | -| Live model | TypeTemplates | Live-proven | Live C# and C++ captures canonicalize to the same 471 template identities; raw projection contains 1309 candidates in both current captures. | -| Live model | VariableTypeDiscoveries | Live-proven | C# direct probes and C++ LN-root probes use different request strategies; common projected DA type signatures compare with blocking=0. | -| Live model | Structural fingerprint | Live-proven / stability pending | Current live capture emits a structure-only fingerprint excluding mutable DataSet/RCB runtime state; repeated-cycle stability remains an acceptance gate. | -| Live model | Runtime snapshot fingerprint | Live-proven | Current live capture includes mutable DataSet inventory and RCB binding/enable/reservation evidence separately from structural identity. | -| Live model | Control-block runtime evidence overlay | Offline-tested / physical integration pending | Mutable GO/SV/SG/LG values are projected after structural model build, so deep runtime evidence does not change the canonical structural fingerprint. Complete/partial/failed/bounded states are reported explicitly. | -| Cross-language | Same-IED C# ↔ C++ structural comparator | Live-proven | OCR7SR12 structural comparison is PASS with blocking=0 and totalFindings=0; `--types` is also PASS with blocking=0 and totalFindings=0 on common exact type evidence. | -| Cross-language | Same-IED runtime comparator | Live-proven / mutable | Runtime comparison is informational; C++-only `DataSetBindingStatus` is treated as enrichment rather than a false cross-schema mismatch. | -| DataSet | Name inventory | Live-proven | Dynamic inventory may legitimately change between sessions. | -| DataSet | Member directory | Live-proven/optional | Explicitly skipped with `--no-datasets`; empty member list then produces evidence warning, not structural failure. | -| DataSet | Dynamic create/verify/delete lifecycle | Live-proven / guarded lab | One bounded lifecycle created a domain-specific DataSet, verified exact member order, used it for reporting, and deleted only the runtime-owned DataSet. | -| Reporting | RCB inventory | Live-proven | Current endpoint exposes 286 RCB identities: 8 BRCB and 278 URCB in the C# oracle capture. | -| Reporting | Full RCB read-only probe | Live-proven | Current C++ full-probe snapshot classified all 286 RCBs with 0 NotRead and 0 ReadFailed. | -| Reporting | Bound/Unbound runtime semantics | Live-proven | One current snapshot observed 6 Bound and 280 Unbound. This is mutable runtime state, not a permanent device capability claim. | -| Reporting | Dynamic RCB candidate planner | Live-proven | Read-only ranking selected an empty dynamic BRCB candidate on current endpoint. | -| Reporting | Conservative operational availability | Live-proven | Separates dynamic-slot eligibility from populated/static report availability. | -| Reporting | Read-only contention pre-claim probe | Live-proven | Three repeated probes on `OCR7SR12CTRL/BI6GGIO1.urcbA01` remained free/stable and returned `StableProceed`; no claim/write was performed. | -| Reporting | Bounded smart pre-claim failover | Offline-tested / stable live probe | Immediately before mutation, repeated read-only probes reject busy/flapping candidates, add them to the command-local exclusion set, and rerank the next candidate. A stable live candidate passed three probes; a live contended switch remains pending. Mutation failures never trigger automatic switching. | -| Reporting | RCB claim/reservation/enable | Live-proven / guarded lab | Explicitly armed Dynamic RCB trial bound and enabled one BRCB, requested GI, then disabled and unbound it with cleanup verified. Live discovery itself remains read-only. | -| Reporting | InformationReport decode / subscription runtime | Live-proven / single profile | The guarded trial received and decoded an InformationReport through the persistent subscription runtime; long-duration and multi-vendor acceptance remain pending. | -| GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | -| GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical multi-vendor process-bus acceptance pending. | -| Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | +| Transport | TCP client transport | Live-proven | Windows/POSIX transport is used by live discovery, reporting, Dynamic DataSet, and C5 control paths. | +| OSI | TPKT / COTP | Live-proven | Live association path reaches MMS endpoints; controlled timeout/recovery evidence exists. | +| OSI | Session / Presentation / ACSE | Live-proven | Current association profiles and the C#-compatible fallback path interoperate with exercised endpoints. | +| MMS client | Initiate / association lifecycle | Live-proven | Fresh association/reconnect cycles are accepted on current evidence sets. | +| MMS client | GetNameList domains/variables | Live-proven | Large live inventories and pagination are exercised. | +| MMS client | GetVariableAccessAttributes | Live-proven | Live model and C5 control discovery consume exact MMS TypeSpecification evidence. | +| MMS client | Read | Live-proven | Discovery, reporting, control descriptor, timeout, and status reads are exercised live. | +| MMS client | Write codec/runtime | Live-proven / guarded simulator | C5 sent explicitly armed `SBOw`, `Cancel`, and `Oper` Writes to IEDScout with JSON/PCAP accounting; physical IED control remains pending. | +| MMS client | GetNamedVariableListAttributes | Live-proven | Dynamic DataSet verification and report binding use the exact ordered directory. | +| MMS client | GO/SV/SG/LG exact-attribute deep Read | Live-proven | OCR7SR12 SGCB deep read accepted 5/5 attributes without mutation. | +| Live model | LD/LN/DO/DA projection | Live-proven | OCR7SR12 structural/type comparison with the C# oracle has zero blocking findings. | +| Live model | IED identity resolver | Live-proven | Identity is inferred conservatively from live logical-device evidence. | +| Live model | CDC inference | Live-proven + offline-tested | CDC inference is conservative; exact command binding uses live TypeSpecification. | +| Live model | Structural fingerprint | Live-proven | Repeated cycles and timeout/recovery retain the structure-only fingerprint. | +| Cross-language | Same-IED C# ↔ C++ structural/type comparator | Live-proven | Structural and common exact-type comparison pass with zero blocking findings on accepted evidence. | +| DataSet | Name inventory/member directory | Live-proven | Dynamic inventory may legitimately vary with runtime configuration. | +| DataSet | Dynamic create/verify/delete | Live-proven / guarded lab | One bounded lifecycle created an owned DataSet, verified exact ordered members, used it for reporting, then deleted only that owned DataSet. | +| Reporting | RCB inventory/read-only probe | Live-proven | Current endpoint evidence includes full RCB inventory and read-only classification. | +| Reporting | Dynamic RCB candidate planner | Live-proven | Read-only ranking identifies safe dynamic candidates without mutation. | +| Reporting | Bounded smart pre-claim failover | Offline-tested / stable live probe | Repeated read-only probes exclude busy/flapping candidates and rerank before mutation; mutation failures never trigger automatic switching. | +| Reporting | Guarded Dynamic RCB lifecycle | Live-proven / guarded lab | Explicitly armed trial bound/enabled one RCB, requested GI, decoded a report, then disabled/unbound with cleanup verification. | +| Reporting | BRCB retained replay / EntryID / PurgeBuf | Offline-tested | Bounded history, replay/resume/rewind, PurgeBuf, overflow, and replay-gap behavior are implemented. | +| Reporting | BRCB Owner / ResvTms / association lifecycle | Offline-tested | Multi-client ownership, reconnect/expiry, and association-loss semantics are hard-profile tested. | +| Reporting | BRCB recovery image v2 | Offline-tested / physical NVM pending | Recovery preserves retained window/cursor/gap with v1 restore; flash endurance and power-loss hardware evidence are separate. | +| Control | `ctlModel` discovery | Live-proven / simulator | C5 rediscovered live `ctlModel` and exact command types before each action on IEDScout. | +| Control | Direct normal | Software-ready for lab | Exactly-one `Oper` semantics are deterministic; retained live Direct normal evidence remains pending. | +| Control | SBO normal | Software-ready for lab | Normal Select is an `SBO` Read; immutable sequence, Oper, and explicit Cancel are implemented; live evidence remains pending. | +| Control | Direct enhanced | Live-proven / simulator | IEDScout OFF and ON-restore each sent one `Oper`, received positive CommandTermination, and changed/restored status without retry. | +| Control | SBO enhanced | Live-proven subset / simulator | IEDScout accepted `SBOw -> Cancel`; full `SBOw -> Oper -> CommandTermination` remains pending for an SBO-enhanced profile. | +| Control | `Oper` / `SBOw` / `Cancel` live-type binding | Live-proven / simulator | Exact Boolean structures from live GVAA were sent successfully; unknown vendor fields still fail closed. | +| Control | Control value types | Offline-tested | SPC/DPC/integer/unsigned/floating/step binding is conservative; DPC uses network bit order. | +| Control | Ownership / immutable sequence | Offline-tested | Second-client takeover, expiry, mismatch, authorization revocation, and association-loss cleanup are covered. | +| Control | CommandTermination | Live-proven / simulator | Positive enhanced termination is exact-control correlated; ordinary ST/MX reports cannot complete a command. | +| Control | LastApplError / ControlError / AddCause | Live-proven / simulator | IEDScout negative path decoded DataAccessError 3, ControlError 3, and AddCause 8; generic reports require exact origin/ctlNum correlation. | +| Control | Automatic command retry | Disabled / live-proven | Every live case reconciled the exact Write count with PCAP; no automatic retry occurred. | +| Control | Live interoperability harness | Live-proven / simulator | Read-only default, exact arm token, typed values, JSON evidence, PCAP reconciliation, and state restoration are exercised. | +| GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests, and fuzz corpus; physical multi-vendor acceptance is separate. | +| GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical process-bus acceptance remains pending. | +| Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Process-bus work remains separate from MMS control evidence. | | Sampled Values | Caller-owned bounded encode path | Offline-tested | `encode_into(span)` avoids fresh frame-buffer allocation in steady-state publisher design. | | Sampled Values | Live MCU publisher | Planned / embedded path staged | First reference hardware is the Waveshare ESP32-S3-POE-ETH-8DI-8DO; synthetic W5500/ESP-IDF MACRAW transmission proof is required before any timing-grade claim. | -| SCL | Read-only parser / engineering support | Offline-tested | Host-only engineering surface; not part of MCU runtime core. | -| COMTRADE | CFG/DAT read support | Offline-tested | Host engineering feature. | -| PCAP | Read/write/evidence support | Offline-tested | Host evidence feature. | +| SCL | Read-only parser / engineering support | Offline-tested | Host engineering surface; mutable standards-aware export remains later work. | +| COMTRADE | CFG/DAT read support | Offline-tested | ASCII/BINARY/BINARY32/FLOAT32 and mapping foundations are implemented. | +| PCAP | Read/write/evidence support | Live-proven for C5 evidence | Npcap loopback captures were reconciled with JSON Write lists and retained SHA-256 hashes. | | Embedded | `ARIEC61850::embedded_core` boundary | Offline-tested | Host-only services excluded by dedicated build profile. | -| Embedded | no-RTTI profile | Offline-tested | GCC/Clang host-simulation gate. | -| Embedded | no-exceptions MCU core | Planned | Legacy protocol error paths still contain exception debt. | +| Embedded | no-RTTI hard profiles | Offline-tested | GCC/Clang embedded/hard-profile gates are maintained. | +| Embedded | no-exceptions guarded control core | CI-gated | C1 has a dedicated `-fno-exceptions -fno-rtti` GCC/Clang hard profile. | +| Embedded | no-exceptions full MCU codec | Partial | Shared legacy validation/convenience paths still carry exception debt. | | Embedded | ESP32-S3 W5500 / ESP-IDF Ethernet adapter | Planned | Reference design uses ESP-IDF W5500 MACRAW integration with raw Ethernet for SV/GOOSE and `esp_netif`/lwIP for MMS TCP on the same interface. | | Embedded | ESP32-S3 8DI/8DO IED application | Planned | Waveshare board application reference after bounded MMS server foundation exists. | -| MMS server | Association acceptor + static browse/read model | Planned | Required for the ESP32 I/O IED; not yet a public server feature. | -| MMS mutation | Write/control | Codec/offline foundation only | Live Phase 4C discovery intentionally does not send these operations. | +| MMS server | Static association/browse/read/write foundations | Offline-tested / partial | Static server/runtime components exist; full deterministic simulator/server integration parity remains later work. | | File services | FileDirectory / FileOpen / FileRead / FileClose client | Live-proven / read-only | Bounded portable streaming runtime, signed FRSM, exact error evidence, rooted-backslash fallback, success cleanup, and forced-failure cleanup were exercised on the controlled lab target. Current directory fit one page; multi-page live continuation remains pending. | | File services | Upload / delete / rename | Not implemented | Remote file mutation is deliberately outside the current milestone. | -| Security | ASan/UBSan | CI-gated | Security workflow; latest tranche must be green before acceptance claim. | -| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting/file-service. | -| Portability | GCC / Clang / MSVC | CI-gated | Required for host Public Alpha. | -| Acceptance | Same-IED repeated structural stability | In progress | Acceptance runner uses `structuralFingerprint`, not mutable legacy canonical fingerprint. Multi-cycle live evidence is the next A1 acceptance tranche. | -| Acceptance | Timeout/reconnect evidence | In progress | Dedicated controlled failure/reconnect runs remain required; successful association cycles alone are not sufficient evidence. | -| Acceptance | Integrated control-block deep-read regression | Pending one physical run | Standalone SGCB 5/5 is live-proven; the newly wired `live_discover --control-block-values` path must now reproduce the result on OCR7SR12. | -| Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before full replacement/industrial claims. | +| Security | ASan/UBSan | CI-gated | Security/Evidence workflow is required on active heads. | +| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting, and file-service corpora are maintained. | +| Portability | GCC / Clang / MSVC | CI-gated | C1-C5 build through normal and dedicated matrices; MSVC was validated locally for this integration. | +| Acceptance | Read-only same-IED structural stability | Live-proven | Repeated-cycle and timeout/recovery evidence are retained. | +| Acceptance | Control C5 simulator subset | Live-proven | SBO enhanced Select/Cancel, negative diagnostics, Direct enhanced positive termination, Write counts, and state restoration are retained. | +| Acceptance | Full physical IEC 61850 control interoperability | Pending | Direct/SBO normal, full SBO enhanced Oper, association-loss, contention, and physical-IED evidence remain required. | +| Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before industrial replacement or broad conformance claims. | -## Current Phase 4C mutation boundary +## Current live mutation boundary -The live discovery workflow may issue read-only MMS operations such as -GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes and -Read. Optional `--control-block-values` performs bounded MMS Read only against -exact GO/SV/SG/LG variable names already returned by live `GetNameList`. It must -not perform Write, control, RCB reservation/enable, GoEna/SvEna mutation, GI, -setting-group mutation, dynamic DataSet mutation, or file-service mutation. +Phase 4C/4C.1 discovery remains read-only and sends only explicitly allowed discovery/read services. -## Current live parity milestone +Dynamic DataSet/reporting and Phase 4D-C control use separate, explicit mutation APIs. The C5 harness performs discovery only unless an action is supplied, and refuses every control Write without the exact laboratory arm token plus target object and value. It never retries a command automatically. -On the currently exercised OCR7SR12 endpoint, C# and C++ `live-ied-model-v1` -exports now agree structurally on LD/LN/DO/DA, RCB/control-block identities and -canonical type-template projection. Common exact DA MMS type signatures also -compare with zero blocking findings. A separate C++ read-only full-RCB snapshot -classified all 286 RCBs (6 Bound, 280 Unbound at that instant) with zero -NotRead/ReadFailed entries. The standalone deep reader also read the real SGCB -5/5 without mutation. These facts are evidence for this endpoint and -configuration only; they are not a multi-vendor or full-stack parity claim. +Reporting/BRCB mutation semantics are distinct from the C5 control gate. Failure in either domain does not authorize automatic mutation or failover in the other. -## Public Alpha exit direction +## Current control milestone -A Public Alpha can be useful before full ARIEC61850 parity if it provides a -reproducible Windows/Linux build, stable read-only live discovery/model JSON, -structural C#↔C++ same-IED comparison, documented GOOSE/SV codecs, and honest -interop evidence. Full server/control/reporting/file/TLS parity is not an A1 -requirement. +C1-C5 provide a guarded production control path plus a live evidence harness. IEDScout simulator evidence is retained for a meaningful subset, but the overall project remains short of complete physical and multi-model acceptance. + +`SOFTWARE_READY_FOR_LAB` applies to all implemented control models. + +`LAB_INTEROP_PASSED` applies only to a specific IED/control-model profile after its required positive, negative, failure, and cleanup evidence agrees across JSON and PCAP. The retained IEDScout results do not constitute IEC 61850 conformance certification. + +## Next parity direction + +The next gaps are full remaining control-model/association-loss evidence, MMS file and fault-record transfer, mutable SCL export/reconstruction, deterministic IED simulator/server integration, and broader application/UI parity. Sampled Values and ESP hardware work remain separate from this MMS-control tranche. From 3bd902ff9d845f15e2f3ad9f1560056cb18f5b66 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:14:17 +0700 Subject: [PATCH 42/50] c5: add read-only live control inventory probe --- tools/control_inventory_probe.cpp | 344 ++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 tools/control_inventory_probe.cpp diff --git a/tools/control_inventory_probe.cpp b/tools/control_inventory_probe.cpp new file mode 100644 index 000000000..985bb8e41 --- /dev/null +++ b/tools/control_inventory_probe.cpp @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#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; + +struct Options final { + MmsEndpoint endpoint{}; + std::chrono::milliseconds timeout{5'000}; + std::string evidence_path; + bool self_test{}; +}; + +struct ControlCandidate final { + std::string domain; + std::string object_reference; + std::string ctl_model_item; +}; + +struct CandidateEvidence final { + ControlCandidate candidate; + bool operationally_ready{}; + std::string ctl_model; + std::string cdc; + std::string status_object; + std::string diagnostic; +}; + +[[nodiscard]] std::string lower_ascii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char ch) { + return static_cast(std::tolower(static_cast(ch))); + }); + return value; +} + +[[nodiscard]] std::vector split_dollar(const std::string& value) { + std::vector parts; + std::size_t begin{}; + while (begin <= value.size()) { + const auto end = value.find('$', begin); + parts.push_back(value.substr( + begin, end == std::string::npos ? std::string::npos : end - begin)); + if (end == std::string::npos) break; + begin = end + 1U; + } + return parts; +} + +[[nodiscard]] std::vector extract_control_candidates( + const MmsDiscoverySnapshot& snapshot) { + std::vector candidates; + std::set> seen; + + for (const auto& [domain, variables] : snapshot.domain_variables) { + for (const auto& item : variables) { + const auto parts = split_dollar(item); + if (parts.size() < 4U || parts.front().empty()) continue; + if (lower_ascii(parts[1]) != "cf" || + lower_ascii(parts.back()) != "ctlmodel") { + continue; + } + + std::string data_object_path; + for (std::size_t index = 2U; index + 1U < parts.size(); ++index) { + if (parts[index].empty()) { + data_object_path.clear(); + break; + } + if (!data_object_path.empty()) data_object_path.push_back('.'); + data_object_path += parts[index]; + } + if (data_object_path.empty()) continue; + + ControlCandidate candidate; + candidate.domain = domain; + candidate.object_reference = domain + "/" + parts.front() + "." + data_object_path; + candidate.ctl_model_item = item; + + const auto identity = lower_ascii(candidate.object_reference); + if (seen.insert(identity).second) { + candidates.push_back(std::move(candidate)); + } + } + } + + std::sort(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { + return left.object_reference < right.object_reference; + }); + return candidates; +} + +[[nodiscard]] const char* model_name(const ControlModel model) noexcept { + switch (model) { + case ControlModel::status_only: return "status-only"; + case ControlModel::direct_normal: return "direct-normal"; + case ControlModel::select_before_operate_normal: return "sbo-normal"; + case ControlModel::direct_enhanced: return "direct-enhanced"; + case ControlModel::select_before_operate_enhanced: return "sbo-enhanced"; + case ControlModel::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::chrono::milliseconds parse_timeout(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument("--timeout-ms requires a positive millisecond value."); + } + return std::chrono::milliseconds{static_cast(parsed)}; +} + +[[nodiscard]] std::string json_escape(const std::string& value) { + std::ostringstream output; + for (const auto ch : value) { + switch (ch) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(ch) < 0x20U) output << '?'; + else output << ch; + } + } + return output.str(); +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_inventory_probe [port] [options]\n\n" + << "Options:\n" + << " --timeout-ms N MMS connect/request timeout (default 5000).\n" + << " --evidence FILE Write JSON inventory evidence.\n" + << " --self-test Offline parser/safety self-test; no network.\n\n" + << "Safety:\n" + << " This executable is read-only. It has no control Write action and no arm token.\n" + << " It enumerates MMS domains/named variables, finds CF/.../ctlModel candidates,\n" + << " and validates candidates with Read/GVAA only.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + options.endpoint.port = 102U; + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + options.self_test = true; + return options; + } + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + return options; + } + + options.endpoint.host = argv[1]; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + options.endpoint.port = parse_port(argv[argument]); + ++argument; + } + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") return Options{}; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--timeout-ms") options.timeout = parse_timeout(value); + else if (option == "--evidence") options.evidence_path = value; + else throw std::invalid_argument("Unknown option: " + option); + } + return options; +} + +void write_evidence( + const Options& options, + const MmsLiveDiscoveryResult& discovery, + const std::vector& evidence) { + if (options.evidence_path.empty()) return; + std::ofstream output{options.evidence_path, std::ios::binary | std::ios::trunc}; + if (!output) { + throw std::runtime_error("Cannot open evidence file: " + options.evidence_path); + } + + std::size_t ready{}; + for (const auto& item : evidence) { + if (item.operationally_ready) ++ready; + } + + output << "{\n" + << " \"schema\": \"arstack61850-control-inventory-v1\",\n" + << " \"endpoint\": \"" << json_escape(options.endpoint.host) << ':' + << options.endpoint.port << "\",\n" + << " \"readOnly\": true,\n" + << " \"mmsControlWriteCount\": 0,\n" + << " \"domainCount\": " << discovery.domain_count() << ",\n" + << " \"variableCount\": " << discovery.variable_count() << ",\n" + << " \"candidateCount\": " << evidence.size() << ",\n" + << " \"operationallyReadyCount\": " << ready << ",\n" + << " \"candidates\": [\n"; + for (std::size_t index = 0U; index < evidence.size(); ++index) { + const auto& item = evidence[index]; + output << " {\"object\": \"" << json_escape(item.candidate.object_reference) + << "\", \"ctlModelItem\": \"" << json_escape(item.candidate.ctl_model_item) + << "\", \"operationallyReady\": " + << (item.operationally_ready ? "true" : "false") + << ", \"ctlModel\": \"" << json_escape(item.ctl_model) + << "\", \"cdc\": \"" << json_escape(item.cdc) + << "\", \"statusObject\": \"" << json_escape(item.status_object) + << "\", \"diagnostic\": \"" << json_escape(item.diagnostic) << "\"}"; + if (index + 1U != evidence.size()) output << ','; + output << '\n'; + } + output << " ]\n}\n"; +} + +int run_self_test() { + MmsDiscoverySnapshot snapshot; + snapshot.domain_variables["LD0"] = { + "LLN0$ST$Mod$stVal", + "CSWI1$CF$Pos$ctlModel", + "CSWI1$CO$Pos$Oper", + "CILO1$CF$EnaOpn$ctlModel", + "MMXU1$MX$A$phsA$cVal$mag$f", + }; + const auto candidates = extract_control_candidates(snapshot); + if (candidates.size() != 2U || + candidates[0].object_reference != "LD0/CILO1.EnaOpn" || + candidates[1].object_reference != "LD0/CSWI1.Pos") { + std::cerr << "CONTROL_INVENTORY_SELF_TEST FAIL candidate extraction mismatch.\n"; + return 1; + } + std::cout << "CONTROL_INVENTORY_SELF_TEST PASS readOnly=true controlWritePath=false candidates=2\n"; + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.self_test) return run_self_test(); + if (options.endpoint.host.empty()) { + print_usage(); + return argc > 1 ? 0 : 2; + } + + MmsAssociationOptions association_options; + association_options.connect_timeout = options.timeout; + association_options.request_timeout = options.timeout; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(options.endpoint); + + MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = false; + discovery_options.probe_report_controls = false; + const auto discovery = live_session.discover(discovery_options); + + std::cout << "CONTROL_INVENTORY_ENDPOINT " << options.endpoint.host << ':' + << options.endpoint.port << '\n'; + std::cout << "MMS_INVENTORY domains=" << discovery.domain_count() + << " variables=" << discovery.variable_count() << '\n'; + + const auto candidates = extract_control_candidates(discovery.names); + MmsAssociationControlTransport transport{live_session.association()}; + std::vector evidence; + evidence.reserve(candidates.size()); + + for (const auto& candidate : candidates) { + CandidateEvidence item; + item.candidate = candidate; + try { + const auto descriptor = ControlDescriptorDiscovery::discover( + transport, candidate.object_reference); + item.operationally_ready = descriptor.operationally_ready(); + item.ctl_model = model_name(descriptor.model); + item.cdc = descriptor.cdc; + if (descriptor.status_object) item.status_object = descriptor.status_object->reference(); + std::cout << "CONTROL_READY object=" << candidate.object_reference + << " ctlModel=" << item.ctl_model + << " cdc=" << item.cdc + << " requiresSelect=" << (descriptor.requires_select() ? "true" : "false") + << " enhanced=" << (descriptor.enhanced() ? "true" : "false"); + if (!item.status_object.empty()) { + std::cout << " status=" << item.status_object; + } + std::cout << '\n'; + } catch (const std::exception& exception) { + item.diagnostic = exception.what(); + std::cout << "CONTROL_CANDIDATE object=" << candidate.object_reference + << " ctlModelItem=" << candidate.ctl_model_item + << " ready=false reason=\"" << item.diagnostic << "\"\n"; + } + evidence.push_back(std::move(item)); + } + + std::size_t ready{}; + for (const auto& item : evidence) { + if (item.operationally_ready) ++ready; + } + std::cout << "CONTROL_INVENTORY_RESULT readOnly=true mmsControlWriteCount=0" + << " candidates=" << evidence.size() + << " ready=" << ready << '\n'; + std::cout << "PCAP_HINT filter=\"tcp.port == " << options.endpoint.port + << " && ip.addr == " << options.endpoint.host << "\"\n"; + + write_evidence(options, discovery, evidence); + live_session.disconnect(); + return ready > 0U ? 0 : 3; + } catch (const std::exception& exception) { + std::cerr << "Control inventory probe failed: " << exception.what() << '\n'; + return 1; + } +} From e59344ec1eabf1045bca1e62bb80c0c91a2827bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:14:32 +0700 Subject: [PATCH 43/50] c5: build read-only control inventory companion --- tools/control_interop_probe/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt index 9206accf6..57ba109b0 100644 --- a/tools/control_interop_probe/CMakeLists.txt +++ b/tools/control_interop_probe/CMakeLists.txt @@ -17,11 +17,19 @@ add_executable(ariec61850_control_interop_probe target_link_libraries(ariec61850_control_interop_probe PRIVATE ARIEC61850::core) target_compile_features(ariec61850_control_interop_probe PRIVATE cxx_std_20) +add_executable(ariec61850_control_inventory_probe + ${ARIEC61850_ROOT}/tools/control_inventory_probe.cpp) +target_link_libraries(ariec61850_control_inventory_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_inventory_probe PRIVATE cxx_std_20) + if(MSVC) target_compile_options(ariec61850_control_interop_probe PRIVATE /W4 /WX /permissive-) + target_compile_options(ariec61850_control_inventory_probe PRIVATE /W4 /WX /permissive-) else() target_compile_options(ariec61850_control_interop_probe PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + target_compile_options(ariec61850_control_inventory_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) endif() enable_testing() @@ -31,7 +39,14 @@ add_test( add_test( NAME ariec61850_control_interop_help COMMAND ariec61850_control_interop_probe --help) +add_test( + NAME ariec61850_control_inventory_self_test + COMMAND ariec61850_control_inventory_probe --self-test) +add_test( + NAME ariec61850_control_inventory_help + COMMAND ariec61850_control_inventory_probe --help) message(STATUS "arstack61850 C5 interop harness: read-only default=ON") message(STATUS "arstack61850 C5 interop harness: explicit arm token required for Write=ON") message(STATUS "arstack61850 C5 interop harness: automatic command retry=OFF") +message(STATUS "arstack61850 C5 control inventory companion: read-only only=ON") From 884ee54ead1d4be454eb908d2a9187adcf6fa552 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:14:45 +0700 Subject: [PATCH 44/50] c5: surface safe control inventory command after build --- scripts/build_control_interop.ps1 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/build_control_interop.ps1 b/scripts/build_control_interop.ps1 index 38b466197..28d95fe8e 100644 --- a/scripts/build_control_interop.ps1 +++ b/scripts/build_control_interop.ps1 @@ -28,14 +28,25 @@ $CandidateA = Join-Path $Build "$Configuration\ariec61850_control_interop_probe. $CandidateB = Join-Path $Build 'ariec61850_control_interop_probe.exe' $Exe = if (Test-Path $CandidateA) { $CandidateA } else { $CandidateB } +$InventoryCandidateA = Join-Path $Build "$Configuration\ariec61850_control_inventory_probe.exe" +$InventoryCandidateB = Join-Path $Build 'ariec61850_control_inventory_probe.exe' +$InventoryExe = if (Test-Path $InventoryCandidateA) { $InventoryCandidateA } else { $InventoryCandidateB } + if (-not (Test-Path $Exe)) { throw "Build succeeded but control interop executable was not found." } +if (-not (Test-Path $InventoryExe)) { + throw "Build succeeded but read-only control inventory executable was not found." +} Write-Host "" Write-Host "[C5] READY: $Exe" -Write-Host "[C5] Safe first command (read-only):" -Write-Host " `"$Exe`" 102 --object LD0/CSWI1.Pos --evidence c5-discovery.json" +Write-Host "[C5] INVENTORY READY: $InventoryExe" +Write-Host "[C5] Safest first command for an unknown IED model (read-only, zero control Write):" +Write-Host " `"$InventoryExe`" 102 --evidence c5-control-inventory.json" +Write-Host "" +Write-Host "[C5] After inventory identifies an exact control object, descriptor discovery remains read-only:" +Write-Host " `"$Exe`" 102 --object --evidence c5-discovery.json" Write-Host "" -Write-Host "Live Write remains locked unless the executable receives:" +Write-Host "Live Write remains locked unless the interop executable receives:" Write-Host " --arm IEC61850-LAB-CONTROL" From 0921d0873197c13b1ede5798f6125cee0dcd870a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:15:18 +0700 Subject: [PATCH 45/50] c5: validate and publish read-only control inventory probe --- .github/workflows/control-interop-harness-ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index 12e355c86..9623c7fed 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -61,8 +61,14 @@ jobs: | tee evidence/control-interop/self-test-${{ matrix.compiler }}.txt ./build-control-interop/ariec61850_control_interop_probe --help \ > evidence/control-interop/help-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_inventory_probe --self-test \ + | tee evidence/control-interop/inventory-self-test-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_inventory_probe --help \ + > evidence/control-interop/inventory-help-${{ matrix.compiler }}.txt cp build-control-interop/ariec61850_control_interop_probe \ evidence/control-interop/ariec61850_control_interop_probe-${{ matrix.compiler }} + cp build-control-interop/ariec61850_control_inventory_probe \ + evidence/control-interop/ariec61850_control_inventory_probe-${{ matrix.compiler }} - name: Stage Windows harness and contract if: runner.os == 'Windows' @@ -70,9 +76,13 @@ jobs: run: | New-Item -ItemType Directory -Force -Path evidence/control-interop | Out-Null $exe = 'build-control-interop/Release/ariec61850_control_interop_probe.exe' + $inventory = 'build-control-interop/Release/ariec61850_control_inventory_probe.exe' & $exe --self-test | Tee-Object -FilePath evidence/control-interop/self-test-msvc.txt & $exe --help | Out-File -Encoding utf8 evidence/control-interop/help-msvc.txt + & $inventory --self-test | Tee-Object -FilePath evidence/control-interop/inventory-self-test-msvc.txt + & $inventory --help | Out-File -Encoding utf8 evidence/control-interop/inventory-help-msvc.txt Copy-Item $exe evidence/control-interop/ariec61850_control_interop_probe.exe + Copy-Item $inventory evidence/control-interop/ariec61850_control_inventory_probe.exe - name: Upload C5 harness evidence and binary uses: actions/upload-artifact@v4 From 9da05144bf6ad4da57045dee3b9964ed44459f0d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:23:10 +0700 Subject: [PATCH 46/50] C5: add raw read-only ctlModel diagnostic probe --- tools/control_ctlmodel_probe.cpp | 188 +++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tools/control_ctlmodel_probe.cpp diff --git a/tools/control_ctlmodel_probe.cpp b/tools/control_ctlmodel_probe.cpp new file mode 100644 index 000000000..8ed7470fd --- /dev/null +++ b/tools/control_ctlmodel_probe.cpp @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_discovery.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; + +[[nodiscard]] const char* kind_name(const MmsDataKind kind) noexcept { + switch (kind) { + case MmsDataKind::array: return "array"; + case MmsDataKind::structure: return "structure"; + case MmsDataKind::boolean: return "boolean"; + case MmsDataKind::bit_string: return "bit-string"; + case MmsDataKind::integer: return "integer"; + case MmsDataKind::unsigned_integer: return "unsigned-integer"; + case MmsDataKind::floating_point: return "floating-point"; + case MmsDataKind::octet_string: return "octet-string"; + case MmsDataKind::visible_string: return "visible-string"; + case MmsDataKind::binary_time: return "binary-time"; + case MmsDataKind::bcd: return "bcd"; + case MmsDataKind::boolean_array: return "boolean-array"; + case MmsDataKind::object_id: return "object-id"; + case MmsDataKind::mms_string: return "mms-string"; + case MmsDataKind::utc_time: return "utc-time"; + case MmsDataKind::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::string raw_hex(const MmsDataValue& value) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : value.raw_value()) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] std::optional numeric_value(const MmsDataValue& value) noexcept { + if (value.kind() == MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) return *number; + } + if (value.kind() == 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]] std::string ctl_model_name(const std::optional value) { + if (!value) return "not-decoded"; + switch (*value) { + case 0: return "status-only"; + case 1: return "direct-normal"; + case 2: return "sbo-normal"; + case 3: return "direct-enhanced"; + case 4: return "sbo-enhanced"; + default: return "out-of-standard-range"; + } +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +int run_self_test() { + const auto value = MmsDataValue::integer(2); + if (std::string{kind_name(value.kind())} != "integer" || + ctl_model_name(numeric_value(value)) != "sbo-normal") { + std::cerr << "CTLMODEL_RAW_SELF_TEST FAIL\n"; + return 1; + } + std::cout << "CTLMODEL_RAW_SELF_TEST PASS readOnly=true controlWritePath=false\n"; + return 0; +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_ctlmodel_probe [port] --object LD/LN.DO\n\n" + << "Safety: read-only diagnostic. Performs one MMS Read of CF/.../ctlModel.\n" + << "It has no Write action and no arm token.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc == 2 && std::string_view{argv[1]} == "--self-test") return run_self_test(); + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + print_usage(); + return argc > 1 ? 0 : 2; + } + + MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + endpoint.port = parse_port(argv[argument]); + ++argument; + } + + std::string object_text; + while (argument < argc) { + const std::string option = argv[argument++]; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--object") object_text = value; + else throw std::invalid_argument("Unknown option: " + option); + } + if (object_text.empty()) throw std::invalid_argument("--object is required."); + + ControlObjectReference object; + if (!try_parse_control_object_reference(object_text, object)) { + throw std::invalid_argument("Control object must use LD/LN.DO form."); + } + std::array item_buffer{}; + std::size_t item_bytes{}; + if (!build_control_item(object, "CF", "ctlModel", item_buffer, item_bytes)) { + throw std::runtime_error("Cannot build ctlModel MMS item."); + } + const std::string item{item_buffer.data(), item_bytes}; + + MmsAssociationOptions association_options; + association_options.connect_timeout = std::chrono::milliseconds{5'000}; + association_options.request_timeout = std::chrono::milliseconds{5'000}; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(endpoint); + MmsAssociationControlTransport transport{live_session.association()}; + + const auto mms_object = MmsObjectName::domain_specific(std::string{object.domain}, item); + const auto value = transport.read(mms_object); + if (!value) { + throw std::runtime_error("MMS Read returned no ctlModel value."); + } + + const auto numeric = numeric_value(*value); + std::cout << "CTLMODEL_RAW object=" << object_text + << " mms=" << mms_object.reference() + << " kind=" << kind_name(value->kind()) + << " display=\"" << MmsDataCodec::to_display_string(*value) << "\"" + << " rawHex=" << (raw_hex(*value).empty() ? "" : raw_hex(*value)); + if (value->unknown_tag_number()) { + std::cout << " unknownTag=" << *value->unknown_tag_number(); + } + if (numeric) { + std::cout << " numeric=" << *numeric; + } + std::cout << " interpreted=" << ctl_model_name(numeric) << '\n'; + std::cout << "CTLMODEL_RAW_RESULT readOnly=true mmsControlWriteCount=0\n"; + std::cout << "PCAP_HINT filter=\"tcp.port == " << endpoint.port + << " && ip.addr == " << endpoint.host << "\"\n"; + + live_session.disconnect(); + return 0; + } catch (const std::exception& exception) { + std::cerr << "ctlModel raw probe failed: " << exception.what() << '\n'; + return 1; + } +} From e6ecf750b9b8056aa7c64d2d5703cd27a1649f77 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:23:30 +0700 Subject: [PATCH 47/50] C5: build raw ctlModel diagnostic companion --- tools/control_interop_probe/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt index 57ba109b0..86772d31e 100644 --- a/tools/control_interop_probe/CMakeLists.txt +++ b/tools/control_interop_probe/CMakeLists.txt @@ -22,14 +22,22 @@ add_executable(ariec61850_control_inventory_probe target_link_libraries(ariec61850_control_inventory_probe PRIVATE ARIEC61850::core) target_compile_features(ariec61850_control_inventory_probe PRIVATE cxx_std_20) +add_executable(ariec61850_control_ctlmodel_probe + ${ARIEC61850_ROOT}/tools/control_ctlmodel_probe.cpp) +target_link_libraries(ariec61850_control_ctlmodel_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_ctlmodel_probe PRIVATE cxx_std_20) + if(MSVC) target_compile_options(ariec61850_control_interop_probe PRIVATE /W4 /WX /permissive-) target_compile_options(ariec61850_control_inventory_probe PRIVATE /W4 /WX /permissive-) + target_compile_options(ariec61850_control_ctlmodel_probe PRIVATE /W4 /WX /permissive-) else() target_compile_options(ariec61850_control_interop_probe PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) target_compile_options(ariec61850_control_inventory_probe PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + target_compile_options(ariec61850_control_ctlmodel_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) endif() enable_testing() @@ -45,8 +53,15 @@ add_test( add_test( NAME ariec61850_control_inventory_help COMMAND ariec61850_control_inventory_probe --help) +add_test( + NAME ariec61850_control_ctlmodel_self_test + COMMAND ariec61850_control_ctlmodel_probe --self-test) +add_test( + NAME ariec61850_control_ctlmodel_help + COMMAND ariec61850_control_ctlmodel_probe --help) message(STATUS "arstack61850 C5 interop harness: read-only default=ON") message(STATUS "arstack61850 C5 interop harness: explicit arm token required for Write=ON") message(STATUS "arstack61850 C5 interop harness: automatic command retry=OFF") message(STATUS "arstack61850 C5 control inventory companion: read-only only=ON") +message(STATUS "arstack61850 C5 raw ctlModel diagnostic companion: read-only only=ON") From b9759f24183fbeeaa60330c3c54e94a995899211 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:01:12 +0700 Subject: [PATCH 48/50] Harden live Smart Control diagnostics --- MIGRATION_CHECKLIST.md | 2 +- PORT_STATUS.md | 2 +- .../CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md | 64 +++++++++++++++++ docs/CONTROL_INTEROP_RUNBOOK.md | 1 + .../control/command_termination.hpp | 14 +++- .../ariec61850/control/control_session.hpp | 6 +- src/control/command_termination.cpp | 72 +++++++++++++++++-- src/control/control_session.cpp | 23 +++--- tests/test_command_termination.cpp | 57 +++++++++++++++ tools/control_interop_probe.cpp | 39 ++++++++++ 10 files changed, 262 insertions(+), 18 deletions(-) create mode 100644 docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 7f5cc5538..9d3022163 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -172,7 +172,7 @@ behavioral oracle until laboratory interoperability is complete. - [x] C1-C4 integrated into the normal `ARIEC61850::core` build and GCC/Clang/MSVC CTest matrix. - [x] C5 guarded live-interoperability harness implemented with read-only default, explicit arm token, typed values, status-before/after reads, deterministic control-Write counting, JSON evidence, Wireshark filter hint, and offline safety self-test. - [x] C5 dedicated GCC/Clang/MSVC harness CI and build artifact workflow implemented. -- [~] Physical/simulator C5 acceptance for Direct normal, SBO normal, Direct enhanced, SBO enhanced, negative LastApplError/AddCause, and association-loss cases is pending retained laboratory evidence. +- [~] IEDScout simulator C5 acceptance is retained for SBO enhanced SelectWithValue/Cancel, negative LastApplError/AddCause, and Direct enhanced Oper/positive CommandTermination with state restoration. Direct normal, SBO normal, association-loss, multi-client contention, and physical-IED cases remain pending. ### Later Phase 4 work diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 794e4d413..734fc29a7 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -59,7 +59,7 @@ The integration preserves Phase 4C live discovery and the Dynamic DataSet/report - Origin category/identifier, Test, synchrocheck, and interlock-check are explicit. - Live status is read before and after the action when the IED exposes a usable ST/MX reference. - Every control Write reference is counted and retained in JSON evidence. -- JSON evidence includes `ctlModel`, CDC, status, Write count/list, report-observation counts, completion, raw ControlError/AddCause, and mapped AddCause. +- JSON evidence includes `ctlModel`, CDC, status, Write count/list, report-observation counts, completion, MMS DataAccessError code/name, raw ControlError/AddCause, and mapped AddCause. - The tool prints a Wireshark MMS/TCP filter for JSON-to-PCAP reconciliation. - Offline `--self-test` verifies the read-only default and arm gate without network access. - A standalone CMake profile, Windows build helper, and cross-platform CI workflow are provided. diff --git a/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md new file mode 100644 index 000000000..1d3d71b61 --- /dev/null +++ b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md @@ -0,0 +1,64 @@ +# Smart Control Live Result — 2026-08-11 + +## Scope and target + +Phase 4D-C5 was exercised against `192.168.1.10:102`. The endpoint was verified locally before any Write: + +- listener process: OMICRON IEDScout 4 (`IEDScout.exe`); +- network path: local `KM_Test` address with traffic captured through Npcap loopback; +- control object: `BCU7SLCTRL/CSWI1.Pos`; +- association profile: `BalancedApTitle`; +- automatic command retry: disabled. + +This is simulator interoperability evidence, not physical-plant or IEC 61850 conformance evidence. + +## Results + +| Case | Live model | Result | Wire evidence | +|---|---|---|---| +| Read-only inventory | 4 domains, 5,567 variables, 80 candidates, 17 control-ready | Passed; zero control Write | `mmsControlWriteCount=0` | +| Read-only descriptor | SBO enhanced, Boolean `ctlVal`, exact `Oper`/`SBOw`/`Cancel` types | Passed; status `0x0680`; zero control Write | PCAP: 0 Write requests | +| Negative SBOw with `Test=true` | SBO enhanced | Correctly rejected and decoded as MMS DataAccessError 3 `object-access-denied`, ControlError 3 `operator-test`, AddCause 8 `blocked-by-mode` | Exactly one `SBOw`; one `LastApplError`; no `Cancel`, `Oper`, or retry | +| SBO enhanced select/cancel with `Test=false` | SBO enhanced | Passed; `SBOw` then `Cancel`; status unchanged | Exactly two Writes in order: `SBOw`, `Cancel` | +| Direct enhanced operate OFF | Direct enhanced | Passed with positive CommandTermination; status `0x0680 -> 0x0640` | Exactly one `Oper`; one InformationReport; no retry | +| Direct enhanced operate ON restore | Direct enhanced | Passed with positive CommandTermination; status restored `0x0640 -> 0x0680` | Exactly one `Oper`; one InformationReport; no retry | + +The status values are MMS two-bit bit strings represented by the harness as raw bytes: `0x0680` means six unused bits with payload `10`, while `0x0640` carries payload `01`. + +The live `ctlModel` was rediscovered before every action. IEDScout exposed SBO enhanced (`4`) for the selection cases and Direct enhanced (`3`) for the later operate cases. A mismatched `select-operate` request was rejected locally before any Write when the rediscovered model was Direct enhanced. + +## Live-discovered interoperability repair + +The negative path exposed two IEDScout `LastApplError` variants: + +1. VMD-specific `LastApplError` with omitted `ctlObj`; +2. an embedded exact CO object root such as `BCU7SLCTRL/CSWI1$CO$Pos`, without an `Oper` leaf. + +The decoder now accepts the omitted-`ctlObj` variant only when origin category, origin identifier, and `ctlNum` exactly match the active command. It also recognizes an exact embedded CO object root. Mismatched sequence correlation remains rejected, so an unrelated generic application error cannot complete or diagnose another command. + +The C5 JSON evidence now records both the MMS DataAccessError numeric code/name and the IEC 61850 ControlError/AddCause diagnostics. + +## Artifact hashes + +Generated artifacts remain under the ignored local build evidence directory and are not committed to the public source tree. + +| Artifact | SHA-256 | +|---|---| +| `control-inventory.json` | `A57A97C9D330FA8F33078A3617E5EBD3D450EF35A32FDB3EE6A9B7F3B4AEE41C` | +| `cswi1-pos-discovery-loopback-captured.json` | `9DBF4BDF020F3E4CCE9C0A985A33C64217EBBEFBE494F67153A2570DD80A32D9` | +| `capture-discovery-loopback.pcapng` | `10449524587E10D41152C8DE578DCB716DE65AA6BA2D9FCAC39A051109529279` | +| `cswi1-pos-sbo-enhanced-select-cancel-accepted-diagnostics.json` | `2B7A2097BC4785553A221ED9B779ACDF6E8D07DA802E3D61915E2010CF44F907` | +| `cswi1-pos-sbo-enhanced-select-cancel-accepted-diagnostics.pcapng` | `6F86D9A7F4086DB818DEF597B15D034E22BE514E9F1EBE6A119386AA9405DCF1` | +| `cswi1-pos-sbo-enhanced-select-cancel-success.json` | `04D4EEDE0A1521DAAB57339D9F3157C5AEDFD007593A490A0608AC67694A0DF2` | +| `cswi1-pos-sbo-enhanced-select-cancel-success.pcapng` | `A9D4F7A2C48210F140FEE8F13C23C26D05A4AA4CE7E6FE285D890460061886CC` | +| `cswi1-pos-direct-enhanced-operate-off.json` | `1A8F92B11C95CDB698483966E08FB9E9B3F743C7D7D8197F34F0425374AF39FB` | +| `cswi1-pos-direct-enhanced-operate-off.pcapng` | `DA315C9132B2BFEE3FAFB61808506462299F67A6926F407FF4D51227BF61CC4E` | +| `cswi1-pos-direct-enhanced-operate-on-restore.json` | `BEAA47AF23BF9E2580034D1504FF3115FA68A33AED2BBD91B3D2FE2C1DC20114` | +| `cswi1-pos-direct-enhanced-operate-on-restore.pcapng` | `E794F20BE2D5A22C9AB29C42AD8A8266281956BABE52A1CB3EAE4778F6C9D0E5` | + +## Remaining acceptance boundary + +- GCC/Clang profiles must pass on the integration PR; only MSVC was available locally. +- Direct normal and SBO normal were not exposed by this tested control object during the retained cases. +- Association-loss and multi-client contention were not forced against the running IEDScout process. +- Physical IED behavior remains unclaimed. diff --git a/docs/CONTROL_INTEROP_RUNBOOK.md b/docs/CONTROL_INTEROP_RUNBOOK.md index e1c9d06dc..9ac2820c5 100644 --- a/docs/CONTROL_INTEROP_RUNBOOK.md +++ b/docs/CONTROL_INTEROP_RUNBOOK.md @@ -239,6 +239,7 @@ Acceptance evidence should preserve: - raw ControlError; - raw AddCause; +- MMS DataAccessError numeric code and standard name when the confirmed Write is rejected; - mapped AddCause text such as `blocked-by-interlocking` or `blocked-by-synchrocheck`; - the exact control object; diff --git a/include/ariec61850/control/command_termination.hpp b/include/ariec61850/control/command_termination.hpp index 9e493d2b6..e4d241ff4 100644 --- a/include/ariec61850/control/command_termination.hpp +++ b/include/ariec61850/control/command_termination.hpp @@ -6,12 +6,17 @@ #include #include +#include #include +#include namespace ar::iec61850::control { struct LastApplError final { std::string control_object; + std::optional origin_category; + std::vector origin_identifier; + std::optional control_number; std::int64_t raw_control_error{}; std::int64_t raw_add_cause{25}; ControlError control_error{ControlError::no_error}; @@ -20,6 +25,12 @@ struct LastApplError final { std::string add_cause_name; }; +struct CommandCorrelation final { + std::int64_t origin_category{}; + std::span origin_identifier; + std::uint8_t control_number{}; +}; + struct CommandTermination final { bool is_for_control_object{}; bool is_termination{}; @@ -37,7 +48,8 @@ class CommandTerminationDecoder final { public: [[nodiscard]] static CommandTermination decode( const mms::MmsInformationReport& report, - const ControlObjectReference& object); + const ControlObjectReference& object, + const CommandCorrelation* correlation = nullptr); [[nodiscard]] static std::optional try_decode_last_appl_error( const mms::MmsDataValue& value); diff --git a/include/ariec61850/control/control_session.hpp b/include/ariec61850/control/control_session.hpp index ae0495708..08846b758 100644 --- a/include/ariec61850/control/control_session.hpp +++ b/include/ariec61850/control/control_session.hpp @@ -230,10 +230,12 @@ class ControlObjectSession final { const std::optional& app_error = std::nullopt) const; [[nodiscard]] std::optional wait_for_termination( std::chrono::milliseconds timeout, - std::stop_token stop_token); + std::stop_token stop_token, + const ActiveContext& context); [[nodiscard]] std::optional wait_for_application_error( std::chrono::milliseconds timeout, - std::stop_token stop_token); + std::stop_token stop_token, + const ActiveContext& context); [[nodiscard]] bool selection_expired(const ActiveContext& context) const noexcept; [[nodiscard]] std::chrono::milliseconds effective_operate_timeout( const ControlRequest& request) const; diff --git a/src/control/command_termination.cpp b/src/control/command_termination.cpp index b415c42be..df150b5c9 100644 --- a/src/control/command_termination.cpp +++ b/src/control/command_termination.cpp @@ -146,6 +146,34 @@ namespace { return std::nullopt; } +[[nodiscard]] std::vector read_octets( + const mms::MmsDataValue& value) { + if (value.kind() != mms::MmsDataKind::octet_string) { + return {}; + } + return value.raw_value(); +} + +[[nodiscard]] bool is_generic_last_appl_error_reference( + const std::string& reference) noexcept { + return ascii_equal(reference, "LastApplError") || + ascii_ends_with(reference, "$LastApplError") || + ascii_ends_with(reference, ".LastApplError"); +} + +[[nodiscard]] bool matches_correlation( + const LastApplError& error, + const CommandCorrelation& expected) noexcept { + return error.control_object.empty() && + error.origin_category.has_value() && + error.origin_category.value() == expected.origin_category && + error.control_number.has_value() && + error.control_number.value() == expected.control_number && + std::equal( + error.origin_identifier.begin(), error.origin_identifier.end(), + expected.origin_identifier.begin(), expected.origin_identifier.end()); +} + [[nodiscard]] ControlError map_control_error(const std::int64_t value) noexcept { switch (value) { case 0: return ControlError::no_error; @@ -245,12 +273,18 @@ bool CommandTerminationDecoder::matches_reported_reference( const auto expected = object_reference(object); const auto logical_suffix = "/" + std::string{object.logical_node} + "." + std::string{object.data_object_path}; - const auto co_fragment = "/" + std::string{object.logical_node} + ".CO." + - std::string{object.data_object_path} + "."; + const auto co_object = std::string{object.domain} + "/" + + std::string{object.logical_node} + ".CO." + + std::string{object.data_object_path}; + const auto co_suffix = "/" + std::string{object.logical_node} + ".CO." + + std::string{object.data_object_path}; return ascii_equal(normalized, expected) || ascii_starts_with(normalized, expected + ".") || ascii_ends_with(normalized, logical_suffix) || - ascii_contains(normalized, co_fragment); + ascii_equal(normalized, co_object) || + ascii_equal(normalized, co_object + ".") || + ascii_ends_with(normalized, co_suffix) || + ascii_contains(normalized, co_suffix + "."); } std::optional CommandTerminationDecoder::try_decode_last_appl_error( @@ -288,6 +322,27 @@ std::optional CommandTerminationDecoder::try_decode_last_appl_err const auto add_cause = numeric.back().second; LastApplError result; result.control_object = read_text(children.front()); + const auto error_index = std::find_if( + numeric.begin(), numeric.end(), [](const auto& entry) { + return entry.first == 0U || entry.first == 1U; + })->first; + const auto origin_index = error_index + 1U; + const auto control_number_index = error_index + 2U; + if (origin_index < children.size() && + children[origin_index].kind() == mms::MmsDataKind::structure) { + const auto& origin = children[origin_index].children(); + if (origin.size() >= 2U) { + result.origin_category = read_number(origin[0]); + result.origin_identifier = read_octets(origin[1]); + } + } + if (control_number_index < children.size()) { + const auto control_number = read_number(children[control_number_index]); + if (control_number.has_value() && control_number.value() >= 0 && + control_number.value() <= std::numeric_limits::max()) { + result.control_number = static_cast(control_number.value()); + } + } result.raw_control_error = error.value(); result.raw_add_cause = add_cause; result.control_error = map_control_error(error.value()); @@ -299,7 +354,8 @@ std::optional CommandTerminationDecoder::try_decode_last_appl_err CommandTermination CommandTerminationDecoder::decode( const mms::MmsInformationReport& report, - const ControlObjectReference& object) { + const ControlObjectReference& object, + const CommandCorrelation* correlation) { CommandTermination result; if (!object.valid()) { return result; @@ -307,10 +363,13 @@ CommandTermination CommandTerminationDecoder::decode( bool matching_object = false; bool matching_operate = false; + bool generic_last_appl_error = false; for (const auto& reference : report.variable_references) { const auto text = mms_reference(reference); matching_object = matching_object || matches_reported_reference(object, text); matching_operate = matching_operate || matches_operate_reference(object, text); + generic_last_appl_error = generic_last_appl_error || + is_generic_last_appl_error_reference(text); } for (const auto& item : report.items) { @@ -326,7 +385,10 @@ CommandTermination CommandTerminationDecoder::decode( const bool embedded_match = !last_error->control_object.empty() && (matches_reported_reference(object, last_error->control_object) || matches_operate_reference(object, last_error->control_object)); - if (!matching_object && !matching_operate && !embedded_match) { + const bool correlated_generic = generic_last_appl_error && + correlation != nullptr && matches_correlation(*last_error, *correlation); + if (!matching_object && !matching_operate && !embedded_match && + !correlated_generic) { continue; } diff --git a/src/control/control_session.cpp b/src/control/control_session.cpp index 484491f50..66c89c68a 100644 --- a/src/control/control_session.cpp +++ b/src/control/control_session.cpp @@ -378,13 +378,13 @@ ControlActionResult ControlObjectSession::select_with_value( value.value.value(), stop_token); if (!write.success) { const auto app_error = wait_for_application_error( - options_.application_error_grace_period, stop_token); + options_.application_error_grace_period, stop_token, context); 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); + options_.application_error_grace_period, stop_token, context); if (post_error.has_value() && !post_error->positive) { planner_.on_association_closed(transport_.association_id()); ControlActionResult result; @@ -521,7 +521,7 @@ ControlActionResult ControlObjectSession::operate( if (!write.success) { const auto app_error = wait_for_application_error( - options_.application_error_grace_period, stop_token); + options_.application_error_grace_period, stop_token, context); clear_selection(); return write_failure(ControlAction::operate, context, write, app_error); } @@ -538,7 +538,7 @@ ControlActionResult ControlObjectSession::operate( } const auto termination = wait_for_termination( - effective_operate_timeout(request), stop_token); + effective_operate_timeout(request), stop_token, context); if (!termination.has_value()) { const bool still_associated = transport_.associated(); clear_selection(); @@ -650,7 +650,8 @@ ControlActionResult ControlObjectSession::write_failure( std::optional ControlObjectSession::wait_for_termination( const std::chrono::milliseconds timeout, - const std::stop_token stop_token) { + const std::stop_token stop_token, + const ActiveContext& context) { if (timeout <= std::chrono::milliseconds::zero()) { return std::nullopt; } @@ -668,7 +669,12 @@ std::optional ControlObjectSession::wait_for_termination( if (!transport_.wait_information_report(remaining, report, stop_token)) { return std::nullopt; } - auto termination = CommandTerminationDecoder::decode(report, descriptor_.object); + const CommandCorrelation correlation{ + static_cast(context.mms.origin_category), + context.mms.origin_identifier, + context.mms.control_number}; + auto termination = CommandTerminationDecoder::decode( + report, descriptor_.object, &correlation); if (termination.is_for_control_object && termination.is_termination) { return termination; } @@ -678,8 +684,9 @@ std::optional ControlObjectSession::wait_for_termination( 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); + const std::stop_token stop_token, + const ActiveContext& context) { + return wait_for_termination(timeout, stop_token, context); } bool ControlObjectSession::selection_expired(const ActiveContext& context) const noexcept { diff --git a/tests/test_command_termination.cpp b/tests/test_command_termination.cpp index a5b61a293..15299440d 100644 --- a/tests/test_command_termination.cpp +++ b/tests/test_command_termination.cpp @@ -35,6 +35,13 @@ MmsObjectName reference(std::string item) { return result; } +MmsObjectName vmd_reference(std::string item) { + MmsObjectName result; + result.kind = MmsObjectNameKind::vmd_specific; + result.item = std::move(item); + return result; +} + MmsInformationReportItem value_item(MmsDataValue value, const std::size_t index = 0U) { MmsInformationReportItem item; item.index = index; @@ -82,6 +89,8 @@ void reference_matching_follows_csharp_oracle() { target, "LD0/CSWI1.Pos.stVal")); CHECK(CommandTerminationDecoder::matches_reported_reference( target, "LD0/CSWI1$CO$Pos$Oper")); + CHECK(CommandTerminationDecoder::matches_reported_reference( + target, "LD0/CSWI1$CO$Pos")); CHECK(!CommandTerminationDecoder::matches_reported_reference( target, "LD0/XCBR1.Pos.stVal")); } @@ -146,6 +155,53 @@ void omitted_ctl_obj_layout_is_supported_when_report_correlates() { CHECK(result.add_cause == AddCause::none); } +void generic_omitted_ctl_obj_requires_exact_sequence_correlation() { + MmsInformationReport report; + report.variable_references.push_back(vmd_reference("LastApplError")); + report.items.push_back(value_item(MmsDataValue::structure({ + MmsDataValue::integer(3), + MmsDataValue::structure({ + MmsDataValue::integer(7), + MmsDataValue::octet_string( + std::vector{'A', 'R', 'S', 'T', 'A', 'C', 'K'}), + }), + MmsDataValue::unsigned_integer(41U), + MmsDataValue::integer(8), + }))); + + const std::vector expected_origin{ + 'A', 'R', 'S', 'T', 'A', 'C', 'K'}; + const CommandCorrelation matching{7, expected_origin, 41U}; + auto result = CommandTerminationDecoder::decode(report, object(), &matching); + CHECK(result.is_for_control_object); + CHECK(result.is_termination); + CHECK(!result.positive); + CHECK(result.raw_control_error == 3); + CHECK(result.control_error == ControlError::operator_test); + CHECK(result.raw_add_cause == 8); + CHECK(result.add_cause == AddCause::blocked_by_mode); + CHECK(result.last_appl_error.has_value()); + CHECK(result.last_appl_error->control_object.empty()); + CHECK(result.last_appl_error->origin_category == 7); + CHECK(result.last_appl_error->origin_identifier == expected_origin); + CHECK(result.last_appl_error->control_number == 41U); + + const CommandCorrelation wrong_number{7, expected_origin, 42U}; + result = CommandTerminationDecoder::decode(report, object(), &wrong_number); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); + + const std::vector wrong_origin{'O', 'T', 'H', 'E', 'R'}; + const CommandCorrelation wrong_identity{7, wrong_origin, 41U}; + result = CommandTerminationDecoder::decode(report, object(), &wrong_identity); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); + + result = CommandTerminationDecoder::decode(report, object()); + CHECK(!result.is_for_control_object); + CHECK(!result.is_termination); +} + void uncorrelated_last_appl_error_cannot_complete_command() { MmsInformationReport report; report.variable_references.push_back(reference("XCBR1$ST$Pos$stVal")); @@ -216,6 +272,7 @@ int main() { positive_termination_requires_exact_oper_reference(); negative_last_appl_error_is_correlated_and_mapped(); omitted_ctl_obj_layout_is_supported_when_report_correlates(); + generic_omitted_ctl_obj_requires_exact_sequence_correlation(); uncorrelated_last_appl_error_cannot_complete_command(); unknown_numeric_codes_are_preserved_without_false_positive(); decoder_output_drives_enhanced_planner_completion(); diff --git a/tools/control_interop_probe.cpp b/tools/control_interop_probe.cpp index d0b7a6983..3754be681 100644 --- a/tools/control_interop_probe.cpp +++ b/tools/control_interop_probe.cpp @@ -185,6 +185,24 @@ struct LabAuthorization final { return "unknown"; } +[[nodiscard]] const char* data_access_error_name(const std::uint32_t code) noexcept { + switch (code) { + case 0U: return "object-invalidated"; + case 1U: return "hardware-fault"; + case 2U: return "temporarily-unavailable"; + case 3U: return "object-access-denied"; + case 4U: return "object-undefined"; + case 5U: return "invalid-address"; + case 6U: return "type-unsupported"; + case 7U: return "type-inconsistent"; + case 8U: return "object-attribute-inconsistent"; + case 9U: return "object-access-unsupported"; + case 10U: return "object-non-existent"; + case 11U: return "object-value-invalid"; + default: return "unknown"; + } +} + [[nodiscard]] std::string json_escape(const std::string& value) { std::ostringstream output; for (const auto ch : value) { @@ -496,6 +514,19 @@ void write_evidence( << " \"positiveTermination\": " << (result->positive_termination ? "true" : "false") << ",\n" << " \"controlNumber\": " << static_cast(result->control_number) << ",\n" + << " \"mmsFailureCode\": "; + if (result->mms_failure_code) { + output << *result->mms_failure_code; + } else { + output << "null"; + } + output << ",\n \"mmsFailure\": "; + if (result->mms_failure_code) { + output << '"' << data_access_error_name(*result->mms_failure_code) << '"'; + } else { + output << "null"; + } + output << ",\n" << " \"rawControlError\": " << result->raw_control_error << ",\n" << " \"rawAddCause\": " << result->raw_add_cause << ",\n" << " \"addCause\": \"" << add_cause_name(result->add_cause) << "\",\n" @@ -705,6 +736,14 @@ int main(int argc, char** argv) { << " termination=" << (result.command_termination_received ? "true" : "false") << " ctlNum=" << static_cast(result.control_number) + << " mmsFailure="; + if (result.mms_failure_code) { + std::cout << *result.mms_failure_code << ':' + << data_access_error_name(*result.mms_failure_code); + } else { + std::cout << "none"; + } + std::cout << " addCause=" << add_cause_name(result.add_cause) << " rawAddCause=" << result.raw_add_cause << " message=\"" << result.message << "\"\n"; From f88f3df4b8b874b51205d20de9c3a32dbecc3191 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 06:07:01 +0700 Subject: [PATCH 49/50] docs(control): record C5 simulator interoperability acceptance --- docs/CONTROL_INTEROP_ACCEPTANCE.md | 239 +++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/CONTROL_INTEROP_ACCEPTANCE.md diff --git a/docs/CONTROL_INTEROP_ACCEPTANCE.md b/docs/CONTROL_INTEROP_ACCEPTANCE.md new file mode 100644 index 000000000..e96b3b585 --- /dev/null +++ b/docs/CONTROL_INTEROP_ACCEPTANCE.md @@ -0,0 +1,239 @@ +# IEC 61850 Control Interoperability Acceptance Record + +## Scope + +This record captures the Phase 4D-C5 live simulator interoperability evidence gathered on 2026-08-11 against an isolated IEC 61850 vendor simulator. + +It documents observed interoperability of the guarded C++ control path. It is not an IEC 61850 conformance certificate and is not evidence of physical primary-plant or production-relay control. + +## Tested profile + +- Endpoint: `192.168.1.10:102` +- Simulator model/profile: `BCU7SL` +- Control object: `BCU7SLCTRL/CSWI1.Pos` +- Discovered CDC: `SPC` +- Live `ctlVal` TypeSpecification: `boolean` +- Status object: `BCU7SLCTRL/CSWI1$ST$Pos$stVal` +- `sboTimeout`: 30000 ms +- `operTimeout`: 1000 ms +- Origin used by the harness: `ARSTACK-C5`, station control +- Interlock-check: enabled +- Synchro-check: enabled +- Harness safety gate: exact `--arm IEC61850-LAB-CONTROL` +- Automatic command retry: disabled + +The live descriptor discovery also confirmed exact named structures for `Oper`, `SBOw` where applicable, and `Cancel`. The control path followed the live TypeSpecification instead of guessing a positional layout. + +## Acceptance matrix + +| ctlModel | Model | Tested path | Result | Observed command Writes | +|---|---|---|---|---| +| 1 | Direct with normal security | OFF and ON | PASS | exactly one `Oper` per deliberate action | +| 2 | SBO with normal security | Select/Operate OFF, Select/Operate ON, Select/Cancel | PASS | exactly one `Oper` per operate action; exactly one `Cancel` for cancel | +| 3 | Direct with enhanced security | OFF and ON | PASS | exactly one `Oper` per deliberate action | +| 4 | SBO with enhanced security | SBOw/Operate OFF and ON | PASS | exactly `SBOw`, then `Oper` per successful action | + +Overall tested-profile state: **SIMULATOR_INTEROP_PASSED**. + +## Direct normal — ctlModel 1 + +Raw discovery reported `numeric=1`, interpreted as `direct-normal`. + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON + +- status before: `0x0640` +- status after: `0x0680` +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +This matches the normal-security completion boundary: successful MMS Oper service acceptance completes the command; CommandTermination is not required. + +Local evidence filenames produced by the harness: + +- `bcu7sl-direct-normal-off.json` +- `bcu7sl-direct-normal-on.json` + +## SBO normal — ctlModel 2 + +Raw discovery reported `numeric=2`, interpreted as `sbo-normal`. + +The model required Select and did not require CommandTermination. + +### Select / Operate OFF + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### Select / Operate ON + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +For this simulator, status could converge shortly after the normal-security MMS completion boundary. A subsequent deliberate command observed the expected new state. The harness therefore does not redefine normal-security completion as a status-change wait. + +### Select / Cancel + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Cancel` +- no `Oper` Write followed + +The SBO normal Select service is a Read of `SBO`, so the read is intentionally not counted in `mmsControlWriteCount`. + +Local evidence filenames produced by the harness: + +- `bcu7sl-sbo-normal-off.json` +- `bcu7sl-sbo-normal-on.json` +- `bcu7sl-sbo-normal-cancel.json` + +## Direct enhanced — ctlModel 3 + +Raw discovery reported `numeric=3`, interpreted as `direct-enhanced`. + +The descriptor reported `requiresSelect=false`, `enhanced=true`, and `commandTermination=true`. + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON + +- status before: `0x0640` +- status after: `0x0680` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +Both directions completed only after a positive correlated CommandTermination. MMS Write acceptance alone was not treated as enhanced-security completion. + +Local evidence filenames produced by the harness: + +- `bcu7sl-direct-enhanced-off.json` +- `bcu7sl-direct-enhanced-on.json` + +## SBO enhanced — ctlModel 4 + +Raw discovery reported `numeric=4`, interpreted as `sbo-enhanced`. + +The descriptor reported `requiresSelect=true`, `enhanced=true`, and `commandTermination=true`. + +Live TypeSpecification discovery reported the named structures: + +```text +Oper=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string) +SBOw=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string) +Cancel=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean) +``` + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 2 +- Write 1: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- Write 2: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON after OFF + +- status before: `0x0640` +- status after: `0x0680` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 2 +- Write 1: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- Write 2: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### Safe rejection / no-retry evidence + +An earlier SBOw attempt was rejected by the simulator before Oper. The harness recorded: + +- completion: `rejected` +- accepted: `false` +- command Writes: 1 +- only Write: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- no `Oper` Write followed +- no automatic retry followed + +The exact application-side reason was not established by retained LastApplError/PCAP evidence, so this record does not assign a specific rejection cause. + +Local evidence filenames produced by the harness: + +- `bcu7sl-sbo-enhanced-off.json` +- `bcu7sl-sbo-enhanced-on-after-off.json` +- `bcu7sl-sbo-enhanced-on.json` (rejected SBOw case before the successful opposite-direction sequence) + +## Type-safety evidence + +Before the successful SPC tests, an intentionally incorrect `--value-kind dpc` attempt was rejected locally because the live `ctlVal` TypeSpecification was Boolean: + +```text +Control value does not match live ctlVal TypeSpecification at ctlVal. +``` + +No valid control sequence was sent for the mismatched value type. This confirms the live TypeSpecification gate prevents the client from guessing a DPC encoding for an SPC/Boolean command. + +## No-retry evidence + +Across the accepted cases, the harness reported exact command Write counts matching the expected control model: + +- Direct normal: 1 `Oper` +- SBO normal Operate: 1 `Oper` after Select Read +- SBO normal Cancel: 1 `Cancel` after Select Read +- Direct enhanced: 1 `Oper` +- SBO enhanced: 1 `SBOw` + 1 `Oper` + +The rejected SBO enhanced selection stopped after the single rejected `SBOw`. No automatic second selection or Oper was generated. + +## Claim boundary + +This evidence is sufficient to label this specific tested simulator profile: + +`SIMULATOR_INTEROP_PASSED` + +It does **not** establish: + +- IEC 61850 conformance certification; +- interoperability with every vendor implementation; +- physical IED / relay acceptance; +- energized primary-plant control suitability; +- negative LastApplError/AddCause coverage for every rejection mode; +- association-loss coverage for every control phase. + +Those remain separate acceptance gates. + +## Retention note + +The JSON filenames above were generated on the operator workstation. For audit-grade retention, pair each JSON with its matching PCAP/PCAPNG and simulator/event evidence according to `docs/CONTROL_INTEROP_RUNBOOK.md`. The JSON `mmsControlWriteCount` / `mmsControlWrites` values must agree with the packet capture before upgrading any physical-IED claim. From 6191e2033695771cc4076c9d57298297816c811b Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:10:08 +0700 Subject: [PATCH 50/50] docs(control): reconcile parallel simulator acceptance --- MIGRATION_CHECKLIST.md | 3 ++- docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md | 2 ++ docs/FEATURE_MATRIX.md | 14 +++++++------- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 9d3022163..c5b8c7fca 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -172,7 +172,8 @@ behavioral oracle until laboratory interoperability is complete. - [x] C1-C4 integrated into the normal `ARIEC61850::core` build and GCC/Clang/MSVC CTest matrix. - [x] C5 guarded live-interoperability harness implemented with read-only default, explicit arm token, typed values, status-before/after reads, deterministic control-Write counting, JSON evidence, Wireshark filter hint, and offline safety self-test. - [x] C5 dedicated GCC/Clang/MSVC harness CI and build artifact workflow implemented. -- [~] IEDScout simulator C5 acceptance is retained for SBO enhanced SelectWithValue/Cancel, negative LastApplError/AddCause, and Direct enhanced Oper/positive CommandTermination with state restoration. Direct normal, SBO normal, association-loss, multi-client contention, and physical-IED cases remain pending. +- [x] C5 tested-simulator acceptance is recorded for Direct normal, SBO normal, Direct enhanced, and SBO enhanced; the complementary locally retained subset includes hashed JSON/PCAP for SBO enhanced SelectWithValue/Cancel, negative LastApplError/AddCause, Direct enhanced Oper/positive CommandTermination, exact Write counts, and state restoration. +- [~] Association-loss, multi-client contention, physical-IED, and broader multi-vendor control cases remain pending. ### Later Phase 4 work diff --git a/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md index 1d3d71b61..8f26f6f2d 100644 --- a/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md +++ b/docs/CONTROL_INTEROP_LIVE_RESULT_2026-08-11.md @@ -58,6 +58,8 @@ Generated artifacts remain under the ignored local build evidence directory and ## Remaining acceptance boundary +These bullets describe the scope of this locally retained, hash-identified capture set. A complementary parallel simulator run covering all four control models is recorded in `CONTROL_INTEROP_ACCEPTANCE.md`; its local artifact filenames are documented there, but those artifacts are not part of this checkout. + - GCC/Clang profiles must pass on the integration PR; only MSVC was available locally. - Direct normal and SBO normal were not exposed by this tested control object during the retained cases. - Association-loss and multi-client contention were not forced against the running IEDScout process. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 616803dcd..e9d61d09d 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -35,10 +35,10 @@ Status terms are intentionally conservative: | Reporting | BRCB Owner / ResvTms / association lifecycle | Offline-tested | Multi-client ownership, reconnect/expiry, and association-loss semantics are hard-profile tested. | | Reporting | BRCB recovery image v2 | Offline-tested / physical NVM pending | Recovery preserves retained window/cursor/gap with v1 restore; flash endurance and power-loss hardware evidence are separate. | | Control | `ctlModel` discovery | Live-proven / simulator | C5 rediscovered live `ctlModel` and exact command types before each action on IEDScout. | -| Control | Direct normal | Software-ready for lab | Exactly-one `Oper` semantics are deterministic; retained live Direct normal evidence remains pending. | -| Control | SBO normal | Software-ready for lab | Normal Select is an `SBO` Read; immutable sequence, Oper, and explicit Cancel are implemented; live evidence remains pending. | +| Control | Direct normal | Live-proven / simulator | The retained C5 acceptance record covers OFF and ON with exactly one `Oper` Write per deliberate action. | +| Control | SBO normal | Live-proven / simulator | The retained C5 acceptance record covers Select Read followed by exactly one `Oper`, plus explicit Select/Cancel with no following `Oper`. | | Control | Direct enhanced | Live-proven / simulator | IEDScout OFF and ON-restore each sent one `Oper`, received positive CommandTermination, and changed/restored status without retry. | -| Control | SBO enhanced | Live-proven subset / simulator | IEDScout accepted `SBOw -> Cancel`; full `SBOw -> Oper -> CommandTermination` remains pending for an SBO-enhanced profile. | +| Control | SBO enhanced | Live-proven / simulator | Retained evidence covers `SBOw -> Oper -> CommandTermination` in both directions, plus a separate accepted `SBOw -> Cancel` case. | | Control | `Oper` / `SBOw` / `Cancel` live-type binding | Live-proven / simulator | Exact Boolean structures from live GVAA were sent successfully; unknown vendor fields still fail closed. | | Control | Control value types | Offline-tested | SPC/DPC/integer/unsigned/floating/step binding is conservative; DPC uses network bit order. | | Control | Ownership / immutable sequence | Offline-tested | Second-client takeover, expiry, mismatch, authorization revocation, and association-loss cleanup are covered. | @@ -67,8 +67,8 @@ Status terms are intentionally conservative: | Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting, and file-service corpora are maintained. | | Portability | GCC / Clang / MSVC | CI-gated | C1-C5 build through normal and dedicated matrices; MSVC was validated locally for this integration. | | Acceptance | Read-only same-IED structural stability | Live-proven | Repeated-cycle and timeout/recovery evidence are retained. | -| Acceptance | Control C5 simulator subset | Live-proven | SBO enhanced Select/Cancel, negative diagnostics, Direct enhanced positive termination, Write counts, and state restoration are retained. | -| Acceptance | Full physical IEC 61850 control interoperability | Pending | Direct/SBO normal, full SBO enhanced Oper, association-loss, contention, and physical-IED evidence remain required. | +| Acceptance | Control C5 tested simulator profile | Live-proven | All four ctlModels are recorded in `CONTROL_INTEROP_ACCEPTANCE.md`; the locally retained subset adds hashed JSON/PCAP evidence for negative diagnostics, SBO enhanced Cancel, Direct enhanced termination, exact Write counts, and state restoration. | +| Acceptance | Full physical IEC 61850 control interoperability | Pending | Association-loss, contention, physical-IED, and broader multi-vendor evidence remain required. | | Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before industrial replacement or broad conformance claims. | ## Current live mutation boundary @@ -81,11 +81,11 @@ Reporting/BRCB mutation semantics are distinct from the C5 control gate. Failure ## Current control milestone -C1-C5 provide a guarded production control path plus a live evidence harness. IEDScout simulator evidence is retained for a meaningful subset, but the overall project remains short of complete physical and multi-model acceptance. +C1-C5 provide a guarded production control path plus a live evidence harness. The tested simulator profile has an acceptance record for all four control models, and a complementary locally retained subset includes hashed JSON/PCAP evidence. The project remains short of physical and broad multi-vendor acceptance. `SOFTWARE_READY_FOR_LAB` applies to all implemented control models. -`LAB_INTEROP_PASSED` applies only to a specific IED/control-model profile after its required positive, negative, failure, and cleanup evidence agrees across JSON and PCAP. The retained IEDScout results do not constitute IEC 61850 conformance certification. +`SIMULATOR_INTEROP_PASSED` applies only to the specific simulator profile recorded in `CONTROL_INTEROP_ACCEPTANCE.md`. Any stronger `LAB_INTEROP_PASSED` or physical-IED claim still requires the runbook's retained positive, negative, failure, cleanup, JSON, and packet-capture evidence. These results do not constitute IEC 61850 conformance certification. ## Next parity direction