From ed7ccc8289a434867e83496cc9217aa1a7367819 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:55:48 +0700 Subject: [PATCH 01/13] fix(control): accept IEDScout integer origin category --- src/mms/static_direct_control.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/mms/static_direct_control.cpp b/src/mms/static_direct_control.cpp index 57c7b53a5..1c7e48e7c 100644 --- a/src/mms/static_direct_control.cpp +++ b/src/mms/static_direct_control.cpp @@ -48,10 +48,18 @@ namespace { asn1::BerTlvView or_cat; asn1::BerTlvView or_ident; if (!read_next(origin.value, offset, or_cat) || - !context_tag(or_cat, 6, false)) { + !(context_tag(or_cat, 5, false) || context_tag(or_cat, 6, false))) { return false; } - const auto category_value = asn1::BerSpanReader::read_unsigned_integer(or_cat); + std::optional category_value; + if (context_tag(or_cat, 5, false)) { + const auto signed_category = asn1::BerSpanReader::read_signed_integer(or_cat); + if (signed_category && *signed_category >= 0) { + category_value = static_cast(*signed_category); + } + } else { + category_value = asn1::BerSpanReader::read_unsigned_integer(or_cat); + } if (!category_value || *category_value > 0xFFU) { return false; } From 84f0b4e87d352c98f077c78876c2d01429d9f268 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:56:09 +0700 Subject: [PATCH 02/13] fix(control): map DPC open-close commands to boolean ctlVal --- src/control/mms_control_structure.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/control/mms_control_structure.cpp b/src/control/mms_control_structure.cpp index 128dd7d4c..e43f8b7d9 100644 --- a/src/control/mms_control_structure.cpp +++ b/src/control/mms_control_structure.cpp @@ -221,6 +221,17 @@ using mms::MmsTypeSpecification; case ControlValueKind::unsigned_integer: result = std::get(value.value()) != 0U; break; + case ControlValueKind::double_point: { + const auto point = std::get(value.value()); + if (point == DoublePointValue::off) { + result = false; + } else if (point == DoublePointValue::on) { + result = true; + } else { + return failure(MmsControlBuildStatus::value_out_of_range, path); + } + break; + } default: return failure(MmsControlBuildStatus::type_mismatch, path); } From 22a30cbcb487a143c2eac38b3c3683b37cd445ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:01:21 +0700 Subject: [PATCH 03/13] fix(iedsim): mirror IEDScout DPC SBO command semantics --- tools/static_ied_server.cpp | 121 ++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 19 deletions(-) diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp index 2c4c881d5..174997b02 100644 --- a/tools/static_ied_server.cpp +++ b/tools/static_ied_server.cpp @@ -701,7 +701,7 @@ struct EncodedValue final { fields.reserve(include_check ? 6U : 5U); fields.push_back(control_scalar(mms::MmsTypeKind::boolean, "ctlVal")); fields.push_back(control_structure("origin", { - control_scalar(mms::MmsTypeKind::unsigned_integer, "orCat"), + control_scalar(mms::MmsTypeKind::integer, "orCat"), control_scalar(mms::MmsTypeKind::octet_string, "orIdent", 64U), })); fields.push_back(control_scalar(mms::MmsTypeKind::unsigned_integer, "ctlNum")); @@ -1338,16 +1338,20 @@ void rebuild_manifest_root_values(ManifestModel& model) { model.objects.push_back(object); } - // Compile configured SPC command Data Objects into virtual IEC 61850 + // Compile configured SPC/DPC command Data Objects into virtual IEC 61850 // control-service objects. Structural ST/CF leaves remain sourced from SCL; // CO$SBO/SBOw/Oper/Cancel are service objects owned by the server runtime. + // DPC process stVal remains DBPOS (2-bit BIT STRING); only the CO service + // ctlVal follows the IEDScout wire contract and is BOOLEAN Open/Close. const auto oper_type = direct_boolean_oper_type_specification(); const auto sbow_type = direct_boolean_sbow_type_specification(); const auto cancel_type = direct_boolean_cancel_type_specification(); const auto sbo_type = sbo_reference_type_specification(); std::set> unique_direct_controls; for (const auto& parsed : parsed_controls) { - if (parsed.control_model == 0U || parsed.control_model > 4U || parsed.cdc != "SPC") { + const bool spc = parsed.cdc == "SPC"; + const bool dpc = parsed.cdc == "DPC"; + if (parsed.control_model == 0U || parsed.control_model > 4U || (!spc && !dpc)) { ++model.omitted_direct_controls; continue; } @@ -1364,14 +1368,25 @@ void rebuild_manifest_root_values(ManifestModel& model) { if (!unique_direct_controls.emplace(parsed.domain, oper_item).second) continue; const auto status = model.value_indices.find(object_key(parsed.domain, status_item)); const auto ctl_model = model.value_indices.find(object_key(parsed.domain, ctl_model_item)); - if (status == model.value_indices.end() || ctl_model == model.value_indices.end() || - model.values[status->second].type.kind != mms::MmsTypeKind::boolean) { + if (status == model.value_indices.end() || ctl_model == model.value_indices.end()) { ++model.omitted_direct_controls; continue; } - const auto& encoded_status = model.values[status->second].encoded; - const auto initial = encoded_status.size() >= 3U && encoded_status[0] == 0x83U && - encoded_status.back() != 0U; + const auto& status_value = model.values[status->second]; + const bool supported_status_type = + (spc && status_value.type.kind == mms::MmsTypeKind::boolean) || + (dpc && status_value.type.kind == mms::MmsTypeKind::bit_string && + status_value.type.size.value_or(0U) == 2U); + if (!supported_status_type) { + ++model.omitted_direct_controls; + continue; + } + const bool initial = spc + ? (status_value.encoded.size() >= 3U && + status_value.encoded[0] == 0x83U && + status_value.encoded.back() != 0U) + : (status_value.text == "on" || status_value.text == "closed" || + status_value.text == "2" || status_value.text == "true"); ManifestDirectControlStorage control; control.domain = parsed.domain; control.logical_node = parsed.logical_node; @@ -2028,6 +2043,7 @@ void serve_connection( std::vector> brcb_runtimes; std::vector direct_control_states; std::vector direct_control_bindings; + std::vector direct_control_accept_counts; std::vector direct_control_objects; std::unique_ptr direct_control_table; std::vector> setting_group_runtimes; @@ -2041,6 +2057,7 @@ void serve_connection( if (manifest_model != nullptr && !manifest_model->direct_control_storage.empty()) { direct_control_states.resize(manifest_model->direct_control_storage.size()); direct_control_bindings.resize(manifest_model->direct_control_storage.size()); + direct_control_accept_counts.resize(manifest_model->direct_control_storage.size()); direct_control_objects.assign(object_table.objects().begin(), object_table.objects().end()); static constexpr std::array unsigned_type{0x86U, 0x00U}; @@ -2063,11 +2080,16 @@ void serve_connection( for (auto& object : direct_control_objects) { if (object.domain != control.domain) continue; if (object.item == control.status_item) { - object.read = read_atomic_boolean; - object.context = &control.shared_state->value; - object.write = nullptr; - object.write_context = nullptr; - object.contextual_write = nullptr; + if (control.cdc == "SPC") { + object.read = read_atomic_boolean; + object.context = &control.shared_state->value; + object.write = nullptr; + object.write_context = nullptr; + object.contextual_write = nullptr; + } + // DPC keeps the SCL-derived DBPOS read callback/type. Oper + // synchronizes its ManifestValue below so Read and reports + // both expose the correct 2-bit process state. status_found = true; } else if (object.item == control.ctl_model_item) { object.type_specification = std::span{unsigned_type}; @@ -2483,6 +2505,57 @@ void serve_connection( return; } + // A successful control Oper is also a process-state transition in the + // simulator. Synchronize the underlying SCL ManifestValue so exact + // process Read plus whole-FCD BRCB/URCB feedback observe the same state. + if (manifest_model != nullptr) { + for (std::size_t index = 0U; index < direct_control_bindings.size(); ++index) { + auto& binding = direct_control_bindings[index]; + if (binding.state == nullptr || + index >= direct_control_accept_counts.size() || + index >= manifest_model->direct_control_storage.size()) { + continue; + } + if (binding.state->accepted_operations <= direct_control_accept_counts[index]) { + continue; + } + direct_control_accept_counts[index] = binding.state->accepted_operations; + if (binding.state->last_test) continue; + + const auto& control = manifest_model->direct_control_storage[index]; + const auto found = manifest_model->value_indices.find( + object_key(control.domain, control.status_item)); + if (found == manifest_model->value_indices.end()) continue; + auto& status = manifest_model->values[found->second]; + const auto next_text = control.cdc == "DPC" + ? (binding.state->value != 0U ? std::string{"on"} : std::string{"off"}) + : (binding.state->value != 0U ? std::string{"true"} : std::string{"false"}); + if (status.text != next_text) { + status.text = next_text; + status.data = mms::MmsSimulatorManifestCodec::data( + status.type, status.raw_type, status.normalized_type, status.text); + status.encoded = mms::MmsDataCodec::encode(*status.data); + rebuild_manifest_root_values(*manifest_model); + + const std::array changed{found->second}; + const auto changed_ms = monotonic_ms(); + if (urcb_runtime != nullptr) { + notify_urcb_changes( + *manifest_model, changed, *urcb_runtime, data_sets, changed_ms); + } + notify_brcb_changes( + *manifest_model, changed, brcb_runtimes, changed_ms); + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=control_process_feedback association=" + << association_id << " object=" << control.selection_reference + << " value=" << next_text + << " ctlNum=" + << static_cast(binding.state->last_control_number) + << '\n'; + } + } + } + const auto now_ms = monotonic_ms(); if (manifest_model != nullptr && session.pending_output_bytes() == 0U) { for (std::size_t index = 0U; index < direct_control_bindings.size(); ++index) { @@ -2500,20 +2573,30 @@ void serve_connection( try { const auto origin = std::span{ command.origin_identifier.data(), command.origin_identifier_size}; - auto last_appl_error = mms::MmsDataValue::structure({ - mms::MmsDataValue::visible_string(control.selection_reference), - mms::MmsDataValue::integer(0), + mms::Iec61850UtcTime timestamp; + if (!mms::Iec61850UtcTime::try_from_bytes( + std::span{command.timestamp}, timestamp)) { + throw std::runtime_error("Stored control timestamp is not valid UTC-Time."); + } + std::array check_bits{ + static_cast( + (command.synchro_check ? 0x80U : 0U) | + (command.interlock_check ? 0x40U : 0U))}; + auto command_echo = mms::MmsDataValue::structure({ + mms::MmsDataValue::boolean(command.control_value), mms::MmsDataValue::structure({ - mms::MmsDataValue::unsigned_integer(command.origin_category), + mms::MmsDataValue::integer(command.origin_category), mms::MmsDataValue::octet_string(origin), }), mms::MmsDataValue::unsigned_integer(command.control_number), - mms::MmsDataValue::integer(25), + mms::MmsDataValue::utc_time(timestamp), + mms::MmsDataValue::boolean(command.test), + mms::MmsDataValue::bit_string(6U, check_bits), }); mms::MmsInformationReport report; report.variable_references.push_back( mms::MmsObjectName::domain_specific(control.domain, control.oper_item)); - report.items.push_back({0U, std::move(last_appl_error), std::nullopt}); + report.items.push_back({0U, std::move(command_echo), std::nullopt}); const auto p_data = mms::MmsInformationReportCodec::encode_p_data( report, runtime.mms_presentation_context_id()); const auto cotp = ar::iec61850::osi::CotpFrameCodec::encode_data(p_data); From c440ef1f9f640dd94bd3bb0a0b6e429632c21adc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:01:53 +0700 Subject: [PATCH 04/13] fix(iedsim): accept ARSAS synchro and interlock control checks --- tools/static_ied_server.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp index 174997b02..e269ceb28 100644 --- a/tools/static_ied_server.cpp +++ b/tools/static_ied_server.cpp @@ -2074,6 +2074,10 @@ void serve_connection( binding.sbo_timeout_ms = 10'000U; binding.now_ms = report_now_ms; binding.now_context = nullptr; + // Golden IEDScout accepts ARSAS Check=0xC0 for this simulator + // control path (synchro + interlock requested). + binding.policy.allow_synchro_check = true; + binding.policy.allow_interlock_check = true; bool status_found{}; bool ctl_model_found{}; From 038baebdf14b3bc6e3d3ad74b2fe3651da78379a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:02:28 +0700 Subject: [PATCH 05/13] test(control): cover IEDScout integer origin category --- tests/test_mms_static_direct_control.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_mms_static_direct_control.cpp b/tests/test_mms_static_direct_control.cpp index 998b130c1..87b95839f 100644 --- a/tests/test_mms_static_direct_control.cpp +++ b/tests/test_mms_static_direct_control.cpp @@ -41,15 +41,19 @@ struct TestClock final { const std::uint8_t ctl_num, const bool test, const std::uint8_t check_bits = 0U, - const std::uint8_t origin_category = 2U) { + const std::uint8_t origin_category = 2U, + const bool integer_origin_category = false) { constexpr std::array origin_id{'H', 'M', 'I'}; constexpr std::array no_check{0U}; const std::array check_byte{check_bits}; + const auto encoded_origin_category = integer_origin_category + ? MmsDataValue::integer(origin_category) + : MmsDataValue::unsigned_integer(origin_category); auto oper = MmsDataValue::structure({ MmsDataValue::boolean(value), MmsDataValue::structure({ - MmsDataValue::unsigned_integer(origin_category), + encoded_origin_category, MmsDataValue::octet_string(origin_id), }), MmsDataValue::unsigned_integer(ctl_num), @@ -136,6 +140,17 @@ void valid_oper_updates_live_state() { CHECK(state.rejected_operations == 0U); } +void golden_iedscout_integer_origin_and_checks_decode() { + const auto bytes = make_oper(true, 41U, false, 0xC0U, 2U, true); + MmsStaticDirectBooleanOperate decoded; + CHECK(try_decode_static_direct_boolean_operate(bytes, decoded)); + CHECK(decoded.control_value); + CHECK(decoded.origin_category == 2U); + CHECK(decoded.control_number == 41U); + CHECK(decoded.synchro_check); + CHECK(decoded.interlock_check); +} + void test_oper_is_non_mutating() { MmsStaticDirectBooleanControlState state{}; state.value = 1U; @@ -352,6 +367,7 @@ void read_callbacks_match_mms_types() { int main() { try { valid_oper_updates_live_state(); + golden_iedscout_integer_origin_and_checks_decode(); test_oper_is_non_mutating(); unsupported_check_bits_fail_closed(); invalid_shape_and_values_are_rejected(); From 74dc488b9de060e177f08b1a9e80b7bf66a7a26e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:02:44 +0700 Subject: [PATCH 06/13] test(control): cover DPC boolean command binding --- tests/test_control_mms_structure.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_control_mms_structure.cpp b/tests/test_control_mms_structure.cpp index 17e157950..d3da75427 100644 --- a/tests/test_control_mms_structure.cpp +++ b/tests/test_control_mms_structure.cpp @@ -121,6 +121,26 @@ void dpc_binding_matches_network_bit_order() { CHECK(result.value->raw_value()[1] == 0x40U); // DPC Off == binary 01. } +void dpc_open_close_bind_to_iedscout_boolean_ctlval() { + const auto specification = scalar(MmsTypeKind::boolean, "ctlVal"); + + auto build = MmsControlStructureBuilder::bind_control_value( + ControlValue::double_point(DoublePointValue::on), specification); + CHECK(build.success()); + CHECK(build.value->kind() == MmsDataKind::boolean); + CHECK(std::get(build.value->value())); + + build = MmsControlStructureBuilder::bind_control_value( + ControlValue::double_point(DoublePointValue::off), specification); + CHECK(build.success()); + CHECK(build.value->kind() == MmsDataKind::boolean); + CHECK(!std::get(build.value->value())); + + build = MmsControlStructureBuilder::bind_control_value( + ControlValue::double_point(DoublePointValue::intermediate), specification); + CHECK(build.status == MmsControlBuildStatus::value_out_of_range); +} + void operate_matches_csharp_structure_contract_and_golden_wire() { const auto specification = oper_spec(); auto build = MmsControlStructureBuilder::build_operate(context(), specification); @@ -252,6 +272,7 @@ void step_position_and_analogue_structures_follow_live_shape() { int main() { try { dpc_binding_matches_network_bit_order(); + dpc_open_close_bind_to_iedscout_boolean_ctlval(); operate_matches_csharp_structure_contract_and_golden_wire(); sbow_uses_same_exact_sequence_contract(); cancel_allows_optional_check_but_not_vendor_guessing(); From 9563f2817c71ac3b9abf4d7b66be8a5a2b2fec69 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:04:29 +0700 Subject: [PATCH 07/13] test(iedsim): build golden DPC control interoperability probe --- tools/static_ied_server_iedsim/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/static_ied_server_iedsim/CMakeLists.txt b/tools/static_ied_server_iedsim/CMakeLists.txt index 740ad75cf..09d7bccce 100644 --- a/tools/static_ied_server_iedsim/CMakeLists.txt +++ b/tools/static_ied_server_iedsim/CMakeLists.txt @@ -92,6 +92,13 @@ target_link_libraries(ariec61850_iedscout_parity_urcb_gi_probe PRIVATE target_compile_features(ariec61850_iedscout_parity_urcb_gi_probe PRIVATE cxx_std_20) parity_strict_warnings(ariec61850_iedscout_parity_urcb_gi_probe) +add_executable(ariec61850_iedscout_parity_control_probe + "${ARSTACK_ROOT}/tools/control_interop_probe.cpp") +target_link_libraries(ariec61850_iedscout_parity_control_probe PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_iedscout_parity_control_probe PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_iedscout_parity_control_probe) + enable_testing() add_test(NAME iedscout_parity_server_help COMMAND ariec61850_static_ied_server_iedsim --help) @@ -114,6 +121,14 @@ add_test( --discovery $ --read-probe $ --urcb-probe $) + +add_test( + NAME iedscout_parity_dpc_control + COMMAND + ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_LIST_DIR}/test_control_golden.py" + --server $ + --control-probe $) add_executable(ariec61850_iedscout_parity_file_transfer "${ARSTACK_ROOT}/tools/file_transfer.cpp") target_link_libraries(ariec61850_iedscout_parity_file_transfer PRIVATE ARIEC61850::core) From 6b3bd3a69e5387f585b37647a7e240fa99217344 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:05:10 +0700 Subject: [PATCH 08/13] test(iedsim): add IEDScout DPC command golden parity --- .../test_control_golden.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tools/static_ied_server_iedsim/test_control_golden.py diff --git a/tools/static_ied_server_iedsim/test_control_golden.py b/tools/static_ied_server_iedsim/test_control_golden.py new file mode 100644 index 000000000..a956d04d7 --- /dev/null +++ b/tools/static_ied_server_iedsim/test_control_golden.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 + +import argparse +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def creation_flags() -> int: + return getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + + +def manifest() -> str: + return "\n".join( + [ + "ARSTACK_IED_MODEL\t2\t1", + "LN\tTESTIEDLD0\tCSWI1", + "OBJ\tTESTIEDLD0\tCSWI1$ST$Pos$stVal\tDBPOS\tEnumeration\toff", + "OBJ\tTESTIEDLD0\tCSWI1$ST$Pos$q\tQUALITY\tQuality\tgood", + "OBJ\tTESTIEDLD0\tCSWI1$ST$Pos$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + "OBJ\tTESTIEDLD0\tCSWI1$CF$Pos$ctlModel\tENUM\tEnumeration\t4", + "CTL\tTESTIEDLD0\tCSWI1\tPos\tDPC\t4", + "", + ] + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--control-probe", required=True) + args = parser.parse_args() + + port = free_port() + with tempfile.TemporaryDirectory(prefix="arstack-iedsim-control-") as directory: + root = Path(directory) + model = root / "control.model" + model.write_text(manifest(), encoding="utf-8") + stdout_path = root / "server.stdout.log" + stderr_path = root / "server.stderr.log" + + with stdout_path.open("w+", encoding="utf-8") as stdout_handle, \ + stderr_path.open("w+", encoding="utf-8") as stderr_handle: + server = subprocess.Popen( + [ + args.server, + "--host", + "127.0.0.1", + "--port", + str(port), + "--model-manifest", + str(model), + "--max-connections", + "8", + "--max-active", + "8", + ], + stdout=stdout_handle, + stderr=stderr_handle, + text=True, + creationflags=creation_flags(), + ) + try: + time.sleep(0.35) + probe = subprocess.run( + [ + args.control_probe, + "127.0.0.1", + str(port), + "--object", + "TESTIEDLD0/CSWI1.Pos", + "--action", + "select-operate", + "--value", + "on", + "--value-kind", + "dpc", + "--origin", + "ARSAS", + "--origin-category", + "station", + "--interlock-check", + "on", + "--synchro-check", + "on", + "--termination-timeout-ms", + "3000", + "--timeout-ms", + "5000", + "--arm", + "IEC61850-LAB-CONTROL", + ], + capture_output=True, + text=True, + timeout=15, + check=False, + creationflags=creation_flags(), + ) + if probe.returncode != 0: + raise RuntimeError( + f"control probe failed (exit={probe.returncode}):\n" + f"{probe.stdout}\n{probe.stderr}" + ) + required = [ + "ctlModel=sbo-enhanced", + "cdc=DPC", + "STATUS_BEFORE 0x0640", + "CONTROL_RESULT action=operate completion=positive-termination " + "accepted=true termination=true", + "NO_RETRY_EVIDENCE controlWrites=2", + "STATUS_AFTER 0x0680", + ] + missing = [item for item in required if item not in probe.stdout] + if missing: + raise RuntimeError( + f"golden DPC control evidence missing {missing!r}:\n{probe.stdout}" + ) + finally: + server.kill() + server.wait(timeout=8) + + server_stdout = stdout_path.read_text(encoding="utf-8", errors="replace") + server_stderr = stderr_path.read_text(encoding="utf-8", errors="replace") + if ( + "kind=command_termination" not in server_stdout + or "positive=true" not in server_stdout + or "kind=control_process_feedback" not in server_stdout + or "value=on" not in server_stdout + ): + raise RuntimeError( + "server did not expose golden command-termination/process-feedback evidence:\n" + f"{server_stdout}\n{server_stderr}" + ) + + print( + "IEDSCOUT_DPC_CONTROL_GOLDEN_PASS " + "sequence=SBOw,Oper,CommandTermination,ProcessFeedback " + "ctlVal=boolean process=DBPOS2 originCat=integer " + "check=sync+interlock" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f4fb174c8156cc0a144b325e55d77ca3612d798d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:26:33 +0700 Subject: [PATCH 09/13] test(iedsim): distinguish DPC engineering CDC from boolean control wire --- tools/static_ied_server_iedsim/test_control_golden.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/static_ied_server_iedsim/test_control_golden.py b/tools/static_ied_server_iedsim/test_control_golden.py index a956d04d7..a3222fb07 100644 --- a/tools/static_ied_server_iedsim/test_control_golden.py +++ b/tools/static_ied_server_iedsim/test_control_golden.py @@ -44,7 +44,10 @@ def main() -> int: with tempfile.TemporaryDirectory(prefix="arstack-iedsim-control-") as directory: root = Path(directory) model = root / "control.model" - model.write_text(manifest(), encoding="utf-8") + model_text = manifest() + if "CTL\tTESTIEDLD0\tCSWI1\tPos\tDPC\t4" not in model_text: + raise RuntimeError("golden fixture lost engineering CDC=DPC control metadata") + model.write_text(model_text, encoding="utf-8") stdout_path = root / "server.stdout.log" stderr_path = root / "server.stderr.log" @@ -112,7 +115,8 @@ def main() -> int: ) required = [ "ctlModel=sbo-enhanced", - "cdc=DPC", + "DISCOVERY_EVIDENCE Oper=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string)", + "DISCOVERY_EVIDENCE SBOw=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string)", "STATUS_BEFORE 0x0640", "CONTROL_RESULT action=operate completion=positive-termination " "accepted=true termination=true", From 13d18a925d718ad7974a9a653904410c0641ccd2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:26:37 +0700 Subject: [PATCH 10/13] test(iedsim): accept IEDScout synchro-interlock Check semantics --- apps/ied_simulator/test_gui_live_value.py | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/ied_simulator/test_gui_live_value.py b/apps/ied_simulator/test_gui_live_value.py index 21b2aa382..58596d530 100644 --- a/apps/ied_simulator/test_gui_live_value.py +++ b/apps/ied_simulator/test_gui_live_value.py @@ -263,8 +263,10 @@ def run_direct_normal_control_regression( f"exit={discovery.returncode} stdout={discovery.stdout!r} stderr={discovery.stderr!r}" ) - # Prove fail-closed Check handling before the accepted command. - rejected = subprocess.run( + # IEDScout accepts ARSAS Check=0xC0 (synchro + interlock requested). + # Keep this compatibility explicit instead of treating valid Check bits as + # an object-value-invalid condition. + checked = subprocess.run( common + [ "--action", @@ -273,6 +275,10 @@ def run_direct_normal_control_regression( "on", "--value-kind", "bool", + "--interlock-check", + "on", + "--synchro-check", + "on", "--arm", "IEC61850-LAB-CONTROL", ], @@ -283,14 +289,15 @@ def run_direct_normal_control_regression( creationflags=creation_flags(), ) if ( - rejected.returncode != 4 - or "accepted=false" not in rejected.stdout - or "mmsFailure=11:object-value-invalid" not in rejected.stdout - or "STATUS_AFTER false" not in rejected.stdout + checked.returncode != 0 + or "completion=accepted" not in checked.stdout + or "accepted=true" not in checked.stdout + or "STATUS_AFTER true" not in checked.stdout + or "NO_RETRY_EVIDENCE controlWrites=1" not in checked.stdout ): raise RuntimeError( - "Direct-Normal fail-closed check-bit regression failed: " - f"exit={rejected.returncode} stdout={rejected.stdout!r} stderr={rejected.stderr!r}" + "Direct-Normal IEDScout Check=0xC0 compatibility regression failed: " + f"exit={checked.returncode} stdout={checked.stdout!r} stderr={checked.stderr!r}" ) accepted = subprocess.run( @@ -333,7 +340,7 @@ def run_direct_normal_control_regression( "Direct-Normal process status did not persist for a second external association: " f"exit={status.returncode} stdout={status.stdout!r} stderr={status.stderr!r}" ) - return discovery.stdout.strip() + "\n" + rejected.stdout.strip() + "\n" + accepted.stdout.strip() + return discovery.stdout.strip() + "\n" + checked.stdout.strip() + "\n" + accepted.stdout.strip() def run_control_action( From 5ccea84a206b93bee3941127b8cd8a0e8a893d59 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:58:40 +0700 Subject: [PATCH 11/13] fix(iedsim): order command termination before process feedback --- tools/static_ied_server.cpp | 103 +++++++++--------- .../test_control_golden.py | 9 +- 2 files changed, 58 insertions(+), 54 deletions(-) diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp index e269ceb28..c6fabd725 100644 --- a/tools/static_ied_server.cpp +++ b/tools/static_ied_server.cpp @@ -2509,57 +2509,6 @@ void serve_connection( return; } - // A successful control Oper is also a process-state transition in the - // simulator. Synchronize the underlying SCL ManifestValue so exact - // process Read plus whole-FCD BRCB/URCB feedback observe the same state. - if (manifest_model != nullptr) { - for (std::size_t index = 0U; index < direct_control_bindings.size(); ++index) { - auto& binding = direct_control_bindings[index]; - if (binding.state == nullptr || - index >= direct_control_accept_counts.size() || - index >= manifest_model->direct_control_storage.size()) { - continue; - } - if (binding.state->accepted_operations <= direct_control_accept_counts[index]) { - continue; - } - direct_control_accept_counts[index] = binding.state->accepted_operations; - if (binding.state->last_test) continue; - - const auto& control = manifest_model->direct_control_storage[index]; - const auto found = manifest_model->value_indices.find( - object_key(control.domain, control.status_item)); - if (found == manifest_model->value_indices.end()) continue; - auto& status = manifest_model->values[found->second]; - const auto next_text = control.cdc == "DPC" - ? (binding.state->value != 0U ? std::string{"on"} : std::string{"off"}) - : (binding.state->value != 0U ? std::string{"true"} : std::string{"false"}); - if (status.text != next_text) { - status.text = next_text; - status.data = mms::MmsSimulatorManifestCodec::data( - status.type, status.raw_type, status.normalized_type, status.text); - status.encoded = mms::MmsDataCodec::encode(*status.data); - rebuild_manifest_root_values(*manifest_model); - - const std::array changed{found->second}; - const auto changed_ms = monotonic_ms(); - if (urcb_runtime != nullptr) { - notify_urcb_changes( - *manifest_model, changed, *urcb_runtime, data_sets, changed_ms); - } - notify_brcb_changes( - *manifest_model, changed, brcb_runtimes, changed_ms); - std::osyncstream{std::cout} - << "IEDSIM_EVENT kind=control_process_feedback association=" - << association_id << " object=" << control.selection_reference - << " value=" << next_text - << " ctlNum=" - << static_cast(binding.state->last_control_number) - << '\n'; - } - } - } - const auto now_ms = monotonic_ms(); if (manifest_model != nullptr && session.pending_output_bytes() == 0U) { for (std::size_t index = 0U; index < direct_control_bindings.size(); ++index) { @@ -2631,6 +2580,58 @@ void serve_connection( break; } } + // Match the IEDScout enhanced-control wire lifecycle: positive Oper + // response first, then positive CommandTermination, then commit process + // state and notify RCB feedback. Direct-Normal controls have no pending + // termination, so they still commit in this same loop iteration. + if (manifest_model != nullptr) { + for (std::size_t index = 0U; index < direct_control_bindings.size(); ++index) { + auto& binding = direct_control_bindings[index]; + if (binding.state == nullptr || + index >= direct_control_accept_counts.size() || + index >= manifest_model->direct_control_storage.size()) { + continue; + } + if (binding.state->accepted_operations <= direct_control_accept_counts[index]) { + continue; + } + direct_control_accept_counts[index] = binding.state->accepted_operations; + if (binding.state->last_test) continue; + + const auto& control = manifest_model->direct_control_storage[index]; + const auto found = manifest_model->value_indices.find( + object_key(control.domain, control.status_item)); + if (found == manifest_model->value_indices.end()) continue; + auto& status = manifest_model->values[found->second]; + const auto next_text = control.cdc == "DPC" + ? (binding.state->value != 0U ? std::string{"on"} : std::string{"off"}) + : (binding.state->value != 0U ? std::string{"true"} : std::string{"false"}); + if (status.text != next_text) { + status.text = next_text; + status.data = mms::MmsSimulatorManifestCodec::data( + status.type, status.raw_type, status.normalized_type, status.text); + status.encoded = mms::MmsDataCodec::encode(*status.data); + rebuild_manifest_root_values(*manifest_model); + + const std::array changed{found->second}; + const auto changed_ms = monotonic_ms(); + if (urcb_runtime != nullptr) { + notify_urcb_changes( + *manifest_model, changed, *urcb_runtime, data_sets, changed_ms); + } + notify_brcb_changes( + *manifest_model, changed, brcb_runtimes, changed_ms); + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=control_process_feedback association=" + << association_id << " object=" << control.selection_reference + << " value=" << next_text + << " ctlNum=" + << static_cast(binding.state->last_control_number) + << '\n'; + } + } + } + if (!brcb_runtimes.empty()) { const auto binary_time = report_binary_time(); for (auto& brcb : brcb_runtimes) { diff --git a/tools/static_ied_server_iedsim/test_control_golden.py b/tools/static_ied_server_iedsim/test_control_golden.py index a3222fb07..60ce9132d 100644 --- a/tools/static_ied_server_iedsim/test_control_golden.py +++ b/tools/static_ied_server_iedsim/test_control_golden.py @@ -134,14 +134,17 @@ def main() -> int: server_stdout = stdout_path.read_text(encoding="utf-8", errors="replace") server_stderr = stderr_path.read_text(encoding="utf-8", errors="replace") + termination_index = server_stdout.find("kind=command_termination") + feedback_index = server_stdout.find("kind=control_process_feedback") if ( - "kind=command_termination" not in server_stdout + termination_index < 0 + or feedback_index < 0 or "positive=true" not in server_stdout - or "kind=control_process_feedback" not in server_stdout or "value=on" not in server_stdout + or termination_index >= feedback_index ): raise RuntimeError( - "server did not expose golden command-termination/process-feedback evidence:\n" + "server did not preserve golden CommandTermination-before-process-feedback order:\n" f"{server_stdout}\n{server_stderr}" ) From e9e823632bae160ae8ae51adb989d9c7d60d846d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:59:25 +0700 Subject: [PATCH 12/13] test(iedsim): pin IEDScout Cancel control type parity --- tools/static_ied_server_iedsim/test_control_golden.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/static_ied_server_iedsim/test_control_golden.py b/tools/static_ied_server_iedsim/test_control_golden.py index 60ce9132d..3e74408f3 100644 --- a/tools/static_ied_server_iedsim/test_control_golden.py +++ b/tools/static_ied_server_iedsim/test_control_golden.py @@ -117,6 +117,7 @@ def main() -> int: "ctlModel=sbo-enhanced", "DISCOVERY_EVIDENCE Oper=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string)", "DISCOVERY_EVIDENCE SBOw=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string)", + "DISCOVERY_EVIDENCE Cancel=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean)", "STATUS_BEFORE 0x0640", "CONTROL_RESULT action=operate completion=positive-termination " "accepted=true termination=true", From 3205d448b473afafd2d5f79f36f7d659408d38f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 16:08:00 +0700 Subject: [PATCH 13/13] fix(iedsim): defer process feedback until command termination --- tools/static_ied_server.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp index c6fabd725..d3006673d 100644 --- a/tools/static_ied_server.cpp +++ b/tools/static_ied_server.cpp @@ -2595,6 +2595,13 @@ void serve_connection( if (binding.state->accepted_operations <= direct_control_accept_counts[index]) { continue; } + // Enhanced controls must expose positive CommandTermination before + // committing process feedback. While the Oper WriteResponse is + // still draining, pending_termination remains true and the status + // transition is intentionally deferred to the next server loop. + if (binding.state->pending_termination) { + continue; + } direct_control_accept_counts[index] = binding.state->accepted_operations; if (binding.state->last_test) continue;