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( 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); } 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; } 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(); 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(); diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp index 2c4c881d5..d3006673d 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}; @@ -2057,17 +2074,26 @@ 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{}; 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}; @@ -2500,20 +2526,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); @@ -2544,6 +2580,65 @@ 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; + } + // 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; + + 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/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) 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..3e74408f3 --- /dev/null +++ b/tools/static_ied_server_iedsim/test_control_golden.py @@ -0,0 +1,162 @@ +#!/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_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" + + 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", + "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", + "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") + termination_index = server_stdout.find("kind=command_termination") + feedback_index = server_stdout.find("kind=control_process_feedback") + if ( + termination_index < 0 + or feedback_index < 0 + or "positive=true" not in server_stdout + or "value=on" not in server_stdout + or termination_index >= feedback_index + ): + raise RuntimeError( + "server did not preserve golden CommandTermination-before-process-feedback order:\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())