diff --git a/.github/p0_3_patch.py b/.github/p0_3_patch.py new file mode 100644 index 000000000..9fe0d3d08 --- /dev/null +++ b/.github/p0_3_patch.py @@ -0,0 +1,566 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text() + + +def write(path: str, text: str) -> None: + Path(path).write_text(text) + + +def replace_once(path: str, old: str, new: str) -> None: + text = read(path) + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one match, found {count}: {old[:120]!r}") + write(path, text.replace(old, new, 1)) + + +def replace_between(path: str, start: str, end: str, new: str) -> None: + text = read(path) + a = text.find(start) + if a < 0: + raise SystemExit(f"{path}: start marker not found: {start!r}") + b = text.find(end, a + len(start)) + if b < 0: + raise SystemExit(f"{path}: end marker not found: {end!r}") + if text.find(start, a + 1) >= 0: + raise SystemExit(f"{path}: start marker not unique: {start!r}") + write(path, text[:a] + new + text[b:]) + + +# Shared, allocation-free COTP DT segmentation + TPKT framing. One source of +# truth is used by association/confirmed responses and unsolicited URCB/BRCB. +stream_header = r'''// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { + +struct CotpTpktDataStreamPlan final { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + std::size_t required_bytes{}; +}; + +class CotpTpktDataStreamSpanCodec final { +public: + [[nodiscard]] static bool try_plan( + const std::size_t user_data_bytes, + const std::size_t negotiated_tpdu_size_bytes, + CotpTpktDataStreamPlan& plan) noexcept { + plan = {}; + if (negotiated_tpdu_size_bytes <= 3U || + negotiated_tpdu_size_bytes > TpktSpanCodec::maximum_payload_bytes) { + return false; + } + + plan.maximum_user_data = negotiated_tpdu_size_bytes - 3U; + plan.segment_count = user_data_bytes == 0U + ? 1U + : 1U + ((user_data_bytes - 1U) / plan.maximum_user_data); + constexpr std::size_t per_segment_overhead = + TpktSpanCodec::header_length + 3U; + if (plan.segment_count > + (std::numeric_limits::max() - user_data_bytes) / + per_segment_overhead) { + plan = {}; + return false; + } + plan.required_bytes = user_data_bytes + + plan.segment_count * per_segment_overhead; + return true; + } + + // Encode one TSDU as one or more complete TPKT frames. Each frame contains + // one COTP Data TPDU whose total TPDU size never exceeds the negotiated C0 + // value. EOT is clear on intermediate segments and set only on the final + // segment. No heap allocation or hidden queue is used. + [[nodiscard]] static wire::EncodeResult encode_into( + const std::span user_data, + const std::size_t negotiated_tpdu_size_bytes, + const std::span destination) noexcept { + CotpTpktDataStreamPlan plan; + if (!try_plan(user_data.size(), negotiated_tpdu_size_bytes, plan)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < plan.required_bytes) { + return {wire::EncodeStatus::buffer_too_small, 0U, plan.required_bytes}; + } + + std::size_t input_offset{}; + std::size_t output_offset{}; + for (std::size_t segment = 0U; segment < plan.segment_count; ++segment) { + const auto remaining = user_data.size() - input_offset; + const auto chunk = std::min(remaining, plan.maximum_user_data); + const bool final_segment = segment + 1U == plan.segment_count; + const auto frame_bytes = TpktSpanCodec::header_length + 3U + chunk; + if (frame_bytes > TpktSpanCodec::maximum_frame_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, plan.required_bytes}; + } + + auto frame = destination.subspan(output_offset, frame_bytes); + const auto cotp = CotpSpanCodec::encode_data_into( + user_data.subspan(input_offset, chunk), + frame.subspan(TpktSpanCodec::header_length), + final_segment, + 0U); + if (!cotp.success() || cotp.bytes_written != chunk + 3U) { + return {wire::EncodeStatus::value_out_of_range, 0U, plan.required_bytes}; + } + + frame[0] = TpktSpanCodec::supported_version; + frame[1] = 0x00U; + frame[2] = static_cast((frame_bytes >> 8U) & 0xFFU); + frame[3] = static_cast(frame_bytes & 0xFFU); + input_offset += chunk; + output_offset += frame_bytes; + } + + if (input_offset != user_data.size() || output_offset != plan.required_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, plan.required_bytes}; + } + return {wire::EncodeStatus::ok, plan.required_bytes, plan.required_bytes}; + } +}; + +} // namespace ar::iec61850::osi +''' +stream_path = Path("include/ariec61850/osi/cotp_tpkt_stream.hpp") +if stream_path.exists(): + raise SystemExit(f"{stream_path}: already exists") +stream_path.write_text(stream_header) + + +# Core connection runtime: replace single-TPDU rejection with the shared stream +# encoder and make capacity probes segmentation-aware. +replace_once( + "src/mms/connection_runtime.cpp", + '#include "ariec61850/osi/cotp_span.hpp"\n', + '#include "ariec61850/osi/cotp_span.hpp"\n#include "ariec61850/osi/cotp_tpkt_stream.hpp"\n') + +core_wrap = r'''[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response( + const std::span session_or_presentation, + const std::size_t consumed, + const MmsStaticConnectionState state, + const std::size_t negotiated_tpdu_size_bytes, + const std::span response) noexcept { + const auto encoded = osi::CotpTpktDataStreamSpanCodec::encode_into( + session_or_presentation, negotiated_tpdu_size_bytes, response); + if (!encoded.success()) { + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity(state, encoded.required_bytes); + } + return make_result( + negotiated_tpdu_size_bytes == 0U + ? MmsStaticConnectionStatus::peer_limit_exceeded + : MmsStaticConnectionStatus::backend_failure, + state, + consumed); + } + return make_result( + MmsStaticConnectionStatus::response_ready, + state, + consumed, + encoded.bytes_written); +} + +[[nodiscard]] bool framed_mms_response_size( + const std::uint32_t presentation_context_id, + const std::size_t mms_bytes, + const std::size_t negotiated_tpdu_size_bytes, + std::size_t& required) noexcept { + required = 0U; + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, mms_bytes); + osi::CotpTpktDataStreamPlan plan; + if (!fully_encoded || + !osi::CotpTpktDataStreamSpanCodec::try_plan( + *fully_encoded, negotiated_tpdu_size_bytes, plan)) { + return false; + } + required = plan.required_bytes; + return true; +} + +''' +replace_between( + "src/mms/connection_runtime.cpp", + "[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response(", + "[[nodiscard]] MmsConfirmedRequestRejectReason reject_reason_for(", + core_wrap) + +core_capacity = r'''[[nodiscard]] bool write_outer_capacity( + const MmsConfirmedPduView& confirmed, + const MmsStaticApplicationDispatcher& dispatcher, + const std::uint32_t presentation_context_id, + const std::size_t negotiated_tpdu_size_bytes, + std::size_t& mms_required, + std::size_t& frame_required) noexcept { + mms_required = 0U; + frame_required = 0U; + if (confirmed.service() != MmsWireConfirmedService::write) { + return false; + } + + MmsWriteRequestView write; + if (!MmsServiceSpanCodec::try_decode_write_request(confirmed, write) || + write.variable_count == 0U || + write.variable_count > dispatcher.policy().maximum_write_variables) { + return false; + } + + std::array worst{}; + for (std::size_t index = 0U; index < write.variable_count; ++index) { + worst[index] = MmsWriteAccessResultInput{ + false, + std::numeric_limits::max()}; + } + const auto probe = MmsServiceSpanCodec::encode_write_response_into( + confirmed.invoke_id, + std::span{worst}.first(write.variable_count), + {}); + if (probe.status != wire::EncodeStatus::buffer_too_small || + probe.required_bytes == 0U) { + return false; + } + mms_required = probe.required_bytes; + return framed_mms_response_size( + presentation_context_id, + probe.required_bytes, + negotiated_tpdu_size_bytes, + frame_required); +} +''' +replace_between( + "src/mms/connection_runtime.cpp", + "[[nodiscard]] bool write_outer_capacity(", + "\n}\n\n} // namespace", + core_capacity) + +replace_once( + "src/mms/connection_runtime.cpp", + " mms_presentation_context_id_,\n write_mms_required,", + " mms_presentation_context_id_,\n negotiated_tpdu_size_bytes_,\n write_mms_required,") +replace_once( + "src/mms/connection_runtime.cpp", + " if (negotiated_mms_pdu_size_ == 0U ||\n write_mms_required > negotiated_mms_pdu_size_ ||\n write_frame_required < osi::TpktSpanCodec::header_length ||\n write_frame_required - osi::TpktSpanCodec::header_length >\n negotiated_tpdu_size_bytes_) {", + " if (negotiated_mms_pdu_size_ == 0U ||\n write_mms_required > negotiated_mms_pdu_size_) {") +replace_once( + "src/mms/connection_runtime.cpp", + " response,\n workspace);\n if (!wrapped.response_ready()) {", + " response);\n if (!wrapped.response_ready()) {") +replace_once( + "src/mms/connection_runtime.cpp", + " response,\n workspace);\n if (wrapped.response_ready()) {", + " response);\n if (wrapped.response_ready()) {") +replace_once( + "src/mms/connection_runtime.cpp", + " const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size(\n mms_presentation_context_id_, application.required_bytes);\n std::size_t required{};\n if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) {", + " std::size_t required{};\n if (framed_mms_response_size(\n mms_presentation_context_id_,\n application.required_bytes,\n negotiated_tpdu_size_bytes_,\n required)) {") +replace_once( + "src/mms/connection_runtime.cpp", + " const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size(\n mms_presentation_context_id_, rejected.required_bytes);\n std::size_t required{};\n if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) {", + " std::size_t required{};\n if (framed_mms_response_size(\n mms_presentation_context_id_,\n rejected.required_bytes,\n negotiated_tpdu_size_bytes_,\n required)) {") +replace_once( + "src/mms/connection_runtime.cpp", + " if (p_data.status == wire::EncodeStatus::buffer_too_small) {\n std::size_t required{};\n if (add_overhead(p_data.required_bytes, 7U, required)) {\n return make_workspace_capacity(state_, required);\n }\n }", + " if (p_data.status == wire::EncodeStatus::buffer_too_small) {\n return make_workspace_capacity(state_, p_data.required_bytes);\n }") + + +# IED-simulator runtime already had local segmentation. Route its planning and +# encoding through the same transport primitive so strict/core and simulator do +# not drift again. +replace_once( + "src/mms/connection_runtime_iedsim.cpp", + '#include "ariec61850/osi/cotp_span.hpp"\n', + '#include "ariec61850/osi/cotp_span.hpp"\n#include "ariec61850/osi/cotp_tpkt_stream.hpp"\n') +iedsim_plan = r'''[[nodiscard]] bool cotp_stream_size( + const std::size_t payload_bytes, + const std::size_t negotiated_tpdu_size_bytes, + std::size_t& maximum_user_data, + std::size_t& segment_count, + std::size_t& required) noexcept { + osi::CotpTpktDataStreamPlan plan; + if (!osi::CotpTpktDataStreamSpanCodec::try_plan( + payload_bytes, negotiated_tpdu_size_bytes, plan)) { + maximum_user_data = 0U; + segment_count = 0U; + required = 0U; + return false; + } + maximum_user_data = plan.maximum_user_data; + segment_count = plan.segment_count; + required = plan.required_bytes; + return true; +} + +''' +replace_between( + "src/mms/connection_runtime_iedsim.cpp", + "[[nodiscard]] bool cotp_stream_size(", + "[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response(", + iedsim_plan) +iedsim_wrap = r'''[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response( + const std::span session_or_presentation, + const std::size_t consumed, + const MmsStaticConnectionState state, + const std::size_t negotiated_tpdu_size_bytes, + const std::span response) noexcept { + const auto encoded = osi::CotpTpktDataStreamSpanCodec::encode_into( + session_or_presentation, negotiated_tpdu_size_bytes, response); + if (!encoded.success()) { + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity(state, encoded.required_bytes); + } + return make_result( + negotiated_tpdu_size_bytes == 0U + ? MmsStaticConnectionStatus::peer_limit_exceeded + : MmsStaticConnectionStatus::backend_failure, + state, + consumed); + } + return make_result( + MmsStaticConnectionStatus::response_ready, + state, + consumed, + encoded.bytes_written); +} + +''' +replace_between( + "src/mms/connection_runtime_iedsim.cpp", + "[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response(", + "[[nodiscard]] MmsStaticConnectionResult application_rejected(", + iedsim_wrap) + + +# URCB unsolicited InformationReport: segment after Presentation wrapping and +# only commit SqNum/GI/integrity state after the complete stream is staged. +replace_once( + "src/mms/static_report_connection.cpp", + '#include "ariec61850/osi/cotp_span.hpp"\n', + '#include "ariec61850/osi/cotp_span.hpp"\n#include "ariec61850/osi/cotp_tpkt_stream.hpp"\n') +replace_once( + "src/mms/static_report_connection.cpp", + "[[nodiscard]] bool final_frame_size(\n const std::uint32_t presentation_context_id,\n const std::size_t mms_bytes,\n std::size_t& required) noexcept {\n const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size(\n presentation_context_id, mms_bytes);\n return fully_encoded && add_size(*fully_encoded, 11U, required);\n}", + "[[nodiscard]] bool final_frame_size(\n const std::uint32_t presentation_context_id,\n const std::size_t mms_bytes,\n const std::size_t negotiated_tpdu_size_bytes,\n std::size_t& required) noexcept {\n required = 0U;\n const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size(\n presentation_context_id, mms_bytes);\n osi::CotpTpktDataStreamPlan plan;\n if (!fully_encoded ||\n !osi::CotpTpktDataStreamSpanCodec::try_plan(\n *fully_encoded, negotiated_tpdu_size_bytes, plan)) {\n return false;\n }\n required = plan.required_bytes;\n return true;\n}") +replace_once( + "src/mms/static_report_connection.cpp", + " connection.mms_presentation_context_id(),\n encoded.required_bytes,\n final_required)) {", + " connection.mms_presentation_context_id(),\n encoded.required_bytes,\n connection.negotiated_tpdu_size_bytes(),\n final_required)) {") +report_stream = r''' const auto framed = osi::CotpTpktDataStreamSpanCodec::encode_into( + workspace.first(p_data.bytes_written), + connection.negotiated_tpdu_size_bytes(), + response); + if (!framed.success()) { + if (framed.status == wire::EncodeStatus::buffer_too_small) { + return response_capacity( + plan, MmsStaticUrcbStatus::response_buffer_too_small, + framed.required_bytes); + } + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + +''' +replace_between( + "src/mms/static_report_connection.cpp", + " std::size_t final_required{};\n if (!add_size(p_data.bytes_written, 7U, final_required))", + " // Commit only after the complete TPKT image exists.", + report_stream) +replace_once( + "src/mms/static_report_connection.cpp", + " // Commit only after the complete TPKT image exists. If capacity failed at\n", + " // Commit only after the complete segmented COTP/TPKT stream exists. If capacity failed at\n") +replace_once( + "src/mms/static_report_connection.cpp", + " std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin());\n auto result = make_result(\n", + " auto result = make_result(\n") +replace_once( + "src/mms/static_report_connection.cpp", + " result.bytes_written = tpkt.bytes_written;\n result.required_response_bytes = final_required;\n result.required_workspace_bytes = final_required;", + " result.bytes_written = framed.bytes_written;\n result.required_response_bytes = framed.required_bytes;\n result.required_workspace_bytes = p_data.bytes_written;") + + +# BRCB retained delivery: same segmentation semantics, while the MMS PDU limit +# remains a separate hard peer limit. +replace_once( + "src/mms/static_brcb_connection.cpp", + '#include "ariec61850/osi/cotp_span.hpp"\n', + '#include "ariec61850/osi/cotp_span.hpp"\n#include "ariec61850/osi/cotp_tpkt_stream.hpp"\n') +replace_once( + "src/mms/static_brcb_connection.cpp", + " std::size_t final_required{};\n if (!add_size(p_data.required_bytes, 7U, final_required)) {\n return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry);\n }\n auto result = make_result(\n MmsStaticBrcbConnectionStatus::workspace_too_small, &entry);\n result.required_workspace_bytes = final_required;\n result.required_response_bytes = final_required;", + " osi::CotpTpktDataStreamPlan plan;\n if (!osi::CotpTpktDataStreamSpanCodec::try_plan(\n p_data.required_bytes, connection.negotiated_tpdu_size_bytes(), plan)) {\n return make_result(MmsStaticBrcbConnectionStatus::peer_limit_exceeded, &entry);\n }\n auto result = make_result(\n MmsStaticBrcbConnectionStatus::workspace_too_small, &entry);\n result.required_workspace_bytes = p_data.required_bytes;\n result.required_response_bytes = plan.required_bytes;") +brcb_stream = r''' const auto framed = osi::CotpTpktDataStreamSpanCodec::encode_into( + workspace.first(p_data.bytes_written), + connection.negotiated_tpdu_size_bytes(), + response); + if (!framed.success()) { + if (framed.status == wire::EncodeStatus::buffer_too_small) { + auto result = make_result( + MmsStaticBrcbConnectionStatus::response_buffer_too_small, &entry); + result.required_response_bytes = framed.required_bytes; + return result; + } + return make_result(MmsStaticBrcbConnectionStatus::peer_limit_exceeded, &entry); + } + +''' +replace_between( + "src/mms/static_brcb_connection.cpp", + " std::size_t cotp_required{};\n if (!add_size(p_data.bytes_written, 3U, cotp_required))", + " auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry);", + brcb_stream) +replace_once( + "src/mms/static_brcb_connection.cpp", + " std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin());\n result.bytes_written = tpkt.bytes_written;\n result.required_response_bytes = final_required;\n result.required_workspace_bytes = final_required;", + " result.bytes_written = framed.bytes_written;\n result.required_response_bytes = framed.required_bytes;\n result.required_workspace_bytes = p_data.bytes_written;") + + +# Deterministic regression: 128-byte negotiated TPDU must produce a stream of +# bounded TPKTs instead of peer_limit_exceeded. Keep MMS max-PDU rejection tests. +helper = r'''[[nodiscard]] bool validate_segmented_stream( + const std::span bytes, + const std::size_t negotiated_tpdu_size_bytes, + const bool require_multiple) noexcept { + std::size_t offset{}; + std::size_t segments{}; + while (offset < bytes.size()) { + const auto remaining = bytes.subspan(offset); + const auto peek = osi::TpktSpanCodec::peek_frame(remaining); + if (!peek.ready() || peek.frame_bytes == 0U || + peek.frame_bytes > negotiated_tpdu_size_bytes + osi::TpktSpanCodec::header_length) { + return false; + } + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(remaining.first(peek.frame_bytes), tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) || + cotp.kind != osi::CotpWireKind::data) { + return false; + } + offset += peek.frame_bytes; + ++segments; + if ((offset == bytes.size()) != cotp.end_of_transmission) { + return false; + } + } + return offset == bytes.size() && segments != 0U && + (!require_multiple || segments > 1U); +} + +''' +replace_once( + "embedded/mms_negotiated_limits_hard_profile_smoke.cpp", + "[[nodiscard]] bool set_maximum_mms_pdu(\n", + helper + "[[nodiscard]] bool set_maximum_mms_pdu(\n") +replace_once( + "embedded/mms_negotiated_limits_hard_profile_smoke.cpp", + " // A 128-byte TPDU cannot carry the fixed association-accept TSDU as one DT\n // TPDU. Until outbound COTP segmentation is implemented, reject it rather\n // than violate the negotiated limit.", + " // A 128-byte negotiated TPDU must segment the association accept and\n // later confirmed responses into bounded TPKTs instead of rejecting them.") +replace_once( + "embedded/mms_negotiated_limits_hard_profile_smoke.cpp", + " if (tiny_association.status != mms::MmsStaticConnectionStatus::peer_limit_exceeded ||\n tiny_association.bytes_written != 0U ||\n tiny_tpdu.negotiated_tpdu_size_bytes() != 128U ||\n tiny_tpdu.state() != mms::MmsStaticConnectionState::awaiting_association) {\n return 3;\n }", + " if (!tiny_association.response_ready() ||\n tiny_tpdu.negotiated_tpdu_size_bytes() != 128U ||\n tiny_tpdu.state() != mms::MmsStaticConnectionState::established ||\n !validate_segmented_stream(\n std::span{response}.first(tiny_association.bytes_written),\n 128U,\n true)) {\n return 3;\n }\n const auto tiny_read_tpkt = build_mms_tpkt(\n kReadRequest, request, presentation, scratch);\n if (!tiny_read_tpkt.success()) return 21;\n const auto tiny_read_result = tiny_tpdu.process_tcp_window(\n std::span{request}.first(tiny_read_tpkt.bytes_written),\n response,\n workspace);\n if (!tiny_read_result.response_ready() ||\n !validate_segmented_stream(\n std::span{response}.first(tiny_read_result.bytes_written),\n 128U,\n true)) {\n return 22;\n }") + + +# URCB server regression: use the external-client-like 1024-byte offer in production, +# but force 128 here plus a long RptID so the InformationReport definitely spans +# multiple TPKTs. Reassemble only inside the test decoder. +replace_once( + "tests/test_mms_server_urcb_report.cpp", + " constexpr std::array tpdu_size{0x0AU};", + " constexpr std::array tpdu_size{0x07U};") +urcb_decode = r'''[[nodiscard]] bool decode_report_frame( + const std::span frame, + mms::MmsInformationReportView& report, + std::size_t* segment_count = nullptr) noexcept { + std::array reassembled{}; + std::size_t input_offset{}; + std::size_t reassembled_size{}; + std::size_t segments{}; + while (input_offset < frame.size()) { + const auto remaining = frame.subspan(input_offset); + const auto peek = osi::TpktSpanCodec::peek_frame(remaining); + if (!peek.ready() || peek.frame_bytes == 0U) return false; + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(remaining.first(peek.frame_bytes), tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) || + cotp.kind != osi::CotpWireKind::data || + cotp.user_data.size() > reassembled.size() - reassembled_size) { + return false; + } + std::copy(cotp.user_data.begin(), cotp.user_data.end(), + reassembled.begin() + static_cast(reassembled_size)); + reassembled_size += cotp.user_data.size(); + input_offset += peek.frame_bytes; + ++segments; + if ((input_offset == frame.size()) != cotp.end_of_transmission) return false; + } + if (segment_count != nullptr) *segment_count = segments; + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + return segments != 0U && + osi::SessionSpanCodec::try_decode_data_transfer_view( + std::span{reassembled}.first(reassembled_size), session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) && + pdv.context_id == 3U && + mms::MmsInformationReportSpanCodec::try_decode_information_report( + pdv.single_asn1_type, report); +} + +''' +replace_between( + "tests/test_mms_server_urcb_report.cpp", + "[[nodiscard]] bool decode_report_frame(", + "[[nodiscard]] bool decode_unsigned_item(", + urcb_decode) +replace_once( + "tests/test_mms_server_urcb_report.cpp", + ' "LD0/LLN0$RP$Events",\n', + ' "LD0/LLN0$RP$Events_P0_3_Negotiated_TPDU_Segmentation_Proof",\n') +replace_once( + "tests/test_mms_server_urcb_report.cpp", + " mms::MmsInformationReportView report;\n if (!decode_report_frame(\n std::span{response}.first(poll.bytes_written), report) ||\n report.item_count != 13U) {", + " mms::MmsInformationReportView report;\n std::size_t report_segments{};\n if (!decode_report_frame(\n std::span{response}.first(poll.bytes_written),\n report,\n &report_segments) ||\n report_segments < 2U || report.item_count != 13U) {") + + +# BRCB delivery regression: same small negotiated TPDU and stream reassembly. +replace_once( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + " result.owner_size = owner.size();\n return result;\n}", + " result.owner_size = owner.size();\n result.maximum_tpdu_size_code = 0x07U;\n return result;\n}") +replace_once( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + " constexpr std::array tpdu_size{0x0AU};", + " constexpr std::array tpdu_size{0x07U};") +brcb_decode = urcb_decode +replace_between( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + "[[nodiscard]] bool decode_report_frame(", + "[[nodiscard]] bool decode_boolean(", + brcb_decode) +replace_once( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + ' "LD0", "LLN0$BR$Events", "LD0/LLN0$BR$Events",\n', + ' "LD0", "LLN0$BR$Events",\n "LD0/LLN0$BR$Events_P0_3_Negotiated_TPDU_Segmentation_Proof",\n') +replace_once( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + " mms::MmsInformationReportView decoded;\n if (!decode_report_frame(\n std::span{response}.first(retry.bytes_written), decoded) ||\n decoded.item_count != 12U) {", + " mms::MmsInformationReportView decoded;\n std::size_t report_segments{};\n if (!decode_report_frame(\n std::span{response}.first(retry.bytes_written),\n decoded,\n &report_segments) ||\n report_segments < 2U || decoded.item_count != 12U) {") diff --git a/.github/p0_3_patch_fix.py b/.github/p0_3_patch_fix.py new file mode 100644 index 000000000..c8da58557 --- /dev/null +++ b/.github/p0_3_patch_fix.py @@ -0,0 +1,108 @@ +from pathlib import Path + +p = Path('.github/p0_3_patch.py') +text = p.read_text() + +old = '''replace_between( + "src/mms/connection_runtime.cpp", + "[[nodiscard]] bool write_outer_capacity(", + "\\n}\\n\\n} // namespace", + core_capacity)''' +new = '''replace_between( + "src/mms/connection_runtime.cpp", + "[[nodiscard]] bool write_outer_capacity(", + "\\n\\n} // namespace", + core_capacity)''' +if old not in text: + raise SystemExit('write_outer end marker patch point missing') +text = text.replace(old, new, 1) + +old = '''replace_once( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + " result.owner_size = owner.size();\\n return result;\\n}", + " result.owner_size = owner.size();\\n result.maximum_tpdu_size_code = 0x07U;\\n return result;\\n}")''' +new = '''replace_once( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + "[[nodiscard]] mms::MmsStaticConnectionPolicy connection_policy(\\n" + " const std::uint64_t association,\\n" + " const std::array& owner) noexcept {\\n" + " mms::MmsStaticConnectionPolicy result;\\n" + " result.association_id = association;\\n" + " result.owner[0] = owner[0];\\n" + " result.owner[1] = owner[1];\\n" + " result.owner_size = owner.size();\\n" + " return result;\\n}", + "[[nodiscard]] mms::MmsStaticConnectionPolicy connection_policy(\\n" + " const std::uint64_t association,\\n" + " const std::array& owner) noexcept {\\n" + " mms::MmsStaticConnectionPolicy result;\\n" + " result.association_id = association;\\n" + " result.owner[0] = owner[0];\\n" + " result.owner[1] = owner[1];\\n" + " result.owner_size = owner.size();\\n" + " result.maximum_tpdu_size_code = 0x07U;\\n" + " return result;\\n}")''' +if old not in text: + raise SystemExit('connection_policy helper patch point missing') +text = text.replace(old, new, 1) + +anchor = '''# URCB server regression: use the external-client-like 1024-byte offer in production, +# but force 128 here plus a long RptID so the InformationReport definitely spans +# multiple TPKTs. Reassemble only inside the test decoder. +''' +insert = anchor + '''replace_once( + "tests/test_mms_server_urcb_report.cpp", + "#include \\n", + "#include \\n#include \\n") +replace_once( + "embedded/mms_brcb_connection_hard_profile_smoke.cpp", + "#include \\n", + "#include \\n#include \\n") +''' +if text.count(anchor) != 1: + raise SystemExit('URCB anchor missing or duplicated') +text = text.replace(anchor, insert, 1) + +anchor = '''replace_once( + "src/mms/static_report_connection.cpp", + " connection.mms_presentation_context_id(),\\n encoded.required_bytes,\\n final_required)) {", + " connection.mms_presentation_context_id(),\\n encoded.required_bytes,\\n connection.negotiated_tpdu_size_bytes(),\\n final_required)) {") +''' +extra = anchor + '''replace_once( + "src/mms/static_report_connection.cpp", + " if (p_data.status == wire::EncodeStatus::buffer_too_small) {\\n" + " std::size_t final_required{};\\n" + " if (!add_size(p_data.required_bytes, 7U, final_required)) {\\n" + " return make_result(\\n" + " MmsStaticReportConnectionStatus::report_encode_failed,\\n" + " MmsStaticUrcbStatus::report_encode_failed,\\n" + " plan);\\n" + " }\\n" + " auto result = workspace_capacity(\\n" + " plan, MmsStaticUrcbStatus::workspace_too_small, final_required);\\n" + " result.required_response_bytes = final_required;\\n" + " return result;\\n" + " }", + " if (p_data.status == wire::EncodeStatus::buffer_too_small) {\\n" + " osi::CotpTpktDataStreamPlan stream_plan;\\n" + " if (!osi::CotpTpktDataStreamSpanCodec::try_plan(\\n" + " p_data.required_bytes,\\n" + " connection.negotiated_tpdu_size_bytes(),\\n" + " stream_plan)) {\\n" + " return make_result(\\n" + " MmsStaticReportConnectionStatus::report_encode_failed,\\n" + " MmsStaticUrcbStatus::report_encode_failed,\\n" + " plan);\\n" + " }\\n" + " auto result = workspace_capacity(\\n" + " plan, MmsStaticUrcbStatus::workspace_too_small,\\n" + " p_data.required_bytes);\\n" + " result.required_response_bytes = stream_plan.required_bytes;\\n" + " return result;\\n" + " }") +''' +if text.count(anchor) != 1: + raise SystemExit('static report p_data anchor missing or duplicated') +text = text.replace(anchor, extra, 1) + +p.write_text(text) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index ccb9ee052..193f6e8e4 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -35,6 +35,12 @@ jobs: run: cmake --build build --parallel - name: Test run: ctest --test-dir build --output-on-failure + - name: Build SCL-assisted connect wire profile + run: | + cmake -S tests/scl_assisted_profile -B build-scl -DCMAKE_BUILD_TYPE=Release + cmake --build build-scl --target scl_assisted_connect_profile --parallel + - name: Run SCL-assisted connect wire profile + run: ./build-scl/scl_assisted_connect_profile - name: Cross-language model parity tool self-test run: python3 tools/compare_live_model_json.py --self-test - name: Read-only evidence runner syntax and CLI smoke @@ -57,6 +63,12 @@ jobs: run: cmake --build build --config Release --parallel - name: Test run: ctest --test-dir build -C Release --output-on-failure + - name: Build SCL-assisted connect wire profile + run: | + cmake -S tests/scl_assisted_profile -B build-scl -A x64 + cmake --build build-scl --config Release --target scl_assisted_connect_profile --parallel + - name: Run SCL-assisted connect wire profile + run: .\build-scl\Release\scl_assisted_connect_profile.exe - name: Evidence tooling self-tests run: | python tools/compare_live_model_json.py --self-test diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index f3682334a..e7520114e 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -97,6 +97,15 @@ jobs: - name: Build embedded profile run: cmake --build build-embedded --parallel + - name: Diagnose external IEC 61850 client RCB-root retained smoke + shell: bash + run: | + set +e + ./build-embedded/ariec61850_embedded_mms_interop_rcb_root_smoke + rc=$? + echo "INTEROP_RCB_ROOT_EXIT=$rc" + exit 0 + - name: Test embedded full-link smoke run: ctest --test-dir build-embedded --output-on-failure diff --git a/.github/workflows/ied-real-device-f4.yml b/.github/workflows/ied-real-device-f4.yml new file mode 100644 index 000000000..fb35771a0 --- /dev/null +++ b/.github/workflows/ied-real-device-f4.yml @@ -0,0 +1,225 @@ +name: F4 Real IED Acceptance RC + +on: + workflow_dispatch: + push: + branches: + - feat/ied-sim-ux-performance-foundation + paths: + - 'apps/ied_simulator/**' + - 'embedded/**' + - 'tests/fixtures/scl/**' + - 'tools/static_ied_server.cpp' + - 'tools/static_file_service_backend.hpp' + - 'tools/static_file_service_backend_test.cpp' + - 'tools/file_transfer.cpp' + - 'tools/mms_*probe.cpp' + - 'tools/control_block_read_probe.cpp' + - 'tools/live_discover.cpp' + - 'tools/static_ied_server_iedsim/**' + - 'src/mms/**' + - 'include/ariec61850/mms/**' + - '.github/workflows/ied-real-device-f4.yml' + +permissions: + contents: read + +jobs: + protocol-gate: + name: Linux protocol gate + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Configure current IEC 61850 interoperability server + run: cmake -S tools/static_ied_server_iedsim -B build-f4-protocol -DCMAKE_BUILD_TYPE=Release + + - name: Build protocol acceptance targets + run: cmake --build build-f4-protocol --parallel 2 + + - name: Run retained server/report/file acceptance + run: ctest --test-dir build-f4-protocol --output-on-failure + + windows-field-kit: + name: Windows F4 RC and field kit + runs-on: windows-2025 + steps: + - uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.3' + arch: 'win64_msvc2022_64' + cache: true + + - name: Install NSIS + shell: pwsh + run: choco install nsis --yes --no-progress + + - name: Configure Workbench release build + shell: pwsh + run: >- + cmake -S apps/ied_simulator -B build-f4-windows + -G "Visual Studio 18 2026" -A x64 + + - name: Build Workbench, server and field-facing QA + shell: pwsh + run: >- + cmake --build build-f4-windows --config Release + --target arstack_ied_simulator ariec61850_ied_simulator_server + ied_mms_client_workbench_qa ied_report_workspace_qa ied_file_settings_workspace_qa + ied_product_hardening_qa + --parallel 2 + + - name: Configure standalone field tools + shell: pwsh + run: >- + cmake -S . -B build-f4-tools + -G "Visual Studio 18 2026" -A x64 + -DARIEC61850_BUILD_TESTS=OFF + -DARIEC61850_BUILD_TOOLS=ON + -DARIEC61850_BUILD_FUZZERS=OFF + + - name: Build standalone field tools + shell: pwsh + run: >- + cmake --build build-f4-tools --config Release + --target ariec61850_live_discover ariec61850_mms_read_probe + ariec61850_mms_urcb_gi_probe ariec61850_mms_brcb_event_probe + ariec61850_mms_sgcb_probe ariec61850_control_block_read_probe + ariec61850_file_transfer + --parallel 2 + + - name: Run client/report/file preflight + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $env:QT_QPA_PLATFORM = 'offscreen' + & '.\build-f4-windows\Release\ied_mms_client_workbench_qa.exe' '.\tests\fixtures\scl\client-workbench.scd' + if ($LASTEXITCODE -ne 0) { throw "MMS client workbench QA failed: $LASTEXITCODE" } + & '.\build-f4-windows\Release\ied_report_workspace_qa.exe' '.\tests\fixtures\scl\minimal-station-brcb.scd' + if ($LASTEXITCODE -ne 0) { throw "Reports workbench QA failed: $LASTEXITCODE" } + & '.\build-f4-windows\Release\ied_report_workspace_qa.exe' '.\tests\fixtures\scl\interop-indexed-reports.scd' + if ($LASTEXITCODE -ne 0) { throw "interoperability indexed Reports QA failed: $LASTEXITCODE" } + & '.\build-f4-windows\Release\ied_file_settings_workspace_qa.exe' + if ($LASTEXITCODE -ne 0) { throw "Files/settings workbench QA failed: $LASTEXITCODE" } + + - name: Run interoperability synthetic golden Windows preflight + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $env:QT_QPA_PLATFORM = 'offscreen' + python '.\apps\ied_simulator\test_interop_golden_synthetic.py' ` + --app '.\build-f4-windows\Release\arstack_ied_simulator.exe' ` + --build-dir '.\build-f4-tools\Release' ` + --scl '.\tests\fixtures\scl\interop-golden-synthetic.cid' + if ($LASTEXITCODE -ne 0) { throw "interoperability synthetic golden preflight failed: $LASTEXITCODE" } + + - name: Stage deployed Qt application + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $stage = Join-Path $PWD 'release-stage\ARStack-IEC61850-Workbench' + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item 'build-f4-windows\Release\arstack_ied_simulator.exe' $stage + Copy-Item 'build-f4-windows\Release\ariec61850_ied_simulator_server.exe' $stage + windeployqt --release --qmldir apps/ied_simulator/qml --dir $stage (Join-Path $stage 'arstack_ied_simulator.exe') + + - name: Build latest portable ZIP and NSIS installer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $stage = (Resolve-Path 'release-stage\ARStack-IEC61850-Workbench').Path + $release = Join-Path $PWD 'release-artifacts' + New-Item -ItemType Directory -Force -Path $release | Out-Null + $zip = Join-Path $release 'ARStack-IEC61850-Workbench-portable-win64.zip' + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip -Force + $installer = Join-Path $release 'ARStack-IEC61850-Workbench-Setup-win64.exe' + $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' + if (-not (Test-Path -LiteralPath $makensis -PathType Leaf)) { throw "makensis not found: $makensis" } + & $makensis "/DSTAGE_DIR=$stage" "/DOUT_FILE=$installer" 'apps\ied_simulator\packaging\windows\ARStackIEC61850Workbench.nsi' + if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" } + + - name: Verify staged and installed release + shell: pwsh + run: >- + ./apps/ied_simulator/packaging/windows/verify-release.ps1 + -StageDir ./release-stage/ARStack-IEC61850-Workbench + -BuildDir ./build-f4-windows/Release + -InstallerPath ./release-artifacts/ARStack-IEC61850-Workbench-Setup-win64.exe + -FixturePath ./tests/fixtures/scl/client-workbench.scd + + - name: Assemble F4 external-acceptance kit + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $kit = Join-Path $PWD 'f4-kit' + $bin = Join-Path $kit 'tools' + $fixtures = Join-Path $kit 'fixtures' + New-Item -ItemType Directory -Force -Path $bin | Out-Null + New-Item -ItemType Directory -Force -Path $fixtures | Out-Null + + Copy-Item 'release-artifacts\ARStack-IEC61850-Workbench-Setup-win64.exe' $kit + Copy-Item 'release-artifacts\ARStack-IEC61850-Workbench-portable-win64.zip' $kit + Copy-Item 'apps\ied_simulator\field_acceptance\F4_REAL_IED_ACCEPTANCE.md' $kit + Copy-Item 'apps\ied_simulator\field_acceptance\ACCEPTANCE_RESULT_TEMPLATE.md' $kit + Copy-Item 'apps\ied_simulator\field_acceptance\collect-f4-evidence.ps1' $kit + Copy-Item 'tests\fixtures\scl\interop-golden-synthetic.cid' $fixtures + Set-Content -LiteralPath (Join-Path $kit 'BUILD_HEAD.txt') -Value $env:GITHUB_SHA -Encoding ascii + + $server = Get-ChildItem -Path 'build-f4-windows' -Recurse -File -Filter 'ariec61850_ied_simulator_server.exe' | + Where-Object { $_.FullName -match '[\\/]Release[\\/]' } | + Select-Object -First 1 + if (-not $server) { throw 'F4 simulator server not found after Workbench build' } + Copy-Item -LiteralPath $server.FullName -Destination (Join-Path $bin $server.Name) + + $toolNames = @( + 'ariec61850_live_discover.exe', + 'ariec61850_mms_read_probe.exe', + 'ariec61850_mms_urcb_gi_probe.exe', + 'ariec61850_mms_brcb_event_probe.exe', + 'ariec61850_mms_sgcb_probe.exe', + 'ariec61850_control_block_read_probe.exe', + 'ariec61850_file_transfer.exe' + ) + foreach ($name in $toolNames) { + $candidate = Get-ChildItem -Path 'build-f4-tools' -Recurse -File -Filter $name | + Where-Object { $_.FullName -match '[\\/]Release[\\/]' } | + Select-Object -First 1 + if (-not $candidate) { throw "F4 field tool not found after standalone build: $name" } + Copy-Item -LiteralPath $candidate.FullName -Destination (Join-Path $bin $name) + } + + $manifest = [ordered]@{ + schemaVersion = 'arstack-f4-field-kit-v2-interop-golden' + headSha = $env:GITHUB_SHA + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + syntheticGolden = 'passed_in_windows_preflight' + externalAcceptance = 'pending_external_client' + issue95CloseAllowed = $false + destructiveFieldFileDelete = $false + } + $manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $kit 'F4_BUILD_MANIFEST.json') -Encoding UTF8 + + Get-ChildItem -Path $kit -Recurse -File | + Where-Object { $_.Name -ne 'SHA256SUMS.txt' } | + ForEach-Object { + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $relative = [IO.Path]::GetRelativePath($kit, $_.FullName).Replace('\', '/') + "$hash $relative" + } | Set-Content -LiteralPath (Join-Path $kit 'SHA256SUMS.txt') -Encoding ascii + + $archive = Join-Path $PWD 'release-artifacts\ARStack-F4-Real-IED-Acceptance-Kit-win64.zip' + Compress-Archive -Path (Join-Path $kit '*') -DestinationPath $archive -Force + + - name: Upload F4 Windows RC + uses: actions/upload-artifact@v4 + with: + name: arstack-f4-real-ied-acceptance-rc + path: | + release-artifacts/ARStack-IEC61850-Workbench-Setup-win64.exe + release-artifacts/ARStack-IEC61850-Workbench-portable-win64.zip + release-artifacts/ARStack-F4-Real-IED-Acceptance-Kit-win64.zip + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 40239f0ef..943695ea6 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -8,8 +8,13 @@ on: - 'src/scl/**' - 'include/ariec61850/mms/**' - 'src/mms/**' + - 'include/ariec61850/goose/**' + - 'src/goose/**' - 'tools/static_ied_server.cpp' - 'tools/mms_read_probe.cpp' + - 'tools/mms_urcb_gi_probe.cpp' + - 'tools/mms_brcb_event_probe.cpp' + - 'tools/mms_sgcb_probe.cpp' - 'tools/static_ied_server_iedsim/**' - 'tests/fixtures/scl/**' - 'CMakeLists.txt' @@ -21,8 +26,13 @@ on: - 'src/scl/**' - 'include/ariec61850/mms/**' - 'src/mms/**' + - 'include/ariec61850/goose/**' + - 'src/goose/**' - 'tools/static_ied_server.cpp' - 'tools/mms_read_probe.cpp' + - 'tools/mms_urcb_gi_probe.cpp' + - 'tools/mms_brcb_event_probe.cpp' + - 'tools/mms_sgcb_probe.cpp' - 'tools/static_ied_server_iedsim/**' - 'tests/fixtures/scl/**' - 'CMakeLists.txt' @@ -51,16 +61,183 @@ jobs: - name: Build run: >- cmake --build build-ied-simulator-qt - --target arstack_ied_simulator ariec61850_mms_read_probe --parallel 2 + --target arstack_ied_simulator ariec61850_mms_read_probe ariec61850_live_discover ariec61850_mms_sgcb_probe ied_setting_control_manifest_qa ied_simulator_guardrails_qa ied_simulator_responsiveness_qa ied_simulator_commissioning_qa ied_simulator_runtime_commissioning_qa ied_simulator_goose_publication_qa ied_goose_workspace_qa ied_mms_client_workbench_qa ied_report_workspace_qa ied_file_settings_workspace_qa ied_scl_workspace_qa ied_product_hardening_qa --parallel 2 + + - name: Bounded activity and child-output guardrails + run: ./build-ied-simulator-qt/ied_simulator_guardrails_qa - name: QML smoke test env: QT_QPA_PLATFORM: offscreen run: ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test + - name: MMS client discovery workbench gates + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/ied_mms_client_workbench_qa + ./tests/fixtures/scl/client-workbench.scd + + - name: Report / RCB commissioning workspace gates + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/ied_report_workspace_qa + ./tests/fixtures/scl/minimal-station-brcb.scd + + - name: external IEC 61850 client indexed RCB production-path gate + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/ied_report_workspace_qa + ./tests/fixtures/scl/interop-indexed-reports.scd + + - name: File transfer + Setting Groups workspace gates + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-qt/ied_file_settings_workspace_qa + + - name: Canonical SettingControl manifest projection gate + run: ./build-ied-simulator-qt/ied_setting_control_manifest_qa + + - name: Production SGCB discovery and ActSG cross-association gate + run: >- + python3 apps/ied_simulator/test_sgcb_production.py + --server ./build-ied-simulator-qt/ariec61850_ied_simulator_server + --build-dir ./build-ied-simulator-qt + + - name: external IEC 61850 client synthetic golden runtime gate + env: + QT_QPA_PLATFORM: offscreen + run: >- + python3 apps/ied_simulator/test_interop_golden_synthetic.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + --build-dir ./build-ied-simulator-qt + --scl ./tests/fixtures/scl/interop-golden-synthetic.cid + + - name: SCL workspace preservation + conversion-policy gates + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/ied_scl_workspace_qa + ./tests/fixtures/scl/minimal-station-brcb.scd + + - name: Product hardening state + runtime readiness gates + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-qt/ied_product_hardening_qa + + - name: Prove interactive SCL import uses bounded async path + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/arstack_ied_simulator + --qa-async-import ./tests/fixtures/scl/minimal-station-brcb.scd + + - name: Prove malformed SCL fails closed + run: >- + python3 apps/ied_simulator/test_malformed_import.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + + - name: Start stop restart lifecycle soak + run: >- + python3 apps/ied_simulator/test_runtime_lifecycle.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + --scl ./tests/fixtures/scl/minimal-station-brcb.scd + --cycles 12 + + - name: Large SCL import performance gates + run: >- + python3 apps/ied_simulator/benchmark_large_scl.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + --targets 5000,20000,50000 + + - name: Runtime scale and responsiveness gates + env: + QT_QPA_PLATFORM: offscreen + run: >- + python3 apps/ied_simulator/test_runtime_responsiveness.py + --qa ./build-ied-simulator-qt/ied_simulator_responsiveness_qa + --multi-scl ./tests/fixtures/scl/compact-multi-stream.scd + --single-scl ./tests/fixtures/scl/minimal-station-brcb.scd + + - name: Commissioning feature depth gates + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/ied_simulator_commissioning_qa + ./tests/fixtures/scl/minimal-station-brcb.scd + ./tests/fixtures/scl/commissioning-unresolved.scd + + - name: Runtime commissioning actions and behavior gates + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/ied_simulator_runtime_commissioning_qa + ./tests/fixtures/scl/minimal-station-brcb.scd + ./build-ied-simulator-qt + + - name: GOOSE wire publication and fail-closed gates + run: >- + ./build-ied-simulator-qt/ied_simulator_goose_publication_qa + ./tests/fixtures/scl/minimal-station-brcb.scd + ./goose-publication-evidence.pcap + + - name: GOOSE monitor + publisher workspace gates + run: >- + ./build-ied-simulator-qt/ied_goose_workspace_qa + ./goose-publication-evidence.pcap + + - name: Upload GOOSE wire evidence + uses: actions/upload-artifact@v4 + with: + name: ied-simulator-goose-wire-evidence + path: goose-publication-evidence.pcap + if-no-files-found: error + retention-days: 14 + + - name: Live runtime data-plane burst gates + env: + QT_QPA_PLATFORM: offscreen + run: >- + python3 apps/ied_simulator/test_live_hot_updates.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + --read-probe ./build-ied-simulator-qt + --scl ./tests/fixtures/scl/minimal-station-brcb.scd + --bursts 1000,10000 + + - name: Reject stale live runtime deltas + run: >- + python3 apps/ied_simulator/test_live_data_plane_negative.py + --server ./build-ied-simulator-qt + --read-probe ./build-ied-simulator-qt + + - name: Capture workbench visual regression + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-qt/arstack_ied_simulator + --scl ./tests/fixtures/scl/minimal-station-brcb.scd + --screenshot ./ied-simulator-workbench.png + + - name: Upload workbench screenshot + uses: actions/upload-artifact@v4 + with: + name: ied-simulator-workbench + path: ied-simulator-workbench.png + if-no-files-found: error + retention-days: 14 + - name: Prove GUI value is visible through MMS run: >- python3 apps/ied_simulator/test_gui_live_value.py --app ./build-ied-simulator-qt/arstack_ied_simulator --read-probe ./build-ied-simulator-qt - --scl ./tests/fixtures/scl/minimal-station.scd + --scl ./tests/fixtures/scl/minimal-station-brcb.scd + + - name: Prove two IEDs can share TCP port across distinct local IPs + run: >- + python3 apps/ied_simulator/test_gui_multi_ied.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + --read-probe ./build-ied-simulator-qt + --scl ./tests/fixtures/scl/compact-multi-stream.scd diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml new file mode 100644 index 000000000..2400fa5cd --- /dev/null +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -0,0 +1,134 @@ +name: IED Simulator Release Hardening + +on: + push: + paths: + - 'apps/ied_simulator/**' + - 'assets/**' + - '.github/workflows/ied-simulator-release-hardening.yml' + pull_request: + paths: + - 'apps/ied_simulator/**' + - '.github/workflows/ied-simulator-release-hardening.yml' + +permissions: + contents: read + +jobs: + linux-release-soak: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.3' + cache: true + + - name: Configure + run: >- + cmake -S apps/ied_simulator -B build-ied-simulator-release -G Ninja + -DCMAKE_BUILD_TYPE=Release + + - name: Build release soak targets + run: >- + cmake --build build-ied-simulator-release + --target arstack_ied_simulator ied_mms_client_workbench_qa ied_product_hardening_qa + --parallel 2 + + - name: Product state and runtime readiness regression + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-release/ied_product_hardening_qa + + - name: MMS reconnect association soak + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-ied-simulator-release/ied_mms_client_workbench_qa + ./tests/fixtures/scl/client-workbench.scd + + - name: Application close child/socket soak + env: + QT_QPA_PLATFORM: offscreen + run: >- + python3 apps/ied_simulator/test_application_close_soak.py + --app ./build-ied-simulator-release/arstack_ied_simulator + --scl ./tests/fixtures/scl/minimal-station-brcb.scd + --cycles 12 + + windows-release-package: + runs-on: windows-2025 + steps: + - uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.3' + arch: 'win64_msvc2022_64' + cache: true + + - name: Install NSIS + shell: pwsh + run: choco install nsis --yes --no-progress + + - name: Configure MSVC release build + shell: pwsh + run: >- + cmake -S apps/ied_simulator -B build-ied-simulator-windows + -G "Visual Studio 18 2026" -A x64 + + - name: Build workbench and release QA + shell: pwsh + run: >- + cmake --build build-ied-simulator-windows --config Release + --target arstack_ied_simulator ariec61850_ied_simulator_server + ied_mms_client_workbench_qa ied_product_hardening_qa + --parallel 2 + + - name: Stage deployed Qt application + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $stage = Join-Path $PWD 'release-stage\ARStack-IEC61850-Workbench' + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item 'build-ied-simulator-windows\Release\arstack_ied_simulator.exe' $stage + Copy-Item 'build-ied-simulator-windows\Release\ariec61850_ied_simulator_server.exe' $stage + windeployqt --release --qmldir apps/ied_simulator/qml --dir $stage (Join-Path $stage 'arstack_ied_simulator.exe') + + - name: Build portable ZIP and NSIS installer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $stage = (Resolve-Path 'release-stage\ARStack-IEC61850-Workbench').Path + $release = Join-Path $PWD 'release-artifacts' + New-Item -ItemType Directory -Force -Path $release | Out-Null + $zip = Join-Path $release 'ARStack-IEC61850-Workbench-portable-win64.zip' + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip -Force + $installer = Join-Path $release 'ARStack-IEC61850-Workbench-Setup-win64.exe' + $icon = (Resolve-Path 'assets\favicon.ico').Path + $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' + if (-not (Test-Path -LiteralPath $makensis -PathType Leaf)) { throw "makensis not found: $makensis" } + & $makensis "/DSTAGE_DIR=$stage" "/DOUT_FILE=$installer" "/DAPP_ICON=$icon" 'apps\ied_simulator\packaging\windows\ARStackIEC61850Workbench.nsi' + if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" } + + - name: Verify staged and installed release + shell: pwsh + run: >- + ./apps/ied_simulator/packaging/windows/verify-release.ps1 + -StageDir ./release-stage/ARStack-IEC61850-Workbench + -BuildDir ./build-ied-simulator-windows/Release + -InstallerPath ./release-artifacts/ARStack-IEC61850-Workbench-Setup-win64.exe + -FixturePath ./tests/fixtures/scl/client-workbench.scd + + - name: Upload Windows release artifacts + uses: actions/upload-artifact@v4 + with: + name: arstack-iec61850-workbench-windows-rc + path: | + release-artifacts/ARStack-IEC61850-Workbench-Setup-win64.exe + release-artifacts/ARStack-IEC61850-Workbench-portable-win64.zip + if-no-files-found: error + retention-days: 14 \ No newline at end of file diff --git a/.github/workflows/ied-simulator-windows-package.yml b/.github/workflows/ied-simulator-windows-package.yml new file mode 100644 index 000000000..1f6147335 --- /dev/null +++ b/.github/workflows/ied-simulator-windows-package.yml @@ -0,0 +1,99 @@ +name: IED Simulator Windows Package + +on: + workflow_dispatch: + push: + branches: + - feat/complete-ariec61850-simulator-parity + paths: + - 'apps/ied_simulator/**' + - 'apps/arstack_studio/assets/LUCIDE_LICENSE.txt' + - 'apps/arstack_studio/assets/lucide/**' + - 'include/ariec61850/**' + - 'src/**' + - 'tools/static_ied_server.cpp' + - 'tools/static_ied_server_sigpipe.cpp' + - 'tests/fixtures/scl/**' + - 'CMakeLists.txt' + - '.github/workflows/ied-simulator-windows-package.yml' + +permissions: + contents: read + +jobs: + package-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + # Match the Qt msvc2022_64 binary package. Without an initialized MSVC + # environment the hosted runner can discover C:\mingw64\bin\c++.exe + # first, which is both ABI-incompatible with this Qt build and exposes a + # different Winsock warning profile under the repository's -Werror gate. + - name: Initialize MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.3' + arch: 'win64_msvc2022_64' + cache: true + + - name: Configure Release build + shell: pwsh + run: >- + cmake -S apps/ied_simulator -B build-ied-simulator-win -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_CXX_COMPILER=cl.exe + + - name: Build GUI and bundled MMS server + shell: pwsh + run: >- + cmake --build build-ied-simulator-win + --target arstack_ied_simulator ariec61850_ied_simulator_server + --parallel 2 + + - name: Assemble portable engineering-test package + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $package = Join-Path $PWD 'package/ARStack-IED-Simulator-Windows' + New-Item -ItemType Directory -Force -Path $package | Out-Null + + Copy-Item 'build-ied-simulator-win/arstack_ied_simulator.exe' $package + Copy-Item 'build-ied-simulator-win/ariec61850_ied_simulator_server.exe' $package + Copy-Item 'tests/fixtures/scl/minimal-station-brcb.scd' $package + Copy-Item 'tests/fixtures/scl/compact-multi-stream.scd' $package + Copy-Item 'apps/ied_simulator/INTEROP_MANUAL_TEST.md' $package + Copy-Item 'apps/arstack_studio/assets/LUCIDE_LICENSE.txt' $package + + $windeployqt = Get-Command windeployqt.exe -ErrorAction Stop + & $windeployqt.Source ` + --release ` + --compiler-runtime ` + --qmldir (Join-Path $PWD 'apps/ied_simulator/qml') ` + (Join-Path $package 'arstack_ied_simulator.exe') + if ($LASTEXITCODE -ne 0) { + throw "windeployqt failed with exit code $LASTEXITCODE" + } + + if (-not (Test-Path (Join-Path $package 'ariec61850_ied_simulator_server.exe'))) { + throw 'Bundled MMS server is missing from package.' + } + if (-not (Test-Path (Join-Path $package 'Qt6Core.dll'))) { + throw 'Qt runtime deployment is incomplete.' + } + if (-not (Test-Path (Join-Path $package 'LUCIDE_LICENSE.txt'))) { + throw 'Lucide license notice is missing from package.' + } + + - name: Upload portable Windows package + uses: actions/upload-artifact@v4 + with: + name: ARStack-IED-Simulator-Windows-${{ github.sha }} + path: package/ARStack-IED-Simulator-Windows + if-no-files-found: error + compression-level: 6 diff --git a/.github/workflows/interoperability-server-ci.yml b/.github/workflows/interoperability-server-ci.yml index e9e7e7585..b9230a4d6 100644 --- a/.github/workflows/interoperability-server-ci.yml +++ b/.github/workflows/interoperability-server-ci.yml @@ -6,6 +6,8 @@ on: - 'tools/static_ied_server_iedsim/**' - 'tools/static_ied_server.cpp' - 'tools/static_ied_server_sigpipe.cpp' + - 'tools/mms_read_probe.cpp' + - 'tools/mms_urcb_gi_probe.cpp' - 'src/asn1/ber_span_reader.cpp' - 'src/osi/**' - 'src/acse/**' @@ -14,6 +16,10 @@ on: - '.github/workflows/interoperability-server-ci.yml' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: linux: strategy: diff --git a/.github/workflows/p0-2-runtime-fixture-correction.yml b/.github/workflows/p0-2-runtime-fixture-correction.yml new file mode 100644 index 000000000..85d243835 --- /dev/null +++ b/.github/workflows/p0-2-runtime-fixture-correction.yml @@ -0,0 +1,38 @@ +name: P0.2 runtime fixture correction + +on: + push: + branches: + - feat/ied-sim-ux-performance-foundation + paths: + - .github/workflows/p0-2-runtime-fixture-correction.yml + +permissions: + contents: write + +jobs: + patch-test-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feat/ied-sim-ux-performance-foundation + fetch-depth: 0 + - name: Correct canonical report fixture + run: python tools/p0_2_runtime_fixture_patch.py + - name: Configure regression + run: cmake -S . -B build-p02-runtime -DCMAKE_BUILD_TYPE=Release + - name: Build runtime and reporting tests + run: cmake --build build-p02-runtime --target ariec61850_mms_runtime_tests ariec61850_reporting_tests --parallel + - name: Run runtime and reporting tests + run: ctest --test-dir build-p02-runtime -R '^(ariec61850_mms_runtime_tests|ariec61850_reporting_tests)$' --output-on-failure + - name: Commit verified fixture correction + shell: bash + run: | + set -euo pipefail + git config user.name "ARStack P0 Bot" + git config user.email "actions@github.com" + git add tests/test_mms_runtime.cpp + git diff --cached --check + git commit -m "test(mms): align runtime report fixture with canonical payload order" + git push origin HEAD:feat/ied-sim-ux-performance-foundation diff --git a/.github/workflows/p0-3-cotp-segmentation-bootstrap.yml b/.github/workflows/p0-3-cotp-segmentation-bootstrap.yml new file mode 100644 index 000000000..a524c8997 --- /dev/null +++ b/.github/workflows/p0-3-cotp-segmentation-bootstrap.yml @@ -0,0 +1,95 @@ +name: P0.3 negotiated COTP segmentation bootstrap + +on: + push: + branches: + - feat/ied-sim-ux-performance-foundation + paths: + - .github/workflows/p0-3-cotp-segmentation-bootstrap.yml + +permissions: + contents: write + +jobs: + patch-test-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feat/ied-sim-ux-performance-foundation + fetch-depth: 0 + + - name: Harden patch bootstrap + run: python .github/p0_3_patch_fix.py + + - name: Apply negotiated COTP segmentation source patch + run: python .github/p0_3_patch.py + + - name: Remove stale single-frame helpers + shell: python + run: | + from pathlib import Path + + helper = '''[[nodiscard]] bool add_size( + const std::size_t base, + const std::size_t extra, + std::size_t& total) noexcept { + if (extra > std::numeric_limits::max() - base) { + total = 0U; + return false; + } + total = base + extra; + return true; + } + + ''' + for name in ( + "src/mms/static_report_connection.cpp", + "src/mms/static_brcb_connection.cpp", + ): + path = Path(name) + text = path.read_text() + if text.count(helper) != 1: + raise SystemExit(f"{name}: stale add_size helper not found exactly once") + path.write_text(text.replace(helper, "", 1)) + + - name: Configure MMS R1-R2 regression + run: cmake -S tools/mms_direct_control_server -B build-p03-r12 -DCMAKE_BUILD_TYPE=Release + + - name: Build and run MMS R1-R2 regression + run: | + cmake --build build-p03-r12 --parallel + ctest --test-dir build-p03-r12 --output-on-failure + + - name: Configure BRCB hard-profile regression + run: cmake -S embedded/brcb_hard_profile -B build-p03-brcb -DCMAKE_BUILD_TYPE=Release + + - name: Build and run negotiated transport/report regressions + run: | + cmake --build build-p03-brcb --target ariec61850_hard_mms_negotiated_limits_smoke ariec61850_hard_mms_brcb_connection_smoke --parallel + ctest --test-dir build-p03-brcb -R 'ariec61850_hard_mms_(negotiated_limits|brcb_connection)_smoke' --output-on-failure + + - name: Configure external IEC 61850 client production server path + run: cmake -S tools/static_ied_server_iedsim -B build-p03-iedsim -DCMAKE_BUILD_TYPE=Release + + - name: Build external IEC 61850 client production server path + run: cmake --build build-p03-iedsim --parallel + + - name: Commit verified P0.3 source only + shell: bash + run: | + set -euo pipefail + git config user.name "ARStack P0 Bot" + git config user.email "actions@github.com" + git add \ + include/ariec61850/osi/cotp_tpkt_stream.hpp \ + src/mms/connection_runtime.cpp \ + src/mms/connection_runtime_iedsim.cpp \ + src/mms/static_report_connection.cpp \ + src/mms/static_brcb_connection.cpp \ + embedded/mms_negotiated_limits_hard_profile_smoke.cpp \ + tests/test_mms_server_urcb_report.cpp \ + embedded/mms_brcb_connection_hard_profile_smoke.cpp + git diff --cached --check + git commit -m "fix(osi): segment outbound COTP data by negotiated TPDU" + git push origin HEAD:feat/ied-sim-ux-performance-foundation diff --git a/.github/workflows/scl-assisted-connect-ci.yml b/.github/workflows/scl-assisted-connect-ci.yml new file mode 100644 index 000000000..cd71df3e9 --- /dev/null +++ b/.github/workflows/scl-assisted-connect-ci.yml @@ -0,0 +1,70 @@ +name: SCL-Assisted Connect CI + +on: + push: + paths: + - 'include/ariec61850/mms/scl_assisted_connect.hpp' + - 'include/ariec61850/mms/scl_association.hpp' + - 'include/ariec61850/mms/scl_sync_health.hpp' + - 'include/ariec61850/mms/association_runtime.hpp' + - 'include/ariec61850/mms/live_discovery.hpp' + - 'src/mms/**' + - 'src/scl/**' + - 'include/ariec61850/scl/**' + - 'apps/ied_simulator/src/MmsSclClientProjection.hpp' + - 'tests/test_scl_assisted_connect.cpp' + - 'tests/test_scl_association_addressing.cpp' + - 'tests/test_scl_sync_health.cpp' + - 'tests/test_scl_projection_health.cpp' + - 'tests/scl_assisted_profile/**' + - '.github/workflows/scl-assisted-connect-ci.yml' + pull_request: + paths: + - 'include/ariec61850/mms/scl_assisted_connect.hpp' + - 'include/ariec61850/mms/scl_association.hpp' + - 'include/ariec61850/mms/scl_sync_health.hpp' + - 'include/ariec61850/mms/association_runtime.hpp' + - 'include/ariec61850/mms/live_discovery.hpp' + - 'src/mms/**' + - 'src/scl/**' + - 'include/ariec61850/scl/**' + - 'apps/ied_simulator/src/MmsSclClientProjection.hpp' + - 'tests/test_scl_assisted_connect.cpp' + - 'tests/test_scl_association_addressing.cpp' + - 'tests/test_scl_sync_health.cpp' + - 'tests/test_scl_projection_health.cpp' + - 'tests/scl_assisted_profile/**' + - '.github/workflows/scl-assisted-connect-ci.yml' + +concurrency: + group: scl-assisted-connect-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux-profile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure portable profile gates + run: >- + cmake -S tests/scl_assisted_profile -B build-scl-assisted-profile + -DCMAKE_BUILD_TYPE=Release + + - name: Build SCL-assisted profile gates + run: >- + cmake --build build-scl-assisted-profile + --target scl_assisted_connect_profile scl_association_addressing_profile scl_sync_health_profile scl_projection_health_profile + --parallel 2 + + - name: Run SCL-assisted wire-profile regression + run: ./build-scl-assisted-profile/scl_assisted_connect_profile + + - name: Run SCL association-addressing regression + run: ./build-scl-assisted-profile/scl_association_addressing_profile + + - name: Run trusted-SCL identity-health regression + run: ./build-scl-assisted-profile/scl_sync_health_profile + + - name: Run trusted-SCL product-projection health regression + run: ./build-scl-assisted-profile/scl_projection_health_profile diff --git a/.github/workflows/scl-assisted-workbench-ci.yml b/.github/workflows/scl-assisted-workbench-ci.yml new file mode 100644 index 000000000..5485105f6 --- /dev/null +++ b/.github/workflows/scl-assisted-workbench-ci.yml @@ -0,0 +1,87 @@ +name: SCL-Assisted Workbench CI + +on: + push: + paths: + - 'apps/ied_simulator/src/MmsClientController.*' + - 'apps/ied_simulator/src/MmsLiveTreeModel.*' + - 'apps/ied_simulator/src/MmsSclClientProjection.hpp' + - 'apps/ied_simulator/src/ied_mms_scl_assisted_workbench_qa.cpp' + - 'apps/ied_simulator/tests/scl_assisted_workbench/**' + - 'apps/ied_simulator/qml/Main.qml' + - 'apps/ied_simulator/qml/MmsClientWorkspace.qml' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/scl/**' + - 'src/scl/**' + - 'tests/fixtures/scl/client-workbench.scd' + - 'tests/fixtures/scl/client-workbench-incompatible.scd' + - '.github/workflows/scl-assisted-workbench-ci.yml' + pull_request: + paths: + - 'apps/ied_simulator/src/MmsClientController.*' + - 'apps/ied_simulator/src/MmsLiveTreeModel.*' + - 'apps/ied_simulator/src/MmsSclClientProjection.hpp' + - 'apps/ied_simulator/src/ied_mms_scl_assisted_workbench_qa.cpp' + - 'apps/ied_simulator/tests/scl_assisted_workbench/**' + - 'apps/ied_simulator/qml/Main.qml' + - 'apps/ied_simulator/qml/MmsClientWorkspace.qml' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/scl/**' + - 'src/scl/**' + - 'tests/fixtures/scl/client-workbench.scd' + - 'tests/fixtures/scl/client-workbench-incompatible.scd' + - '.github/workflows/scl-assisted-workbench-ci.yml' + +permissions: + contents: read + +concurrency: + group: scl-assisted-workbench-${{ github.ref }} + cancel-in-progress: true + +jobs: + product-path: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.3' + cache: true + + - name: Configure P5 workbench gate + run: >- + cmake -S apps/ied_simulator/tests/scl_assisted_workbench + -B build-scl-assisted-workbench -G Ninja + -DCMAKE_BUILD_TYPE=Release + + - name: Build P5 workbench gate + run: >- + cmake --build build-scl-assisted-workbench + --target ied_mms_scl_assisted_workbench_qa --parallel 2 + + - name: Run trusted-SCL degraded product-path regression + env: + QT_QPA_PLATFORM: offscreen + run: >- + ./build-scl-assisted-workbench/ied_mms_scl_assisted_workbench_qa + ./tests/fixtures/scl/client-workbench.scd + + - name: Prove incompatible trusted SCL fails closed + env: + QT_QPA_PLATFORM: offscreen + shell: bash + run: | + set +e + output="$(./build-scl-assisted-workbench/ied_mms_scl_assisted_workbench_qa ./tests/fixtures/scl/client-workbench-incompatible.scd 2>&1)" + status=$? + set -e + printf '%s\n' "$output" + test "$status" -eq 5 + grep -F "SCL online identity incompatible" <<<"$output" + ! grep -F "Live MMS discovery" <<<"$output" + echo "MMS_SCL_INCOMPATIBLE_WORKBENCH_PASS connected=false health=incompatible silent_fallback=false" diff --git a/.github/workflows/zz-agent-control-slice.yml b/.github/workflows/zz-agent-control-slice.yml new file mode 100644 index 000000000..b1443f8bf --- /dev/null +++ b/.github/workflows/zz-agent-control-slice.yml @@ -0,0 +1,75 @@ +name: Agent Control Wire E2E + +on: + push: + branches: + - feat/complete-ariec61850-simulator-parity + paths: + - '.github/workflows/zz-agent-control-slice.yml' + +permissions: + contents: read + +jobs: + validate-control-wire: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + ref: feat/complete-ariec61850-simulator-parity + fetch-depth: 0 + + - name: Configure root regression build + run: >- + cmake -S . -B build-control-slice -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DARIEC61850_BUILD_TESTS=ON + -DARIEC61850_BUILD_TOOLS=ON + + - name: Build root control targets + run: >- + cmake --build build-control-slice + --target ariec61850_scl_tests ariec61850_static_ied_server ariec61850_control_interop_probe + --parallel 2 + + - name: Run SCL configured-control regression + run: ./build-control-slice/ariec61850_scl_tests + + - name: Configure static-control regression profile + run: >- + cmake -S tools/mms_direct_control_server -B build-direct-control -G Ninja + -DCMAKE_BUILD_TYPE=Release + + - name: Run static-control state-machine regression + run: | + cmake --build build-direct-control --target ariec61850_mms_direct_control_tests --parallel 2 + ./build-direct-control/ariec61850_mms_direct_control_tests + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.3' + cache: true + + - name: Configure Qt simulator + run: >- + cmake -S apps/ied_simulator -B build-ied-simulator-qt -G Ninja + -DCMAKE_BUILD_TYPE=Release + + - name: Build Qt simulator and external probes + run: >- + cmake --build build-ied-simulator-qt + --target arstack_ied_simulator ariec61850_mms_read_probe ariec61850_control_interop_probe + --parallel 2 + + - name: QML smoke test + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test + + - name: Prove Direct, SBO and enhanced control over external MMS wire + run: >- + python3 apps/ied_simulator/test_gui_live_value.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + --read-probe ./build-ied-simulator-qt + --scl ./tests/fixtures/scl/minimal-station-brcb.scd diff --git a/.github/workflows/zz-agent-simulation-runtime.yml b/.github/workflows/zz-agent-simulation-runtime.yml new file mode 100644 index 000000000..c42e27520 --- /dev/null +++ b/.github/workflows/zz-agent-simulation-runtime.yml @@ -0,0 +1,121 @@ +name: Agent Simulator Runtime Port + +on: + workflow_dispatch: + push: + branches: + - feat/complete-ariec61850-simulator-parity + paths: + - '.github/workflows/zz-agent-simulation-runtime.yml' + - 'scripts/agent_*.py' + +permissions: + contents: write + +jobs: + port-and-validate: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + ref: feat/complete-ariec61850-simulator-parity + fetch-depth: 0 + + - name: Integrate native simulator runtime + run: python3 scripts/agent_integrate_simulation_runtime.py + + - name: Finalize milestone metadata + run: python3 scripts/agent_finalize_simulation_runtime_port.py + + - name: Repair control and URCB object-bank composition + run: python3 scripts/agent_fix_iedsim_urcb_control_composition.py + + - name: Preserve configured-control MMS type lifetime + run: python3 scripts/agent_fix_control_type_lifetime.py + + - name: Share simulated control state across associations + run: python3 scripts/agent_fix_cross_association_control_state.py + + - name: Keep report banks stable during live edits + run: python3 scripts/agent_fix_report_bank_live_refresh.py + + - name: Add BRCB invariant diagnostics + run: python3 scripts/agent_diagnose_iedsim_brcb.py + + - name: Enable headless child-server tracing + run: python3 scripts/agent_trace_iedsim_server.py + + - name: Check patch hygiene + run: git diff --check + + - name: Configure strict root build + run: >- + cmake -S . -B build-sim-runtime -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DARIEC61850_BUILD_TESTS=ON + -DARIEC61850_BUILD_TOOLS=ON + + - name: Build native simulator and protocol regressions + run: >- + cmake --build build-sim-runtime + --target ariec61850_simulation_tests ariec61850_scl_tests + ariec61850_static_ied_server ariec61850_control_interop_probe + --parallel 2 + + - name: Run native simulator runtime regression + run: ./build-sim-runtime/ariec61850_simulation_tests + + - name: Run SCL regression + run: ./build-sim-runtime/ariec61850_scl_tests + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.3' + cache: true + + - name: Configure Qt simulator + run: >- + cmake -S apps/ied_simulator -B build-ied-simulator-qt -G Ninja + -DCMAKE_BUILD_TYPE=Release + + - name: Build Qt simulator and external probes + run: >- + cmake --build build-ied-simulator-qt + --target arstack_ied_simulator ariec61850_mms_read_probe + ariec61850_mms_urcb_gi_probe ariec61850_mms_brcb_event_probe + ariec61850_control_interop_probe + --parallel 2 + + - name: QML smoke test + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test + + - name: Prove portable profile adapter over external MMS wire + env: + ARSTACK_IEDSIM_TRACE_SERVER: '1' + run: >- + python3 apps/ied_simulator/test_gui_live_value.py + --app ./build-ied-simulator-qt/arstack_ied_simulator + --read-probe ./build-ied-simulator-qt + --scl ./tests/fixtures/scl/minimal-station-brcb.scd + + - name: Commit validated Phase 5A source + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add \ + CMakeLists.txt \ + MIGRATION_CHECKLIST.md \ + include/ariec61850/simulation/ied_simulator_profile.hpp \ + src/simulation/ied_simulator_profile.cpp \ + src/simulation/ied_simulator_engine.cpp \ + tests/test_simulation.cpp \ + apps/ied_simulator/src/IedSimulatorController.hpp \ + apps/ied_simulator/src/IedSimulatorController.cpp \ + tools/static_ied_server.cpp + git diff --cached --quiet && exit 0 + git commit -m 'feat(simulation): complete native simulator runtime core milestone' + git push origin HEAD:feat/complete-ariec61850-simulator-parity diff --git a/CMakeLists.txt b/CMakeLists.txt index c1bac2c47..67714eb50 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,8 @@ add_library(ariec61850_core src/evidence/pcap_equivalence.cpp src/scl/dataset_reference.cpp src/scl/parser.cpp + src/simulation/ied_simulator_profile.cpp + src/simulation/ied_simulator_engine.cpp src/comtrade/model.cpp src/comtrade/reader.cpp src/comtrade/channel_mapper.cpp @@ -83,9 +85,15 @@ add_library(ariec61850_mms_server_core src/mms/static_object_table.cpp src/mms/static_write.cpp src/mms/static_data_set_table.cpp + src/mms/static_direct_control.cpp + src/mms/static_setting_group.cpp src/mms/static_dispatcher.cpp src/mms/connection_runtime.cpp src/mms/static_server_session.cpp + src/mms/static_information_report.cpp + src/mms/static_report_connection.cpp + src/mms/static_urcb_objects.cpp + src/mms/static_urcb_runtime.cpp src/mms/static_brcb_runtime.cpp src/mms/static_brcb_control.cpp src/mms/static_brcb_objects.cpp @@ -175,6 +183,30 @@ if(ARIEC61850_BUILD_TOOLS) ariec61850_apply_warnings(ariec61850_mms_read_probe) ariec61850_apply_sanitizers(ariec61850_mms_read_probe) + add_executable(ariec61850_mms_urcb_gi_probe tools/mms_urcb_gi_probe.cpp) + target_link_libraries(ariec61850_mms_urcb_gi_probe PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_mms_urcb_gi_probe PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_urcb_gi_probe) + ariec61850_apply_sanitizers(ariec61850_mms_urcb_gi_probe) + + add_executable(ariec61850_mms_brcb_event_probe tools/mms_brcb_event_probe.cpp) + target_link_libraries(ariec61850_mms_brcb_event_probe PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_mms_brcb_event_probe PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_brcb_event_probe) + ariec61850_apply_sanitizers(ariec61850_mms_brcb_event_probe) + + add_executable(ariec61850_mms_sgcb_probe tools/mms_sgcb_probe.cpp) + target_link_libraries(ariec61850_mms_sgcb_probe PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_mms_sgcb_probe PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_sgcb_probe) + ariec61850_apply_sanitizers(ariec61850_mms_sgcb_probe) + + add_executable(ariec61850_control_interop_probe 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) + ariec61850_apply_warnings(ariec61850_control_interop_probe) + ariec61850_apply_sanitizers(ariec61850_control_interop_probe) + add_executable(ariec61850_rcb_contention_probe tools/rcb_contention_probe.cpp) target_link_libraries(ariec61850_rcb_contention_probe PRIVATE ARIEC61850::core) target_compile_features(ariec61850_rcb_contention_probe PRIVATE cxx_std_20) @@ -266,6 +298,15 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_scl_tests) add_test(NAME ariec61850_scl_tests COMMAND ariec61850_scl_tests) + add_executable(ariec61850_simulation_tests tests/test_simulation.cpp) + target_link_libraries(ariec61850_simulation_tests PRIVATE ARIEC61850::core) + target_compile_definitions(ariec61850_simulation_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_simulation_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_simulation_tests) + ariec61850_apply_sanitizers(ariec61850_simulation_tests) + add_test(NAME ariec61850_simulation_tests COMMAND ariec61850_simulation_tests) + add_executable(ariec61850_comtrade_tests tests/test_comtrade.cpp) target_link_libraries(ariec61850_comtrade_tests PRIVATE ARIEC61850::core) target_compile_definitions(ariec61850_comtrade_tests PRIVATE @@ -354,6 +395,13 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_rcb_selection_tests) add_test(NAME ariec61850_rcb_selection_tests COMMAND ariec61850_rcb_selection_tests) + add_executable(ariec61850_mms_static_setting_group_tests tests/test_mms_static_setting_group.cpp) + target_link_libraries(ariec61850_mms_static_setting_group_tests PRIVATE ARIEC61850::mms_server_core) + target_compile_features(ariec61850_mms_static_setting_group_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_static_setting_group_tests) + ariec61850_apply_sanitizers(ariec61850_mms_static_setting_group_tests) + add_test(NAME ariec61850_mms_static_setting_group_tests COMMAND ariec61850_mms_static_setting_group_tests) + add_executable(ariec61850_control_block_read_tests tests/test_control_block_read.cpp) target_link_libraries(ariec61850_control_block_read_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_control_block_read_tests PRIVATE cxx_std_20) @@ -462,6 +510,12 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_file_transfer_help COMMAND ariec61850_file_transfer --help) + add_test( + NAME ariec61850_mms_urcb_gi_probe_help + COMMAND ariec61850_mms_urcb_gi_probe --help) + add_test( + NAME ariec61850_mms_brcb_event_probe_help + COMMAND ariec61850_mms_brcb_event_probe --help) add_test( NAME ariec61850_static_ied_server_help COMMAND ariec61850_static_ied_server --help) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index c5b8c7fca..84609030c 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -192,6 +192,15 @@ behavioral oracle until laboratory interoperability is complete. ## Phase 5 — simulator and applications +### Phase 5A — native deterministic simulator domain runtime + +- [x] Native `IedSimulatorProfile` domain model ported from the C# simulation surface. +- [x] Native SCL-to-simulator profile builder uses the complete structural `DataTypeTemplates` projection; DataSets enrich but never shrink the model. +- [x] Selected/runtime IED identity remap, structural-only leaves, DataSets, URCB/BRCB metadata, configured `ctlModel`, Quality, and Timestamp defaults are regression-tested. +- [x] Deterministic `IedSimulatorEngine` reset/start/stop/manual mutation/step/snapshot state model ported without wall-clock dependencies in the core. +- [x] Qt simulator value presentation consumes the portable profile builder instead of maintaining a second leaf-discovery/default-classification implementation. +- [x] GCC strict regression plus Qt public-wire simulator regression guards the ported runtime adapter. + - [ ] 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. diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 24090a48f..a81855747 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(ARStackIedSimulator VERSION 0.1.0 LANGUAGES CXX) +project(ARStackIedSimulator VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -28,21 +28,74 @@ qt_add_executable(arstack_ied_simulator resources.qrc ) +if(WIN32) + set(ARSTACK_APP_ICON "${CMAKE_CURRENT_LIST_DIR}/../../assets/favicon.ico") + configure_file( + "${CMAKE_CURRENT_LIST_DIR}/packaging/windows/arstack_ied_simulator.rc.in" + "${CMAKE_CURRENT_BINARY_DIR}/arstack_ied_simulator.rc" + @ONLY) + target_sources( + arstack_ied_simulator + PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/arstack_ied_simulator.rc") +endif() + qt_add_qml_module(arstack_ied_simulator URI ARStack.IedSimulator VERSION 1.0 SOURCES - src/IedSimulatorController.cpp - src/IedSimulatorController.hpp + src/IedActivityModel.cpp + src/IedActivityModel.hpp + src/IedRuntimeGuardrails.cpp + src/IedRuntimeGuardrails.hpp + src/IedPointStore.cpp + src/IedPointStore.hpp + src/IedFleetController.cpp + src/IedFleetControllerAsync.cpp + src/IedFleetController.hpp + src/IedCommissioningModel.cpp + src/IedCommissioningRuntime.cpp + src/IedGoosePublication.cpp + src/IedCommissioningModel.hpp + src/IedNavigationModel.cpp + src/IedNavigationModel.hpp + src/IedSignalModel.cpp + src/IedSignalModel.hpp + src/MmsLiveTreeModel.cpp + src/MmsLiveTreeModel.hpp + src/MmsClientController.cpp + src/MmsClientController.hpp + src/MmsReportController.cpp + src/MmsReportController.hpp + src/MmsSettingGroupPolicy.hpp + src/MmsFileSettingsController.cpp + src/MmsFileSettingsController.hpp + src/SclWorkspaceController.cpp + src/SclWorkspaceController.hpp + src/GooseMonitorController.cpp + src/GooseMonitorController.hpp + src/ProductHardeningController.cpp + src/ProductHardeningController.hpp QML_FILES qml/Main.qml qml/AppTheme.qml qml/SurfaceCard.qml qml/ActionButton.qml + qml/IconAction.qml qml/StatusPill.qml qml/ToggleSwitch.qml qml/LaunchWorkspace.qml qml/RuntimeWorkspace.qml + qml/IedExplorer.qml + qml/FleetWorkspace.qml + qml/InteropWorkspace.qml + qml/MmsClientWorkspace.qml + qml/ReportsWorkspace.qml + qml/FilesWorkspace.qml + qml/SettingsWorkspace.qml + qml/SclWorkspace.qml + qml/GooseWorkspace.qml + qml/ActivityMonitor.qml + qml/CommissioningWorkspace.qml ) target_link_libraries(arstack_ied_simulator PRIVATE @@ -60,10 +113,216 @@ target_include_directories(arstack_ied_simulator PRIVATE target_compile_features(arstack_ied_simulator PRIVATE cxx_std_20) +add_executable(ied_setting_control_manifest_qa + src/ied_setting_control_manifest_qa.cpp) +target_link_libraries(ied_setting_control_manifest_qa PRIVATE + Qt6::Core) +target_include_directories(ied_setting_control_manifest_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src + ${CMAKE_CURRENT_LIST_DIR}/../../include) +target_compile_features(ied_setting_control_manifest_qa PRIVATE cxx_std_20) + +add_executable(ied_simulator_guardrails_qa + src/ied_simulator_guardrails_qa.cpp + src/IedActivityModel.cpp + src/IedActivityModel.hpp + src/IedRuntimeGuardrails.cpp + src/IedRuntimeGuardrails.hpp) +target_link_libraries(ied_simulator_guardrails_qa PRIVATE + Qt6::Core + Qt6::Qml) +target_include_directories(ied_simulator_guardrails_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_simulator_guardrails_qa PRIVATE cxx_std_20) + +add_executable(ied_simulator_responsiveness_qa + src/ied_simulator_responsiveness_qa.cpp + src/IedActivityModel.cpp + src/IedActivityModel.hpp + src/IedRuntimeGuardrails.cpp + src/IedRuntimeGuardrails.hpp + src/IedPointStore.cpp + src/IedPointStore.hpp + src/IedFleetController.cpp + src/IedFleetControllerAsync.cpp + src/IedFleetController.hpp + src/IedSignalModel.cpp + src/IedSignalModel.hpp) +target_link_libraries(ied_simulator_responsiveness_qa PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + ARIEC61850::core) +target_include_directories(ied_simulator_responsiveness_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_simulator_responsiveness_qa PRIVATE cxx_std_20) + +add_executable(ied_simulator_commissioning_qa + src/ied_simulator_commissioning_qa.cpp + src/IedCommissioningModel.cpp + src/IedCommissioningRuntime.cpp + src/IedGoosePublication.cpp + src/IedCommissioningModel.hpp + src/IedActivityModel.cpp + src/IedActivityModel.hpp + src/IedRuntimeGuardrails.cpp + src/IedRuntimeGuardrails.hpp + src/IedPointStore.cpp + src/IedPointStore.hpp + src/IedFleetController.cpp + src/IedFleetControllerAsync.cpp + src/IedFleetController.hpp) +target_link_libraries(ied_simulator_commissioning_qa PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + ARIEC61850::core) +target_include_directories(ied_simulator_commissioning_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_simulator_commissioning_qa PRIVATE cxx_std_20) + +add_executable(ied_simulator_runtime_commissioning_qa + src/ied_simulator_runtime_commissioning_qa.cpp + src/IedCommissioningModel.cpp + src/IedCommissioningRuntime.cpp + src/IedGoosePublication.cpp + src/IedCommissioningModel.hpp + src/IedActivityModel.cpp + src/IedActivityModel.hpp + src/IedRuntimeGuardrails.cpp + src/IedRuntimeGuardrails.hpp + src/IedPointStore.cpp + src/IedPointStore.hpp + src/IedFleetController.cpp + src/IedFleetControllerAsync.cpp + src/IedFleetController.hpp) +target_link_libraries(ied_simulator_runtime_commissioning_qa PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + ARIEC61850::core) +target_include_directories(ied_simulator_runtime_commissioning_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_simulator_runtime_commissioning_qa PRIVATE cxx_std_20) + +add_executable(ied_simulator_goose_publication_qa + src/ied_simulator_goose_publication_qa.cpp) +target_link_libraries(ied_simulator_goose_publication_qa PRIVATE + ARIEC61850::core) +target_compile_features(ied_simulator_goose_publication_qa PRIVATE cxx_std_20) + +add_executable(ied_goose_workspace_qa + src/ied_goose_workspace_qa.cpp + src/GooseMonitorController.cpp + src/GooseMonitorController.hpp) +target_link_libraries(ied_goose_workspace_qa PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + ARIEC61850::core) +target_include_directories(ied_goose_workspace_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_goose_workspace_qa PRIVATE cxx_std_20) + +add_executable(ied_mms_client_workbench_qa + src/ied_mms_client_workbench_qa.cpp + src/MmsLiveTreeModel.cpp + src/MmsLiveTreeModel.hpp + src/MmsClientController.cpp + src/MmsClientController.hpp + src/IedActivityModel.cpp + src/IedActivityModel.hpp + src/IedRuntimeGuardrails.cpp + src/IedRuntimeGuardrails.hpp + src/IedPointStore.cpp + src/IedPointStore.hpp + src/IedFleetController.cpp + src/IedFleetControllerAsync.cpp + src/IedFleetController.hpp) +target_link_libraries(ied_mms_client_workbench_qa PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + ariec61850_ied_simulator_server_core + ARIEC61850::core) +target_include_directories(ied_mms_client_workbench_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_mms_client_workbench_qa PRIVATE cxx_std_20) + +add_executable(ied_report_workspace_qa + src/ied_report_workspace_qa.cpp + src/MmsReportController.cpp + src/MmsReportController.hpp + src/IedActivityModel.cpp + src/IedActivityModel.hpp + src/IedRuntimeGuardrails.cpp + src/IedRuntimeGuardrails.hpp + src/IedPointStore.cpp + src/IedPointStore.hpp + src/IedFleetController.cpp + src/IedFleetControllerAsync.cpp + src/IedFleetController.hpp + src/IedSignalModel.cpp + src/IedSignalModel.hpp) +target_link_libraries(ied_report_workspace_qa PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + ARIEC61850::core) +target_include_directories(ied_report_workspace_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_report_workspace_qa PRIVATE cxx_std_20) + +add_executable(ied_file_settings_workspace_qa + src/ied_file_settings_workspace_qa.cpp + src/MmsFileSettingsController.cpp + src/MmsFileSettingsController.hpp + src/MmsSettingGroupPolicy.hpp) +target_link_libraries(ied_file_settings_workspace_qa PRIVATE + Qt6::Core + Qt6::Network + Qt6::Qml + ariec61850_ied_simulator_server_core + ARIEC61850::core) +target_include_directories(ied_file_settings_workspace_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_file_settings_workspace_qa PRIVATE cxx_std_20) + +add_executable(ied_scl_workspace_qa + src/ied_scl_workspace_qa.cpp + src/SclWorkspaceController.cpp + src/SclWorkspaceController.hpp) +target_link_libraries(ied_scl_workspace_qa PRIVATE + Qt6::Core + Qt6::Qml + ARIEC61850::core) +target_include_directories(ied_scl_workspace_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_scl_workspace_qa PRIVATE cxx_std_20) + +add_executable(ied_product_hardening_qa + src/ied_product_hardening_qa.cpp + src/ProductHardeningController.cpp + src/ProductHardeningController.hpp) +target_link_libraries(ied_product_hardening_qa PRIVATE + Qt6::Core + Qt6::Qml) +target_include_directories(ied_product_hardening_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_product_hardening_qa PRIVATE cxx_std_20) + # The regular library intentionally keeps a strict portable connection runtime. # The desktop simulator needs the separate compatibility runtime validated -# against external-client AARQ, selector, browse, Identify and FileDirectory -# traffic. Keep the two runtimes isolated so the embedded core is unchanged. +# against common engineering-client AARQ, selector, browse, Identify and +# FileDirectory traffic. Keep the two runtimes isolated so embedded behavior is +# unchanged. Reporting is wired here as an application feature over the same +# bounded per-association server rather than by launching another protocol fork. add_library(ariec61850_ied_simulator_server_core STATIC ${CMAKE_CURRENT_LIST_DIR}/../../src/asn1/ber_span_reader.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/osi/tpkt_span.cpp @@ -78,12 +337,23 @@ add_library(ariec61850_ied_simulator_server_core STATIC ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/write_span.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/data_set_span.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/information_report_span.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/buffered_selective_information_report_span.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_object_table.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_write.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_data_set_table.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_direct_control.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_setting_group.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_dispatcher.cpp ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/connection_runtime_iedsim.cpp - ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_server_session.cpp) + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_server_session.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_information_report.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_report_connection.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_urcb_objects.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_urcb_runtime.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_brcb_runtime.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_brcb_control.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_brcb_objects.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_brcb_connection.cpp) target_include_directories(ariec61850_ied_simulator_server_core PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../../include) target_compile_features(ariec61850_ied_simulator_server_core PUBLIC cxx_std_20) @@ -98,8 +368,26 @@ if(WIN32) target_link_libraries(ariec61850_ied_simulator_server PRIVATE ws2_32) endif() target_compile_features(ariec61850_ied_simulator_server PRIVATE cxx_std_20) +add_dependencies(ied_simulator_responsiveness_qa ariec61850_ied_simulator_server) +add_dependencies(ied_mms_client_workbench_qa ariec61850_ied_simulator_server) +add_dependencies(ied_report_workspace_qa ariec61850_ied_simulator_server) +add_dependencies(ied_file_settings_workspace_qa ariec61850_ied_simulator_server) +add_dependencies( + ied_simulator_runtime_commissioning_qa + ariec61850_ied_simulator_server + ariec61850_mms_read_probe + ariec61850_mms_brcb_event_probe) -add_dependencies(arstack_ied_simulator ariec61850_ied_simulator_server) +# The public-wire URCB/BRCB regression clients are root ARIEC61850 tool targets. +# Reuse them here instead of defining GUI-local copies so the same sources are +# compiled by the normal GCC/Clang/MSVC toolchain matrix and by the Qt harness. +add_dependencies( + arstack_ied_simulator + ariec61850_ied_simulator_server + ariec61850_mms_urcb_gi_probe + ariec61850_mms_brcb_event_probe + ariec61850_mms_sgcb_probe + ariec61850_control_interop_probe) if(WIN32) set_target_properties(arstack_ied_simulator PROPERTIES WIN32_EXECUTABLE TRUE) diff --git a/apps/ied_simulator/EXTERNAL_INTEROP_ACCEPTANCE_R2.md b/apps/ied_simulator/EXTERNAL_INTEROP_ACCEPTANCE_R2.md new file mode 100644 index 000000000..b81b95ad8 --- /dev/null +++ b/apps/ied_simulator/EXTERNAL_INTEROP_ACCEPTANCE_R2.md @@ -0,0 +1,150 @@ +# external IEC 61850 client External Acceptance R2 + +Date: 2026-09-17 + +This ledger records external IEC 61850 client evidence separately from synthetic/CI evidence. It is intentionally conservative: capabilities are only marked accepted when observed with the real client. + +## Locked external baseline + +Tested Windows runtime lineage: `b8cfe4442e1c570de4158657599ba72962873bca` (runtime-identical CI-only follow-up `23130da7fbfb1d748dc63e26f8676bc549936a0d`). + +Model: `reference IEC 61850 model_084F06BCU_AA1E1F06R4.cid` +Endpoint: `192.168.81.103:102` + +Observed with real external IEC 61850 client: + +- MMS association/discovery succeeds. +- Reports hierarchy is visible in external IEC 61850 client. +- Application BRCBs `Unbuffer01`, `Unbuffer02`, `Buffer01`, and `Buffer02` are visible in the Reports workspace. +- MMS FileDirectory/Open/Read/Close succeeds against the configured ARStack file-service root. +- URCB enable/GI produces an InformationReport; simulator diagnostics recorded `LLN0$RP$Unbuffer01`, `SqNum 1`, reason `4`. + +These surfaces are treated as retained regression requirements. Reporting fixes must not weaken discovery, report inventory, or file transfer. + +## Original R2 reporting blocker captured on wire + +Capture: `Arstack_Reporting.pcapng` supplied from the real-client session. + +external IEC 61850 client performs the following BRCB edit sequence on `LLN0$BR$Buffer01`: + +1. Disable `RptEna`. +2. Send one MMS Write containing two variables in order: + - `TrgOps` as MMS BIT STRING, unused-bits `2`, value `0x7C`. + - `RptEna = true`. +3. The pre-fix ARStack server returns Confirmed-Error instead of a two-result WriteResponse. + +Root cause was twofold: + +- Production simulator dispatch policy allowed only one variable per MMS Write. +- BRCB `TrgOps` was exposed read-only even though the URCB path already supported mutable trigger options. + +## BRCB fix under external verification + +Implementation head: `ab8768338e17fd630c9c464e3df9314ae3d177cf` + +The fix: + +- allows a bounded host-side MMS Write transaction of up to 16 variables (one complete BRCB attribute set), +- makes BRCB `TrgOps` mutable only while the report control is disabled, +- keeps BRCB configuration behind the existing association/Owner reservation gate, +- validates the six-bit Trigger Options BIT STRING and reserved bits, +- makes the runtime scheduler consume the mutable TrgOps value, +- preserves BRCB `IntgPd` when compiling the simulator manifest into the runtime definition, +- adds a hard-profile regression reproducing the real external IEC 61850 client `TrgOps=0x7C + RptEna=true` two-variable Write transaction. + +The BRCB hard-profile regression passed before this source commit was pushed. + +## P0 reporting hardening checkpoint + +The external acceptance boundary below remains conservative, but protocol-level hardening is retained as regression requirements. + +### P0.2 — canonical InformationReport payload order + +Implementation commit: `6a7dfec7b9cb82460169eb2188a4e384835e104f`. + +The owning report mapper now consumes the IEC 61850 report access-result groups in canonical order after the inclusion bitstring: + +1. `DataRef*` when selected by `OptFlds`, +2. process `Value*`, +3. `ReasonForInclusion*` when selected. + +Regression coverage includes all four DataRef/Reason combinations and rejects the former false-green `Value-before-DataRef` fixture layout. The selective BRCB encoder regression separately locks the same wire order. The runtime client fixture was subsequently aligned in `bff75e60694ccf4997223016683863d760fa7d98` so retained end-to-end tests no longer encode the legacy ordering. + +### P0.3 — negotiated COTP TPDU segmentation + +Implementation commit: `e1937d90c2c57bdb53f840845cce3746e22a62af`. + +Outbound server TSDUs are now segmented into one or more complete TPKT/COTP Data frames according to the negotiated COTP TPDU-size parameter. Intermediate Data TPDUs clear EOT and only the final segment sets EOT. The implementation uses bounded span-based encoding and does not introduce a hidden heap-backed transport queue. + +Focused regression coverage negotiates a 128-byte TPDU, requires multi-segment association and MMS responses, verifies every emitted TPKT remains inside the negotiated TPDU limit, and reassembles a segmented BRCB InformationReport before decoding it. This directly protects the field-capture case where external IEC 61850 client negotiates a bounded COTP TPDU and the server must not emit oversized single-frame responses. + +A follow-up regression-only lifetime defect was corrected in `3e65ba19b6f8682640b711425e6f24ecef6ccfee`: the decoded `MmsInformationReportView` now keeps its segmented reassembly backing storage alive for the complete assertion scope instead of referencing a local buffer that had already gone out of scope. No production protocol behavior changed in that follow-up. + +### P0.2/P0.3 exact-head synthetic closure + +Synthetic/protocol acceptance for P0.2 and P0.3 closed on exact head `3e65ba19b6f8682640b711425e6f24ecef6ccfee`. + +All 15 PR workflows attached to that exact head completed successfully, including MMS R1-R2 Server CI, external IEC 61850 client Parity Server CI, BRCB Hard Profile CI, Embedded Profile CI, Dynamic RCB Trial Harness CI, Control Interop Harness CI, C++ CI, IED Simulator Qt, and IED Simulator Release Hardening. + +The exact-head Windows RC was produced by IED Simulator Release Hardening run `35205603573`: + +- artifact: `arstack-iec61850-workbench-windows-rc` +- artifact ID: `10490192212` +- digest: `sha256:8e3533b7d681f504a3a50739f05252366740c86ede8903fbf4e5852835c3f1fa` +- contains `ARStack-IEC61850-Workbench-Setup-win64.exe` +- contains `ARStack-IEC61850-Workbench-portable-win64.zip` + +These P0.2/P0.3 results are synthetic/protocol regression evidence, not a replacement for real-client acceptance. + +## R4 real-client reporting evidence + +Capture: `Arstack_DiscoveryIED_R4_rcb.pcapng` plus simulator diagnostics from the same real external vendor external IEC 61850 client session. + +### BRCB result — externally accepted + +`LLN0$BR$Buffer01` now enables and reports successfully both when external IEC 61850 client leaves Trigger Options / Optional Fields at their IED values and when the user modifies those fields in external IEC 61850 client. This closes the original BRCB write-interoperability symptom for the tested R4 session. BRCB behavior remains a retained regression requirement. + +### URCB result — remaining OptFlds interoperability gap isolated + +`LLN0$RP$Unbuffer01` enables and reports when Trigger Options / Optional Fields are left unchanged. The R4 diagnostics also show repeated `Unbuffer01` reports after enable, so the remaining failure is not the URCB report scheduler or InformationReport emission path. + +When the user modifies Trigger Options / Optional Fields, external IEC 61850 client sends one grouped MMS Write containing, in order: + +1. `LLN0$RP$Unbuffer01$IntgPd = 5000`, +2. `LLN0$RP$Unbuffer01$TrgOps` BIT STRING bytes `02 FC`, +3. `LLN0$RP$Unbuffer01$OptFlds` BIT STRING bytes `06 7B 80`, +4. `LLN0$RP$Unbuffer01$RptEna = true`. + +The captured ARStack WriteResponse returns success for `IntgPd`, success for `TrgOps`, failure code `11` (`object-value-invalid`) for `OptFlds`, then success for `RptEna`. external IEC 61850 client surfaces that third result as `parameter-value-inconsistent`. Therefore this R4 failure is specifically the URCB `OptFlds=0x7B80` write, not `TrgOps` and not generic report enable. + +The external IEC 61850 client value uses its generic RCB Options editor and includes first-octet bits for `BufferOverflow` and `EntryID`, which have meaning for BRCB but are not emitted by URCB reports. ARStack's strict URCB validator previously accepted only first-octet mask `0x7C`, so `0x7B80` was rejected. + +### P0.4 — URCB generic OptFlds normalization + +Implementation commit: `2361809ab319a7a57fd23100d0e1cb7b96972cb2`. + +The URCB runtime now: + +- accepts client first-octet Optional Fields through mask `0x7F`, so external IEC 61850 client's generic `0x7B80` request is interoperable, +- normalizes the effective URCB first octet through `0x7C`, therefore captured `0x7B80` becomes effective URCB `0x7880`, +- continues to reject the unsupported segmentation bit in the second octet, +- never advertises or encodes BRCB-only `BufferOverflow` / `EntryID` metadata in an URCB InformationReport, +- applies the same normalization during initialization and later OptFlds writes. + +Focused host URCB regression, embedded URCB hard-profile regression, and the production external IEC 61850 client server build all passed in bootstrap run `35219711196` before the source commit was pushed. Temporary bootstrap files were removed after that proof. + +P0.4 remains externally open until a new exact-head Windows build proves the modified URCB path in real external IEC 61850 client. + +## External closure condition + +R2/R4 reporting remains open until the new P0.4 exact-head Windows artifact is tested with real external IEC 61850 client and proves: + +- modified `LLN0$RP$Unbuffer01` Trigger Options / Optional Fields no longer produce `parameter-value-inconsistent`, +- the grouped URCB write and subsequent `RptEna=true` result in an enabled URCB, +- changed trigger selection actually controls emitted URCB reports, +- GI/report delivery remains functional, +- the already-accepted BRCB modified Trigger Options / Optional Fields path does not regress, +- segmented responses remain transparent to external IEC 61850 client under its negotiated COTP TPDU size, +- the locked discovery/report-inventory/file-transfer baseline still passes. + +Do not close Issue #95 or merge PR #82 solely from synthetic CI evidence. diff --git a/apps/ied_simulator/INTEROP_DISCOVERY_PROFILE_V1.md b/apps/ied_simulator/INTEROP_DISCOVERY_PROFILE_V1.md new file mode 100644 index 000000000..51ae99aaa --- /dev/null +++ b/apps/ied_simulator/INTEROP_DISCOVERY_PROFILE_V1.md @@ -0,0 +1,121 @@ +# external IEC 61850 client Discovery Profile v1 + +This file records the measured vendor/external vendor-style external IEC 61850 client discovery pattern used as an external interoperability target for the ARStack IEC 61850 Workbench simulator. + +It is an evidence profile, not a claim that external external IEC 61850 client interoperability is complete. Real-client validation remains the release gate. + +## Transport + +| Field | Measured value | +| --- | ---: | +| TCP port | 102 | +| COTP Source TSAP | `0000` | +| COTP Destination TSAP | `0001` | +| COTP TPDU size | 1024 bytes | + +## MMS Initiate + +| Field | Measured value | +| --- | ---: | +| `localDetailCalling` | 65000 | +| `maxOutstandingCalling` | 10 | +| `maxOutstandingCalled` | 10 | +| `nestingLevel` | 5 | + +ARStack IED Simulator parity runtime currently negotiates the same server ceilings: MMS PDU 65000, outstanding calling/called 10/10, nesting level 5, and COTP TPDU code `0x0A` (1024 bytes). Negotiated values remain the minimum of peer and server limits. + +## Invoke behavior + +- first confirmed-service invoke ID: **1** +- last observed invoke ID: **418** +- progression: **strict +1** +- request flow: predominantly synchronous / ordered + +Server contract: echo the request invoke ID exactly. Do not require this specific starting value for generic MMS interoperability. + +## Discovery service distribution + +| MMS service | Count | +| --- | ---: | +| GetNameList | 140 | +| GetVariableAccessAttributes | 119 | +| Read | 157 | +| GetNamedVariableListAttributes | 2 | + +No mutation was observed in this discovery capture: + +- Write: 0 +- Control: 0 +- RCB enable: 0 + +## Initial discovery + +The first directory request is: + +- `GetNameList` +- object class: **Domain** +- scope: **VMD** + +Observed result: **32 MMS domains**. + +## Named-variable discovery + +external IEC 61850 client then walks NamedVariables per domain. + +Required behavior: + +- domain-specific `GetNameList(NamedVariable)`; +- pagination supported; +- `continueAfter` is the **last identifier returned by the previous page**; +- no duplicate or missing identifiers across page boundaries; +- stable deterministic ordering for the lifetime of the association/model generation. + +Measured profile: + +- NamedVariable pages: **105** +- continuation pages: approximately **73** + +This exceeds the assumptions of a dispatcher that first materializes an entire domain directory into a small fixed identifier array. ARStack therefore must page directly from the bounded static object table rather than require the complete directory to fit in one intermediate buffer. + +## Type discovery + +- `GetVariableAccessAttributes` is used primarily at Logical Node roots. +- Type discovery is interleaved with `GetNameList`; it is not a separate all-at-once phase. + +The simulator must therefore keep root TypeSpecification and leaf aliases mutually consistent throughout discovery. + +## DataSet discovery + +Observed sequence: + +1. `GetNameList(NamedVariableList)` +2. `GetNamedVariableListAttributes` + +The measured discovery performs two `GetNamedVariableListAttributes` operations. + +ARStack also retains compatibility for VMD-specific, AA-specific and domain-specific NamedVariableList probes observed in the known-good ARIEC61850 interoperability trace. + +## Professional interoperability gates + +A build may be described as matching this discovery profile only when all of the following hold: + +1. COTP and MMS association negotiation completes without fallback hacks. +2. Initial VMD Domain discovery returns the complete domain set. +3. Domain-specific NamedVariable pagination can exceed the internal one-page identifier capacity and still complete using `continueAfter`. +4. Invoke IDs are echoed exactly across the full ordered walk. +5. GVAA and Read operations remain aligned with the requested objects and one Read AccessResult is retained per requested variable/member. +6. DataSet discovery completes through `GetNamedVariableListAttributes`. +7. The association remains active through the complete discovery sequence. +8. A real external IEC 61850 client client reproduces the progression; CI/loopback evidence alone is not relabelled as vendor interoperability. + +## Current implementation evidence + +The external-profile hardening series that consumes this profile includes: + +- Read compatibility for list-of-variable, DataSet `variableListName`, and the proven unwrapped compatibility form; +- bounded 128-variable bulk Read support covering the observed 78-variable discovery Read; +- VMD / AA / domain DataSet discovery compatibility; +- streaming domain-specific NamedVariable pagination so a large directory is not rejected merely because its total identifier count exceeds `MmsServiceSpanCodec::maximum_identifiers`; +- an embedded hard-profile regression with 160 NamedVariables in one domain, multiple ordered invoke IDs, exact `continueAfter`, and final `moreFollows=false`. + +The real-client acceptance gate remains open until an external external IEC 61850 client run progresses past the previous `GetNameList -> Read invoke 2 -> close` failure point and completes the later discovery stages. diff --git a/apps/ied_simulator/INTEROP_MANUAL_TEST.md b/apps/ied_simulator/INTEROP_MANUAL_TEST.md new file mode 100644 index 000000000..95a84efd5 --- /dev/null +++ b/apps/ied_simulator/INTEROP_MANUAL_TEST.md @@ -0,0 +1,61 @@ +# ARStack IED Simulator -> external IEC 61850 client Manual Acceptance + +This is the manual Windows acceptance gate for the simulator branch. Hosted CI proves the same MMS endpoint with ARStack wire probes; this procedure adds an independent external IEC 61850 client client check. It is interoperability evidence, not IEC 61850 conformance certification. + +## Package layout + +Keep these files in the same extracted directory: + +- `arstack_ied_simulator.exe` +- `ariec61850_ied_simulator_server.exe` +- the Qt DLL/plugin/QML runtime deployed by `windeployqt` +- `minimal-station-brcb.scd` + +The GUI intentionally launches `ariec61850_ied_simulator_server.exe` from beside the GUI executable. + +## Baseline connection + +1. Start `arstack_ied_simulator.exe`. +2. Open `minimal-station-brcb.scd`. +3. Select IED `SIM_IED`. +4. Select listener `127.0.0.1` for a same-PC test. +5. Use MMS port `102` unless another process already owns it. +6. Press **Start** and confirm the simulator reports that the MMS endpoint is listening. +7. In external IEC 61850 client, create/connect an IED at `127.0.0.1`, port `102`. + +If Windows Firewall prompts for the simulator server, allow Private-network access for the local interoperability test. + +## Acceptance gates + +### A. Association and model browse + +external IEC 61850 client must associate without TCP reset or reconnect loops. Browse must expose domain `MU01LD0` and the logical-node/object hierarchy from the SCL, including leaves that are not members of a DataSet. The structural-only regression leaf is `TCTR1$MX$AmpUnmapped$instMag$i`. + +### B. Read exact leaf values + +Confirm external IEC 61850 client can read at least: + +- `MU01LD0/TCTR1$MX$Amp$instMag$i` +- `MU01LD0/XCBR1$ST$Pos$stVal` +- `MU01LD0/XCBR1$ST$Pos$q` +- `MU01LD0/XCBR1$ST$Pos$t` + +Quality must be represented as IEC 61850 Quality / MMS BIT STRING(13), and Timestamp as MMS UTC time rather than text placeholders. + +### C. Live GUI edit without reconnect + +Keep the external IEC 61850 client association open. In the simulator GUI, change a writable leaf value, then read the same object again in external IEC 61850 client. The new value must be visible on the existing association. Repeat once for a scalar and once for either `q` or `t`. + +### D. Reporting + +Browse report-control objects under `LLN0` and verify the fixture exposes both URCB and BRCB controls. For the first independent-client gate, enable `LLN0$RP$URCB01`, request GI, and require an InformationReport on the same association. Then enable `LLN0$BR$BRCB01`, change a member of `LLN0$dsGO` in the simulator, and require a buffered report containing an EntryID. + +### E. Controls + +The fixture carries Direct/SBO normal/enhanced control models for `GGIO1.SPCSO1` through `GGIO1.SPCSO4`. After browse/read/reporting gates are green, exercise the models in external IEC 61850 client. A successful operation must update the associated `ST` value; enhanced models must complete through CommandTermination rather than treating the confirmed MMS Write response as final command completion. + +## Result to capture + +Capture one external IEC 61850 client screenshot showing the connected server tree, one screenshot showing a live value changed from the GUI and re-read without reconnect, and if reporting is tested, one report view showing GI/event delivery. Record the ARStack commit SHA, external IEC 61850 client version, Windows version, selected IP/port, and whether each gate A-E passed. + +If a gate fails, preserve the exact object reference, external IEC 61850 client error text, simulator status/log output, and a short Wireshark capture filtered to `tcp.port == 102`. That evidence is sufficient to compare the external client behavior with the already-automated ARStack wire regression without guessing. diff --git a/apps/ied_simulator/INTEROP_PRODUCT_PARITY_ROADMAP.md b/apps/ied_simulator/INTEROP_PRODUCT_PARITY_ROADMAP.md new file mode 100644 index 000000000..f85a023c2 --- /dev/null +++ b/apps/ied_simulator/INTEROP_PRODUCT_PARITY_ROADMAP.md @@ -0,0 +1,305 @@ +# external IEC 61850 client Product Parity Roadmap + +This roadmap defines the desktop-product track for turning existing ARStack IEC 61850 capabilities into a practical engineering workbench comparable in daily workflow to external IEC 61850 client. + +It is intentionally separate from the broader ARStack platform roadmap. ARStack may continue to advance Sampled Values, PTP, embedded/ESP targets, process-bus instrumentation, and other protocol/platform work, but those items are **not blockers** for the external IEC 61850 client-alternative desktop product. + +## Product objective + +Ship one desktop engineering application that surfaces the mature ARStack protocol/runtime capabilities through a coherent technician workflow instead of continuing protocol development feature-by-feature. + +The application converges on one workbench with these top-level workspaces: + +- **IED Connection** — connect, associate, discover, browse, read/write, inspect live model; +- **Reports** — DataSet, URCB/BRCB, static/dynamic RCB commissioning and received reports; +- **GOOSE** — Monitor and Publisher in one workspace; +- **Files** — remote MMS file browsing/download and only the mutation features justified by parity needs; +- **Settings** — Setting Group inspection and guarded activation/edit transactions; +- **SCL** — reconstructed/live engineering model, export and Save As; +- **Simulator** — the existing A-M simulator/fleet work, retained in the same desktop application rather than split into another product. + +Protocol/core code remains modular. The product UX does not expose the user to library boundaries that are irrelevant to commissioning work. + +## Current leverage and confirmed boundaries + +The product roadmap is based on the repository state, not on rewriting IEC 61850 from scratch: + +- MMS association, GetNameList, exact type discovery, Read, guarded Write, DataSet discovery and live LD/LN/DO/DA projection underpin the client workbench. +- Dynamic and static RCB paths include inventory/planning/session/GI/lifecycle evidence; BRCB retained replay, EntryID/PurgeBuf/overflow, Owner/ResvTms and cleanup semantics exist at their explicitly proven evidence levels. +- Live discovery exposes DataSet inventory and GOOSE/SV/SettingGroup/Log control-block identities with exact-attribute deep reads where implemented. +- Stage M closes the desktop simulator's real Layer-2 GOOSE publication path with explicit NIC binding, SCL addressing, sequencing/retransmission semantics, bounded lifecycle and PCAP evidence. +- MMS file service remains read-oriented: FileDirectory + FileOpen/FileRead/FileClose download. Upload/delete/rename are not claimed. +- Setting Group support includes SGCB discovery/deep-read and guarded ActSG activation/verification; full edit remains unclaimed until `EditSG -> edit SE values -> CnfEdit` exists end-to-end. +- SCL supports exact-source preservation plus deterministic canonical reconstruction/export for modeled semantics, explicit Ed1/Ed2/Ed2.1 conversion, and guarded SCD/ICD/CID output. Canonical conversion intentionally does **not** claim lossless vendor-extension preservation; exact-source Save As is the lossless path for original source bytes. +- Release hardening is closed with persistent crash-safe product state, bounded diagnostics export, reconnect/application-close soak, Windows deployed/installed verification, Npcap readiness guidance, cross-platform CI and an explicit evidence ledger. + +## Scope rule after Milestone M + +After M, choose work by **external IEC 61850 client product impact**, not by breadth of the IEC 61850 platform. + +Do **not** make Sampled Values runtime simulation, PTP, ESP32/embedded work, or broader process-bus instrumentation prerequisites for the desktop external IEC 61850 client-alternative release. Those remain valuable ARStack platform capabilities on their own track. + +The completed desktop-product sequence is: + +**M GOOSE TX ✅ -> N MMS Client/Discovery ✅ -> O RCB/Reports ✅ -> P GOOSE Monitor/Publisher ✅ -> Q Files + Setting Groups ✅ -> R SCL Export/Ed1-Ed2 ✅ -> S Release Hardening ✅** + +## Milestone M — Real GOOSE Publication — CLOSED + +Stage M is the bridge from offline GOOSE foundations to the simulator/product publication surface. + +Closed capability: + +- real Layer-2 GOOSE TX from configured GSEControl + Address; +- explicit Ethernet interface binding with fail-closed behavior; +- APPID, destination MAC, VLAN ID/priority, gocbRef, DataSet, goID and ConfRev from SCL; +- stNum/sqNum/timeAllowedToLive and retransmission sequencing; +- bounded publishers/members, deterministic raw-transport teardown; +- commissioning Start/Stop/status/counters; +- decode-backed PCAP evidence and negative interface-binding QA. + +No additional SV milestone follows M on this desktop product track. + +## Milestone N — MMS Client & IED Discovery Workbench — CLOSED + +### Closed capability + +- explicit IP/hostname + TCP port connection profile with Connect, Disconnect and Reconnect state; +- persistent MMS association reused for live discovery and subsequent Read/Write operations; +- canonical live discovery through `MmsTcpLiveDiscoverySession` and `MmsLiveModelBuilder`, with no GUI-specific protocol stack or CLI subprocess; +- typed/virtualized `IED -> LD -> LN -> DO -> DA` tree with expand/collapse and search/filter; +- selected DA inspection for reference, FC, exact MMS type, SCL type, type evidence, value, and sibling quality/timestamp when available; +- selected Read plus bounded visible-range refresh capped at 64 MMS variables per operator action; +- guarded exact-type scalar Write only for FC `SP`, `CF`, `DC`, and `SE`; `ST`, `MX`, `CO`, unknown, structured, array, and unrepresentable values fail closed; +- no automatic Write retry; successful Write is followed by verification Read on the same association; +- one bounded client I/O worker, stop-token cancellation, monotonic session generation, and stale-completion rejection across disconnect/reconnect; +- diagnostic presentation bounded to 128 entries; +- deterministic loopback QA using the real static MMS compatibility runtime and an explicitly writable SP fixture; +- the existing A-M simulator automation and `--scl` path remain intact under the `Simulator` workspace. + +### Closure evidence + +Milestone N implementation head `9cc87444c5c48c45785f401dbee490966558d8bf` passed **IED Simulator Qt #515**, run `34757769779`, job `103725871734`. + +- `MMS_CLIENT_WORKBENCH_PASS ld=1 ln=1 do=1 da=1 read=7 write_verified=9 exact_type=integer fc=SP persistent_association=pass` +- `MMS_CLIENT_STALE_SESSION_NEGATIVE_PASS stale_generation=1 current_generation=2 stale_state_not_applied=true bounded_io_worker=1` +- Build/QML smoke and retained A-M simulator regressions passed on the same implementation head. +- **MMS R1-R2 Server CI #139**, run `34757769750`, passed Linux GCC, Linux Clang, and Windows MSVC. + +Documentation-only closure commits after the proven N head do not reopen N unless executable/protocol behavior changes. + +## Milestone O — Report / RCB Commissioning — CLOSED + +### Closed capability + +- DataSet browser with ordered members and canonical references; +- URCB/BRCB inventory and selected RCB inspector; +- static and dynamic candidate paths surfaced through the canonical `MmsRcbPoolSelector`; +- reservation/ownership state plus explicit Enable, Enable + GI, Disable/Release and Retry Cleanup actions; +- RptID, DatSet, ConfRev, BufTm, IntgPd, SqNum, RptEna, Resv/ResvTms/Owner, TrgOps and OptFlds inspection; +- received-report stream with decoded values, reason-for-inclusion, sequence, EntryID, overflow and duplicate/gap/reset indicators; +- BRCB EntryID/PurgeBuf capability and overflow indicators surfaced where present without claiming replay/rewind mutation support that is not exposed by the reusable client runtime; +- bounded one-worker client I/O and non-fatal bounded idle polling; +- strict single-candidate RCB policy, polling fallback disabled, and no automatic mutation/failover after ambiguous failure; +- active reconnect QA proving prior RptEna/Resv ownership is cleaned before association replacement. + +### Closure evidence + +Milestone O product implementation landed in `c8f33e1259cf066de3b359db30eda41726345292`; strict explicit-URCB policy correction is `ceb5fd021523643b4ca63d197a8bb102b2a52993`, and final lifecycle proof head is `f2cca6971e6697365e99535ad187a7dacc8e12a4`. + +That final head passed **IED Simulator Qt #531**, run `34786507802`, job `103802862092`: + +- `REPORTS_WORKBENCH_PASS datasets=2 rcbs=2 static_candidates=2 dynamic_candidates=2 members=3 gi_reports=1 urcb=pass brcb_inventory=pass entryid_indicator=pass cleanup=pass active_reconnect_cleanup=pass` +- `REPORTS_WORKBENCH_NEGATIVE_PASS no_connection_enable=rejected inactive_disable=rejected invalid_selection=rejected strict_single_candidate=true idle_poll_nonfatal=true reconnect_reacquire=true` +- retained N and A-M regressions also passed. + +Documentation-only closure commits after the proven O head do not reopen O unless executable/protocol behavior changes. + +## Milestone P — GOOSE Workspace: Monitor + Publisher — CLOSED + +### Monitor + +- explicit NIC selection; +- capture EtherType 0x88B8; +- bounded publisher/stream table; +- destination/source MAC, APPID, VLAN, goID, gocbRef, DataSet, ConfRev; +- stNum, sqNum, TTL and live decoded values; +- timeout, duplicate, stale/out-of-order and sequence-reset indicators; +- bounded packet/event retention and optional PCAP save. + +### Publisher + +- reuse Stage M real Layer-2 publisher and SCL-derived addressing; +- expose configured streams and current publisher runtime state; +- allow deliberate test stimulus through canonical data state, not a duplicate GOOSE-only value store. + +### Closure evidence + +Milestone P implementation head `94bbf0a707ace442da1f0c4b0c18ab806f873886` passed **IED Simulator Qt #523**, run `34759978848`, job `103730994758`. + +- `GOOSE_WORKSPACE_PASS pcap_packets=3 streams=256 appid=0x1001 values=decoded duplicate=pass gap=pass regression=pass state_jump=pass state_regression=pass timeout=pass pcap_export=pass` +- `GOOSE_MONITOR_NEGATIVE_PASS malformed=rejected oversize=rejected capacity=rejected missing_interface=rejected explicit_binding=required bounded_streams=256 bounded_members=256 bounded_events=256 retained_packets=4096` +- Stage M wire/negative gates remained green. + +Documentation-only closure commits after the proven P head do not reopen P unless executable/protocol behavior changes. + +## Milestone Q — File Transfer + Setting Groups — CLOSED + +### Closed Files capability + +- one persistent MMS utility association shared by Files and Settings; +- bounded FileDirectory continuation/pagination with remote path, size and modified metadata where supplied; +- streaming FileOpen/FileRead/FileClose download through canonical `MmsFileTransferRuntime`; +- progress and cancellation with stop-token propagation; +- deterministic FileClose/partial-output cleanup on failure; +- upload/delete/rename remain intentionally unexposed. + +### Closed Setting Groups capability + +- SGCB inventory and deep-read of `NumOfSG`, `ActSG`, `EditSG`, `CnfEdit` and `LActTm`; +- guarded Activate Group through the canonical exact-type MMS write path; +- successful activation followed by verification Read on the same association; +- invalid group numbers and edit-in-progress state fail closed; +- no automatic activation retry after ambiguous write outcome; +- full Setting Group editing is explicitly **not** claimed. + +### Closure evidence + +Milestone Q implementation landed in `8cb7ff3f63e12aaa03aebc54d2356f0a874829d8`; QML hardening is `2925f76814101c0b279afc885205eb151e262c25`, and warning-only cleanup is `86fde29fdc22dfcfc5aeb631f4962ce99202e669`. + +The behavioral proof passed **IED Simulator Qt #537**, run `34793801291`, job `103822906491`; the cleanup head passed **IED Simulator Qt #539**, run `34794848866`, job `103825843204`. + +- `FILE_SETTINGS_WORKSPACE_PASS file_pages=2 file_entries=3 download_bytes=6 file_close=pass sgcb=1 attributes=5 actsg_verified=3 activation_writes=1 persistent_association=pass full_sg_edit_claimed=false` +- `FILE_SETTINGS_WORKSPACE_NEGATIVE_PASS disconnected_ops=rejected invalid_group=rejected edit_in_progress=rejected sink_failure_cleanup=pass bounded_pages=4 bounded_entries=8 no_activation_retry=true` + +Documentation-only closure commits after the proven Q heads do not reopen Q unless executable/protocol behavior changes. + +## Milestone R — SCL Reconstruction / Export / Save As — CLOSED + +Milestone R closes the SCL product gap without pretending normalized reconstruction is byte-for-byte vendor lossless. + +### Closed capability + +- dedicated top-level SCL workspace backed by canonical `SclParser` / `SclDocument`; +- bounded one-worker source loading with a 64 MiB cap and cancellable operations; +- exact-source Save As preserving original bytes and verifying modeled semantic equivalence; +- deterministic canonical reconstruction/export from `SclDocument` for the semantic surface ARStack models; +- deterministic DataTypeTemplates and reference generation; +- configured DAI values, DataSet/FCDA, ReportControl, GOOSE, Sampled Values and Communication/address reconstruction where modeled; +- explicit normalized Edition 1, Edition 2 and Edition 2.1 policy; +- SCD canonical output plus guarded single-IED ICD/CID output; +- atomic write, reread, reparse, semantic round-trip comparison and repeated-output determinism; +- fail-closed rejection for unsupported profiles, exact-source profile relabel, multi-IED ICD/CID, generic Enum ordinals that cannot be reconstructed safely and nested-SDO ambiguity; +- exact-source preservation remains the vendor-lossless path; canonical reconstruction does not claim unknown vendor XML/extensions lossless. + +### Closure evidence + +The source-preservation foundation spans `b92d2af1a6bff8ef4295c74807cb00f15fa987f0` through `9205c8b4227bb33293ce4fa910c607bd14a7dc97`. Full deterministic reconstruction/export landed in `be7050e6d44d88838f91aeacbe0d4b7282b5adcc`. + +Implementation head `be7050e6d44d88838f91aeacbe0d4b7282b5adcc` passed **IED Simulator Qt #551**, run `34798050373`, job `103834905561`: + +- `SCL_WORKSPACE_PASS edition_source=ed2 exact_preserve=pass canonical=pass dtt=pass references=pass configured_values=pass deterministic=pass ed1=pass ed2=pass ed21=pass scd=pass icd=pass cid=pass semantic_roundtrip=pass ieds=1 lns=4 leaves=23` +- `SCL_WORKSPACE_NEGATIVE_PASS unsupported_profile=rejected exact_relabel=rejected multi_ied_icd=rejected unknown_enum=rejected nested_sdo_ambiguity=rejected malformed=rejected vendor_lossless_claimed=false` +- complete retained desktop regression also passed. + +The first S hardening proof head `fbaeb39445c80e1e111cedbdf52bfacf4ba54d57` reran R successfully in **IED Simulator Qt #554**, run `34802981455`, job `103849210782`. + +Documentation-only closure commits after the proven R heads do not reopen R unless executable/SCL behavior changes. + +## Milestone S — external IEC 61850 client Product Hardening / Release Candidate — CLOSED + +Milestone S unifies and hardens the desktop product rather than adding another protocol feature. + +### Closed product-hardening capability + +- coherent navigation across IED Connection, Reports, GOOSE, Files, Settings, SCL and Simulator; +- versioned crash-safe product state using atomic `QSaveFile`, bounded recent connections, workspace restore and fail-closed corrupt/unknown-state recovery; +- startup deliberately restores endpoint context without automatically connecting to a field device; +- bounded activity/event retention plus atomic diagnostics export capped at 2 MiB; +- PCAP save/export where applicable; +- Windows `windeployqt` staging, portable ZIP and installable NSIS package from one staged tree; +- staged executable and silent-installed executable smoke validation, including deployed Qt/QML/plugins and simulator helper; +- Windows Npcap detection/guidance using both `wpcap` and `Packet` readiness checks; no false raw-Ethernet readiness when Npcap is absent; +- 24-cycle MMS association reacquire/clean-disconnect soak with bounded worker and stale-generation rejection; +- 12-cycle process-external normal application-close soak proving `aboutToQuit` cleanup, zero orphan simulator children and immediate socket reuse; +- retained start/stop/restart lifecycle, large-SCL/reload performance, responsiveness, GOOSE, live-data, control, reports and multi-IED regressions; +- release evidence classified as LIVE-PROVEN, SIMULATOR-PROVEN, OFFLINE-TESTED or NOT CLAIMED rather than upgrading unavailable evidence; +- portability proven across Windows/MSVC, Linux/GCC and Linux/Clang. + +### S1 — persistent product state + runtime readiness — PROVEN + +The first hardening tranche is `ff3e2a41c53fdb2f5939074e54d41111ae3e1e8c`, followed by Qt 6.8 compatibility correction `fbaeb39445c80e1e111cedbdf52bfacf4ba54d57`. + +Final S1 proof head `fbaeb39445c80e1e111cedbdf52bfacf4ba54d57` passed **IED Simulator Qt #554**, run `34802981455`, job `103849210782`: + +- `PRODUCT_HARDENING_PASS state=atomic workspace_restore=4 recent=8 dedupe=pass bounded_recent=8 crash_recovery=pass auto_reconnect=false npcap_policy=explicit` +- `PRODUCT_HARDENING_NEGATIVE_PASS corrupt_state=ignored unsupported_schema=rejected invalid_workspace=rejected invalid_endpoint=rejected unbounded_recent=rejected` + +### S2/S3 — release soak, Windows packaging, diagnostics and evidence — PROVEN + +Release closure work spans: + +- `615abd9260a018470d64ed3226cfba91821cf816` — release-hardening workflow, reconnect/application-close soak, Windows packaging and release evidence foundation; +- `65c2bc88dcee326aaffd2c90e9de70db9f6bfc60` — Visual Studio 18/2026 generator correction for the Windows 2025 runner; +- `93ee771064cc290205383da6e4a8ce356f46aced` — atomic bounded Activity Monitor diagnostics export and QA; +- `37a1d5c1e0d57b52661421d03864bbc82d556abd` — explicit NSIS executable resolution; +- `67ffa58e91fe11e2af7a2de4559e4451a1d5a34d` — cross-platform corruption-recovery QA correction; this is the final executable/release behavior proof head. + +**IED Simulator Release Hardening #10**, run `34811313865`, passed both final-head jobs: + +- Linux release-soak job `103873063840`: SUCCESS. +- Windows release-package job `103873063622`: SUCCESS. +- `WINDOWS_RUNTIME_READINESS_PASS npcap_required=true npcap_available=false guidance=pass raw_ethernet_ready=false` +- `MMS_CLIENT_RECONNECT_SOAK_PASS cycles=24 association_reacquire=pass clean_disconnect=pass stale_state_not_applied=true bounded_io_worker=1` +- `APPLICATION_CLOSE_SOAK_PASS cycles=12 runtime_started=pass about_to_quit_cleanup=pass orphan_children=0 socket_release=pass` +- `WINDOWS_RELEASE_PACKAGE_PASS staged_smoke=pass installed_smoke=pass qt_runtime=pass helper_server=pass npcap_guidance=pass installer=nsis portable=zip reconnect_cycles=24` + +The same behavior head passed **IED Simulator Qt #565**, run `34811313883`, job `103873063746`, through every retained desktop gate. Release-specific markers include: + +- `IEDSIM_GUARDRAILS_PASS output_cap=65536 drain_budget=64 retained=129 flood_dropped=2031616 diagnostics_export=atomic` +- `PRODUCT_HARDENING_PASS state=atomic workspace_restore=4 recent=8 dedupe=pass bounded_recent=8 crash_recovery=pass auto_reconnect=false npcap_policy=explicit` +- R SCL reconstruction/export remained green; +- lifecycle, 5k/20k/50k SCL performance and reload soak remained green; +- responsiveness, commissioning, GOOSE wire/workspace, live-data, direct/SBO normal/enhanced control, URCB/BRCB and multi-IED coexistence remained green. + +**C++ CI #1501**, run `34811313919`, passed Windows/MSVC job `103873063736`, Linux/Clang job `103873063924` and Linux/GCC job `103873063955`. + +All twelve workflows associated with behavior proof head `67ffa58e91fe11e2af7a2de4559e4451a1d5a34d` completed SUCCESS: IED Simulator Release Hardening #10, IED Simulator Qt #565, C++ CI #1501, MMS R1-R2 Server CI #165, Security and Evidence #1476, external IEC 61850 client Parity Server CI #463, Control Interop Harness CI #753, Dynamic RCB Trial Harness CI #792, BRCB Hard Profile CI #749, Embedded Profile CI #1146, ARStack Studio Qt #521 and SMV Injector GUI #334. + +### Windows release artifact + +Release Hardening #10 uploaded artifact `arstack-iec61850-workbench-windows-rc`: + +- artifact ID `10334848336`; +- size `60,552,499` bytes; +- digest `sha256:cbb81b61a596a46d3e8882d842057b6a71c96a9005cdb6a1e393d56c76d7e28c`; +- contains `ARStack-IEC61850-Workbench-Setup-win64.exe` and `ARStack-IEC61850-Workbench-portable-win64.zip`. + +The package does **not** silently bundle Npcap. The final Windows runner intentionally had no Npcap; the workbench correctly reported guidance and kept raw Ethernet unavailable rather than claiming a false pass. + +### Evidence boundary at S closure + +No third-party vendor IED or proprietary vendor simulator was attached to the GitHub runner. Therefore external multi-vendor field interoperability is explicitly **NOT CLAIMED** rather than fabricated. Internal deterministic simulator interoperability, real MMS socket behavior and real Layer-2 GOOSE encode/publication evidence remain classified according to `RELEASE_EVIDENCE.md`. + +Unsupported product surfaces also remain explicit: remote MMS file upload/delete/rename, full Setting Group edit transaction, lossless unknown-vendor XML reconstruction in canonical SCL, and automatic field-device reconnect at startup are not claimed. + +Documentation-only commits after executable proof head `67ffa58e91fe11e2af7a2de4559e4451a1d5a34d` do not reopen S unless executable/runtime behavior changes. + +### Release principle + +A visually complete UI is not a release candidate unless each workspace preserves the underlying core safety/lifecycle contracts and the application survives repeated connect/disconnect/reconnect/open/close workflows without unbounded memory, worker, queue or socket growth. Milestone S closes only because those release-specific gates and the retained protocol/product regression matrix are green together. + +## Product architecture decision + +The existing A-M IED Simulator work is retained as the **Simulator workspace** of the same desktop product. + +Do not fork the GUI into many executable tools unless a platform constraint forces it. One application can act as the engineering client while also hosting simulator/fleet workflows for laboratory regression, giving ARStack a useful advantage over a client-only tool. + +## Relationship to ARStack platform roadmap + +This document is the desktop **product roadmap**. + +`MIGRATION_PLAN.md`, `NORTH_STAR.md`, embedded plans, Sampled Values/PTP work and other protocol/platform documents may continue independently. Their completion does not gate Milestones N-S unless a specific desktop parity requirement directly depends on them. + +The post-M desktop product strategy is now complete: + +**surface existing ARStack capabilities cleanly, close the real parity gaps, harden the release candidate, then ship.** diff --git a/apps/ied_simulator/README.md b/apps/ied_simulator/README.md index 904b06b84..76f4bb323 100644 --- a/apps/ied_simulator/README.md +++ b/apps/ied_simulator/README.md @@ -1,5 +1,7 @@ # ARStack IED Simulator +**Stable release candidate: v0.2.0.** The current candidate has passed the retained cross-platform release matrix and a real external vendor external IEC 61850 client interoperability retest using the reference IEC 61850 model CID profile exercised by the parity work. + `arstack_ied_simulator` is the Qt/QML desktop shell for the portable IEC 61850 server stack. It keeps the commissioning flow and the live value workspace in one executable while reusing the repository SCL parser and MMS server. @@ -11,21 +13,28 @@ one executable while reusing the repository SCL parser and MMS server. 3. Select the local interface, MMS port, GOOSE option, and file-service folder. 4. Start the bundled IEC 61850 interoperability `ariec61850_ied_simulator_server`. 5. Use the runtime workspace to switch between imported IEDs and edit resolved - DataSet/report/GOOSE members with type-aware controls, quality, origin, and - undo history. - -The desktop server projects the imported SCL logical-device, logical-node, and -resolved leaf-value hierarchy into a bounded host MMS model (up to 128 objects). -Static DataSets retain their ordered SCL member references. While an association -is open, a value applied in the runtime editor is atomically published to the -server backing model and is visible to a subsequent MMS Read without forcing the -client to reconnect. TCP/COTP/ACSE/MMS activity and value synchronization are -reported back to the GUI. - -This phase does not claim dynamic DataSet creation/deletion, URCB/BRCB report -delivery, client-originated control/write handling, GOOSE transmission, or host -file-service transfer. Those services stay disabled or diagnostic-only until -their protocol paths have independent interoperability coverage. + SCL leaf values with type-aware controls, quality, origin, and undo history. + +The native SCL parser is the structural-model compiler for the simulator. The +desktop server projects the selected IED's logical-device, logical-node, and +resolved DO/DA/BDA leaf hierarchy into the bounded host MMS object model. The +host validation ceiling is 8192 MMS objects; this is a validation bound, not a +static allocation in the embedded core. Static DataSets retain their ordered SCL +member references. + +While an association is open, a value applied in the runtime editor is +atomically published to the server backing model and is visible to a subsequent +MMS Read without forcing the client to reconnect. Quality and Timestamp values +use their IEC 61850 MMS wire types rather than a display-string substitute. +Multiple associations can be served concurrently. TCP/COTP/ACSE/MMS activity +and value synchronization are reported back to the GUI. + +ReportControl blocks are carried from SCL into the same simulator endpoint. +The regression-covered reporting path includes URCB reservation/enable/GI and BRCB enable/GI/event delivery with `EntryID`, ordered DataSet membership, ReasonForInclusion, ConfRev, SqNum progression, and retained buffered delivery semantics. The accepted external vendor external IEC 61850 client retest exercised URCB and BRCB reporting against the reference IEC 61850 model CID profile used for parity work. + +Client-originated IEC 61850 controls are supported for the guarded Direct/SBO normal and enhanced paths implemented by the shared control stack. The accepted external IEC 61850 client retest proved SBO Enhanced Open/Close with external `ctlNum=0`, positive CommandTermination before process feedback, and BRCB event delivery after feedback. The desktop workbench also includes the regression-covered GOOSE Monitor/Publisher, bounded MMS file download service, Setting Group ActSG workflow, and SCL export/conversion workspaces. + +Intentional release boundaries remain: dynamic DataSet creation/deletion is not claimed; MMS remote file upload/delete/rename is not claimed; full `EditSG -> SE edits -> CnfEdit` is not claimed; canonical SCL conversion does not claim lossless unknown vendor extensions; and the external vendor/reference IEC 61850 model result is vendor-specific interoperability evidence rather than universal IEC 61850 conformance certification. ## Windows build @@ -36,8 +45,9 @@ cmake -S apps/ied_simulator -B build-ied-simulator-qt -G Ninja ` cmake --build build-ied-simulator-qt --target arstack_ied_simulator --parallel ``` -The app target also builds `ariec61850_ied_simulator_server`. Keep both -executables beside each other when packaging. +The app target also builds `ariec61850_ied_simulator_server` and the external +URCB/BRCB regression probes. Keep the simulator and server executables beside +each other when packaging. The discovery status becomes green only after the server emits listener-ready evidence. Use **Copy diagnostics** to capture the endpoint, external IEC 61850 client association @@ -45,15 +55,18 @@ profile, process state, model counts, and recent protocol activity. ## Runtime interoperability test -The integration test starts the real Qt controller, imports the fixture SCL, -starts its child MMS server, applies a value in the runtime editor, and reads -that value through an independent MMS association: +The integration test starts the real Qt controller, imports an SCL containing +both URCB and BRCB controls, starts its child MMS server, and proves the public +wire contract through independent MMS clients. It covers complete structural +model reads, a live edited value, concurrent associations, URCB GI delivery, +BRCB event delivery with `EntryID`, and same-association Quality/Timestamp +refreshes: ```powershell python apps/ied_simulator/test_gui_live_value.py ` --app build-ied-simulator-qt/arstack_ied_simulator.exe ` --read-probe build-ied-simulator-qt ` - --scl tests/fixtures/scl/minimal-station.scd + --scl tests/fixtures/scl/minimal-station-brcb.scd ``` ## Deterministic UI smoke test diff --git a/apps/ied_simulator/RELEASE_EVIDENCE.md b/apps/ied_simulator/RELEASE_EVIDENCE.md new file mode 100644 index 000000000..180a2ad15 --- /dev/null +++ b/apps/ied_simulator/RELEASE_EVIDENCE.md @@ -0,0 +1,120 @@ +# external IEC 61850 client Product Release Evidence + +## v0.2.0 stable-release candidate + +Current promotion branch includes the accepted external IEC 61850 client parity baseline through P1 reporting golden parity. External external vendor external IEC 61850 client retest on 2026-09-19 using `reference IEC 61850 model_084F06BCU_AA1E1F06R4.cid` demonstrated full discovery of 32 logical devices / 4925 typed points, URCB and BRCB report delivery, SBO Enhanced Open/Close with external `ctlNum=0`, positive CommandTermination before process feedback, and BRCB SqNum progression 1 -> 2 -> 3. The same diagnostic also retained successful control with ctlNum 1/2 on a second client path. + +This evidence is specific to the tested external vendor external IEC 61850 client + reference IEC 61850 model CID profile. It is not an IEC 61850 conformance certificate and is not generalized into universal multi-vendor interoperability. + +P1 regression head `68cad00fed15bf77104593c93bfd66d5c5590d1c` passed all eight triggered workflows, including external IEC 61850 client Parity 11/11 on Linux GCC, Linux Clang and Windows/MSVC. P1 was merged into the accepted baseline as `17a6932633328daef8647fc6e4b18a053df6b4e1` before stable-release promotion. + +This document is the release-candidate evidence ledger for the ARStack IEC 61850 desktop workbench. It deliberately distinguishes what is proven live, what is proven against the deterministic simulator/runtime harnesses, what is offline/model tested, and what is not claimed. + +## Evidence classes + +- **LIVE-PROVEN** — exercised through a real socket, association, raw Ethernet transport, deployed executable, or installed application path rather than only a model/unit helper. +- **SIMULATOR-PROVEN** — exercised end-to-end against ARStack's deterministic MMS/IED simulator runtime, including real protocol encoding/transport where the harness provides it. +- **OFFLINE-TESTED** — deterministic parser/model/serialization or file-level behavior that does not require a live peer. +- **NOT CLAIMED** — intentionally unsupported, unavailable in the automated environment, or not safe to infer from another evidence class. + +A feature may have more than one evidence class. The strongest applicable class is stated without upgrading adjacent behavior that was not actually exercised. + +## Release-candidate matrix + +| Product surface | Evidence class | Proven release behavior | Important boundary | +| --- | --- | --- | --- | +| IED Connection | LIVE-PROVEN + SIMULATOR-PROVEN | persistent MMS association; live discovery; exact-type Read; guarded SP/CF/DC/SE scalar Write; verification Read; stale-generation rejection; 24-cycle reconnect/clean-disconnect soak | no automatic Write retry; startup restores endpoint but deliberately does not auto-connect | +| Reports | LIVE-PROVEN + SIMULATOR-PROVEN | DataSet/URCB/BRCB inventory; enable/GI/disable-release; decoded report stream; EntryID/ConfRev/SqNum/ReasonForInclusion; external vendor external IEC 61850 client URCB/BRCB acceptance on the tested reference IEC 61850 model profile | replay/rewind mutation is not claimed where the reusable client runtime does not expose it | +| Files | SIMULATOR-PROVEN | bounded FileDirectory pagination and streaming download with FileClose/partial-output cleanup | remote upload/delete/rename are NOT CLAIMED | +| Settings | SIMULATOR-PROVEN | SGCB inventory/deep read; guarded ActSG activation followed by verification Read | full EditSG -> SE edits -> CnfEdit transaction is NOT CLAIMED | +| SCL | OFFLINE-TESTED + LIVE-UI-PROVEN | exact-source Save As; deterministic canonical reconstruction; Ed1/Ed2/Ed2.1 normalized conversion; guarded SCD/ICD/CID; semantic round trip | canonical conversion does not claim lossless unknown vendor XML/extensions; exact-source Save As is the vendor-lossless path | +| GOOSE Monitor/Publisher | LIVE-PROVEN + SIMULATOR-PROVEN | explicit NIC binding; real Layer-2 publisher path; APPID/MAC/VLAN/goID/DataSet/ConfRev; stNum/sqNum/TTL; bounded monitor tables; PCAP export | Windows raw Ethernet requires Npcap runtime availability | +| Simulator | LIVE-PROVEN + SIMULATOR-PROVEN | child MMS server lifecycle; multi-IED same-port/distinct-address coexistence; live value plane; direct/SBO normal and enhanced controls; URCB/BRCB; external vendor external IEC 61850 client discovery/report/control acceptance for the tested reference IEC 61850 model CID; bounded start/stop/restart | broader Sampled Values/PTP/embedded platform roadmap is not a desktop RC blocker; vendor-specific acceptance is not universal conformance | +| Product persistence | OFFLINE-TESTED + UI-PROVEN | atomic versioned state; workspace restore; eight-entry deduplicated recent endpoint list; corrupt/unknown state fail-closed recovery | automatic connection on startup is intentionally disabled | +| Diagnostics | OFFLINE-TESTED + UI-PROVEN | bounded activity retention plus atomic diagnostics export with a 2 MiB payload guard | diagnostics export is operational evidence, not a protocol trace substitute | +| Windows package | DEPLOYED/INSTALLED-PROVEN | `windeployqt` staging; portable ZIP; NSIS installer; silent install; staged and installed smoke test; helper server and Qt runtime verification | installer does not silently redistribute Npcap; runtime guidance remains explicit | + +## Final Milestone S proof head + +Executable/release behavior is proven on head `67ffa58e91fe11e2af7a2de4559e4451a1d5a34d`. + +**IED Simulator Release Hardening #10**, run `34811313865`: + +- Linux release-soak job `103873063840`: SUCCESS. +- Windows release-package job `103873063622`: SUCCESS. +- `PRODUCT_HARDENING_PASS state=atomic workspace_restore=4 recent=8 dedupe=pass bounded_recent=8 crash_recovery=pass auto_reconnect=false npcap_policy=explicit` +- `PRODUCT_HARDENING_NEGATIVE_PASS corrupt_state=ignored unsupported_schema=rejected invalid_workspace=rejected invalid_endpoint=rejected unbounded_recent=rejected` +- `WINDOWS_RUNTIME_READINESS_PASS npcap_required=true npcap_available=false guidance=pass raw_ethernet_ready=false` +- `MMS_CLIENT_RECONNECT_SOAK_PASS cycles=24 association_reacquire=pass clean_disconnect=pass stale_state_not_applied=true bounded_io_worker=1` +- `APPLICATION_CLOSE_SOAK_PASS cycles=12 runtime_started=pass about_to_quit_cleanup=pass orphan_children=0 socket_release=pass` +- `WINDOWS_RELEASE_PACKAGE_PASS staged_smoke=pass installed_smoke=pass qt_runtime=pass helper_server=pass npcap_guidance=pass installer=nsis portable=zip reconnect_cycles=24` + +**IED Simulator Qt #565**, run `34811313883`, job `103873063746`: SUCCESS through the complete retained desktop regression tail. Key release markers include: + +- `IEDSIM_GUARDRAILS_PASS output_cap=65536 drain_budget=64 retained=129 flood_dropped=2031616 diagnostics_export=atomic` +- `SCL_WORKSPACE_PASS edition_source=ed2 exact_preserve=pass canonical=pass dtt=pass references=pass configured_values=pass deterministic=pass ed1=pass ed2=pass ed21=pass scd=pass icd=pass cid=pass semantic_roundtrip=pass ieds=1 lns=4 leaves=23` +- `RUNTIME_LIFECYCLE_PASS cycles=12 ... started=12 finished=12 delayed_kill_guard=pass` +- `RUNTIME_SCALE_RESPONSIVENESS_PASS large=20000,50000 multi_ied=3 fleet_rollback=pass nonblocking_clear=pass` +- `GOOSE_WIRE_INTEROP_PASS appid=4097 vlan=100 members=3 st_initial=1 sq_retx=1 st_change=2 ttl_initial=8 ttl_retx=16 pcap_packets=3` +- `GOOSE_WORKSPACE_PASS ... pcap_export=pass` +- `LIVE_RUNTIME_DATA_PLANE_PASS cases=1000,10000 manifest_hot_rewrites=0 bounded_pending=256 bounded_inflight=256` +- `IEDSIM_GUI_LIVE_VALUE_PASS ... control_direct_normal=pass control_sbo_normal=pass control_direct_enhanced=pass control_sbo_enhanced=pass urcb_gi=pass brcb_event=pass` +- `MULTI_IED_PASS` for same TCP port on distinct loopback addresses. + +**C++ CI #1501**, run `34811313919`: Windows/MSVC job `103873063736`, Linux/Clang job `103873063924`, and Linux/GCC job `103873063955` all SUCCESS. + +All twelve workflows associated with executable proof head `67ffa58e91fe11e2af7a2de4559e4451a1d5a34d` completed SUCCESS: IED Simulator Release Hardening #10, IED Simulator Qt #565, C++ CI #1501, MMS R1-R2 Server CI #165, Security and Evidence #1476, external IEC 61850 client Parity Server CI #463, Control Interop Harness CI #753, Dynamic RCB Trial Harness CI #792, BRCB Hard Profile CI #749, Embedded Profile CI #1146, ARStack Studio Qt #521 and SMV Injector GUI #334. + +Documentation-only commits after this proof head do not reopen Milestone S unless executable/runtime behavior changes. + +## Runtime and shutdown release gates + +The release-candidate behavior head passes all of the following: + +- `PRODUCT_HARDENING_PASS` +- `PRODUCT_HARDENING_NEGATIVE_PASS` +- `WINDOWS_RUNTIME_READINESS_PASS` on the Windows release job +- `MMS_CLIENT_RECONNECT_SOAK_PASS cycles=24` +- `APPLICATION_CLOSE_SOAK_PASS cycles=12 ... orphan_children=0 socket_release=pass` +- `WINDOWS_RELEASE_PACKAGE_PASS ... staged_smoke=pass installed_smoke=pass qt_runtime=pass helper_server=pass npcap_guidance=pass installer=nsis portable=zip` +- retained `RUNTIME_LIFECYCLE_PASS`, large-SCL performance, responsiveness, GOOSE, live-data, control, URCB/BRCB and multi-IED gates from `IED Simulator Qt`. + +The application-close soak is intentionally process-external. Each cycle starts the real Qt workbench and its MMS helper, proves the configured endpoint becomes live, allows normal application exit, then rejects any orphan helper process or unreleased listen socket. + +## Windows / Npcap policy + +The Windows application checks for both `wpcap` and `Packet` runtime libraries. If they are unavailable, the application remains usable for MMS/SCL/simulator workflows and explicitly reports that Npcap must be installed before Windows raw-Ethernet GOOSE Monitor/Publisher use. The final Windows runner intentionally had no Npcap runtime; the release gate proved `guidance=pass` and `raw_ethernet_ready=false` rather than manufacturing a false-positive raw-Ethernet result. + +The release package does **not** bundle or silently redistribute Npcap. + +## External-vendor interoperability status + +The stable-release candidate now includes **vendor-specific external interoperability evidence** from external vendor external IEC 61850 client using the reference IEC 61850 model CID profile exercised throughout the parity work. The retained diagnostic proves full model discovery, URCB/BRCB reporting, SBO Enhanced Open/Close with external IEC 61850 client `ctlNum=0`, positive CommandTermination ordering, process feedback, and BRCB event progression. + +This evidence is deliberately scoped. No physical relay was attached to the GitHub runner, and the external vendor/reference IEC 61850 model result is not relabelled as universal multi-vendor interoperability or IEC 61850 conformance certification. Additional vendor hardware/simulator results should be appended as separate profile-specific evidence. + +## Known intentional limitations at RC + +- MMS remote file mutation (upload/delete/rename): **NOT CLAIMED**. +- Full Setting Group edit transaction: **NOT CLAIMED**. +- Lossless unknown vendor XML/extensions during canonical SCL conversion: **NOT CLAIMED**; exact-source Save As preserves the source bytes. +- Automatic field-device connection after application restart: intentionally disabled. +- Windows raw Ethernet without Npcap: unavailable by design and surfaced with operator guidance. +- Universal multi-vendor interoperability: **NOT CLAIMED**. external vendor external IEC 61850 client + the tested reference IEC 61850 model CID profile is externally evidenced; other vendor/device profiles require their own acceptance evidence. +- Sampled Values runtime simulation, PTP, ESP/embedded and broader process-bus platform work remain separate ARStack platform tracks and are not desktop RC prerequisites. + +## Packaging artifacts + +`IED Simulator Release Hardening #10` produced Windows RC artifact `arstack-iec61850-workbench-windows-rc` from behavior head `67ffa58e91fe11e2af7a2de4559e4451a1d5a34d`. + +- GitHub Actions artifact ID: `10334848336` +- Size: `60,552,499` bytes +- Digest: `sha256:cbb81b61a596a46d3e8882d842057b6a71c96a9005cdb6a1e393d56c76d7e28c` +- Artifact retention expiry: `2026-09-28T06:02:54Z` + +The artifact contains: + +1. `ARStack-IEC61850-Workbench-Setup-win64.exe` — installable NSIS package. +2. `ARStack-IEC61850-Workbench-portable-win64.zip` — portable deployed tree. + +Both contain the workbench executable, simulator helper executable and Qt runtime/QML/plugin deployment produced by `windeployqt`. The workflow smoke-tested the staged application and a silent-installed copy before upload. diff --git a/apps/ied_simulator/UX_PERFORMANCE_PLAN.md b/apps/ied_simulator/UX_PERFORMANCE_PLAN.md new file mode 100644 index 000000000..17f9ac4a9 --- /dev/null +++ b/apps/ied_simulator/UX_PERFORMANCE_PLAN.md @@ -0,0 +1,163 @@ +# IED Simulator UX + performance plan + +This plan applies the repository `AGENTS.md` production contract to the desktop IED Simulator redesign. The UX target is a familiar IEC 61850 commissioning workflow (Open SCL -> select IED -> Start -> browse -> edit values) without copying another vendor's branding or pixel geometry. + +## Performance invariants + +- Protocol/core behavior stays independent from QML and view state. +- Large SCL data must be exposed through typed C++ models, not copied/re-grouped in QML JavaScript on every change. +- Views must virtualize rows and reuse delegates; no hidden full-tree delegate creation. +- UI refreshes are coalesced. Stale intermediate presentation updates may be skipped; protocol state may not be skipped or reordered. +- No unbounded GUI/event/log queue. Diagnostic presentation remains bounded. +- Expensive parse/index/filter work must not become a steady-state GUI-thread loop. +- One data-value change should become a targeted model notification, not a full workspace rebuild. +- Every worker/process/timer/model has explicit ownership and a deterministic shutdown path. +- Failures in SCL input, server output, a child process, or a raw-network transport must not crash the GUI. + +## UX architecture + +### Stage A - model/view foundation — implemented + +- `IedNavigationModel`: compact C++ navigation index for LD/LN selection. +- `IedSignalModel`: C++ signal table projection with static roles and coalesced filtering/refresh. +- `ListView.reuseItems=true`, fixed-height simple delegates, zero speculative cache. +- Replace the four-panel workspace with a two-pane navigation/details layout. +- Start is a deliberate server-settings dialog; editing is a deliberate Set Values dialog. + +### Stage B - asynchronous import/indexing — implemented for interactive Open SCL + +- Interactive Open SCL parses on one bounded `QThreadPool` worker instead of the GUI thread. +- Only one import runs at a time; repeated Open SCL requests are coalesced to one newest pending request instead of creating an unbounded worker queue. +- A monotonically increasing generation token plus source-path ownership prevents an older import, a cleared workspace, or an older request from replacing newer state. +- The same bounded worker now builds the selected IED simulator profile, source-ordered point projection, structural counts, LD/LN navigation index, and per-LN source-index lookup before handing ownership to the GUI thread. +- GUI completion adopts the prepared containers and creates the small per-IED runtime objects; it no longer calls `rebuildPresentation()` / `rebuildValues()` on the interactive import path. +- The synchronous `loadFile()` path remains for deterministic CLI/CI automation and compatibility; the desktop FileDialog uses `loadFileAsync()`. +- Import while a simulator endpoint is active is rejected instead of blocking the GUI on process shutdown. + +### Stage C - incremental live updates — hot presentation path implemented + +- Signal projection keeps a source-index -> visible-row routing table. +- Live refreshes are coalesced to ~16 ms and compare only source rows represented by the active projection. +- `dataChanged()` is emitted only for contiguous rows whose value/quality/writable/changed roles actually changed. +- Selected-row invalidation targets only the previous/new source rows. +- Active value-based search deliberately falls back to the existing coalesced rebuild because a live value can change filtered membership. +- `IedSignalModel` retains only compact typed rows for the active LD/LN; it does not retain a second complete point snapshot. +- On the async import path, signal rebuild uses the worker-built per-LN source-index vector, making scope changes proportional to the selected LN instead of the entire 20k/50k-point catalog. +- The 16 ms refresh timer is the latest-state presentation accumulator: multiple `valuesChanged` bursts collapse into one read of authoritative typed point state while protocol/runtime state remains ordered and lossless. + +### Stage D - diagnostics and lifecycle hardening — implemented + +- Activity presentation is a bounded/coalesced C++ model instead of QML-side list filtering. +- Child stdout/stderr framing has bounded buffers, bounded line length, and bounded per-turn drain work. +- Generation-safe delayed-kill semantics prevent an old stop timer from killing a restarted endpoint. +- Negative/lifecycle CI covers malformed SCL, child-output flooding, repeated start/stop/restart, fleet partial-start rollback, and non-blocking interactive clear. + +### Stage E - performance evidence — implemented + +- `benchmark_large_scl.py` generates deterministic engineering models that expand to about 5k, 20k, and 50k data attributes using reusable SCL type templates. +- The benchmark drives the same `loadFileAsync()` path used by interactive Open SCL, records end-to-end wall time and Linux peak RSS, and fails on crash/deadlock/non-completion. +- Import tracing separates parser time, worker-side profile/index preparation time, and GUI adoption time. +- CI enforces a **<=50 ms GUI-adoption budget** for 5k/20k/50k imports and every iteration of the repeated 20k reload soak. +- The one-process 20k reload soak guards against obvious ownership/memory-slope regressions. + +### Stage H - Core Data Path Performance — implemented + +- High-cardinality interactive import preparation stays on the one bounded worker: parse -> profile build -> source ordering -> typed point construction -> navigation/scope indexes. +- `IedPointStore` is the canonical high-cardinality point state: compact `PointRecord` storage plus a stable IED/reference index replaces canonical per-point `QVariantMap` containers. +- The QML-facing `values()` API is retained only as a compatibility boundary and materializes maps on demand; C++ navigation and signal hot paths do not call it. +- GUI-thread import completion is an ownership/adoption step instead of a second profile/index construction pass. +- Navigation consumes the worker-built compact LD/LN index; its synchronous fallback also reads typed records directly and no longer materializes a complete QVariant point list. +- Signal-table rebuild consumes the worker-built selected-LN index and reads `PointRecord` fields directly; filter/rebuild and 16 ms refresh paths avoid QVariant-map conversion. +- Signal presentation storage is typed and scoped; the previous duplicate complete presentation snapshot is removed. +- CI records `IEDSIM_IMPORT_PATH worker_ms=... parser_ms=... prepare_ms=... gui_apply_ms=... points=... scopes=... typed_store=1` and fails if direct GUI adoption exceeds 50 ms. + +### Stage I - Runtime Scale & Responsiveness — implemented + +- Interactive import prepares the full multi-IED runtime projection set on the bounded worker, so selecting or starting a previously non-selected IED does not synchronously construct its profile on the GUI thread. +- Per-IED prepared projections reuse one canonical typed point store and retain integer source indices plus compact navigation/scope indexes instead of duplicating full point maps. +- Runtime switching at 20k/50k scale is measured through `ied_simulator_responsiveness_qa`; CI requires every IED in the imported fleet to be prepared before normal interactive switching. +- Live presentation burst evidence proves thousands of source updates can collapse into a bounded GUI refresh rather than one GUI invalidation per update. +- GUI heartbeat/stall evidence is captured during large-model switch/search/live-update workloads. +- Fleet start is transactional from the operator perspective: if one endpoint cannot start, already-started peers are rolled back to a recoverable Ready state. +- Interactive Clear no longer waits synchronously for child-process shutdown; destructive runtime teardown is scheduled asynchronously with generation-safe completion. +- CI gate `RUNTIME_SCALE_RESPONSIVENESS_PASS` covers 20k, 50k, full multi-IED preparation, partial-start rollback, and non-blocking clear. + +### Stage J - Live Runtime Data Plane & Hot Updates — implemented + +- Runtime startup still emits one complete model manifest, but steady-state Set Value / Undo updates no longer rewrite that manifest. +- GUI/controller -> child runtime uses a bounded delta channel with explicit runtime generation and monotonic revision numbers. +- Pending and in-flight live-update tracking are each bounded at 256 entries; same-point bursts are coalesced so stale intermediate presentation values do not create an unbounded pipe backlog. +- The child server accepts only the active generation and a strictly newer revision, applies accepted deltas directly to the authoritative MMS runtime store, and rejects stale generation or duplicate/stale revision updates fail-closed. +- ACKs are parsed by the controller and recorded as `IEDSIM_LIVE_ACK generation=... revision=... latency_ms=... pending=... inflight=...`; raw child diagnostics do not need to leak into the GUI log contract. +- Hot-update burst regression sends 1,000 and 10,000 writes to one live MMS value, requires all writes to be accepted by the controller, heavily coalesced, bounded in pending/in-flight storage, visible over an independent MMS association, and leaves startup manifest revision/value unchanged. +- Negative regression explicitly rejects stale-generation and duplicate-revision deltas and verifies rejected state never reaches MMS. +- Existing GUI -> MMS value, control-model, URCB/BRCB, quality/timestamp refresh, and multi-IED same-port regressions remain required after the new data plane is enabled. +- CI gate `LIVE_RUNTIME_DATA_PLANE_PASS` requires `manifest_hot_rewrites=0`, `bounded_pending=256`, and `bounded_inflight=256`; `LIVE_DATA_PLANE_NEGATIVE_PASS` proves stale-state rejection. + +### Stage K - Commissioning Feature Depth — implemented + +- `CommissioningWorkspace` adds a dedicated right-edge commissioning explorer for the selected IED without replacing the normal live signal workspace. +- `IedCommissioningModel` projects DataSet, Report, GOOSE, and control topology from the already parsed canonical `SclDocument`; it does not parse XML again or retain a second complete FCDA tree. +- Service catalog and member views remain virtualized with delegate reuse and zero speculative cache; canonical FCDA member maps are materialized only for the visible/selected rows requested by QML or QA. +- Report inspection exposes URCB/BRCB mode, RptID, ConfRev, BufTm, IntgPd, binding status, canonical DataSet reference, and member list. +- GOOSE inspection exposes goID, canonical hexadecimal APPID text, destination MAC, VLAN ID/priority, Min/Max time, ConfRev, bound DataSet, and canonical members. +- Control inspection exposes configured CDC and `ctlModel` semantics while live Direct/SBO normal/enhanced execution remains on the existing runtime/control path. +- Report and GOOSE entries can navigate directly to their bound DataSet. Unresolved DataSet bindings remain visible as `Unresolved`, expose zero invented members, and reject the DataSet jump fail-closed. +- Commissioning search/filtering covers service type, name/reference, DataSet reference, APPID/goID, and control model without disturbing the simulator runtime state. +- CI target `ied_simulator_commissioning_qa` validates the positive inventory/metadata/member/navigation path and an unresolved-binding negative fixture. Gates: `COMMISSIONING_DEPTH_PASS` and `COMMISSIONING_NEGATIVE_PASS`. + +### Stage L - Runtime Commissioning Actions & Simulation Behavior — implemented + +- Runtime commissioning actions reuse the canonical `IedPointStore` and the existing bounded GUI/controller -> child live-delta channel; they do not create a second simulator state store or protocol path. +- Configured DataSet/Report/GOOSE members are resolved back to canonical point records. Only writable ST/MX source values are drivable; quality/timestamp leaves remain protected. +- Simulation behavior is intentionally bounded: one coarse scheduler owns at most 16 active slots, the minimum interval is 100 ms, and a scheduler turn emits at most one update per due slot rather than catching up missed intervals in a burst. +- Supported behaviors are Pulse, Toggle, and numeric Ramp. Operator stop can restore the original value and metadata. If another edit replaces the behavior-owned value, the behavior stops rather than overwriting the newer external state. +- Runtime stop also removes active behavior ownership and restores still-owned canonical values locally before the next startup manifest is built; CI restarts the simulator and verifies the baseline did not retain the temporary stimulus. +- A Report source pulse is proven through the real MMS reporting path: an enabled BRCB receives an event caused by the commissioning stimulus. +- GOOSE commissioning in Stage L stimulates the configured source DataSet state through the runtime data plane. Raw Ethernet publication is intentionally outside Stage L and is supplied by Stage M below. +- Control commissioning deliberately does not mutate control status as a shortcut. The explorer can focus the associated ST/stVal, while Direct/SBO normal/enhanced execution remains on the existing IEC 61850 MMS control-service path and its configured `ctlModel` semantics. +- The behavior engine stays bound to the backend when the drawer closes, so UI visibility does not silently destroy active simulation ownership; runtime/session validity still governs execution and teardown. +- CI target `ied_simulator_runtime_commissioning_qa` verifies BRCB event generation, GOOSE source stimulus/restore, periodic MMS visibility, control-status focus, explicit restore, runtime-stop auto-restore/restart baseline, and negative rejection of stopped-runtime, unknown-mode, and too-fast behavior requests. Gates: `RUNTIME_COMMISSIONING_ACTIONS_PASS` and `RUNTIME_COMMISSIONING_NEGATIVE_PASS`. + +### Stage M - GOOSE Runtime Publication & Network Commissioning — implemented + +- The commissioning runtime can publish the selected configured `GSEControl` as a real IEC 61850 Layer-2 GOOSE Ethernet stream. It consumes the canonical parsed `SclGooseStream` plus canonical `IedPointStore` values; it does not reparse SCL or create a second process-data store. +- Publication requires an explicit, currently up/running non-loopback Ethernet interface. There is no adapter guessing and no IP/UDP fallback. Linux uses a bound `AF_PACKET/SOCK_RAW` transport and resolves the source MAC from the selected interface. Windows dynamically loads Npcap and resolves the selected Npcap adapter plus source MAC through the Windows IP Helper inventory; missing Npcap/interface/privilege fails closed. +- Ethernet/PDU fields come from configured SCL and the selected adapter: destination MAC, APPID, optional VLAN ID/priority, `gocbRef`, DataSet reference, `goID`, `ConfRev`, and ordered DataSet payload. +- `GoosePublisherRuntime` owns `stNum`/`sqNum` and retransmission sequencing. Initial publication starts at `stNum=1/sqNum=0`; retransmission keeps `stNum` and increments `sqNum`; a source-state change increments `stNum`, resets `sqNum=0`, and restarts the MinTime retransmission schedule. +- `timeAllowedToLive` is derived from the next retransmission delay and saturates instead of overflowing; each emitted frame advertises a validity window ahead of its scheduled retransmission. +- Runtime resources are bounded at 16 active publisher slots and 256 canonical members per stream. Publishers share one explicit raw-interface transport and one precise timer; value notifications are collapsed to a dirty flag and snapshots are compared before creating a state change. +- Runtime/session invalidation, explicit stop, transport error, unrepresentable data, invalid SCL addressing/timing, or interface loss tears publication down deterministically and closes the raw transport when no publisher remains. +- The wire QA decodes initial/retransmission/state-change Ethernet frames, validates SCL addressing and `stNum/sqNum/TTL`, and writes a three-packet PCAP artifact. Negative QA proves empty and nonexistent interface binding are rejected. The root GOOSE test includes the raw-publisher header so platform-specific transport code is compiled by the normal C++ CI matrix. +- Hosted CI does not claim a packet reached a physical station LAN; live publication requires the selected host adapter and the platform's raw-packet permission/Npcap runtime. Completion evidence is standards-wire encode/decode/PCAP plus fail-closed transport checks and cross-platform compilation. +- CI gates: `GOOSE_WIRE_INTEROP_PASS` and `GOOSE_PUBLICATION_NEGATIVE_PASS`; artifact: `ied-simulator-goose-wire-evidence`. + +### Stage N - MMS Client & IED Discovery Workbench — implemented + +- The desktop shell now exposes an `IED Connection` workspace beside the existing simulator workspace. It connects to a user-selected hostname/IP and TCP port, performs a real MMS association, and builds the engineering tree from canonical `MmsTcpLiveDiscoverySession` / `MmsLiveModelBuilder` results instead of reparsing SCL or invoking a CLI subprocess. +- `MmsLiveTreeModel` projects the live IED hierarchy as IED -> LD -> LN -> DO -> DA with typed C++ roles, virtualized QML delegates, expand/collapse, and search. Selection inspection exposes IEC 61850 reference, functional constraint, exact MMS type, SCL basic type, discovery status, current value, and sibling quality/timestamp when those attributes are available. +- Discovery and subsequent Read/Write operations reuse one persistent association. `Read selected` includes the selected DA plus sibling `q`/`t` where available; `Refresh visible` is bounded to at most 64 live MMS variables per operator action rather than walking the full discovered model from QML. +- Generic Write is deliberately fail-closed. Only exact scalar MMS types discovered for FC `SP`, `CF`, `DC`, or `SE` are writable through this workbench; `ST`, `MX`, `CO`, unknown types, structures, arrays, and unrepresentable values are rejected. A successful Write is followed by a verification Read on the same association and is never automatically retried. +- Client network work runs on one bounded `QThreadPool` worker. A stop token plus monotonic session generation invalidates disconnect/reconnect completions so a stale discovery, Read, or Write cannot repopulate a newer or disconnected UI session. Diagnostic history is bounded to 128 entries. +- The existing simulator A-M workflow remains intact under the `Simulator` workspace and command-line `--scl` automation still opens that path, so adding engineering-client capability does not fork or replace the validated simulator runtime. +- Deterministic loopback QA starts the real simulator with `client-workbench.scd`, associates through the live client, discovers the live model, reads an exact `SP` integer, performs one guarded Write, verifies it by Read, then disconnects during a new Read and proves the stale generation cannot repopulate model state. +- CI target `ied_mms_client_workbench_qa` is part of the normal `IED Simulator Qt` build and executes before all existing A-M regressions. Gates: `MMS_CLIENT_WORKBENCH_PASS` and `MMS_CLIENT_STALE_SESSION_NEGATIVE_PASS`. + +## Initial budgets + +- No ordinary user interaction should create sustained GUI event-loop stalls; expensive construction remains off the GUI thread. +- Search typing coalesces within ~80 ms and never allocates a second complete SCL tree. +- Large lists instantiate approximately viewport-sized delegates, not one delegate per model row. +- Presentation/event/diagnostic/live-delta queues are explicitly bounded. +- Repeated open/start/stop/close stress must show no monotonically growing owned worker/process/timer count. +- CI large-import guardrails: 5k <= 15 s / 512 MiB, 20k <= 30 s / 768 MiB, 50k <= 55 s / 1024 MiB. These are regression tripwires, not claimed product targets. +- Direct async-import GUI adoption is gated at <=50 ms for each 5k/20k/50k case and each repeated 20k reload iteration. +- Live ACK latency regression budget is <=1500 ms under the deterministic 1k/10k CI burst, with pending and in-flight tracking each <=256. +- Commissioning browsing must reuse parsed SCL ownership, virtualize lists, and fail closed on unresolved DataSet bindings rather than fabricating members or silently rebinding references. +- Runtime commissioning behavior is capped at 16 slots with a >=100 ms interval and reuses the existing 256-pending / 256-in-flight live-delta bounds; it must never create unbounded per-point timers or a catch-up burst after GUI/event-loop delay. +- GOOSE publication is capped at 16 active streams and 256 canonical members per stream; one transport/timer is shared for the commissioning model and a source notification may cause at most one state-change emission per active stream after snapshot comparison. +- MMS client engineering actions use one I/O worker per controller, one persistent association per live session, at most 64 variables per explicit visible-range refresh, and at most 128 retained diagnostic entries. Disconnect/reconnect must invalidate stale queued completions before they can mutate the live tree. + +## Definition of done for the redesign + +The redesign is complete only when the new workflow passes the existing simulator wire regressions, large-model UI tests, negative/failure tests, lifecycle stress, runtime-scale responsiveness gates, commissioning-depth positive/negative gates, runtime-commissioning action/behavior positive+negative gates, GOOSE wire-publication positive/negative gates with PCAP evidence, live-data-plane burst/negative gates, MMS client discovery/read/write positive and stale-session negative gates, and measured performance checks. A visually improved screenshot alone is not completion evidence. diff --git a/apps/ied_simulator/assets/black-fascia-ied.svg b/apps/ied_simulator/assets/black-fascia-ied.svg new file mode 100644 index 000000000..b32c27e91 --- /dev/null +++ b/apps/ied_simulator/assets/black-fascia-ied.svg @@ -0,0 +1,128 @@ + + + ARSAS Premium IED Icon + Premium vector icon of a protection relay front fascia for ARSAS IED cards. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ARSAS + + + + + + + + BAY 133kV + + + + TRIP + ALM + PU + + + + + + + + OK + + + diff --git a/apps/ied_simulator/benchmark_large_scl.py b/apps/ied_simulator/benchmark_large_scl.py new file mode 100755 index 000000000..e74e0ede6 --- /dev/null +++ b/apps/ied_simulator/benchmark_large_scl.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Deterministic large-SCL import + reload-soak benchmark. + +Generates compact SCL sources whose reusable type templates expand to roughly +5k/20k/50k data attributes, then drives the same bounded async import path used +by the FileDialog. The harness records end-to-end wall time, worker/profile +preparation time, GUI-thread adoption time and Linux peak RSS, and keeps one +process alive across repeated 20k reloads to detect obvious ownership/leak +regressions. Budgets are deliberately generous except for GUI adoption: that +phase is expected to remain a small ownership hand-off, not hidden O(N) work. +""" + +from __future__ import annotations + +import argparse +import math +import os +from pathlib import Path +import re +import select +import subprocess +import tempfile +import time + +DO_PER_LN = 25 +ATTRIBUTES_PER_DO = 3 +ATTRIBUTES_PER_LN = DO_PER_LN * ATTRIBUTES_PER_DO +IMPORT_PATH_RE = re.compile( + r"IEDSIM_IMPORT_PATH\s+worker_ms=(?P\d+)\s+" + r"parser_ms=(?P\d+)\s+prepare_ms=(?P\d+)\s+" + r"gui_apply_ms=(?P\d+)\s+points=(?P\d+)\s+" + r"scopes=(?P\d+)\s+typed_store=(?P[01])" +) + + +def build_scl(target_points: int) -> tuple[str, int]: + ln_count = max(1, math.ceil(target_points / ATTRIBUTES_PER_LN)) + generated_points = ln_count * ATTRIBUTES_PER_LN + + logical_nodes = "\n".join( + f' ' + for index in range(1, ln_count + 1) + ) + data_objects = "\n".join( + f' ' + for index in range(1, DO_PER_LN + 1) + ) + + xml = f''' + +
+ + + + + +{logical_nodes} + + + + + + + +{data_objects} + + + + + + + + +''' + return xml, generated_points + + +def read_rss_kib(pid: int) -> int: + try: + for line in Path(f"/proc/{pid}/status").read_text(encoding="utf-8").splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except (FileNotFoundError, ProcessLookupError, PermissionError, ValueError): + pass + return 0 + + +def parse_import_path(output: str, target: int) -> dict[str, int]: + matches = list(IMPORT_PATH_RE.finditer(output)) + if not matches: + raise RuntimeError( + f"{target} point import did not emit typed IEDSIM_IMPORT_PATH evidence; " + f"output={output[-4000:]}" + ) + metrics = {key: int(value) for key, value in matches[-1].groupdict().items()} + if metrics["typed_store"] != 1: + raise RuntimeError(f"{target} point import did not use the typed canonical point store") + if metrics["points"] > 0 and metrics["scopes"] <= 0: + raise RuntimeError(f"{target} point import prepared points without a worker-built scope index") + return metrics + + +def run_case( + app: Path, target: int, timeout_s: float +) -> tuple[float, float, int, dict[str, int], str]: + xml, generated_points = build_scl(target) + with tempfile.TemporaryDirectory(prefix="arstack-scl-perf-") as temporary: + scl_path = Path(temporary) / f"synthetic-{target}.scd" + scl_path.write_text(xml, encoding="utf-8") + + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + started = time.perf_counter() + process = subprocess.Popen( + [str(app), "--qa-async-import", str(scl_path)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + peak_rss_kib = 0 + output = "" + try: + while process.poll() is None: + peak_rss_kib = max(peak_rss_kib, read_rss_kib(process.pid)) + if time.perf_counter() - started > timeout_s: + process.kill() + raise RuntimeError( + f"{target} point import exceeded hard timeout {timeout_s:.0f}s" + ) + time.sleep(0.02) + stdout, _ = process.communicate(timeout=2) + output = stdout or "" + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=2) + + elapsed_s = time.perf_counter() - started + peak_rss_mib = peak_rss_kib / 1024.0 + if process.returncode != 0: + raise RuntimeError( + f"{target} point import exited {process.returncode}; output={output[-4000:]}" + ) + if "ASYNC_IMPORT_OK" not in output: + raise RuntimeError( + f"{target} point import never reported async completion; output={output[-4000:]}" + ) + + path_metrics = parse_import_path(output, target) + if path_metrics["points"] != generated_points: + raise RuntimeError( + f"{target} point import prepared {path_metrics['points']} points; " + f"expected {generated_points}" + ) + + summary = ( + f"SCL_PERF target={target} generated={generated_points} " + f"elapsed_ms={elapsed_s * 1000:.0f} peak_rss_mib={peak_rss_mib:.1f} " + f"worker_ms={path_metrics['worker']} parser_ms={path_metrics['parser']} " + f"prepare_ms={path_metrics['prepare']} gui_apply_ms={path_metrics['gui']} " + f"scopes={path_metrics['scopes']} typed_store={path_metrics['typed_store']}" + ) + print(summary, flush=True) + return elapsed_s, peak_rss_mib, path_metrics["gui"], path_metrics, summary + + +def run_reload_soak( + app: Path, + target: int, + repeats: int, + timeout_s: float, +) -> tuple[list[float], float]: + xml, generated_points = build_scl(target) + with tempfile.TemporaryDirectory(prefix="arstack-scl-soak-") as temporary: + scl_path = Path(temporary) / f"synthetic-soak-{target}.scd" + scl_path.write_text(xml, encoding="utf-8") + + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + process = subprocess.Popen( + [ + str(app), + "--qa-async-import", + str(scl_path), + "--qa-async-import-repeat", + str(repeats), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + assert process.stdout is not None + + started = time.perf_counter() + interval_peak_kib = 0 + iteration_peaks_mib: list[float] = [] + gui_apply_samples_ms: list[int] = [] + output_lines: list[str] = [] + try: + while True: + interval_peak_kib = max(interval_peak_kib, read_rss_kib(process.pid)) + readable, _, _ = select.select([process.stdout], [], [], 0.02) + if readable: + line = process.stdout.readline() + if line: + output_lines.append(line) + path_match = IMPORT_PATH_RE.search(line) + if path_match: + metrics = { + key: int(value) + for key, value in path_match.groupdict().items() + } + if metrics["typed_store"] != 1: + raise RuntimeError( + "reload soak iteration left the typed canonical point-store path" + ) + gui_apply_samples_ms.append(metrics["gui"]) + if "ASYNC_IMPORT_ITERATION" in line: + iteration_peaks_mib.append(interval_peak_kib / 1024.0) + interval_peak_kib = read_rss_kib(process.pid) + + if process.poll() is not None: + remainder = process.stdout.read() + if remainder: + output_lines.append(remainder) + for match in IMPORT_PATH_RE.finditer(remainder): + metrics = { + key: int(value) + for key, value in match.groupdict().items() + } + if metrics["typed_store"] != 1: + raise RuntimeError( + "reload soak iteration left the typed canonical point-store path" + ) + gui_apply_samples_ms.append(metrics["gui"]) + break + if time.perf_counter() - started > timeout_s: + process.kill() + raise RuntimeError( + f"reload soak exceeded hard timeout {timeout_s:.0f}s" + ) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=2) + + output = "".join(output_lines) + elapsed_s = time.perf_counter() - started + if process.returncode != 0: + raise RuntimeError( + f"reload soak exited {process.returncode}; output={output[-4000:]}" + ) + if "ASYNC_IMPORT_OK" not in output: + raise RuntimeError( + f"reload soak never reported completion; output={output[-4000:]}" + ) + if len(iteration_peaks_mib) != repeats: + raise RuntimeError( + f"reload soak reported {len(iteration_peaks_mib)} iterations; expected {repeats}; " + f"output={output[-4000:]}" + ) + if len(gui_apply_samples_ms) != repeats: + raise RuntimeError( + f"reload soak reported {len(gui_apply_samples_ms)} typed GUI adoption samples; " + f"expected {repeats}; output={output[-4000:]}" + ) + + growth_mib = iteration_peaks_mib[-1] - iteration_peaks_mib[0] + print( + f"SCL_RELOAD_SOAK target={target} generated={generated_points} repeats={repeats} " + f"elapsed_ms={elapsed_s * 1000:.0f} first_peak_mib={iteration_peaks_mib[0]:.1f} " + f"last_peak_mib={iteration_peaks_mib[-1]:.1f} growth_mib={growth_mib:.1f} " + f"max_peak_mib={max(iteration_peaks_mib):.1f} " + f"max_gui_apply_ms={max(gui_apply_samples_ms)} typed_store=1", + flush=True, + ) + if max(gui_apply_samples_ms) > 50: + raise RuntimeError( + f"reload soak GUI adoption reached {max(gui_apply_samples_ms)} ms; 50 ms budget" + ) + return iteration_peaks_mib, growth_mib + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True, type=Path) + parser.add_argument( + "--targets", + default="5000,20000,50000", + help="Comma-separated expanded data-attribute targets.", + ) + parser.add_argument( + "--reload-soak-repeats", + type=int, + default=10, + help="Repeated 20k async imports in one process for memory-slope evidence; 0 disables.", + ) + args = parser.parse_args() + + app = args.app.resolve() + if not app.is_file(): + raise SystemExit(f"simulator executable not found: {app}") + + targets = [int(value) for value in args.targets.split(",") if value.strip()] + budgets = { + 5_000: (15.0, 512.0), + 20_000: (30.0, 768.0), + 50_000: (55.0, 1024.0), + } + + failures: list[str] = [] + for target in targets: + time_budget, rss_budget = budgets.get(target, (60.0, 1024.0)) + elapsed, peak_rss, gui_apply_ms, _, _ = run_case( + app, target, timeout_s=max(65.0, time_budget + 10.0) + ) + if elapsed > time_budget: + failures.append( + f"{target}: {elapsed:.2f}s > {time_budget:.2f}s wall-time budget" + ) + if peak_rss > rss_budget: + failures.append( + f"{target}: {peak_rss:.1f} MiB > {rss_budget:.1f} MiB RSS budget" + ) + if gui_apply_ms > 50: + failures.append( + f"{target}: {gui_apply_ms} ms GUI adoption > 50 ms budget" + ) + + if args.reload_soak_repeats > 0: + try: + peaks, growth = run_reload_soak( + app, + target=20_000, + repeats=args.reload_soak_repeats, + timeout_s=60.0, + ) + if max(peaks) > 768.0: + failures.append( + f"reload soak: {max(peaks):.1f} MiB > 768.0 MiB peak RSS budget" + ) + if growth > 96.0: + failures.append( + f"reload soak: +{growth:.1f} MiB first-to-last growth > 96.0 MiB budget" + ) + except RuntimeError as error: + failures.append(str(error)) + + if failures: + print("SCL_PERF_REGRESSION", flush=True) + for failure in failures: + print(f" - {failure}", flush=True) + return 1 + + print("SCL_PERF_OK", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md b/apps/ied_simulator/field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md new file mode 100644 index 000000000..f0d02b80a --- /dev/null +++ b/apps/ied_simulator/field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md @@ -0,0 +1,90 @@ +# F4 External Acceptance Result + +Build head: `______________________________` + +Date/time: `______________________________` + +Operator: `______________________________` + +Scenario: `[ ] external IEC 61850 client -> ARStack [ ] ARStack -> real IED` + +Target / endpoint: `______________________________` + +SCL/CID identity: `______________________________` + +PCAP/PCAPNG SHA-256: `______________________________` + +## A. external IEC 61850 client -> ARStack simulator server + +- [ ] ARStack running in normal/process mode. +- [ ] MMS association completes. +- [ ] Discover IED Model from IP completes without early association loss. +- [ ] Reports inventory is non-empty. +- [ ] DataSets are visible/readable. +- [ ] URCB root is visible, typed and readable. +- [ ] BRCB root is visible, typed and readable. +- [ ] Safe RCB enable/reserve succeeds where required. +- [ ] GI produces an InformationReport. +- [ ] RCB disable/release succeeds. +- [ ] Association remains alive through FileDirectory. +- [ ] Full TCP/102 PCAP/PCAPNG retained. +- [ ] Workbench/server diagnostics retained. + +Observed RCB/DataSet references: + +```text + +``` + +FileDirectory result / last successful service before disconnect, if any: + +```text + +``` + +## B. ARStack Workbench -> real IED + +- [ ] TCP/102 reachable. +- [ ] MMS association accepted. +- [ ] Live discovery completes. +- [ ] Typed Read succeeds. +- [ ] DataSet/URCB/BRCB inventory is populated when exposed by device. +- [ ] Safe report cycle completes: enable/reserve -> GI -> InformationReport -> disable/release. +- [ ] FileDirectory is read-only and succeeds. +- [ ] Optional download completes with FileClose confirmed. +- [ ] No remote FileDelete was performed on the field IED. +- [ ] No control write was performed unless explicitly authorized on a dedicated bench/test point. +- [ ] Full TCP/102 PCAP/PCAPNG retained. +- [ ] Workbench diagnostics retained. + +## Evidence review + +TCP reset count: `__________` + +Association disconnect point, if any: `______________________________` + +First failing MMS service / invoke id, if any: `______________________________` + +Relevant Wireshark frame numbers: + +```text + +``` + +Diagnostics excerpt: + +```text + +``` + +## Verdict + +- [ ] PASS — external acceptance criteria satisfied. +- [ ] FAIL — blocker reproduced; keep issue open. +- [ ] INCOMPLETE — capture/evidence insufficient; rerun required. + +Notes: + +```text + +``` diff --git a/apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md b/apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md new file mode 100644 index 000000000..c63837f2e --- /dev/null +++ b/apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md @@ -0,0 +1,82 @@ +# F4 — Real IED / external IEC 61850 client External Acceptance + +F4 is the external interoperability gate for PR #82. Internal simulator/loopback CI is necessary but is **not** external acceptance. + +## Acceptance lanes + +### A. external IEC 61850 client -> ARStack simulator server (mandatory for issue #95) + +Use the same CID/SCL model used for the golden external IEC 61850 client capture. Run the ARStack Workbench/server in normal/process mode, then connect external vendor external IEC 61850 client to the ARStack endpoint. + +Required evidence: + +1. Association completes without an early disconnect. +2. Discover IED Model from IP completes far enough to show a non-empty Reports inventory. +3. DataSets are visible/readable. +4. URCB and BRCB roots are visible, typed and readable. +5. At least one RCB can be enabled/reserved as appropriate and GI produces an InformationReport. +6. Association remains alive through FileDirectory. A FileDirectory request must not be treated as success merely because earlier discovery worked. +7. The complete TCP/102 session is retained in PCAP/PCAPNG together with Workbench/server diagnostics. + +FileDelete is **not** part of the real-IED safety gate. If FileDelete interoperability is exercised, do it only against the ARStack sandbox file root with a disposable test file and explicit `--allow-file-delete`. + +### B. ARStack Workbench -> real IED (field/client lane) + +This lane validates the client against a real device or trusted vendor simulator. + +Required evidence: + +1. TCP/102 and MMS association succeed. +2. Live discovery completes and typed reads work. +3. DataSet/URCB/BRCB inventory is populated when exposed by the device. +4. A safe report-control cycle can be completed: enable/reserve as required -> GI -> InformationReport -> disable/release. +5. FileDirectory is read-only by default. Optional download must complete with FileClose confirmed. +6. Do **not** perform remote FileDelete on a field IED as part of F4. Do not perform control writes unless the point is a dedicated bench/test point and the operator has explicitly authorized it. +7. Capture the complete TCP/102 session and Workbench diagnostics. + +## Windows field kit + +The F4 workflow produces a Windows artifact containing: + +- current Workbench installer and portable ZIP; +- current `ariec61850_ied_simulator_server.exe`; +- live discovery/read/RCB/file-transfer probes; +- `collect-f4-evidence.ps1`; +- `ACCEPTANCE_RESULT_TEMPLATE.md`; +- `BUILD_HEAD.txt` and SHA-256 checksums. + +### Capture preparation + +Install Wireshark/Npcap so `dumpcap.exe` is available. List capture interfaces: + +```powershell +& "C:\Program Files\Wireshark\dumpcap.exe" -D +``` + +Note the numeric interface id. Start the ARStack server/Workbench or make the real IED reachable **before** starting evidence collection. + +Example, external IEC 61850 client testing ARStack on loopback: + +```powershell +.\collect-f4-evidence.ps1 ` + -Mode ExternalClientToARStack ` + -TargetIp 127.0.0.1 ` + -InterfaceId 1 ` + -DurationSeconds 180 +``` + +Example, ARStack testing a real IED: + +```powershell +.\collect-f4-evidence.ps1 ` + -Mode ARStackToIED ` + -TargetIp 192.168.1.10 ` + -InterfaceId 4 ` + -DurationSeconds 180 +``` + +During the capture, execute the acceptance steps above. The collector writes a timestamped folder containing the PCAPNG, environment/preflight information, SHA-256, optional tshark packet/reset counts, and a manual-result template. + +## Pass / close rule + +Internal CI may mark the F4 **RC ready**, but issue #95 stays open until lane A is executed with real external IEC 61850 client and the captured evidence shows the required discovery/reporting behavior without an association-loss blocker. A simulator-only result must never be relabelled as external external IEC 61850 client acceptance. diff --git a/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 b/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 new file mode 100644 index 000000000..a8204260a --- /dev/null +++ b/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 @@ -0,0 +1,136 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('ExternalClientToARStack', 'ARStackToIED')] + [string]$Mode, + + [Parameter(Mandatory = $true)] + [string]$TargetIp, + + [int]$Port = 102, + + [Parameter(Mandatory = $true)] + [ValidateRange(1, 999)] + [int]$InterfaceId, + + [ValidateRange(30, 1800)] + [int]$DurationSeconds = 180, + + [string]$OutputRoot = (Join-Path $PWD 'f4-evidence') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-Dumpcap { + $command = Get-Command dumpcap.exe -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + $candidate = Join-Path ${env:ProgramFiles} 'Wireshark\dumpcap.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + throw 'dumpcap.exe not found. Install Wireshark/Npcap before F4 external acceptance.' +} + +function Resolve-Tshark { + $command = Get-Command tshark.exe -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + $candidate = Join-Path ${env:ProgramFiles} 'Wireshark\tshark.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + return $null +} + +if ($Port -lt 1 -or $Port -gt 65535) { + throw 'Port must be in the range 1..65535.' +} + +$dumpcap = Resolve-Dumpcap +$tshark = Resolve-Tshark +$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmssZ') +$evidenceDir = Join-Path $OutputRoot ("F4-{0}-{1}" -f $Mode, $stamp) +New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null + +$transcriptPath = Join-Path $evidenceDir 'collector-transcript.txt' +Start-Transcript -Path $transcriptPath -Force | Out-Null +try { + Write-Host "F4 evidence collection" + Write-Host "Mode: $Mode" + Write-Host "Target: ${TargetIp}:$Port" + Write-Host "Interface id: $InterfaceId" + Write-Host "Duration: $DurationSeconds seconds" + + $interfacesPath = Join-Path $evidenceDir 'capture-interfaces.txt' + & $dumpcap -D | Tee-Object -FilePath $interfacesPath | Out-Host + + $buildHead = 'unknown' + $headFile = Join-Path $PSScriptRoot 'BUILD_HEAD.txt' + if (Test-Path -LiteralPath $headFile -PathType Leaf) { + $buildHead = (Get-Content -LiteralPath $headFile -Raw).Trim() + } + + $preflight = Test-NetConnection -ComputerName $TargetIp -Port $Port -WarningAction SilentlyContinue + $preflight | Format-List * | Out-String | Set-Content -LiteralPath (Join-Path $evidenceDir 'tcp-preflight.txt') + if (-not $preflight.TcpTestSucceeded) { + throw "TCP preflight failed for ${TargetIp}:$Port. Start the ARStack server or make the real IED reachable before collecting F4 evidence." + } + + $pcapPath = Join-Path $evidenceDir 'mms-session.pcapng' + $filter = "tcp port $Port" + Write-Host "Starting dumpcap. Execute the F4 acceptance sequence now." + Write-Host "Capture filter: $filter" + + $argumentLine = "-q -i $InterfaceId -f `"$filter`" -a duration:$DurationSeconds -w `"$pcapPath`"" + $capture = Start-Process -FilePath $dumpcap -ArgumentList $argumentLine -PassThru -NoNewWindow + $capture.WaitForExit() + if ($capture.ExitCode -ne 0) { + throw "dumpcap failed with exit code $($capture.ExitCode)." + } + if (-not (Test-Path -LiteralPath $pcapPath -PathType Leaf)) { + throw 'Capture completed without producing a PCAPNG file.' + } + + $pcapInfo = Get-Item -LiteralPath $pcapPath + if ($pcapInfo.Length -le 24) { + throw "PCAPNG is unexpectedly small ($($pcapInfo.Length) bytes); external acceptance evidence is incomplete." + } + + $pcapHash = (Get-FileHash -LiteralPath $pcapPath -Algorithm SHA256).Hash.ToLowerInvariant() + $packetCount = $null + $tcpResetCount = $null + if ($tshark) { + $packetLines = & $tshark -r $pcapPath -Y "tcp.port==$Port" -T fields -e frame.number 2>$null + $packetCount = @($packetLines | Where-Object { $_ -ne '' }).Count + $resetLines = & $tshark -r $pcapPath -Y "tcp.port==$Port && tcp.flags.reset==1" -T fields -e frame.number 2>$null + $tcpResetCount = @($resetLines | Where-Object { $_ -ne '' }).Count + } + + $metadata = [ordered]@{ + schemaVersion = 'arstack-f4-evidence-v1' + buildHead = $buildHead + mode = $Mode + targetIp = $TargetIp + port = $Port + interfaceId = $InterfaceId + captureDurationSeconds = $DurationSeconds + capturedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + tcpPreflightSucceeded = [bool]$preflight.TcpTestSucceeded + pcapFile = $pcapInfo.Name + pcapBytes = [uint64]$pcapInfo.Length + pcapSha256 = $pcapHash + tcp102PacketCount = $packetCount + tcpResetCount = $tcpResetCount + externalAcceptance = 'pending_manual_review' + } + $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $evidenceDir 'evidence.json') -Encoding UTF8 + + $template = Join-Path $PSScriptRoot 'ACCEPTANCE_RESULT_TEMPLATE.md' + if (Test-Path -LiteralPath $template -PathType Leaf) { + Copy-Item -LiteralPath $template -Destination (Join-Path $evidenceDir 'ACCEPTANCE_RESULT.md') -Force + } + + Write-Host "F4 capture complete: $evidenceDir" + Write-Host "PCAP SHA-256: $pcapHash" + if ($null -ne $packetCount) { Write-Host "TCP/$Port packets: $packetCount" } + if ($null -ne $tcpResetCount) { Write-Host "TCP resets: $tcpResetCount" } + Write-Host 'Complete ACCEPTANCE_RESULT.md and retain Workbench/server diagnostics with this folder.' +} +finally { + try { Stop-Transcript | Out-Null } catch { } +} diff --git a/apps/ied_simulator/packaging/windows/ARStackIEC61850Workbench.nsi b/apps/ied_simulator/packaging/windows/ARStackIEC61850Workbench.nsi new file mode 100644 index 000000000..66d308b90 --- /dev/null +++ b/apps/ied_simulator/packaging/windows/ARStackIEC61850Workbench.nsi @@ -0,0 +1,57 @@ +Unicode True + +!ifndef STAGE_DIR + !error "STAGE_DIR define is required" +!endif +!ifndef OUT_FILE + !error "OUT_FILE define is required" +!endif +!ifndef APP_ICON + !error "APP_ICON define is required" +!endif + +Name "ARStack IEC 61850 Workbench" +OutFile "${OUT_FILE}" +InstallDir "$PROGRAMFILES64\ARStack IEC 61850 Workbench" +InstallDirRegKey HKLM "Software\ARStack61850\IEC61850Workbench" "InstallDir" +RequestExecutionLevel admin +SetCompressor /SOLID lzma +Icon "${APP_ICON}" +UninstallIcon "${APP_ICON}" + +VIProductVersion "0.2.0.0" +VIAddVersionKey /LANG=1033 "ProductName" "ARStack IEC 61850 Workbench" +VIAddVersionKey /LANG=1033 "CompanyName" "ARStack61850" +VIAddVersionKey /LANG=1033 "FileDescription" "IEC 61850 engineering workbench" +VIAddVersionKey /LANG=1033 "FileVersion" "0.2.0" +VIAddVersionKey /LANG=1033 "ProductVersion" "0.2.0" + +Page directory +Page instfiles +UninstPage uninstConfirm +UninstPage instfiles + +Section "ARStack IEC 61850 Workbench" SEC_MAIN + SetOutPath "$INSTDIR" + File /r "${STAGE_DIR}\*.*" + + WriteRegStr HKLM "Software\ARStack61850\IEC61850Workbench" "InstallDir" "$INSTDIR" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackIEC61850Workbench" "DisplayName" "ARStack IEC 61850 Workbench" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackIEC61850Workbench" "DisplayVersion" "0.2.0" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackIEC61850Workbench" "Publisher" "ARStack61850" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackIEC61850Workbench" "InstallLocation" "$INSTDIR" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackIEC61850Workbench" "DisplayIcon" "$INSTDIR\arstack_ied_simulator.exe" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackIEC61850Workbench" "UninstallString" '"$INSTDIR\Uninstall.exe"' + + WriteUninstaller "$INSTDIR\Uninstall.exe" + CreateDirectory "$SMPROGRAMS\ARStack IEC 61850 Workbench" + CreateShortcut "$SMPROGRAMS\ARStack IEC 61850 Workbench\ARStack IEC 61850 Workbench.lnk" "$INSTDIR\arstack_ied_simulator.exe" +SectionEnd + +Section "Uninstall" + Delete "$SMPROGRAMS\ARStack IEC 61850 Workbench\ARStack IEC 61850 Workbench.lnk" + RMDir "$SMPROGRAMS\ARStack IEC 61850 Workbench" + DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackIEC61850Workbench" + DeleteRegKey HKLM "Software\ARStack61850\IEC61850Workbench" + RMDir /r "$INSTDIR" +SectionEnd diff --git a/apps/ied_simulator/packaging/windows/arstack_ied_simulator.rc.in b/apps/ied_simulator/packaging/windows/arstack_ied_simulator.rc.in new file mode 100644 index 000000000..cbf5b97fa --- /dev/null +++ b/apps/ied_simulator/packaging/windows/arstack_ied_simulator.rc.in @@ -0,0 +1,2 @@ +// Official ARStack61850 product icon, sourced from repository assets. +IDI_ICON1 ICON "@ARSTACK_APP_ICON@" diff --git a/apps/ied_simulator/packaging/windows/verify-release.ps1 b/apps/ied_simulator/packaging/windows/verify-release.ps1 new file mode 100644 index 000000000..18fea35f6 --- /dev/null +++ b/apps/ied_simulator/packaging/windows/verify-release.ps1 @@ -0,0 +1,81 @@ +param( + [Parameter(Mandatory = $true)][string]$StageDir, + [Parameter(Mandatory = $true)][string]$BuildDir, + [Parameter(Mandatory = $true)][string]$InstallerPath, + [Parameter(Mandatory = $true)][string]$FixturePath +) + +$ErrorActionPreference = 'Stop' + +function Assert-File([string]$Path, [string]$Label) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "$Label missing: $Path" + } +} + +function Invoke-Checked([string]$FilePath, [string[]]$ArgumentList, [string]$Label) { + $process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Wait -PassThru -NoNewWindow + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } +} + +$stage = (Resolve-Path -LiteralPath $StageDir).Path +$build = (Resolve-Path -LiteralPath $BuildDir).Path +$installer = (Resolve-Path -LiteralPath $InstallerPath).Path +$fixture = (Resolve-Path -LiteralPath $FixturePath).Path + +$app = Join-Path $stage 'arstack_ied_simulator.exe' +$server = Join-Path $stage 'ariec61850_ied_simulator_server.exe' +Assert-File $app 'Staged workbench executable' +Assert-File $server 'Staged MMS simulator helper' +Assert-File (Join-Path $stage 'Qt6Core.dll') 'Qt Core runtime' +Assert-File (Join-Path $stage 'Qt6Gui.dll') 'Qt GUI runtime' +Assert-File (Join-Path $stage 'Qt6Network.dll') 'Qt Network runtime' +Assert-File (Join-Path $stage 'Qt6Qml.dll') 'Qt QML runtime' +Assert-File (Join-Path $stage 'Qt6Quick.dll') 'Qt Quick runtime' +Assert-File (Join-Path $stage 'platforms\qwindows.dll') 'Qt Windows platform plugin' + +Invoke-Checked $app @('--smoke-test') 'Staged package smoke test' + +$hardeningQa = Join-Path $build 'ied_product_hardening_qa.exe' +$mmsQa = Join-Path $build 'ied_mms_client_workbench_qa.exe' +Assert-File $hardeningQa 'Product hardening QA executable' +Assert-File $mmsQa 'MMS reconnect QA executable' + +$hardeningOutput = (& $hardeningQa 2>&1 | Out-String) +Write-Host $hardeningOutput +if ($LASTEXITCODE -ne 0) { + throw "Product hardening QA failed with exit code $LASTEXITCODE" +} +if ($hardeningOutput -notmatch 'PRODUCT_HARDENING_PASS' -or + $hardeningOutput -notmatch 'WINDOWS_RUNTIME_READINESS_PASS' -or + $hardeningOutput -notmatch 'guidance=pass') { + throw 'Windows Npcap/runtime readiness evidence marker missing.' +} + +$mmsOutput = (& $mmsQa $fixture 2>&1 | Out-String) +Write-Host $mmsOutput +if ($LASTEXITCODE -ne 0) { + throw "MMS reconnect QA failed with exit code $LASTEXITCODE" +} +if ($mmsOutput -notmatch 'MMS_CLIENT_RECONNECT_SOAK_PASS' -or + $mmsOutput -notmatch 'cycles=24') { + throw 'MMS reconnect soak evidence marker missing.' +} + +$installDir = Join-Path $env:RUNNER_TEMP 'arstack-iec61850-installed' +if (Test-Path -LiteralPath $installDir) { + Remove-Item -LiteralPath $installDir -Recurse -Force +} + +Invoke-Checked $installer @('/S', "/D=$installDir") 'Silent NSIS install' +$installedApp = Join-Path $installDir 'arstack_ied_simulator.exe' +$installedServer = Join-Path $installDir 'ariec61850_ied_simulator_server.exe' +Assert-File $installedApp 'Installed workbench executable' +Assert-File $installedServer 'Installed MMS simulator helper' +Assert-File (Join-Path $installDir 'Qt6Core.dll') 'Installed Qt Core runtime' +Assert-File (Join-Path $installDir 'platforms\qwindows.dll') 'Installed Qt Windows platform plugin' +Invoke-Checked $installedApp @('--smoke-test') 'Installed package smoke test' + +Write-Host 'WINDOWS_RELEASE_PACKAGE_PASS staged_smoke=pass installed_smoke=pass qt_runtime=pass helper_server=pass npcap_guidance=pass installer=nsis portable=zip reconnect_cycles=24' diff --git a/apps/ied_simulator/qml/ActionButton.qml b/apps/ied_simulator/qml/ActionButton.qml index a9e8d82cf..ca798e45c 100644 --- a/apps/ied_simulator/qml/ActionButton.qml +++ b/apps/ied_simulator/qml/ActionButton.qml @@ -29,7 +29,12 @@ Button { } Label { text: control.text - color: control.enabled ? theme.text : theme.muted + color: { + if (!control.enabled) return theme.muted + if (control.primary) return "#ffffff" + if (control.danger && control.hovered) return "#ffffff" + return theme.text + } font.family: control.font.family font.pixelSize: theme.labelSize font.weight: Font.DemiBold @@ -48,8 +53,8 @@ Button { } color: { if (!control.enabled) return theme.chrome - if (control.primary) return control.pressed ? "#286fca" : (control.hovered ? theme.accentHover : theme.accent) - if (control.danger) return control.hovered ? theme.redSoft : theme.surfaceRaised + if (control.primary) return control.pressed ? theme.navigationDark : (control.hovered ? theme.accentHover : theme.accent) + if (control.danger) return control.hovered ? theme.red : theme.surface return control.pressed ? theme.surfaceSoft : (control.hovered ? theme.surfaceRaised : theme.surface) } } diff --git a/apps/ied_simulator/qml/ActivityMonitor.qml b/apps/ied_simulator/qml/ActivityMonitor.qml new file mode 100644 index 000000000..f6f0138ce --- /dev/null +++ b/apps/ied_simulator/qml/ActivityMonitor.qml @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +Drawer { + id: root + + required property var theme + required property var backend + + edge: Qt.RightEdge + modal: false + interactive: true + width: parent ? Math.min(520, Math.max(390, parent.width * 0.42)) : 480 + height: parent ? parent.height : 760 + padding: 0 + + FileDialog { + id: diagnosticsDialog + title: "Export diagnostics" + fileMode: FileDialog.SaveFile + nameFilters: ["Text files (*.txt)", "All files (*)"] + defaultSuffix: "txt" + onAccepted: root.backend.activityModel.exportDiagnostics( + selectedFile, root.backend.diagnosticsText()) + } + + background: Rectangle { + color: root.theme.chrome + border.width: 1 + border.color: root.theme.line + } + + contentItem: ColumnLayout { + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 58 + color: root.theme.navigation + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 10 + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + text: "Activity Monitor" + color: root.theme.navigationText + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Label { + text: "IEC 61850 runtime, reports and diagnostics" + color: root.theme.navigationMuted + font.pixelSize: 8 + } + } + + ToolButton { + text: "Copy" + enabled: root.backend.imported + onClicked: root.backend.copyDiagnostics() + ToolTip.visible: hovered + ToolTip.text: "Copy full diagnostics" + } + ToolButton { + text: "Export" + onClicked: diagnosticsDialog.open() + ToolTip.visible: hovered + ToolTip.text: "Save full diagnostics atomically" + } + ToolButton { + text: "Clear" + enabled: root.backend.activityModel.retainedCount > 0 + onClicked: root.backend.clearActivity() + } + ToolButton { + text: "×" + font.pixelSize: 16 + onClicked: root.close() + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 48 + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 7 + + TextField { + id: searchField + Layout.fillWidth: true + Layout.preferredHeight: 31 + placeholderText: "Filter IED, service, report or message" + selectByMouse: true + font.pixelSize: 9 + onTextChanged: root.backend.activityModel.filterText = text + } + + ComboBox { + id: severityBox + Layout.preferredWidth: 105 + Layout.preferredHeight: 31 + model: ["All", "Info", "Success", "Warning", "Error"] + font.pixelSize: 9 + onCurrentTextChanged: root.backend.activityModel.severityFilter = currentText + } + } + } + + ListView { + id: activityList + objectName: "iedActivityMonitorList" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.opened ? root.backend.activityModel : null + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: eventRow + required property string time + required property string category + required property string message + required property string severity + required property string ied + + width: activityList.width + height: 70 + color: rowMouse.containsMouse ? root.theme.surfaceRaised : root.theme.surface + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: { + if (eventRow.severity === "Error") return root.theme.red + if (eventRow.severity === "Warning") return root.theme.amber + if (eventRow.severity === "Success") return root.theme.green + return root.theme.accent + } + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 10 + anchors.topMargin: 7 + anchors.bottomMargin: 7 + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 7 + Label { + text: eventRow.time + color: root.theme.muted + font.pixelSize: 8 + } + Rectangle { + implicitWidth: categoryLabel.implicitWidth + 12 + implicitHeight: 17 + radius: 8 + color: root.theme.surfaceRaised + Label { + id: categoryLabel + anchors.centerIn: parent + text: eventRow.category.length ? eventRow.category : "Event" + color: root.theme.textSoft + font.pixelSize: 7 + font.weight: Font.DemiBold + } + } + Label { + Layout.fillWidth: true + text: eventRow.ied + color: root.theme.accent + font.pixelSize: 8 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignRight + elide: Text.ElideLeft + } + } + + Label { + Layout.fillWidth: true + text: eventRow.message + color: root.theme.textSoft + font.pixelSize: 9 + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: root.theme.lineSoft + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + + Label { + anchors.centerIn: parent + visible: root.opened && root.backend.activityModel.visibleCount === 0 + text: root.backend.activityModel.retainedCount === 0 + ? "No activity yet" + : "No activity matches this filter" + color: root.theme.muted + font.pixelSize: 11 + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: root.theme.statusChrome + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + Label { + Layout.fillWidth: true + text: { + var retained = root.backend.activityModel.retainedCount + var visible = root.backend.activityModel.visibleCount + return visible === retained + ? retained + " retained events · newest first" + : visible + " shown · " + retained + " retained" + } + color: root.theme.statusText + font.pixelSize: 8 + } + Label { + text: "Ctrl+Shift+A" + color: root.theme.navigationMuted + font.pixelSize: 8 + } + } + } + } + + onOpened: { + root.backend.activityModel.filterText = searchField.text + root.backend.activityModel.severityFilter = severityBox.currentText + searchField.forceActiveFocus() + activityList.positionViewAtBeginning() + } + + Connections { + target: root.backend.activityModel + function onRetainedCountChanged() { + if (root.opened) + activityList.positionViewAtBeginning() + } + } +} diff --git a/apps/ied_simulator/qml/AppTheme.qml b/apps/ied_simulator/qml/AppTheme.qml index c7abf717b..bc6652406 100644 --- a/apps/ied_simulator/qml/AppTheme.qml +++ b/apps/ied_simulator/qml/AppTheme.qml @@ -2,31 +2,40 @@ import QtQuick QtObject { - readonly property color background: "#090e14" - readonly property color chrome: "#0c1219" - readonly property color surface: "#101821" - readonly property color surfaceRaised: "#141e28" - readonly property color surfaceSoft: "#18232e" - readonly property color line: "#273440" - readonly property color lineSoft: "#1c2833" - readonly property color text: "#f2f6fa" - readonly property color textSoft: "#c5d0da" - readonly property color muted: "#7f91a3" - readonly property color accent: "#3f91f7" - readonly property color accentHover: "#5aa4ff" - readonly property color accentSoft: "#162c45" - readonly property color green: "#49c875" - readonly property color greenSoft: "#153323" - readonly property color amber: "#e9ad4d" - readonly property color amberSoft: "#372b18" - readonly property color red: "#ee6c75" - readonly property color redSoft: "#3b1e25" - readonly property int panelRadius: 8 - readonly property int controlRadius: 6 - readonly property int controlHeight: 36 + // ARStack Lab uses its own calm graphite + teal engineering palette. The + // data canvas stays light for long IEC 61850 references while navigation + // and actions are visually distinct from vendor commissioning tools. + readonly property color background: "#f2f6f4" + readonly property color chrome: "#f8faf9" + readonly property color surface: "#ffffff" + readonly property color surfaceRaised: "#edf3f0" + readonly property color surfaceSoft: "#e3ebe8" + readonly property color line: "#b9c9c3" + readonly property color lineSoft: "#d7e2de" + readonly property color text: "#17241f" + readonly property color textSoft: "#40534d" + readonly property color muted: "#71827c" + readonly property color accent: "#0f766e" + readonly property color accentHover: "#0b625c" + readonly property color accentSoft: "#d9f1ec" + readonly property color navigation: "#182522" + readonly property color navigationDark: "#101b18" + readonly property color navigationText: "#f5faf8" + readonly property color navigationMuted: "#b7cbc5" + readonly property color green: "#16834b" + readonly property color greenSoft: "#e1f5e8" + readonly property color amber: "#b77900" + readonly property color amberSoft: "#fff1cf" + readonly property color red: "#c23f4b" + readonly property color redSoft: "#fbe5e7" + readonly property color statusChrome: "#182522" + readonly property color statusText: "#f7fbf9" + readonly property int panelRadius: 10 + readonly property int controlRadius: 8 + readonly property int controlHeight: 34 readonly property int captionSize: 11 readonly property int labelSize: 12 - readonly property int bodySize: 14 - readonly property int subtitleSize: 16 - readonly property int titleSize: 21 + readonly property int bodySize: 13 + readonly property int subtitleSize: 15 + readonly property int titleSize: 20 } diff --git a/apps/ied_simulator/qml/CommissioningWorkspace.qml b/apps/ied_simulator/qml/CommissioningWorkspace.qml new file mode 100644 index 000000000..70c369abc --- /dev/null +++ b/apps/ied_simulator/qml/CommissioningWorkspace.qml @@ -0,0 +1,924 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.IedSimulator 1.0 + +Drawer { + id: root + + required property var theme + required property var backend + property int selectedMemberIndex: -1 + property var selectedMember: { + var revision = commissioningModel.revision + return selectedMemberIndex >= 0 && selectedMemberIndex < commissioningModel.memberCount + ? commissioningModel.member(selectedMemberIndex) : ({}) + } + property var selectedBehavior: { + var activityRevision = commissioningModel.behaviorTickCount + + commissioningModel.behaviorRejectedCount + + commissioningModel.behaviorCount + return selectedMemberIndex >= 0 + ? commissioningModel.memberBehavior(selectedMemberIndex) : ({"active": false}) + } + property bool selectedMemberDrivable: selectedMemberIndex >= 0 + && commissioningModel.canDriveMember(selectedMemberIndex) + property var selectedGooseRuntime: commissioningModel.selectedGooseRuntime + + edge: Qt.RightEdge + modal: false + interactive: true + width: parent ? Math.min(1180, Math.max(820, parent.width * 0.86)) : 1080 + height: parent ? parent.height : 760 + padding: 0 + + function display(value) { + if (value === undefined || value === null || String(value).length === 0) return "—" + return String(value) + } + + function syncMemberSelection() { + if (commissioningModel.memberCount <= 0) { + selectedMemberIndex = -1 + return + } + if (selectedMemberIndex < 0 || selectedMemberIndex >= commissioningModel.memberCount) + selectedMemberIndex = 0 + } + + background: Rectangle { + color: root.theme.chrome + border.width: 1 + border.color: root.theme.line + } + + IedCommissioningModel { + id: commissioningModel + // Keep commissioning behavior/publication ownership alive when the drawer closes; + // runtime/session validity, not UI visibility, owns deterministic teardown. + backend: root.backend + } + + component MetaRow: RowLayout { + property string title: "" + property string value: "" + Layout.fillWidth: true + spacing: 8 + Label { + Layout.preferredWidth: 86 + text: parent.title + color: root.theme.muted + font.pixelSize: 8 + } + Label { + Layout.fillWidth: true + text: parent.value.length ? parent.value : "—" + color: root.theme.textSoft + font.pixelSize: 9 + horizontalAlignment: Text.AlignRight + elide: Text.ElideMiddle + } + } + + component SmallButton: Button { + id: button + implicitHeight: 29 + implicitWidth: Math.max(78, label.implicitWidth + 20) + font.pixelSize: 9 + font.weight: Font.DemiBold + property bool primary: false + contentItem: Label { + id: label + text: button.text + color: !button.enabled ? root.theme.muted + : button.primary ? "#ffffff" : root.theme.textSoft + font: button.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 5 + color: !button.enabled ? root.theme.surfaceRaised + : button.primary ? root.theme.accent + : button.down ? root.theme.surfaceSoft : root.theme.surface + border.width: 1 + border.color: button.primary ? root.theme.accent : root.theme.line + } + } + + Connections { + target: commissioningModel + function onFilterChanged() { + var wanted = commissioningModel.kindFilter + kindBox.currentIndex = Math.max(0, kindBox.model.indexOf(wanted)) + if (commissioningModel.filterText.length === 0 && searchField.text.length > 0) searchField.clear() + } + function onSelectionChanged() { root.syncMemberSelection() } + function onModelChanged() { root.syncMemberSelection() } + } + + contentItem: ColumnLayout { + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 62 + color: root.theme.navigation + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 10 + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + text: "Commissioning Explorer" + color: root.theme.navigationText + font.pixelSize: 14 + font.weight: Font.DemiBold + } + Label { + text: root.backend.imported + ? (root.backend.selectedIed.name || "IED") + " · inspect, stimulate and verify runtime state" + : "DataSet, Report, GOOSE and control topology" + color: root.theme.navigationMuted + font.pixelSize: 8 + } + } + + Label { + visible: root.backend.imported + text: commissioningModel.dataSetCount + " DS · " + + commissioningModel.reportCount + " RPT · " + + commissioningModel.gooseCount + " GOOSE · " + + commissioningModel.controlCount + " CTRL" + color: root.theme.navigationMuted + font.pixelSize: 8 + } + + Rectangle { + visible: commissioningModel.behaviorCount > 0 + implicitWidth: behaviorHeader.implicitWidth + 16 + implicitHeight: 24 + radius: 12 + color: root.theme.greenSoft + Label { + id: behaviorHeader + anchors.centerIn: parent + text: "SIM " + commissioningModel.behaviorCount + "/" + commissioningModel.behaviorCapacity + color: root.theme.green + font.pixelSize: 8 + font.weight: Font.Bold + } + } + + ToolButton { + text: "×" + font.pixelSize: 17 + onClicked: root.close() + ToolTip.visible: hovered + ToolTip.text: "Close · Ctrl+Shift+C" + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 50 + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + ComboBox { + id: kindBox + Layout.preferredWidth: 126 + Layout.preferredHeight: 32 + model: ["All", "DataSet", "Report", "GOOSE", "Control"] + font.pixelSize: 9 + onActivated: commissioningModel.kindFilter = currentText + } + + TextField { + id: searchField + Layout.fillWidth: true + Layout.preferredHeight: 32 + placeholderText: "Search service, reference, DataSet, APPID, goID or control model" + selectByMouse: true + font.pixelSize: 9 + enabled: root.backend.imported + onTextChanged: commissioningModel.filterText = text + } + + Label { + text: commissioningModel.itemCount + " item" + (commissioningModel.itemCount === 1 ? "" : "s") + color: root.theme.muted + font.pixelSize: 9 + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Rectangle { + Layout.preferredWidth: 310 + Layout.minimumWidth: 270 + Layout.fillHeight: true + color: root.theme.chrome + border.width: 1 + border.color: root.theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: root.theme.surfaceRaised + RowLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 9 + Label { + Layout.fillWidth: true + text: "CONFIGURED SERVICES" + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Label { + text: root.backend.running ? "Runtime online" : "Runtime offline" + color: root.backend.running ? root.theme.green : root.theme.muted + font.pixelSize: 7 + } + } + } + + ListView { + id: serviceList + objectName: "iedCommissioningServiceList" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: commissioningModel.itemCount + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: serviceRow + required property int index + property var entry: { + var version = commissioningModel.revision + return commissioningModel.item(index) + } + + width: serviceList.width + height: 64 + color: entry.selected ? root.theme.accentSoft + : serviceMouse.containsMouse ? root.theme.surfaceRaised + : root.theme.surface + + Rectangle { + visible: serviceRow.entry.selected === true + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: root.theme.accent + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 9 + anchors.topMargin: 7 + anchors.bottomMargin: 7 + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Rectangle { + implicitWidth: typeLabel.implicitWidth + 12 + implicitHeight: 17 + radius: 8 + color: serviceRow.entry.kind === "GOOSE" ? root.theme.greenSoft + : serviceRow.entry.kind === "Report" ? root.theme.accentSoft + : serviceRow.entry.kind === "Control" ? root.theme.amberSoft + : root.theme.surfaceRaised + Label { + id: typeLabel + anchors.centerIn: parent + text: serviceRow.entry.kind || "Service" + color: serviceRow.entry.kind === "GOOSE" ? root.theme.green + : serviceRow.entry.kind === "Control" ? root.theme.amber + : root.theme.accent + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: serviceRow.entry.name || "—" + color: root.theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + text: serviceRow.entry.status || "" + color: serviceRow.entry.status === "Unresolved" ? root.theme.red : root.theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } + } + + Label { + Layout.fillWidth: true + text: serviceRow.entry.summary || serviceRow.entry.reference || "" + color: root.theme.muted + font.pixelSize: 8 + elide: Text.ElideMiddle + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: root.theme.lineSoft + } + + MouseArea { + id: serviceMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + commissioningModel.select(serviceRow.index) + root.syncMemberSelection() + } + } + } + + Label { + anchors.centerIn: parent + visible: commissioningModel.itemCount === 0 + text: root.backend.imported ? "No services match this filter" : "Open an SCL model first" + color: root.theme.muted + font.pixelSize: 10 + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: root.theme.surfaceRaised + RowLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + Label { + Layout.fillWidth: true + text: "CANONICAL MEMBERS" + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Label { + text: commissioningModel.memberCount + " member" + (commissioningModel.memberCount === 1 ? "" : "s") + color: root.theme.muted + font.pixelSize: 8 + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 28 + color: root.theme.chrome + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + Label { Layout.fillWidth: true; text: "FCDA / object"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 44; text: "FC"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 64; text: "CDC"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 95; text: "Type"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + } + } + + ListView { + id: memberList + objectName: "iedCommissioningMemberList" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: commissioningModel.memberCount + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: memberRow + required property int index + property var entry: { + var version = commissioningModel.revision + return commissioningModel.member(index) + } + property bool drivable: commissioningModel.canDriveMember(index) + property var behavior: { + var tickVersion = commissioningModel.behaviorTickCount + + commissioningModel.behaviorCount + return commissioningModel.memberBehavior(index) + } + + width: memberList.width + height: 38 + color: root.selectedMemberIndex === index ? root.theme.accentSoft + : memberMouse.containsMouse ? root.theme.surfaceRaised : root.theme.surface + + Rectangle { + visible: root.selectedMemberIndex === memberRow.index + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 2 + color: root.theme.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: (memberRow.entry.dataObject || "") + + (memberRow.entry.dataAttribute ? "." + memberRow.entry.dataAttribute : "") + color: root.theme.textSoft + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: memberRow.entry.reference || memberRow.entry.mmsItem || "" + color: root.theme.muted + font.pixelSize: 7 + elide: Text.ElideMiddle + } + } + Label { + visible: memberRow.behavior.active === true + Layout.preferredWidth: 30 + text: "SIM" + color: root.theme.green + font.pixelSize: 7 + font.weight: Font.Bold + } + Label { Layout.preferredWidth: 44; text: memberRow.entry.fc || ""; color: memberRow.drivable ? root.theme.textSoft : root.theme.muted; font.pixelSize: 8 } + Label { Layout.preferredWidth: 64; text: memberRow.entry.cdc || ""; color: root.theme.muted; font.pixelSize: 8 } + Label { Layout.preferredWidth: 95; text: memberRow.entry.type || ""; color: root.theme.muted; font.pixelSize: 8; elide: Text.ElideRight } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: root.theme.lineSoft + } + MouseArea { + id: memberMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.selectedMemberIndex = memberRow.index + } + } + + Column { + anchors.centerIn: parent + visible: commissioningModel.memberCount === 0 + spacing: 4 + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: commissioningModel.selectedItem.kind === "Control" + ? "Control object has no DataSet member list" + : "No canonical members resolved" + color: root.theme.textSoft + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: commissioningModel.selectedItem.status === "Unresolved" + ? "The configured DataSet reference is unresolved in this SCL model." + : "Select another configured service." + color: root.theme.muted + font.pixelSize: 8 + } + } + } + } + } + + Rectangle { + Layout.preferredWidth: 338 + Layout.minimumWidth: 304 + Layout.fillHeight: true + color: root.theme.chrome + border.width: 1 + border.color: root.theme.lineSoft + + ScrollView { + anchors.fill: parent + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: 9 + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 12 + spacing: 3 + Label { + text: commissioningModel.selectedItem.kind || "INSPECTOR" + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.Bold + } + Label { + Layout.fillWidth: true + text: commissioningModel.selectedItem.name || "No service selected" + color: root.theme.text + font.pixelSize: 13 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: commissioningModel.selectedItem.reference || "Select a configured service in the catalog." + color: root.theme.muted + font.pixelSize: 8 + wrapMode: Text.WrapAnywhere + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 5 + MetaRow { title: "Status"; value: root.display(commissioningModel.selectedItem.status) } + MetaRow { title: "DataSet"; value: root.display(commissioningModel.selectedItem.dataSetReference) } + MetaRow { title: "Members"; value: commissioningModel.selectedItem.kind ? String(commissioningModel.selectedItem.memberCount || 0) : "" } + } + + ColumnLayout { + visible: commissioningModel.selectedItem.kind === "Report" + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 5 + Label { text: "REPORT CONTROL"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.Bold } + MetaRow { title: "Mode"; value: commissioningModel.selectedItem.buffered ? "BRCB" : "URCB" } + MetaRow { title: "RptID"; value: root.display(commissioningModel.selectedItem.reportId) } + MetaRow { title: "ConfRev"; value: root.display(commissioningModel.selectedItem.confRev) } + MetaRow { title: "BufTm"; value: root.display(commissioningModel.selectedItem.bufferTimeMs) + " ms" } + MetaRow { title: "IntgPd"; value: root.display(commissioningModel.selectedItem.integrityMs) + " ms" } + } + + ColumnLayout { + visible: commissioningModel.selectedItem.kind === "GOOSE" + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 5 + Label { text: "GOOSE CONTROL"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.Bold } + MetaRow { title: "goID"; value: root.display(commissioningModel.selectedItem.goId) } + MetaRow { title: "APPID"; value: root.display(commissioningModel.selectedItem.appId) } + MetaRow { title: "MAC"; value: root.display(commissioningModel.selectedItem.mac) } + MetaRow { title: "VLAN"; value: root.display(commissioningModel.selectedItem.vlanId) } + MetaRow { title: "Priority"; value: root.display(commissioningModel.selectedItem.vlanPriority) } + MetaRow { title: "Min/Max"; value: root.display(commissioningModel.selectedItem.minTimeMs) + " / " + root.display(commissioningModel.selectedItem.maxTimeMs) + " ms" } + MetaRow { title: "ConfRev"; value: root.display(commissioningModel.selectedItem.confRev) } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + Label { + text: "LAYER-2 PUBLICATION" + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.Bold + } + ComboBox { + id: gooseInterfaceBox + Layout.fillWidth: true + Layout.preferredHeight: 30 + model: commissioningModel.gooseInterfaces + font.pixelSize: 8 + enabled: !commissioningModel.goosePublishing + currentIndex: commissioningModel.gooseInterfaces.indexOf(commissioningModel.gooseInterfaceName) + displayText: currentIndex >= 0 ? currentText : "Select Ethernet interface" + onActivated: commissioningModel.gooseInterfaceName = currentText + ToolTip.visible: hovered + ToolTip.text: "Explicit Ethernet interface used for raw IEC 61850 GOOSE publication" + } + RowLayout { + Layout.fillWidth: true + spacing: 6 + SmallButton { + Layout.fillWidth: true + text: root.selectedGooseRuntime.active === true ? "Publishing" : "Start GOOSE" + primary: root.selectedGooseRuntime.active !== true + enabled: root.backend.running + && commissioningModel.selectedItem.status !== "Unresolved" + && commissioningModel.gooseInterfaceName.length > 0 + && root.selectedGooseRuntime.active !== true + onClicked: commissioningModel.startSelectedGoosePublication() + } + SmallButton { + Layout.fillWidth: true + text: "Stop GOOSE" + enabled: root.selectedGooseRuntime.active === true + onClicked: commissioningModel.stopSelectedGoosePublication() + } + } + MetaRow { title: "Publication"; value: commissioningModel.goosePublicationStatus } + MetaRow { + title: "Wire state" + value: root.selectedGooseRuntime.active === true + ? "stNum " + root.display(root.selectedGooseRuntime.stNum) + + " · next sqNum " + root.display(root.selectedGooseRuntime.nextSqNum) + : "Idle" + } + MetaRow { + title: "TX / changes" + value: root.selectedGooseRuntime.active === true + ? root.display(root.selectedGooseRuntime.transmits) + " / " + + root.display(root.selectedGooseRuntime.stateChanges) + : "0 / 0" + } + Label { + Layout.fillWidth: true + text: "Publishes the configured SCL APPID/MAC/VLAN/goID/DataSet as real Layer-2 GOOSE. Linux requires raw-socket permission; Windows requires Npcap. Interface, privilege or transport failures stop publication fail-closed." + color: root.theme.muted + font.pixelSize: 7 + wrapMode: Text.WordWrap + } + } + + ColumnLayout { + visible: commissioningModel.selectedItem.kind === "Control" + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 5 + Label { text: "CONTROL SEMANTICS"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.Bold } + MetaRow { title: "CDC"; value: root.display(commissioningModel.selectedItem.cdc) } + MetaRow { title: "ctlModel"; value: root.display(commissioningModel.selectedItem.controlModel) } + MetaRow { title: "LN"; value: root.display(commissioningModel.selectedItem.logicalNode) } + Label { + Layout.fillWidth: true + text: "Operate/SBO execution stays on the IEC 61850 MMS control-service path; this workspace never bypasses configured control semantics." + color: root.theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + SmallButton { + Layout.fillWidth: true + text: "Open status in Values" + primary: true + enabled: root.backend.imported + onClicked: commissioningModel.focusControlStatus() + } + } + + Rectangle { + visible: commissioningModel.selectedItem.kind === "Report" || commissioningModel.selectedItem.kind === "GOOSE" + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.preferredHeight: visible ? 1 : 0 + color: root.theme.lineSoft + } + + SmallButton { + visible: commissioningModel.selectedItem.kind === "Report" || commissioningModel.selectedItem.kind === "GOOSE" + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + text: "Open bound DataSet" + primary: true + enabled: commissioningModel.selectedItem.dataSetReference !== undefined + && String(commissioningModel.selectedItem.dataSetReference).length > 0 + && commissioningModel.selectedItem.status !== "Unresolved" + onClicked: { + if (commissioningModel.focusBoundDataSet()) { + searchField.clear() + root.syncMemberSelection() + } + } + } + + ColumnLayout { + visible: commissioningModel.selectedItem.kind !== "Control" + && commissioningModel.selectedItem.kind !== undefined + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 7 + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: "RUNTIME ACTIONS" + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.Bold + } + Label { + text: commissioningModel.behaviorCount + "/" + commissioningModel.behaviorCapacity + " active" + color: commissioningModel.behaviorCount > 0 ? root.theme.green : root.theme.muted + font.pixelSize: 7 + } + } + + Label { + Layout.fillWidth: true + text: root.selectedMemberIndex >= 0 + ? ((root.selectedMember.dataObject || "member") + + (root.selectedMember.dataAttribute ? "." + root.selectedMember.dataAttribute : "")) + : "Select a canonical member" + color: root.theme.textSoft + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + + Label { + Layout.fillWidth: true + text: root.selectedMemberDrivable + ? "Drivable ST/MX source · bounded live delta" + : "Select a writable ST/MX source value; quality/timestamp leaves are protected." + color: root.selectedMemberDrivable ? root.theme.green : root.theme.muted + font.pixelSize: 7 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + SmallButton { + Layout.fillWidth: true + text: "Open in Values" + enabled: root.selectedMemberIndex >= 0 + && commissioningModel.focusMemberValue !== undefined + onClicked: commissioningModel.focusMemberValue(root.selectedMemberIndex) + } + SmallButton { + Layout.fillWidth: true + text: "Pulse source" + primary: true + enabled: root.backend.running + && commissioningModel.firstDrivableMember() >= 0 + && commissioningModel.selectedItem.status !== "Unresolved" + onClicked: commissioningModel.pulseSelectedService(intervalBox.value) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + ComboBox { + id: behaviorMode + Layout.fillWidth: true + Layout.preferredHeight: 30 + model: ["Pulse", "Toggle", "Ramp"] + currentIndex: 1 + font.pixelSize: 8 + } + SpinBox { + id: intervalBox + Layout.preferredWidth: 92 + from: 100 + to: 10000 + stepSize: 100 + value: 500 + editable: true + font.pixelSize: 8 + ToolTip.visible: hovered + ToolTip.text: "Behavior interval in milliseconds" + } + SpinBox { + id: stepBox + Layout.preferredWidth: 76 + from: -100000 + to: 100000 + value: 1 + editable: true + font.pixelSize: 8 + ToolTip.visible: hovered + ToolTip.text: "Ramp/toggle numeric step" + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + SmallButton { + Layout.fillWidth: true + text: root.selectedBehavior.active === true ? "Behavior active" : "Start behavior" + primary: root.selectedBehavior.active !== true + enabled: root.backend.running + && root.selectedMemberDrivable + && root.selectedBehavior.active !== true + && commissioningModel.behaviorCount < commissioningModel.behaviorCapacity + onClicked: commissioningModel.startMemberBehavior( + root.selectedMemberIndex, + behaviorMode.currentText, + intervalBox.value, + stepBox.value) + } + SmallButton { + Layout.fillWidth: true + text: "Stop + restore" + enabled: root.selectedBehavior.active === true + onClicked: commissioningModel.stopMemberBehavior(root.selectedMemberIndex, true) + } + } + + MetaRow { + title: "Behavior" + value: root.selectedBehavior.active === true + ? root.display(root.selectedBehavior.mode) + " · " + + root.display(root.selectedBehavior.ticks) + " ticks" + : "Idle" + } + MetaRow { title: "Engine"; value: commissioningModel.behaviorTickCount + " ticks · " + commissioningModel.behaviorRejectedCount + " rejected" } + + SmallButton { + visible: commissioningModel.behaviorCount > 0 + Layout.fillWidth: true + text: "Stop all + restore owned values" + onClicked: commissioningModel.stopAllBehaviors(true) + } + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.bottomMargin: 14 + text: "Member rows are materialized on demand from the canonical parsed SCL document. Simulation behavior uses one bounded scheduler (16 slots, minimum 100 ms); GOOSE publication uses one bounded transport/timer with at most 16 streams and 256 members per stream." + color: root.theme.muted + font.pixelSize: 7 + wrapMode: Text.WordWrap + } + } + } + } + } + } +} diff --git a/apps/ied_simulator/qml/FilesWorkspace.qml b/apps/ied_simulator/qml/FilesWorkspace.qml new file mode 100644 index 000000000..cdc311187 --- /dev/null +++ b/apps/ied_simulator/qml/FilesWorkspace.qml @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts + +Item { + id: root + required property var theme + required property var files + + property string selectedRemotePath: "" + property bool selectedIsDirectory: false + + function parentDirectory(path) { + var normalized = path || "" + while (normalized.length > 0 && normalized.endsWith("/")) + normalized = normalized.slice(0, -1) + var slash = normalized.lastIndexOf("/") + if (slash <= 0) + return "" + return normalized.slice(0, slash) + } + + FileDialog { + id: saveDialog + title: "Save downloaded MMS file" + fileMode: FileDialog.SaveFile + onAccepted: files.downloadFile(root.selectedRemotePath, selectedFile.toString()) + } + + Rectangle { + anchors.fill: parent + color: theme.background + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + + SurfaceCard { + Layout.fillWidth: true + Layout.preferredHeight: 72 + theme: root.theme + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + ColumnLayout { + Layout.preferredWidth: 235 + spacing: 2 + Label { + text: "MMS FILES" + color: theme.text + font.pixelSize: theme.subtitleSize + font.weight: Font.DemiBold + } + Label { + text: "Read-only FileDirectory + streaming download" + color: theme.muted + font.pixelSize: theme.captionSize + } + } + + TextField { + Layout.preferredWidth: 210 + text: files.host + placeholderText: "IED host" + enabled: !files.busy && !files.connected + onEditingFinished: files.host = text + } + SpinBox { + Layout.preferredWidth: 105 + from: 1 + to: 65535 + editable: true + value: files.port + enabled: !files.busy && !files.connected + onValueModified: files.port = value + } + ActionButton { + theme: root.theme + text: files.connected ? "Connected" : "Connect" + primary: !files.connected + enabled: !files.busy && !files.connected + onClicked: files.connectToIed() + } + ActionButton { + theme: root.theme + text: "Reconnect" + enabled: !files.busy && files.connected + onClicked: files.reconnect() + } + ActionButton { + theme: root.theme + text: "Disconnect" + danger: true + enabled: files.connected + onClicked: files.disconnectFromIed() + } + Item { Layout.fillWidth: true } + ColumnLayout { + spacing: 1 + Label { + Layout.alignment: Qt.AlignRight + text: files.stateText + color: files.connected ? theme.green : (files.lastError.length ? theme.red : theme.textSoft) + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Label { + Layout.alignment: Qt.AlignRight + text: files.endpoint + color: theme.muted + font.pixelSize: theme.captionSize + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + TextField { + id: directoryField + Layout.fillWidth: true + placeholderText: "Remote directory (blank = root)" + text: files.currentDirectory + enabled: files.connected && !files.operationBusy + onAccepted: files.browseDirectory(text) + } + ActionButton { + theme: root.theme + text: "Root" + enabled: files.connected && !files.operationBusy + onClicked: files.browseDirectory("") + } + ActionButton { + theme: root.theme + text: "Up" + enabled: files.connected && !files.operationBusy && files.currentDirectory.length > 0 + onClicked: files.browseDirectory(root.parentDirectory(files.currentDirectory)) + } + ActionButton { + theme: root.theme + text: "Browse" + primary: true + enabled: files.connected && !files.operationBusy + onClicked: files.browseDirectory(directoryField.text) + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + SurfaceCard { + Layout.fillWidth: true + Layout.fillHeight: true + theme: root.theme + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + Label { + text: "Directory" + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: files.currentDirectory.length ? files.currentDirectory : "/" + elide: Text.ElideMiddle + color: theme.textSoft + font.pixelSize: theme.captionSize + } + Label { + text: files.fileEntryCount + " entries · " + files.filePageCount + " page(s)" + color: theme.muted + font.pixelSize: theme.captionSize + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: theme.chrome + radius: 6 + border.width: 1 + border.color: theme.lineSoft + + ListView { + id: fileList + anchors.fill: parent + anchors.margins: 1 + clip: true + model: files.fileEntries + spacing: 1 + + delegate: Rectangle { + id: row + required property var modelData + width: fileList.width + height: 46 + color: root.selectedRemotePath === modelData.path + ? theme.accentSoft + : mouse.containsMouse ? theme.surfaceRaised : theme.surface + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 10 + Label { + text: row.modelData.directory ? "DIR" : "FILE" + color: row.modelData.directory ? theme.accent : theme.textSoft + font.pixelSize: 9 + font.weight: Font.Bold + Layout.preferredWidth: 34 + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: row.modelData.name + elide: Text.ElideMiddle + color: theme.text + font.pixelSize: theme.labelSize + } + Label { + Layout.fillWidth: true + text: row.modelData.path + elide: Text.ElideMiddle + color: theme.muted + font.pixelSize: 9 + } + } + Label { + text: row.modelData.directory || row.modelData.sizeBytes === undefined + ? "" + : Number(row.modelData.sizeBytes).toLocaleString(Qt.locale(), 'f', 0) + " B" + color: theme.textSoft + font.pixelSize: theme.captionSize + Layout.preferredWidth: 95 + horizontalAlignment: Text.AlignRight + } + Label { + text: row.modelData.modified || "" + color: theme.muted + font.pixelSize: 9 + Layout.preferredWidth: 120 + horizontalAlignment: Text.AlignRight + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + root.selectedRemotePath = row.modelData.path + root.selectedIsDirectory = row.modelData.directory + } + onDoubleClicked: { + root.selectedRemotePath = row.modelData.path + root.selectedIsDirectory = row.modelData.directory + if (row.modelData.directory && files.connected && !files.operationBusy) + files.browseDirectory(row.modelData.path) + } + } + } + + ScrollBar.vertical: ScrollBar {} + } + } + } + } + + SurfaceCard { + Layout.preferredWidth: 360 + Layout.fillHeight: true + theme: root.theme + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 9 + + Label { + text: "Download" + color: theme.text + font.pixelSize: theme.subtitleSize + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: root.selectedRemotePath.length + ? root.selectedRemotePath + : "Select a remote file" + wrapMode: Text.WrapAnywhere + color: root.selectedRemotePath.length ? theme.textSoft : theme.muted + font.pixelSize: theme.captionSize + } + + ActionButton { + Layout.fillWidth: true + theme: root.theme + text: "Download / Save As" + primary: true + enabled: files.connected && !files.operationBusy && + root.selectedRemotePath.length > 0 && !root.selectedIsDirectory + onClicked: saveDialog.open() + } + ActionButton { + Layout.fillWidth: true + theme: root.theme + text: "Cancel transfer" + danger: true + enabled: files.downloadActive + onClicked: files.cancelOperation() + } + + ProgressBar { + Layout.fillWidth: true + from: 0 + to: 100 + indeterminate: files.downloadActive && !files.downloadExpectedKnown + value: files.downloadPercent >= 0 ? files.downloadPercent : 0 + } + Label { + Layout.fillWidth: true + text: files.downloadExpectedKnown + ? Number(files.downloadBytes).toLocaleString(Qt.locale(), 'f', 0) + + " / " + Number(files.downloadExpectedBytes).toLocaleString(Qt.locale(), 'f', 0) + " B" + : Number(files.downloadBytes).toLocaleString(Qt.locale(), 'f', 0) + " B" + color: theme.textSoft + font.pixelSize: theme.captionSize + } + Label { + Layout.fillWidth: true + visible: files.lastDownloadLocalPath.length > 0 + text: files.lastDownloadLocalPath + wrapMode: Text.WrapAnywhere + color: theme.muted + font.pixelSize: 9 + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: theme.lineSoft + } + Label { + text: "Safety contract" + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: "Read-only remote access. Directory continuation is bounded to 64 pages / 4096 entries. Downloads are capped at 512 MiB, use canonical FileOpen/FileRead/FileClose, and partial local files are removed on failure or cancellation. Upload/delete/rename are intentionally not exposed." + wrapMode: Text.Wrap + color: theme.textSoft + font.pixelSize: theme.captionSize + } + + Item { Layout.fillHeight: true } + Label { + Layout.fillWidth: true + visible: files.lastError.length > 0 + text: files.lastError + wrapMode: Text.Wrap + color: theme.red + font.pixelSize: theme.captionSize + } + } + } + } + } +} diff --git a/apps/ied_simulator/qml/FleetWorkspace.qml b/apps/ied_simulator/qml/FleetWorkspace.qml new file mode 100644 index 000000000..0fab317c8 --- /dev/null +++ b/apps/ied_simulator/qml/FleetWorkspace.qml @@ -0,0 +1,847 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + + required property var theme + required property var backend + + signal openSclRequested() + signal addIedRequested() + signal folderRequested() + + property string selectedLd: "" + property string selectedLn: "" + property var logicalDevices: [] + property var logicalNodes: [] + property var tableRows: [] + property var collapsedObjects: ({}) + property string searchText: "" + property bool eventStreamVisible: true + property string pendingValue: backend.selectedValue.value || "" + property string pendingQuality: backend.selectedValue.quality || "Good" + property string pendingOrigin: backend.selectedValue.origin || "Simulator" + + function hasString(values, needle) { + for (var i = 0; i < values.length; ++i) { + if (values[i] === needle) return true + } + return false + } + + function refreshEditor() { + pendingValue = backend.selectedValue.value || "" + pendingQuality = backend.selectedValue.quality || "Good" + pendingOrigin = backend.selectedValue.origin || "Simulator" + } + + function refreshNavigation() { + var values = backend.values || [] + var devices = [] + for (var i = 0; i < values.length; ++i) { + var ld = values[i].logicalDevice || "" + if (ld.length && !hasString(devices, ld)) devices.push(ld) + } + logicalDevices = devices + if (!hasString(devices, selectedLd)) selectedLd = devices.length ? devices[0] : "" + rebuildLogicalNodes() + } + + function rebuildLogicalNodes() { + var values = backend.values || [] + var nodes = [] + for (var i = 0; i < values.length; ++i) { + if ((values[i].logicalDevice || "") !== selectedLd) continue + var ln = values[i].logicalNode || "" + if (ln.length && !hasString(nodes, ln)) nodes.push(ln) + } + logicalNodes = nodes + if (!hasString(nodes, selectedLn)) selectedLn = nodes.length ? nodes[0] : "" + rebuildTable() + } + + function rowMatches(point, query) { + if (!query.length) return true + var haystack = [ + point.dataObject || "", + point.dataAttribute || "", + point.value || "", + point.fc || "", + point.type || "", + point.reference || "" + ].join(" ").toLowerCase() + return haystack.indexOf(query) >= 0 + } + + function rebuildTable() { + var values = backend.values || [] + var order = [] + var groups = ({}) + var query = searchText.trim().toLowerCase() + + for (var i = 0; i < values.length; ++i) { + var point = values[i] + if ((point.logicalDevice || "") !== selectedLd || + (point.logicalNode || "") !== selectedLn) continue + var objectName = point.dataObject || "(unnamed)" + if (groups[objectName] === undefined) { + groups[objectName] = { rows: [], preferred: -1, matches: false } + order.push(objectName) + } + var attribute = point.dataAttribute || "" + if (groups[objectName].preferred < 0 || attribute === "stVal" || + attribute.endsWith(".stVal") || attribute === "mag.f") { + groups[objectName].preferred = i + } + if (rowMatches(point, query) || objectName.toLowerCase().indexOf(query) >= 0) { + groups[objectName].matches = true + } + groups[objectName].rows.push({ + kind: "DA", + name: attribute.length ? attribute : objectName, + value: point.value || "", + fc: point.fc || "", + type: point.type || "", + quality: point.quality || "", + sourceIndex: i + }) + } + + var result = [] + for (var j = 0; j < order.length; ++j) { + var name = order[j] + var group = groups[name] + if (query.length && !group.matches) continue + var preferredIndex = group.preferred >= 0 ? group.preferred : group.rows[0].sourceIndex + var preferredPoint = values[preferredIndex] || ({}) + result.push({ + kind: "DO", + name: name, + value: preferredPoint.value || "", + fc: "", + type: preferredPoint.cdc || "", + quality: preferredPoint.quality || "", + sourceIndex: preferredIndex + }) + if (collapsedObjects[name] === true) continue + for (var k = 0; k < group.rows.length; ++k) result.push(group.rows[k]) + } + tableRows = result + } + + function chooseLogicalDevice(name) { + if (selectedLd === name) return + selectedLd = name + rebuildLogicalNodes() + } + + function chooseLogicalNode(name) { + if (selectedLn === name) return + selectedLn = name + rebuildTable() + } + + function toggleObject(name) { + var next = ({}) + for (var key in collapsedObjects) next[key] = collapsedObjects[key] + next[name] = !(collapsedObjects[name] === true) + collapsedObjects = next + rebuildTable() + } + + function severityColor(severity) { + if (severity === "Error") return theme.red + if (severity === "Warning") return theme.amber + if (severity === "Success") return theme.green + return theme.accent + } + + function selectedOptions() { + return backend.selectedValue.options || [] + } + + Connections { + target: backend + function onValuesChanged() { root.refreshNavigation() } + function onSelectionChanged() { + root.refreshEditor() + root.refreshNavigation() + } + function onModelChanged() { root.refreshNavigation() } + } + + Component.onCompleted: refreshNavigation() + + Shortcut { sequence: "Ctrl+O"; onActivated: root.openSclRequested() } + Shortcut { + sequence: "F5" + enabled: backend.imported && !backend.running && !backend.starting + onActivated: backend.startSimulation() + } + Shortcut { + sequence: "Shift+F5" + enabled: backend.imported + onActivated: backend.startAllSimulations() + } + Shortcut { + sequence: "Ctrl+Shift+F5" + enabled: backend.anyRunning + onActivated: backend.stopAllSimulations() + } + + Rectangle { anchors.fill: parent; color: theme.background } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Compact original ARStack command bar. ARSAS contributes the IED + // Explorer interaction/identity below, while this app keeps its own + // fleet-oriented workflow and visual hierarchy. + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 64 + color: theme.navigation + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 10 + + RowLayout { + Layout.preferredWidth: 258 + spacing: 10 + Image { + width: 34 + height: 34 + source: "qrc:/iedsim/assets/black-fascia-ied.svg" + sourceSize: Qt.size(68, 68) + fillMode: Image.PreserveAspectFit + } + ColumnLayout { + spacing: 0 + Label { + text: "ARStack IED Lab" + color: theme.navigationText + font.pixelSize: 17 + font.weight: Font.DemiBold + } + Label { + text: "Multi-IED simulation workspace" + color: theme.navigationMuted + font.pixelSize: 9 + } + } + } + + Rectangle { + width: 1 + Layout.fillHeight: true + Layout.topMargin: 13 + Layout.bottomMargin: 13 + color: "#36504a" + } + + IconAction { + theme: root.theme + text: "Open model" + iconSource: "qrc:/iedsim/assets/folder-open.svg" + onClicked: root.openSclRequested() + } + IconAction { + theme: root.theme + text: "Add model" + iconSource: "qrc:/iedsim/assets/upload.svg" + enabled: !backend.anyRunning + onClicked: root.addIedRequested() + } + + Item { Layout.fillWidth: true } + + IconAction { + theme: root.theme + text: "Start selected" + iconSource: "qrc:/iedsim/assets/play.svg" + primary: true + enabled: backend.imported && !backend.running && !backend.starting && + backend.selectedIed.enabled && backend.endpointConflict.length === 0 && + backend.listenAddress.length > 0 + onClicked: backend.startSimulation() + } + IconAction { + theme: root.theme + text: "Start all" + iconSource: "qrc:/iedsim/assets/radio-tower.svg" + enabled: backend.imported + onClicked: backend.startAllSimulations() + } + IconAction { + theme: root.theme + text: "Stop selected" + iconSource: "qrc:/iedsim/assets/square.svg" + danger: true + enabled: backend.running || backend.starting + onClicked: backend.stopSimulation() + } + IconAction { + theme: root.theme + text: "Stop all" + iconSource: "qrc:/iedsim/assets/x.svg" + danger: true + enabled: backend.anyRunning + onClicked: backend.stopAllSimulations() + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 38 + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 12 + Rectangle { + width: 8 + height: 8 + radius: 4 + color: backend.runningCount > 0 ? theme.green : theme.muted + } + Label { + text: backend.imported ? backend.modelStatus : "Open a model to build the IED Explorer" + color: theme.textSoft + font.pixelSize: 11 + elide: Text.ElideRight + Layout.fillWidth: true + } + Label { + text: backend.imported + ? (backend.logicalDeviceCount + " LD · " + backend.dataObjectCount + " DO · " + backend.dataAttributeCount + " leaves") + : "" + color: theme.muted + font.pixelSize: 10 + } + IconAction { + theme: root.theme + compact: true + text: "" + iconSource: "qrc:/iedsim/assets/activity.svg" + onClicked: root.eventStreamVisible = !root.eventStreamVisible + ToolTip.visible: hovered + ToolTip.text: root.eventStreamVisible ? "Hide event stream" : "Show event stream" + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + // Ported from ARSAS: relay-fascia IED cards, live/ready badge, + // per-IED fast actions, and a fixed endpoint workflow below the list. + IedExplorer { + Layout.preferredWidth: root.width >= 1360 ? 286 : 262 + Layout.fillHeight: true + theme: root.theme + backend: root.backend + } + + Rectangle { + Layout.preferredWidth: 194 + Layout.fillHeight: true + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 42 + Layout.leftMargin: 10 + Layout.rightMargin: 8 + spacing: 7 + Image { + width: 21 + height: 21 + source: "qrc:/iedsim/assets/black-fascia-ied.svg" + sourceSize: Qt.size(42, 42) + fillMode: Image.PreserveAspectFit + } + Label { + Layout.fillWidth: true + text: "MODEL TREE" + color: theme.text + font.pixelSize: 10 + font.weight: Font.Bold + } + } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + + Label { + Layout.fillWidth: true + Layout.preferredHeight: 29 + Layout.leftMargin: 11 + text: "Logical devices" + verticalAlignment: Text.AlignVCenter + color: theme.muted + font.pixelSize: 9 + font.weight: Font.DemiBold + } + ListView { + id: ldList + Layout.fillWidth: true + Layout.preferredHeight: Math.min(220, contentHeight + 2) + clip: true + model: root.logicalDevices + ScrollBar.vertical: ScrollBar { } + delegate: Rectangle { + required property string modelData + width: ldList.width + height: 31 + color: root.selectedLd === modelData ? theme.accentSoft : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 11 + spacing: 6 + Label { + text: "LD" + color: root.selectedLd === modelData ? theme.accent : theme.muted + font.pixelSize: 8 + font.weight: Font.Bold + anchors.verticalCenter: parent.verticalCenter + } + Label { + width: parent.width - 34 + text: modelData + verticalAlignment: Text.AlignVCenter + color: root.selectedLd === modelData ? theme.accent : theme.textSoft + font.pixelSize: 9 + font.weight: root.selectedLd === modelData ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + } + MouseArea { anchors.fill: parent; onClicked: root.chooseLogicalDevice(modelData) } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + Label { + Layout.fillWidth: true + Layout.preferredHeight: 29 + Layout.leftMargin: 11 + text: "Logical nodes" + verticalAlignment: Text.AlignVCenter + color: theme.muted + font.pixelSize: 9 + font.weight: Font.DemiBold + } + ListView { + id: lnList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.logicalNodes + ScrollBar.vertical: ScrollBar { } + delegate: Rectangle { + required property string modelData + width: lnList.width + height: 31 + color: root.selectedLn === modelData ? theme.accentSoft : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 11 + spacing: 6 + Label { + text: "LN" + color: root.selectedLn === modelData ? theme.accent : theme.muted + font.pixelSize: 8 + font.weight: Font.Bold + anchors.verticalCenter: parent.verticalCenter + } + Label { + width: parent.width - 34 + text: modelData + color: root.selectedLn === modelData ? theme.accent : theme.textSoft + font.pixelSize: 9 + font.weight: root.selectedLn === modelData ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + } + MouseArea { anchors.fill: parent; onClicked: root.chooseLogicalNode(modelData) } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 54 + color: theme.surface + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 13 + anchors.rightMargin: 13 + spacing: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + text: (backend.selectedIed.name || "IED") + " / " + + (root.selectedLd || "—") + " / " + (root.selectedLn || "—") + color: theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + Layout.fillWidth: true + } + Label { + text: (backend.selectedIed.manufacturer || "IEC 61850") + + (backend.running ? " · LIVE MMS" : " · model view") + color: backend.running ? theme.green : theme.muted + font.pixelSize: 9 + } + } + TextField { + id: searchField + Layout.preferredWidth: 226 + placeholderText: "Search signal / value / FC" + text: root.searchText + onTextChanged: { + root.searchText = text + root.rebuildTable() + } + selectByMouse: true + font.pixelSize: 10 + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: theme.surfaceRaised + RowLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + spacing: 0 + Label { Layout.fillWidth: true; text: "Object / attribute"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 66; text: "FC"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 105; text: "Type"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 148; text: "Value"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + } + } + + ListView { + id: dataList + objectName: "iedLiveSignalTable" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.tableRows + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { } + + delegate: Rectangle { + required property var modelData + required property int index + width: dataList.width + height: modelData.kind === "DO" ? 34 : 31 + color: { + if (modelData.kind === "DA" && backend.selectedValueIndex === modelData.sourceIndex) return theme.accentSoft + if (modelData.kind === "DO") return theme.chrome + return index % 2 === 0 ? theme.surface : "#fbfcfc" + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + spacing: 0 + RowLayout { + Layout.fillWidth: true + spacing: 6 + Item { Layout.preferredWidth: modelData.kind === "DO" ? 0 : 16 } + Label { + visible: modelData.kind === "DO" + text: root.collapsedObjects[modelData.name] === true ? "›" : "⌄" + color: theme.muted + font.pixelSize: 14 + } + Rectangle { + width: modelData.kind === "DO" ? 24 : 20 + height: 18 + radius: 5 + color: modelData.kind === "DO" ? theme.accentSoft : theme.surfaceRaised + Label { + anchors.centerIn: parent + text: modelData.kind + color: modelData.kind === "DO" ? theme.accent : theme.muted + font.pixelSize: 8 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: modelData.name + color: modelData.kind === "DO" ? theme.text : theme.textSoft + font.pixelSize: 10 + font.weight: modelData.kind === "DO" ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + } + Label { Layout.preferredWidth: 66; text: modelData.fc ? "[" + modelData.fc + "]" : ""; color: theme.muted; font.pixelSize: 9 } + Label { Layout.preferredWidth: 105; text: modelData.type; color: theme.muted; font.pixelSize: 9; elide: Text.ElideRight } + Label { + Layout.preferredWidth: 148 + text: modelData.value + color: modelData.kind === "DA" ? theme.text : theme.textSoft + font.pixelSize: 10 + font.weight: modelData.kind === "DA" ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + } + + Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: theme.lineSoft } + MouseArea { + anchors.fill: parent + onClicked: { + if (modelData.kind === "DO") root.toggleObject(modelData.name) + else backend.selectValue(modelData.sourceIndex) + } + } + } + + Label { + anchors.centerIn: parent + visible: root.tableRows.length === 0 + text: backend.imported ? "No matching IEC 61850 leaves" : "Open an engineering model" + color: theme.muted + font.pixelSize: 11 + } + } + } + } + + Rectangle { + Layout.preferredWidth: root.width >= 1360 ? 288 : 256 + Layout.fillHeight: true + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 11 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + Label { + text: "LIVE INSPECTOR" + color: theme.text + font.pixelSize: 10 + font.weight: Font.Bold + Layout.fillWidth: true + } + Rectangle { width: 8; height: 8; radius: 4; color: backend.running ? theme.green : theme.muted } + } + + Label { + Layout.fillWidth: true + text: backend.selectedValue.reference || "Select a data attribute" + color: backend.selectedValue.reference ? theme.textSoft : theme.muted + font.pixelSize: 9 + wrapMode: Text.WrapAnywhere + } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 8 + rowSpacing: 5 + Label { text: "FC"; color: theme.muted; font.pixelSize: 9 } + Label { text: backend.selectedValue.fc || "—"; color: theme.textSoft; font.pixelSize: 10 } + Label { text: "Type"; color: theme.muted; font.pixelSize: 9 } + Label { text: backend.selectedValue.type || "—"; color: theme.textSoft; font.pixelSize: 10 } + Label { text: "Updated"; color: theme.muted; font.pixelSize: 9 } + Label { text: backend.selectedValue.updated || "—"; color: theme.textSoft; font.pixelSize: 10 } + } + + Label { text: "Value"; color: theme.muted; font.pixelSize: 9 } + Loader { + Layout.fillWidth: true + sourceComponent: root.selectedOptions().length > 0 ? optionEditor : textEditor + } + Component { + id: textEditor + TextField { + text: root.pendingValue + enabled: backend.selectedValue.writable && backend.running + selectByMouse: true + font.pixelSize: 11 + onTextEdited: root.pendingValue = text + } + } + Component { + id: optionEditor + ComboBox { + model: root.selectedOptions() + enabled: backend.selectedValue.writable && backend.running + currentIndex: Math.max(0, root.selectedOptions().indexOf(root.pendingValue)) + font.pixelSize: 11 + onActivated: root.pendingValue = currentText + } + } + + Label { text: "Quality"; color: theme.muted; font.pixelSize: 9 } + ComboBox { + Layout.fillWidth: true + model: ["Good", "Questionable", "Invalid"] + enabled: backend.selectedValue.writable && backend.running + currentIndex: Math.max(0, model.indexOf(root.pendingQuality)) + font.pixelSize: 11 + onActivated: root.pendingQuality = currentText + } + + Label { text: "Origin"; color: theme.muted; font.pixelSize: 9 } + TextField { + Layout.fillWidth: true + text: root.pendingOrigin + enabled: backend.selectedValue.writable && backend.running + font.pixelSize: 11 + onTextEdited: root.pendingOrigin = text + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + IconAction { + theme: root.theme + text: "Apply" + iconSource: "qrc:/iedsim/assets/circle-check.svg" + primary: true + Layout.fillWidth: true + enabled: backend.running && backend.selectedValue.writable + onClicked: backend.applySelectedValue(root.pendingValue, root.pendingQuality, root.pendingOrigin) + } + IconAction { + theme: root.theme + text: "Undo" + iconSource: "qrc:/iedsim/assets/chevron-up.svg" + compact: true + enabled: backend.running + onClicked: backend.undoLastChange() + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft; Layout.topMargin: 4 } + Label { text: "SERVICES"; color: theme.text; font.pixelSize: 10; font.weight: Font.Bold } + RowLayout { + Layout.fillWidth: true + spacing: 5 + StatusPill { theme: root.theme; text: backend.reportCount + " Reports"; tone: theme.accent; fill: theme.accentSoft } + StatusPill { theme: root.theme; text: backend.dataSetCount + " DataSets"; tone: theme.textSoft; fill: theme.surfaceRaised } + } + StatusPill { theme: root.theme; text: backend.gooseCount + " GOOSE"; tone: theme.muted; fill: theme.surfaceRaised } + + Item { Layout.fillHeight: true } + Label { + Layout.fillWidth: true + text: "Explorer identity is shared with ARSAS; runtime state remains isolated per simulated IED." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: root.eventStreamVisible ? 150 : 0 + visible: root.eventStreamVisible + color: theme.navigationDark + clip: true + + ColumnLayout { + anchors.fill: parent + spacing: 0 + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 33 + Layout.leftMargin: 14 + Layout.rightMargin: 10 + Label { text: "EVENT STREAM"; color: theme.navigationText; font.pixelSize: 10; font.weight: Font.Bold; Layout.fillWidth: true } + Label { text: backend.activity.length + " events"; color: theme.navigationMuted; font.pixelSize: 9 } + IconAction { + theme: root.theme + compact: true + text: "Clear" + iconSource: "qrc:/iedsim/assets/x.svg" + enabled: backend.activity.length > 0 + onClicked: backend.clearActivity() + } + IconAction { + theme: root.theme + compact: true + text: "Copy" + iconSource: "qrc:/iedsim/assets/activity.svg" + onClicked: backend.copyDiagnostics() + } + } + + ListView { + id: activityList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: backend.activity + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { } + delegate: RowLayout { + required property var modelData + width: activityList.width + height: 27 + spacing: 8 + Rectangle { Layout.leftMargin: 14; width: 6; height: 6; radius: 3; color: root.severityColor(modelData.severity || "Info") } + Label { Layout.preferredWidth: 76; text: modelData.time || ""; color: theme.navigationMuted; font.pixelSize: 9 } + Label { Layout.preferredWidth: 112; text: modelData.ied || modelData.category || "Workspace"; color: theme.navigationText; font.pixelSize: 9; font.weight: Font.DemiBold; elide: Text.ElideRight } + Label { Layout.preferredWidth: 76; text: modelData.category || ""; color: root.severityColor(modelData.severity || "Info"); font.pixelSize: 9 } + Label { Layout.fillWidth: true; Layout.rightMargin: 12; text: modelData.message || ""; color: theme.navigationMuted; font.pixelSize: 9; elide: Text.ElideRight } + } + } + } + } + } +} diff --git a/apps/ied_simulator/qml/GooseWorkspace.qml b/apps/ied_simulator/qml/GooseWorkspace.qml new file mode 100644 index 000000000..8dae11cf4 --- /dev/null +++ b/apps/ied_simulator/qml/GooseWorkspace.qml @@ -0,0 +1,516 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import ARStack.IedSimulator 1.0 + +Item { + id: root + required property var theme + required property IedFleetController simulator + required property GooseMonitorController monitor + signal openSimulatorRequested() + + IedCommissioningModel { + id: publisherModel + backend: root.simulator + kindFilter: "GOOSE" + } + + FileDialog { + id: savePcapDialog + title: "Save retained GOOSE capture" + fileMode: FileDialog.SaveFile + defaultSuffix: "pcap" + nameFilters: ["PCAP capture (*.pcap)", "All files (*)"] + onAccepted: root.monitor.exportPcap(selectedFile) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: "GOOSE Monitor + Publisher" + color: root.theme.text + font.pixelSize: root.theme.titleSize + font.weight: Font.DemiBold + } + Label { + text: "One explicit NIC · EtherType 0x88B8 · canonical publisher state · bounded live supervision" + color: root.theme.muted + font.pixelSize: root.theme.captionSize + } + } + + Rectangle { + implicitWidth: 126 + implicitHeight: 28 + radius: 5 + color: root.monitor.capturing ? root.theme.greenSoft : root.theme.surfaceSoft + border.width: 1 + border.color: root.monitor.capturing ? root.theme.green : root.theme.line + Label { + anchors.centerIn: parent + text: root.monitor.capturing ? "MONITOR LIVE" : "MONITOR STOPPED" + color: root.monitor.capturing ? root.theme.green : root.theme.textSoft + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 52 + radius: root.theme.controlRadius + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Label { + text: "Ethernet" + color: root.theme.textSoft + font.pixelSize: root.theme.captionSize + font.weight: Font.DemiBold + } + + ComboBox { + id: interfaceSelector + Layout.preferredWidth: 220 + model: root.monitor.interfaces + enabled: !root.monitor.capturing + currentIndex: Math.max(0, root.monitor.interfaces.indexOf(root.monitor.interfaceName)) + onActivated: { + const selected = currentText + root.monitor.interfaceName = selected + publisherModel.gooseInterfaceName = selected + } + Component.onCompleted: { + if (root.monitor.interfaceName.length === 0 && count > 0) { + root.monitor.interfaceName = currentText + publisherModel.gooseInterfaceName = currentText + } + } + } + + ActionButton { + theme: root.theme + text: root.monitor.capturing ? "Stop Monitor" : "Start Monitor" + primary: !root.monitor.capturing + danger: root.monitor.capturing + onClicked: root.monitor.capturing ? root.monitor.stopCapture() : root.monitor.startCapture() + } + ActionButton { + theme: root.theme + text: "Clear" + onClicked: root.monitor.clear() + } + ActionButton { + theme: root.theme + text: "Save PCAP" + enabled: root.monitor.retainedPacketCount > 0 + onClicked: savePcapDialog.open() + } + + Item { Layout.fillWidth: true } + + Label { + text: "Streams " + root.monitor.streamCount + "/" + root.monitor.streamCapacity + + " Packets " + root.monitor.packetCount + + " Timeout " + root.monitor.timeoutCount + + " Seq issues " + root.monitor.sequenceIssueCount + color: root.theme.textSoft + font.pixelSize: root.theme.captionSize + } + } + } + + SplitView { + Layout.fillWidth: true + Layout.fillHeight: true + orientation: Qt.Horizontal + + Rectangle { + SplitView.fillWidth: true + SplitView.minimumWidth: 560 + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + radius: root.theme.panelRadius + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + Label { + text: "Live Monitor" + color: root.theme.text + font.pixelSize: root.theme.subtitleSize + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: root.monitor.statusText + color: root.monitor.capturing ? root.theme.green : root.theme.muted + font.pixelSize: root.theme.captionSize + elide: Text.ElideRight + Layout.maximumWidth: 320 + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 26 + color: root.theme.chrome + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + Label { Layout.preferredWidth: 72; text: "APPID"; color: root.theme.muted; font.pixelSize: 9 } + Label { Layout.preferredWidth: 120; text: "Source"; color: root.theme.muted; font.pixelSize: 9 } + Label { Layout.fillWidth: true; text: "gocbRef / DataSet"; color: root.theme.muted; font.pixelSize: 9 } + Label { Layout.preferredWidth: 56; text: "st/sq"; color: root.theme.muted; font.pixelSize: 9 } + Label { Layout.preferredWidth: 110; text: "Status"; color: root.theme.muted; font.pixelSize: 9 } + } + } + + ListView { + id: streamList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + cacheBuffer: 0 + spacing: 2 + model: root.monitor + + delegate: Rectangle { + width: streamList.width + height: 46 + radius: 5 + color: timedOut ? root.theme.redSoft + : anomaly ? root.theme.amberSoft + : root.monitor.selectedRow === index + ? root.theme.accentSoft : root.theme.surface + border.width: root.monitor.selectedRow === index ? 1 : 0 + border.color: root.theme.accent + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + Label { + Layout.preferredWidth: 72 + text: appIdText + color: root.theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + Layout.preferredWidth: 120 + text: sourceMac + color: root.theme.textSoft + font.pixelSize: 9 + elide: Text.ElideRight + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: goCbRef + color: root.theme.text + font.pixelSize: 10 + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: dataSetReference + color: root.theme.muted + font.pixelSize: 9 + elide: Text.ElideMiddle + } + } + Label { + Layout.preferredWidth: 56 + text: stNum + "/" + sqNum + color: root.theme.textSoft + font.pixelSize: 10 + } + Label { + Layout.preferredWidth: 110 + text: status + color: timedOut ? root.theme.red : anomaly ? root.theme.amber : root.theme.green + font.pixelSize: 9 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + MouseArea { + anchors.fill: parent + onClicked: root.monitor.select(index) + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 174 + radius: 6 + color: root.theme.chrome + border.width: 1 + border.color: root.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + ColumnLayout { + Layout.preferredWidth: 250 + Layout.fillHeight: true + spacing: 3 + Label { text: "Selected stream"; color: root.theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + Label { text: root.monitor.selectedStream.goId || "—"; color: root.theme.textSoft; font.pixelSize: 10; elide: Text.ElideMiddle; Layout.fillWidth: true } + Label { text: "Dst " + (root.monitor.selectedStream.destinationMac || "—"); color: root.theme.muted; font.pixelSize: 9 } + Label { text: "VLAN " + (root.monitor.selectedStream.vlanId === undefined ? "—" : root.monitor.selectedStream.vlanId) + + " PCP " + (root.monitor.selectedStream.vlanPriority === undefined ? "—" : root.monitor.selectedStream.vlanPriority); color: root.theme.muted; font.pixelSize: 9 } + Label { text: "ConfRev " + (root.monitor.selectedStream.confRev || "—") + + " TTL " + (root.monitor.selectedStream.ttlMs || "—") + " ms"; color: root.theme.muted; font.pixelSize: 9 } + Label { text: "Frames " + (root.monitor.selectedStream.packets || 0) + + " Values " + (root.monitor.selectedStream.valueCount || 0); color: root.theme.muted; font.pixelSize: 9 } + Item { Layout.fillHeight: true } + } + + Rectangle { Layout.fillHeight: true; width: 1; color: root.theme.lineSoft } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 3 + Label { text: "Decoded allData"; color: root.theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.monitor.selectedValues + delegate: Label { + width: ListView.view.width + text: modelData + color: root.theme.textSoft + font.pixelSize: 9 + elide: Text.ElideRight + } + } + } + } + } + } + } + + Rectangle { + SplitView.preferredWidth: 430 + SplitView.minimumWidth: 360 + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + radius: root.theme.panelRadius + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + Label { + text: "Publisher" + color: root.theme.text + font.pixelSize: root.theme.subtitleSize + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: publisherModel.goosePublisherCount + " active" + color: publisherModel.goosePublishing ? root.theme.green : root.theme.muted + font.pixelSize: root.theme.captionSize + } + } + + Label { + Layout.fillWidth: true + visible: !root.simulator.imported + text: "Load an SCL/CID/SCD in Simulator to expose configured GSEControl publishers. Monitoring remains independent and can run without a simulator model." + wrapMode: Text.WordWrap + color: root.theme.muted + font.pixelSize: root.theme.captionSize + } + + ListView { + id: publisherList + Layout.fillWidth: true + Layout.preferredHeight: Math.min(190, contentHeight) + Layout.minimumHeight: root.simulator.imported ? 72 : 0 + clip: true + reuseItems: true + cacheBuffer: 0 + model: publisherModel.itemCount + delegate: Rectangle { + required property int index + property var itemData: { + publisherModel.revision + return publisherModel.item(index) + } + width: publisherList.width + height: 54 + radius: 5 + color: itemData.selected ? root.theme.accentSoft : root.theme.chrome + border.width: itemData.selected ? 1 : 0 + border.color: root.theme.accent + ColumnLayout { + anchors.fill: parent + anchors.margins: 7 + spacing: 1 + Label { Layout.fillWidth: true; text: itemData.reference || itemData.name || "GOOSE"; color: root.theme.text; font.pixelSize: 10; font.weight: Font.DemiBold; elide: Text.ElideMiddle } + Label { Layout.fillWidth: true; text: (itemData.appId || "APPID —") + " · " + (itemData.mac || "MAC —") + " · " + (itemData.memberCount || 0) + " members"; color: root.theme.muted; font.pixelSize: 9; elide: Text.ElideRight } + } + MouseArea { anchors.fill: parent; onClicked: publisherModel.select(index) } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 112 + radius: 6 + color: root.theme.chrome + border.width: 1 + border.color: root.theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 3 + Label { text: publisherModel.selectedItem.goId || "No GOOSE publisher selected"; color: root.theme.text; font.pixelSize: 11; font.weight: Font.DemiBold; elide: Text.ElideMiddle; Layout.fillWidth: true } + Label { text: publisherModel.selectedItem.dataSetReference || "—"; color: root.theme.textSoft; font.pixelSize: 9; elide: Text.ElideMiddle; Layout.fillWidth: true } + Label { text: "ConfRev " + (publisherModel.selectedItem.confRev || "—") + " · Min/Max " + (publisherModel.selectedItem.minTimeMs || "—") + "/" + (publisherModel.selectedItem.maxTimeMs || "—") + " ms"; color: root.theme.muted; font.pixelSize: 9 } + Label { text: publisherModel.goosePublicationStatus; color: publisherModel.goosePublishing ? root.theme.green : root.theme.muted; font.pixelSize: 9; elide: Text.ElideRight; Layout.fillWidth: true } + Label { text: "TX " + publisherModel.gooseTransmitCount + " · state changes " + publisherModel.gooseStateChangeCount + " · stNum " + (publisherModel.selectedGooseRuntime.stNum || "—"); color: root.theme.muted; font.pixelSize: 9 } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + ActionButton { + theme: root.theme + text: publisherModel.selectedGooseRuntime.active ? "Stop Publisher" : "Start Publisher" + primary: !publisherModel.selectedGooseRuntime.active + danger: publisherModel.selectedGooseRuntime.active + enabled: root.simulator.imported && publisherModel.itemCount > 0 + onClicked: publisherModel.selectedGooseRuntime.active + ? publisherModel.stopSelectedGoosePublication() + : publisherModel.startSelectedGoosePublication() + } + ActionButton { + theme: root.theme + text: "Edit Source Value" + enabled: publisherModel.firstDrivableMember() >= 0 + onClicked: { + const row = publisherModel.firstDrivableMember() + if (row >= 0 && publisherModel.focusMemberValue(row)) root.openSimulatorRequested() + } + } + } + + Label { + Layout.fillWidth: true + text: "Publisher stimulus always edits the canonical Simulator point store. This workspace does not maintain a GOOSE-only value copy." + wrapMode: Text.WordWrap + color: root.theme.muted + font.pixelSize: 9 + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: root.theme.lineSoft + } + + Label { text: "Configured DataSet members"; color: root.theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + cacheBuffer: 0 + model: publisherModel.memberCount + delegate: Rectangle { + required property int index + property var memberData: { + publisherModel.revision + return publisherModel.member(index) + } + width: ListView.view.width + height: 40 + color: "transparent" + ColumnLayout { + anchors.fill: parent + spacing: 0 + Label { Layout.fillWidth: true; text: memberData.reference || memberData.mmsItem || "—"; color: root.theme.textSoft; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { Layout.fillWidth: true; text: (memberData.fc || "—") + " · " + (memberData.type || memberData.cdc || "—"); color: root.theme.muted; font.pixelSize: 8; elide: Text.ElideRight } + } + MouseArea { + anchors.fill: parent + onDoubleClicked: { + if (publisherModel.focusMemberValue(index)) root.openSimulatorRequested() + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 78 + radius: 5 + color: root.theme.navigationDark + ColumnLayout { + anchors.fill: parent + anchors.margins: 7 + spacing: 2 + Label { text: "Monitor events (bounded " + root.monitor.eventCapacity + ")"; color: root.theme.navigationText; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { + Layout.fillWidth: true + Layout.fillHeight: true + text: root.monitor.recentEvents.length ? root.monitor.recentEvents[root.monitor.recentEvents.length - 1] : "No events" + color: root.theme.navigationMuted + font.pixelSize: 8 + wrapMode: Text.Wrap + maximumLineCount: 3 + elide: Text.ElideRight + } + } + } + } + } + } + } +} diff --git a/apps/ied_simulator/qml/IconAction.qml b/apps/ied_simulator/qml/IconAction.qml new file mode 100644 index 000000000..a11912bed --- /dev/null +++ b/apps/ied_simulator/qml/IconAction.qml @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Button { + id: control + + required property var theme + property url iconSource: "" + property bool primary: false + property bool danger: false + property bool compact: false + + hoverEnabled: true + implicitHeight: compact ? 32 : 36 + implicitWidth: Math.max(compact ? 36 : 84, implicitContentWidth + (compact ? 16 : 24)) + leftPadding: compact ? 8 : 12 + rightPadding: compact ? 8 : 12 + spacing: 7 + + icon.source: iconSource + icon.width: compact ? 15 : 16 + icon.height: compact ? 15 : 16 + icon.color: { + if (!enabled) return theme.muted + if (primary) return "#ffffff" + if (danger) return theme.red + return theme.textSoft + } + display: text.length === 0 ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + + palette.buttonText: { + if (!enabled) return theme.muted + if (primary) return "#ffffff" + if (danger) return theme.red + return theme.text + } + + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + + background: Rectangle { + radius: 8 + border.width: 1 + border.color: { + if (!control.enabled) return control.theme.lineSoft + if (control.primary) return control.hovered ? control.theme.accentHover : control.theme.accent + if (control.danger) return control.hovered ? control.theme.red : control.theme.redSoft + return control.hovered ? control.theme.accent : control.theme.lineSoft + } + color: { + if (!control.enabled) return control.theme.surfaceRaised + if (control.primary) return control.pressed ? control.theme.navigationDark : (control.hovered ? control.theme.accentHover : control.theme.accent) + if (control.danger) return control.hovered ? control.theme.redSoft : control.theme.surface + return control.pressed ? control.theme.surfaceSoft : (control.hovered ? control.theme.accentSoft : control.theme.surface) + } + } +} diff --git a/apps/ied_simulator/qml/IedExplorer.qml b/apps/ied_simulator/qml/IedExplorer.qml new file mode 100644 index 000000000..621661412 --- /dev/null +++ b/apps/ied_simulator/qml/IedExplorer.qml @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// ARStack port of the ARSAS IED Explorer interaction pattern. This component +// deliberately owns presentation only: the authoritative IED/runtime model +// remains IedFleetController and the SCL parser/profile builder behind it. +Rectangle { + id: root + + required property var theme + required property var backend + + objectName: "iedExplorer" + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + function statusColor(status) { + if (status === "Running") return theme.green + if (status === "Starting" || status === "Stopping") return theme.amber + if (status === "Failed") return theme.red + return theme.muted + } + + function statusFill(status) { + if (status === "Running") return theme.greenSoft + if (status === "Starting" || status === "Stopping") return theme.amberSoft + if (status === "Failed") return theme.redSoft + return theme.surfaceRaised + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 48 + Layout.leftMargin: 13 + Layout.rightMargin: 10 + spacing: 8 + + Rectangle { + width: 8 + height: 8 + radius: 4 + color: backend.runningCount > 0 ? theme.green : theme.accent + } + Label { + text: "IED EXPLORER" + color: theme.text + font.pixelSize: 11 + font.weight: Font.Bold + Layout.fillWidth: true + } + Label { + text: backend.runningCount + "/" + backend.ieds.length + color: backend.runningCount > 0 ? theme.green : theme.muted + font.pixelSize: 10 + font.weight: Font.DemiBold + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + + ListView { + id: iedList + objectName: "iedExplorerList" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: backend.ieds + spacing: 8 + topMargin: 9 + bottomMargin: 9 + leftMargin: 8 + rightMargin: 8 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: card + required property var modelData + required property int index + + width: iedList.width - iedList.leftMargin - iedList.rightMargin + height: 112 + radius: 14 + color: { + if (backend.selectedIedIndex === index && modelData.status === "Running") return "#e9f6ef" + if (backend.selectedIedIndex === index) return theme.accentSoft + if (modelData.status === "Running") return "#f2fbf6" + return theme.surface + } + border.width: 1 + border.color: { + if (backend.selectedIedIndex === index && modelData.status === "Running") return "#71b58e" + if (backend.selectedIedIndex === index) return theme.accent + if (modelData.status === "Running") return "#a7d5bb" + return theme.lineSoft + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 5 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 62 + spacing: 8 + + Item { + Layout.preferredWidth: 58 + Layout.preferredHeight: 62 + + Image { + id: relayIcon + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: 49 + height: 49 + source: "qrc:/iedsim/assets/black-fascia-ied.svg" + sourceSize: Qt.size(98, 98) + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: true + opacity: modelData.enabled ? 1.0 : 0.42 + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + height: 15 + width: 42 + radius: 5 + color: root.statusFill(modelData.status) + border.width: 1 + border.color: root.statusColor(modelData.status) + Row { + anchors.centerIn: parent + spacing: 3 + Rectangle { + width: 4 + height: 4 + radius: 2 + color: root.statusColor(modelData.status) + anchors.verticalCenter: parent.verticalCenter + } + Label { + text: modelData.status === "Running" ? "LIVE" : + (modelData.status === "Starting" ? "START" : + (modelData.status === "Stopping" ? "STOP…" : + (modelData.status === "Failed" ? "FAIL" : "READY"))) + color: root.statusColor(modelData.status) + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 2 + + Label { + Layout.fillWidth: true + text: modelData.name || "Unnamed IED" + color: theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: modelData.endpoint || "Assign IP" + color: (modelData.endpoint || "") === "Assign IP" ? theme.amber : theme.muted + font.pixelSize: 9 + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: [modelData.manufacturer || "", modelData.type || ""].filter(function(v) { return v.length > 0 }).join(" · ") + color: theme.textSoft + font.pixelSize: 8 + elide: Text.ElideRight + } + Item { Layout.fillHeight: true } + } + + Switch { + checked: modelData.enabled === undefined ? true : modelData.enabled + enabled: !modelData.running && !modelData.starting + scale: 0.72 + Layout.alignment: Qt.AlignTop + onToggled: backend.setIedEnabled(index, checked) + ToolTip.visible: hovered + ToolTip.text: checked ? "IED enabled" : "IED disabled" + } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 28 + Layout.leftMargin: 62 + spacing: 5 + + IconAction { + theme: root.theme + compact: true + text: "" + iconSource: "qrc:/iedsim/assets/play.svg" + enabled: modelData.enabled && !modelData.running && !modelData.starting && + (modelData.endpoint || "") !== "Assign IP" + onClicked: { + backend.selectIed(index) + backend.startIed(index) + } + ToolTip.visible: hovered + ToolTip.text: "Start this IED" + } + IconAction { + theme: root.theme + compact: true + text: "" + danger: true + iconSource: "qrc:/iedsim/assets/square.svg" + enabled: modelData.running || modelData.starting + onClicked: backend.stopIed(index) + ToolTip.visible: hovered + ToolTip.text: "Stop this IED" + } + Item { Layout.fillWidth: true } + Label { + text: backend.selectedIedIndex === index ? "SELECTED" : "" + color: theme.accent + font.pixelSize: 7 + font.weight: Font.Bold + Layout.alignment: Qt.AlignVCenter + } + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: backend.selectIed(card.index) + } + } + + Label { + anchors.centerIn: parent + visible: backend.ieds.length === 0 + text: "Open an SCL/SCD/CID model\nto build the IED Explorer" + horizontalAlignment: Text.AlignHCenter + color: theme.muted + font.pixelSize: 10 + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + + // ARSAS-inspired fixed quick actions stay visible independently from the + // scrolling IED list. They are adapted to simulator semantics. + ColumnLayout { + Layout.fillWidth: true + Layout.preferredHeight: backend.imported ? 267 : 0 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: 9 + Layout.bottomMargin: 9 + spacing: 7 + visible: backend.imported + + RowLayout { + Layout.fillWidth: true + spacing: 6 + IconAction { + theme: root.theme + text: "Start all" + iconSource: "qrc:/iedsim/assets/play.svg" + primary: true + Layout.fillWidth: true + enabled: backend.ieds.length > 0 + onClicked: backend.startAllSimulations() + } + IconAction { + theme: root.theme + compact: true + text: "" + iconSource: "qrc:/iedsim/assets/radio-tower.svg" + enabled: !backend.anyRunning + onClicked: backend.autoAssignIedAddresses() + ToolTip.visible: hovered + ToolTip.text: "Auto assign local IPv4 addresses" + } + } + + RowLayout { + Layout.fillWidth: true + Label { + text: "SELECTED ENDPOINT" + color: theme.text + font.pixelSize: 9 + font.weight: Font.Bold + Layout.fillWidth: true + } + Rectangle { + width: 7 + height: 7 + radius: 4 + color: root.statusColor(backend.selectedIed.status || "Ready") + } + } + + ComboBox { + id: addressBox + Layout.fillWidth: true + enabled: !backend.running && !backend.starting + model: backend.availableAddresses + currentIndex: Math.max(0, backend.availableAddresses.indexOf(backend.listenAddress)) + onActivated: backend.listenAddress = currentText + font.pixelSize: 10 + } + + RowLayout { + Layout.fillWidth: true + Label { text: "Port"; color: theme.muted; font.pixelSize: 9 } + SpinBox { + Layout.fillWidth: true + from: 1 + to: 65535 + editable: true + value: backend.port + enabled: !backend.running && !backend.starting + onValueModified: backend.port = value + font.pixelSize: 10 + } + IconAction { + theme: root.theme + compact: true + text: "" + iconSource: "qrc:/iedsim/assets/scan-search.svg" + enabled: !backend.anyRunning + onClicked: backend.refreshNetworkInterfaces() + ToolTip.visible: hovered + ToolTip.text: "Refresh laptop IPv4 interfaces" + } + } + + Label { + Layout.fillWidth: true + visible: backend.endpointConflict.length > 0 + text: backend.endpointConflict + color: theme.red + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: backend.networkAddresses.length + " local IPv4 entries · secondary Ethernet IPs are supported" + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + IconAction { + theme: root.theme + text: backend.running ? "Stop selected" : "Start selected" + iconSource: backend.running ? "qrc:/iedsim/assets/square.svg" : "qrc:/iedsim/assets/play.svg" + primary: !backend.running + danger: backend.running + Layout.fillWidth: true + enabled: backend.running || + (backend.imported && !backend.starting && backend.selectedIed.enabled && + backend.endpointConflict.length === 0 && backend.listenAddress.length > 0) + onClicked: backend.running ? backend.stopSimulation() : backend.startSimulation() + } + } + } + } +} diff --git a/apps/ied_simulator/qml/InteropWorkspace.qml b/apps/ied_simulator/qml/InteropWorkspace.qml new file mode 100644 index 000000000..e56423708 --- /dev/null +++ b/apps/ied_simulator/qml/InteropWorkspace.qml @@ -0,0 +1,1233 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import ARStack.IedSimulator 1.0 + +Item { + id: root + + required property var theme + required property var backend + + signal openSclRequested() + + property string pendingValue: "" + property string pendingQuality: "Good" + property string pendingOrigin: "Simulator" + readonly property bool showInspector: width >= 1260 + + function localPathFromUrl(value) { + var text = decodeURIComponent(String(value)) + if (text.indexOf("file:///") === 0) { + var local = text.substring(8) + if (/^[A-Za-z]:\//.test(local)) return local + return "/" + local + } + if (text.indexOf("file://") === 0) return "//" + text.substring(7) + return text + } + + IedNavigationModel { + id: navigationModel + backend: root.backend + } + + IedSignalModel { + id: signalModel + backend: root.backend + logicalDevice: navigationModel.selectedLogicalDevice + logicalNode: navigationModel.selectedLogicalNode + filterText: searchField.text + } + + component CommandButton: Button { + id: control + implicitHeight: 32 + implicitWidth: Math.max(76, label.implicitWidth + 24) + font.pixelSize: 11 + font.weight: Font.DemiBold + property bool primary: false + property bool danger: false + property bool quiet: false + property color foreground: !enabled ? root.theme.muted + : primary ? "#ffffff" + : danger ? root.theme.red + : root.theme.textSoft + contentItem: Text { + id: label + text: control.text + color: control.foreground + font: control.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 5 + color: !control.enabled ? root.theme.surfaceRaised + : control.primary ? root.theme.accent + : control.down ? root.theme.surfaceSoft + : control.quiet ? "transparent" + : root.theme.surface + border.width: control.quiet ? 0 : 1 + border.color: control.primary ? root.theme.accent + : control.danger ? root.theme.red + : root.theme.line + } + } + + component MetaRow: RowLayout { + property string title: "" + property string value: "" + Layout.fillWidth: true + spacing: 8 + Label { + Layout.preferredWidth: 72 + text: parent.title + color: root.theme.muted + font.pixelSize: 9 + } + Label { + Layout.fillWidth: true + text: parent.value.length ? parent.value : "—" + color: root.theme.textSoft + font.pixelSize: 9 + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignRight + } + } + + Shortcut { sequence: "Ctrl+O"; onActivated: root.openSclRequested() } + Shortcut { + sequence: "Ctrl+F" + enabled: backend.imported + onActivated: { + searchField.forceActiveFocus() + searchField.selectAll() + } + } + Shortcut { + sequence: "Ctrl+E" + enabled: backend.running && backend.selectedValue.writable === true + onActivated: valueDialog.open() + } + Shortcut { + sequence: "F5" + enabled: backend.imported && !backend.running && !backend.starting + onActivated: startDialog.open() + } + Shortcut { + sequence: "Shift+F5" + enabled: backend.running || backend.starting + onActivated: backend.stopSimulation() + } + + Rectangle { anchors.fill: parent; color: theme.background } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 56 + color: theme.navigation + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 8 + + ColumnLayout { + Layout.preferredWidth: 216 + spacing: 0 + Label { + text: "ARStack IED Simulator" + color: theme.navigationText + font.pixelSize: 14 + font.weight: Font.DemiBold + } + Label { + text: "IEC 61850 commissioning workspace" + color: theme.navigationMuted + font.pixelSize: 9 + } + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + Layout.topMargin: 11 + Layout.bottomMargin: 11 + color: "#32423d" + } + + CommandButton { + text: "Open SCL" + enabled: !backend.importing + onClicked: root.openSclRequested() + } + CommandButton { + text: "Start" + primary: true + enabled: backend.imported && !backend.importing && !backend.running && !backend.starting && backend.selectedIed.enabled + onClicked: startDialog.open() + } + CommandButton { + text: "Stop" + danger: true + enabled: backend.running || backend.starting + onClicked: backend.stopSimulation() + } + CommandButton { + text: "Set value" + enabled: backend.running && backend.selectedValue.writable === true + onClicked: valueDialog.open() + } + + Item { Layout.fillWidth: true } + + ColumnLayout { + visible: backend.imported + Layout.maximumWidth: 260 + spacing: 0 + Label { + Layout.fillWidth: true + text: backend.selectedIed.name || "IED" + color: theme.navigationText + font.pixelSize: 10 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: (backend.selectedIed.endpoint || (backend.listenAddress + ":" + backend.port)) + color: theme.navigationMuted + font.pixelSize: 8 + horizontalAlignment: Text.AlignRight + elide: Text.ElideMiddle + } + } + + Rectangle { + width: 8 + height: 8 + radius: 4 + color: backend.running ? theme.green + : backend.starting || backend.importing ? theme.amber + : theme.muted + } + Label { + text: backend.importing ? "IMPORTING" + : backend.running ? "LIVE" + : backend.starting ? "STARTING" : "READY" + color: backend.running ? "#9ff0c1" : theme.navigationMuted + font.pixelSize: 10 + font.weight: Font.DemiBold + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Rectangle { + Layout.preferredWidth: 286 + Layout.minimumWidth: 246 + Layout.maximumWidth: 320 + Layout.fillHeight: true + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 34 + Layout.leftMargin: 12 + Layout.rightMargin: 10 + Label { + Layout.fillWidth: true + text: "ACTIVE IED" + color: theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Rectangle { + visible: backend.imported + implicitWidth: runtimeLabel.implicitWidth + 14 + implicitHeight: 18 + radius: 9 + color: backend.running ? theme.greenSoft + : backend.starting ? theme.amberSoft + : theme.surfaceRaised + Label { + id: runtimeLabel + anchors.centerIn: parent + text: backend.running ? "RUNNING" : backend.starting ? "STARTING" : "OFFLINE" + color: backend.running ? theme.green + : backend.starting ? theme.amber + : theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + + ComboBox { + id: iedSelector + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.preferredHeight: 33 + model: backend.ieds + textRole: "name" + currentIndex: backend.selectedIedIndex + enabled: backend.ieds.length > 0 && !backend.anyRunning && !backend.importing + font.pixelSize: 10 + onActivated: backend.selectIed(currentIndex) + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: 8 + Layout.bottomMargin: 9 + Layout.preferredHeight: 76 + radius: 6 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 9 + spacing: 3 + MetaRow { title: "Endpoint"; value: backend.imported ? (backend.listenAddress + ":" + backend.port) : "—" } + MetaRow { title: "Model"; value: backend.sourceName || "—" } + MetaRow { + title: "Profile" + value: backend.selectedIed.type || backend.selectedIed.manufacturer || "—" + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 34 + Layout.leftMargin: 12 + Layout.rightMargin: 7 + Label { + Layout.fillWidth: true + text: "DATA MODEL" + color: theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + ToolButton { + text: "−" + font.pixelSize: 12 + ToolTip.visible: hovered + ToolTip.text: "Collapse all" + onClicked: navigationModel.collapseAll() + } + ToolButton { + text: "+" + font.pixelSize: 12 + ToolTip.visible: hovered + ToolTip.text: "Expand all" + onClicked: navigationModel.expandAll() + } + } + + ListView { + id: navigationList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: navigationModel + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: navRow + required property int index + required property string kind + required property string name + required property int depth + required property bool expanded + required property bool expandable + required property bool selected + + width: navigationList.width + height: kind === "SECTION" ? 31 : 28 + color: selected ? theme.accentSoft + : navMouse.containsMouse ? theme.surfaceRaised + : "transparent" + + Rectangle { + visible: navRow.selected + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: theme.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + navRow.depth * 15 + anchors.rightMargin: 8 + spacing: 5 + + Label { + Layout.preferredWidth: 12 + text: navRow.expandable ? (navRow.expanded ? "⌄" : "›") : "" + color: theme.muted + font.pixelSize: 12 + } + Rectangle { + visible: navRow.kind !== "SECTION" + width: 23 + height: 17 + radius: 3 + color: navRow.kind === "LD" ? theme.surfaceSoft : theme.accentSoft + Label { + anchors.centerIn: parent + text: navRow.kind + color: navRow.kind === "LN" ? theme.accent : theme.textSoft + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: navRow.name + color: navRow.selected ? theme.accent : theme.textSoft + font.pixelSize: navRow.kind === "SECTION" ? 10 : 9 + font.weight: navRow.kind === "SECTION" || navRow.selected + ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + } + + MouseArea { + id: navMouse + anchors.fill: parent + hoverEnabled: true + onClicked: navigationModel.activate(navRow.index) + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 32 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Label { + Layout.fillWidth: true + text: backend.reportCount + " reports" + color: theme.muted + font.pixelSize: 9 + } + Label { + text: backend.dataSetCount + " data sets" + color: theme.muted + font.pixelSize: 9 + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 56 + color: theme.surface + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 10 + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: (backend.selectedIed.name || "IED") + " › " + + (navigationModel.selectedLogicalDevice || "Data Model") + " › " + + (navigationModel.selectedLogicalNode || "—") + color: theme.text + font.pixelSize: 11 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + text: signalModel.visibleRowCount + " visible rows" + + (searchField.text.length ? " · filtered" : "") + color: theme.muted + font.pixelSize: 9 + } + } + + RowLayout { + Layout.preferredWidth: Math.min(360, Math.max(250, root.width * 0.25)) + spacing: 4 + TextField { + id: searchField + Layout.fillWidth: true + Layout.preferredHeight: 32 + placeholderText: "Search object, value, FC or reference" + selectByMouse: true + font.pixelSize: 10 + enabled: backend.imported + } + ToolButton { + visible: searchField.text.length > 0 + text: "×" + font.pixelSize: 13 + ToolTip.visible: hovered + ToolTip.text: "Clear search" + onClicked: { + searchField.clear() + searchField.forceActiveFocus() + } + } + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: theme.surfaceRaised + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + Label { Layout.fillWidth: true; text: "Object / attribute"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 54; text: "FC"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 96; text: "Type"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 88; text: "Quality"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 150; text: "Value"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; horizontalAlignment: Text.AlignRight } + } + } + + ListView { + id: signalList + objectName: "iedLiveSignalTable" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: signalModel + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: signalRow + required property int index + required property string kind + required property string name + required property int depth + required property var value + required property var fc + required property var type + required property var quality + required property bool writable + required property bool changed + required property bool selected + required property var reference + + width: signalList.width + height: signalRow.kind === "DO" ? 32 : 29 + color: signalRow.selected ? theme.accentSoft + : signalRow.kind === "DO" ? theme.chrome + : signalMouse.containsMouse ? "#f6f9f8" + : theme.surface + + Rectangle { + visible: signalRow.selected + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: theme.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Item { Layout.preferredWidth: signalRow.depth * 18 } + Rectangle { + width: 22 + height: 16 + radius: 3 + color: signalRow.kind === "DO" ? theme.accentSoft : theme.surfaceRaised + Label { + anchors.centerIn: parent + text: signalRow.kind + color: signalRow.kind === "DO" ? theme.accent : theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: signalRow.name + color: signalRow.kind === "DO" ? theme.text : theme.textSoft + font.pixelSize: 10 + font.weight: signalRow.kind === "DO" ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + ToolTip.visible: nameMouse.containsMouse && implicitWidth > width + ToolTip.text: signalRow.reference ? String(signalRow.reference) : signalRow.name + MouseArea { id: nameMouse; anchors.fill: parent; hoverEnabled: true; acceptedButtons: Qt.NoButton } + } + Label { + visible: signalRow.changed + text: "●" + color: theme.amber + font.pixelSize: 8 + } + } + + Label { + Layout.preferredWidth: 54 + text: signalRow.fc ? "[" + signalRow.fc + "]" : "" + color: theme.muted + font.pixelSize: 9 + } + Label { + Layout.preferredWidth: 96 + text: signalRow.type || "" + color: theme.muted + font.pixelSize: 9 + elide: Text.ElideRight + } + Label { + Layout.preferredWidth: 88 + text: signalRow.kind === "DA" && signalRow.quality ? String(signalRow.quality) : "" + color: { + var q = signalRow.quality ? String(signalRow.quality).toLowerCase() : "" + return q === "good" ? theme.green : q === "invalid" ? theme.red : theme.muted + } + font.pixelSize: 9 + font.weight: signalRow.kind === "DA" ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + Label { + Layout.preferredWidth: 150 + text: signalRow.value === undefined ? "" : String(signalRow.value) + color: signalRow.writable && backend.running ? theme.accent : theme.textSoft + font.pixelSize: 10 + font.weight: signalRow.kind === "DA" ? Font.DemiBold : Font.Normal + elide: Text.ElideLeft + horizontalAlignment: Text.AlignRight + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: theme.lineSoft + } + + MouseArea { + id: signalMouse + anchors.fill: parent + hoverEnabled: true + onClicked: signalModel.activate(signalRow.index) + onDoubleClicked: { + signalModel.activate(signalRow.index) + if (backend.running && backend.selectedValue.writable === true) valueDialog.open() + } + } + } + + Column { + anchors.centerIn: parent + visible: signalModel.visibleRowCount === 0 + spacing: 5 + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: backend.imported ? "No matching data attributes" : "Open an IEC 61850 model" + color: theme.textSoft + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: backend.imported + ? "Select another logical node or clear the search filter." + : "CID, SCD, ICD, IID and SCL files are supported." + color: theme.muted + font.pixelSize: 9 + } + } + } + } + } + + Rectangle { + visible: root.showInspector + Layout.preferredWidth: visible ? 296 : 0 + Layout.minimumWidth: visible ? 276 : 0 + Layout.fillHeight: true + color: theme.chrome + border.width: visible ? 1 : 0 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Label { + Layout.fillWidth: true + text: "INSPECTOR" + color: theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Rectangle { + visible: backend.selectedValue.reference !== undefined + implicitWidth: inspectorAccess.implicitWidth + 14 + implicitHeight: 18 + radius: 9 + color: backend.selectedValue.writable === true ? theme.accentSoft : theme.surfaceRaised + Label { + id: inspectorAccess + anchors.centerIn: parent + text: backend.selectedValue.writable === true ? "WRITABLE" : "READ ONLY" + color: backend.selectedValue.writable === true ? theme.accent : theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 12 + spacing: 4 + Label { + Layout.fillWidth: true + text: backend.selectedValue.dataAttribute || backend.selectedValue.dataObject || "No value selected" + color: theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: backend.selectedValue.reference || "Select a data attribute in the table to inspect it." + color: theme.muted + font.pixelSize: 9 + wrapMode: Text.WrapAnywhere + } + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.preferredHeight: 76 + radius: 7 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + Label { text: "CURRENT VALUE"; color: theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { + Layout.fillWidth: true + text: backend.selectedValue.value === undefined ? "—" : String(backend.selectedValue.value) + color: backend.selectedValue.writable === true && backend.running ? theme.accent : theme.text + font.pixelSize: 20 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 5 + MetaRow { title: "FC"; value: backend.selectedValue.fc ? String(backend.selectedValue.fc) : "" } + MetaRow { title: "Type"; value: backend.selectedValue.type ? String(backend.selectedValue.type) : "" } + MetaRow { title: "Quality"; value: backend.selectedValue.quality ? String(backend.selectedValue.quality) : "" } + MetaRow { title: "Unit"; value: backend.selectedValue.unit ? String(backend.selectedValue.unit) : "" } + MetaRow { title: "Origin"; value: backend.selectedValue.origin ? String(backend.selectedValue.origin) : "" } + MetaRow { title: "Updated"; value: backend.selectedValue.updated ? String(backend.selectedValue.updated) : "" } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 6 + Label { text: "ACTIONS"; color: theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + CommandButton { + Layout.fillWidth: true + text: "Set value Ctrl+E" + primary: true + enabled: backend.running && backend.selectedValue.writable === true + onClicked: valueDialog.open() + } + CommandButton { + Layout.fillWidth: true + text: "Undo last change" + enabled: backend.running && backend.selectedValue.changed === true + onClicked: backend.undoLastChange() + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.bottomMargin: 12 + spacing: 5 + Label { text: "MODEL SUMMARY"; color: theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + MetaRow { title: "Endpoint"; value: backend.imported ? (backend.listenAddress + ":" + backend.port) : "" } + MetaRow { title: "Data sets"; value: backend.imported ? String(backend.dataSetCount) : "" } + MetaRow { title: "Reports"; value: backend.imported ? String(backend.reportCount) : "" } + MetaRow { title: "GOOSE"; value: backend.imported ? String(backend.gooseCount) : "" } + CommandButton { + Layout.fillWidth: true + Layout.topMargin: 4 + text: "Copy diagnostics" + enabled: backend.imported + onClicked: backend.copyDiagnostics() + } + } + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: theme.statusChrome + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + Rectangle { + width: 6 + height: 6 + radius: 3 + color: backend.fatalError.length ? theme.red + : backend.running ? theme.green + : backend.importing ? theme.amber : theme.navigationMuted + } + Label { + Layout.fillWidth: true + text: backend.fatalError.length ? backend.fatalError + : backend.importing ? "Importing and indexing engineering model…" + : backend.imported ? backend.modelStatus + : "Ready. Open an IEC 61850 engineering model." + color: backend.fatalError.length ? "#ffb2b8" : theme.statusText + font.pixelSize: 9 + elide: Text.ElideRight + } + Label { + text: backend.imported + ? backend.logicalDeviceCount + " LD · " + backend.dataObjectCount + " DO · " + backend.dataAttributeCount + " DA/BDA" + : "" + color: theme.navigationMuted + font.pixelSize: 9 + } + } + } + } + + Rectangle { + anchors.fill: parent + visible: backend.importing + z: 100 + color: "#660c1714" + + Rectangle { + anchors.centerIn: parent + width: 350 + height: 116 + radius: 10 + color: theme.surface + border.width: 1 + border.color: theme.line + + RowLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 14 + BusyIndicator { running: backend.importing; implicitWidth: 34; implicitHeight: 34 } + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + Label { text: "Loading engineering model"; color: theme.text; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { + Layout.fillWidth: true + text: "Parsing is running on a bounded worker. The workspace remains responsive." + color: theme.muted + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + } + } + } + + FolderDialog { + id: fileRootDialog + title: "Choose File transfer folder" + onAccepted: { + var local = root.localPathFromUrl(selectedFolder) + fileRootField.text = local + backend.fileFolder = local + } + } + + Dialog { + id: startDialog + modal: true + anchors.centerIn: parent + width: 460 + title: "Start simulated IED" + closePolicy: Popup.CloseOnEscape + + onOpened: { + var addressIndex = backend.availableAddresses.indexOf(backend.listenAddress) + addressBox.currentIndex = Math.max(0, addressIndex) + portField.text = String(backend.port) + fileRootField.text = backend.fileFolder || "" + } + + contentItem: ColumnLayout { + spacing: 10 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + text: backend.selectedIed.name || "Selected IED" + color: theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + text: "MMS server endpoint" + color: theme.muted + font.pixelSize: 9 + } + } + Rectangle { + width: 8 + height: 8 + radius: 4 + color: theme.accent + } + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + Label { text: "Listening address"; color: theme.muted; font.pixelSize: 9 } + RowLayout { + Layout.fillWidth: true + ComboBox { + id: addressBox + Layout.fillWidth: true + model: backend.availableAddresses + font.pixelSize: 10 + } + CommandButton { + text: "Refresh" + onClicked: { + backend.refreshNetworkInterfaces() + var addressIndex = backend.availableAddresses.indexOf(backend.listenAddress) + addressBox.currentIndex = Math.max(0, addressIndex) + } + } + } + Label { text: "TCP port"; color: theme.muted; font.pixelSize: 9 } + TextField { + id: portField + Layout.fillWidth: true + inputMethodHints: Qt.ImhDigitsOnly + selectByMouse: true + font.pixelSize: 10 + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + RowLayout { + Layout.fillWidth: true + CheckBox { + id: fileServiceCheck + checked: backend.fileServiceEnabled + onToggled: backend.fileServiceEnabled = checked + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + text: "Enable MMS file service" + color: theme.textSoft + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + text: "external IEC 61850 client file access is sandboxed to this folder." + color: theme.muted + font.pixelSize: 8 + } + } + } + Label { + text: "File transfer folder" + color: fileServiceCheck.checked ? theme.muted : theme.line + font.pixelSize: 9 + } + RowLayout { + Layout.fillWidth: true + TextField { + id: fileRootField + Layout.fillWidth: true + enabled: fileServiceCheck.checked + selectByMouse: true + placeholderText: "Choose folder exposed to MMS client" + font.pixelSize: 10 + onEditingFinished: backend.fileFolder = text + } + CommandButton { + text: "Browse…" + enabled: fileServiceCheck.checked + onClicked: fileRootDialog.open() + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 52 + radius: 6 + color: theme.surfaceRaised + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 1 + Label { text: "ENDPOINT PREVIEW"; color: theme.muted; font.pixelSize: 7; font.weight: Font.Bold } + Label { + text: (addressBox.currentText || "0.0.0.0") + ":" + (portField.text || "102") + color: theme.text + font.pixelSize: 11 + font.weight: Font.DemiBold + } + } + } + + Label { + Layout.fillWidth: true + text: "Each simulated IED uses its own local IPv4 endpoint. Multiple IEDs may share TCP 102 when bound to different local addresses." + color: theme.muted + wrapMode: Text.WordWrap + font.pixelSize: 9 + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CommandButton { text: "Cancel"; onClicked: startDialog.close() } + CommandButton { + text: "Start IED" + primary: true + enabled: addressBox.currentText.length > 0 && + Number(portField.text) >= 1 && Number(portField.text) <= 65535 && + (!fileServiceCheck.checked || fileRootField.text.trim().length > 0) + onClicked: { + var requestedPort = Number(portField.text) + backend.fileServiceEnabled = fileServiceCheck.checked + backend.fileFolder = fileRootField.text + if (backend.configureIedEndpoint(backend.selectedIedIndex, addressBox.currentText, requestedPort) && + backend.startSimulation()) { + startDialog.close() + } + } + } + } + } + } + + Dialog { + id: valueDialog + modal: true + anchors.centerIn: parent + width: 520 + title: "Set IED value" + closePolicy: Popup.CloseOnEscape + property var availableOptions: backend.selectedValue.options || [] + + onOpened: { + root.pendingValue = backend.selectedValue.value === undefined ? "" : String(backend.selectedValue.value) + root.pendingQuality = backend.selectedValue.quality || "Good" + root.pendingOrigin = backend.selectedValue.origin || "Simulator" + } + + contentItem: ColumnLayout { + spacing: 9 + + Label { + Layout.fillWidth: true + text: backend.selectedValue.reference || "Select a writable data attribute" + color: theme.textSoft + font.pixelSize: 10 + wrapMode: Text.WrapAnywhere + } + RowLayout { + Layout.fillWidth: true + Label { + text: backend.selectedValue.fc ? "FC " + backend.selectedValue.fc : "" + color: theme.muted + font.pixelSize: 8 + } + Label { + text: backend.selectedValue.type || "" + color: theme.muted + font.pixelSize: 8 + } + Item { Layout.fillWidth: true } + Label { + text: "Current: " + (backend.selectedValue.value === undefined ? "—" : String(backend.selectedValue.value)) + color: theme.muted + font.pixelSize: 8 + } + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + Label { text: "New value"; color: theme.muted; font.pixelSize: 9 } + Loader { + Layout.fillWidth: true + sourceComponent: valueDialog.availableOptions.length > 0 ? optionValueEditor : textValueEditor + } + Component { + id: textValueEditor + TextField { + text: root.pendingValue + selectByMouse: true + font.pixelSize: 10 + onTextEdited: root.pendingValue = text + } + } + Component { + id: optionValueEditor + ComboBox { + model: valueDialog.availableOptions + currentIndex: Math.max(0, valueDialog.availableOptions.indexOf(root.pendingValue)) + font.pixelSize: 10 + onActivated: root.pendingValue = currentText + } + } + + Label { text: "Quality"; color: theme.muted; font.pixelSize: 9 } + ComboBox { + Layout.fillWidth: true + model: ["Good", "Questionable", "Invalid"] + currentIndex: Math.max(0, model.indexOf(root.pendingQuality)) + font.pixelSize: 10 + onActivated: root.pendingQuality = currentText + } + + Label { text: "Origin"; color: theme.muted; font.pixelSize: 9 } + TextField { + Layout.fillWidth: true + text: root.pendingOrigin + selectByMouse: true + font.pixelSize: 10 + onTextEdited: root.pendingOrigin = text + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CommandButton { text: "Cancel"; onClicked: valueDialog.close() } + CommandButton { + text: "Apply value" + primary: true + enabled: backend.running && backend.selectedValue.writable === true + onClicked: { + if (backend.applySelectedValue(root.pendingValue, root.pendingQuality, root.pendingOrigin)) { + valueDialog.close() + } + } + } + } + } + } +} diff --git a/apps/ied_simulator/qml/InteropWorkspaceV2.qml b/apps/ied_simulator/qml/InteropWorkspaceV2.qml new file mode 100644 index 000000000..dc8aad8a3 --- /dev/null +++ b/apps/ied_simulator/qml/InteropWorkspaceV2.qml @@ -0,0 +1,1044 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.IedSimulator 1.0 + +Item { + id: root + + property var theme + property var backend + signal openSclRequested() + + property string activeSection: "DataModel" + property var activeService: ({}) + property int activeMemberCount: 0 + readonly property bool showInspector: width >= 1260 && activeSection === "DataModel" + + function catalogFor(section) { + if (section === "GOOSE") return gooseCatalog + if (section === "Reports") return reportCatalog + if (section === "SettingGroups") return settingGroupCatalog + if (section === "DataSets") return dataSetCatalog + return null + } + + function refreshService() { + var model = catalogFor(activeSection) + activeService = model ? model.selectedItem : ({}) + activeMemberCount = model ? model.memberCount : 0 + } + + function activateSection(section) { + activeSection = section + var model = catalogFor(section) + if (model && model.itemCount > 0 && model.selectedCatalogIndex < 0) model.select(0) + refreshService() + } + + function serviceTitle() { + if (activeSection === "SettingGroups") return "Setting Groups" + if (activeSection === "DataSets") return "DataSets" + return activeSection + } + + function serviceEmptyText() { + if (activeSection === "GOOSE") return "No configured GSEControl blocks in this IED." + if (activeSection === "Reports") return "No ReportControl blocks in this IED." + if (activeSection === "SettingGroups") return "No FC=SE values or SGCB object found in this IED." + if (activeSection === "DataSets") return "No configured DataSets in this IED." + return "No configured service objects." + } + + InteropWorkspace { + id: stableShell + anchors.fill: parent + theme: root.theme + backend: root.backend + onOpenSclRequested: root.openSclRequested() + } + + IedNavigationModel { + id: navigationModel + backend: root.backend + } + + IedSignalModel { + id: signalModel + backend: root.backend + logicalDevice: navigationModel.selectedLogicalDevice + logicalNode: navigationModel.selectedLogicalNode + filterText: searchField.text + } + + IedCommissioningModel { + id: gooseCatalog + backend: root.backend + kindFilter: "GOOSE" + onSelectionChanged: if (root.activeSection === "GOOSE") root.refreshService() + onModelChanged: if (root.activeSection === "GOOSE") root.refreshService() + } + IedCommissioningModel { + id: reportCatalog + backend: root.backend + kindFilter: "Report" + onSelectionChanged: if (root.activeSection === "Reports") root.refreshService() + onModelChanged: if (root.activeSection === "Reports") root.refreshService() + } + IedCommissioningModel { + id: settingGroupCatalog + backend: root.backend + kindFilter: "SettingGroup" + onSelectionChanged: if (root.activeSection === "SettingGroups") root.refreshService() + onModelChanged: if (root.activeSection === "SettingGroups") root.refreshService() + } + IedCommissioningModel { + id: dataSetCatalog + backend: root.backend + kindFilter: "DataSet" + onSelectionChanged: if (root.activeSection === "DataSets") root.refreshService() + onModelChanged: if (root.activeSection === "DataSets") root.refreshService() + } + + Connections { + target: root.backend + function onSelectionChanged() { root.refreshService() } + function onModelChanged() { root.refreshService() } + } + + component MetaRow: RowLayout { + property string title: "" + property string value: "" + Layout.fillWidth: true + spacing: 8 + Label { + Layout.preferredWidth: 86 + text: parent.title + color: root.theme.muted + font.pixelSize: 9 + } + Label { + Layout.fillWidth: true + text: parent.value.length ? parent.value : "—" + color: root.theme.textSoft + font.pixelSize: 9 + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignRight + } + } + + component SectionButton: Rectangle { + required property string section + required property string title + required property int count + property string badge: "" + Layout.fillWidth: true + Layout.preferredHeight: 34 + color: root.activeSection === section ? root.theme.accentSoft + : sectionMouse.containsMouse ? root.theme.surfaceRaised : "transparent" + + Rectangle { + visible: root.activeSection === parent.section + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: root.theme.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 9 + spacing: 7 + Label { + Layout.preferredWidth: 12 + text: root.activeSection === parent.parent.section ? "⌄" : "›" + color: root.theme.muted + font.pixelSize: 12 + } + Label { + Layout.fillWidth: true + text: parent.parent.title + color: root.activeSection === parent.parent.section ? root.theme.accent : root.theme.textSoft + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + visible: parent.parent.badge.length > 0 + text: parent.parent.badge + color: root.theme.muted + font.pixelSize: 8 + } + Rectangle { + implicitWidth: countLabel.implicitWidth + 12 + implicitHeight: 18 + radius: 9 + color: root.activeSection === parent.parent.section ? root.theme.surface : root.theme.surfaceRaised + Label { + id: countLabel + anchors.centerIn: parent + text: String(parent.parent.parent.count) + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + } + + MouseArea { + id: sectionMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.activateSection(parent.section) + } + } + + component CatalogList: Item { + required property var catalog + required property string emptyText + required property string section + + ListView { + id: catalogList + anchors.fill: parent + clip: true + model: parent.catalog ? parent.catalog.itemCount : 0 + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + required property int index + property var rowData: catalogList.parent.catalog.item(index) + width: catalogList.width + height: 31 + color: rowData.selected ? root.theme.accentSoft + : catalogMouse.containsMouse ? root.theme.surfaceRaised : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 22 + anchors.rightMargin: 9 + spacing: 6 + Rectangle { + width: 23 + height: 17 + radius: 3 + color: root.theme.surfaceSoft + Label { + anchors.centerIn: parent + text: section === "GOOSE" ? "G" : section === "Reports" ? "R" : + section === "SettingGroups" ? "SG" : "DS" + color: root.theme.accent + font.pixelSize: 7 + font.weight: Font.Bold + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: rowData.name || rowData.reference || "Configured object" + color: rowData.selected ? root.theme.accent : root.theme.textSoft + font.pixelSize: 9 + font.weight: rowData.selected ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: rowData.summary || rowData.status || "" + color: root.theme.muted + font.pixelSize: 7 + elide: Text.ElideRight + } + } + } + + MouseArea { + id: catalogMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + catalogList.parent.catalog.select(index) + root.activeSection = catalogList.parent.section + root.refreshService() + } + } + } + + Label { + anchors.centerIn: parent + width: Math.max(120, parent.width - 28) + visible: parent.model === 0 + text: catalogList.parent.emptyText + color: root.theme.muted + font.pixelSize: 9 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + } + + Rectangle { + id: workspaceBody + z: 10 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 56 + anchors.bottom: parent.bottom + anchors.bottomMargin: 30 + color: root.theme.background + + RowLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.preferredWidth: 296 + Layout.minimumWidth: 272 + Layout.maximumWidth: 326 + Layout.fillHeight: true + color: root.theme.chrome + border.width: 1 + border.color: root.theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 34 + Layout.leftMargin: 12 + Layout.rightMargin: 10 + Label { + Layout.fillWidth: true + text: "ACTIVE IED" + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Rectangle { + visible: root.backend && root.backend.imported + implicitWidth: runtimeStateLabel.implicitWidth + 14 + implicitHeight: 18 + radius: 9 + color: root.backend.running ? root.theme.greenSoft : root.theme.surfaceRaised + Label { + id: runtimeStateLabel + anchors.centerIn: parent + text: root.backend && root.backend.running ? "RUNNING" : "OFFLINE" + color: root.backend && root.backend.running ? root.theme.green : root.theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + + ComboBox { + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.preferredHeight: 33 + model: root.backend ? root.backend.ieds : [] + textRole: "name" + currentIndex: root.backend ? root.backend.selectedIedIndex : -1 + enabled: root.backend && root.backend.ieds.length > 0 && !root.backend.anyRunning && !root.backend.importing + font.pixelSize: 10 + onActivated: root.backend.selectIed(currentIndex) + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.topMargin: 8 + Layout.bottomMargin: 8 + Layout.preferredHeight: 67 + radius: 6 + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + MetaRow { + title: "Endpoint" + value: root.backend && root.backend.imported + ? (root.backend.listenAddress + ":" + root.backend.port) : "" + } + MetaRow { title: "Model"; value: root.backend ? root.backend.sourceName : "" } + MetaRow { + title: "Profile" + value: root.backend + ? (root.backend.selectedIed.type || root.backend.selectedIed.manufacturer || "") : "" + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + Label { + Layout.fillWidth: true + Layout.preferredHeight: 28 + Layout.leftMargin: 12 + text: "IED CONTENTS" + verticalAlignment: Text.AlignVCenter + color: root.theme.muted + font.pixelSize: 8 + font.weight: Font.Bold + } + + SectionButton { + section: "GOOSE" + title: "GOOSE" + count: gooseCatalog.itemCount + } + SectionButton { + section: "Reports" + title: "Reports" + count: reportCatalog.itemCount + } + SectionButton { + section: "SettingGroups" + title: "Setting Groups" + count: settingGroupCatalog.itemCount + } + SectionButton { + section: "DataSets" + title: "DataSets" + count: dataSetCatalog.itemCount + } + SectionButton { + section: "DataModel" + title: "Data Model" + count: root.backend ? root.backend.logicalDeviceCount : 0 + badge: "LD" + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: root.activeSection === "GOOSE" ? 0 + : root.activeSection === "Reports" ? 1 + : root.activeSection === "SettingGroups" ? 2 + : root.activeSection === "DataSets" ? 3 : 4 + + CatalogList { + catalog: gooseCatalog + section: "GOOSE" + emptyText: "No configured GOOSE control blocks." + } + CatalogList { + catalog: reportCatalog + section: "Reports" + emptyText: "No configured report control blocks." + } + CatalogList { + catalog: settingGroupCatalog + section: "SettingGroups" + emptyText: "No setting-group capable Logical Device found." + } + CatalogList { + catalog: dataSetCatalog + section: "DataSets" + emptyText: "No configured DataSets." + } + + ListView { + id: navigationList + clip: true + model: navigationModel + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: navRow + required property int index + required property string kind + required property string name + required property int depth + required property bool expanded + required property bool expandable + required property bool selected + width: navigationList.width + height: kind === "SECTION" ? 31 : 28 + color: selected ? root.theme.accentSoft + : navMouse.containsMouse ? root.theme.surfaceRaised : "transparent" + + Rectangle { + visible: navRow.selected + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: root.theme.accent + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: 11 + navRow.depth * 15 + anchors.rightMargin: 8 + spacing: 5 + Label { + Layout.preferredWidth: 12 + text: navRow.expandable ? (navRow.expanded ? "⌄" : "›") : "" + color: root.theme.muted + font.pixelSize: 12 + } + Rectangle { + visible: navRow.kind !== "SECTION" + width: 23 + height: 17 + radius: 3 + color: navRow.kind === "LD" ? root.theme.surfaceSoft : root.theme.accentSoft + Label { + anchors.centerIn: parent + text: navRow.kind + color: navRow.kind === "LN" ? root.theme.accent : root.theme.textSoft + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: navRow.name + color: navRow.selected ? root.theme.accent : root.theme.textSoft + font.pixelSize: navRow.kind === "SECTION" ? 10 : 9 + font.weight: navRow.kind === "SECTION" || navRow.selected ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + } + MouseArea { + id: navMouse + anchors.fill: parent + hoverEnabled: true + onClicked: navigationModel.activate(navRow.index) + } + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 29 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Label { + Layout.fillWidth: true + text: (root.backend ? root.backend.reportCount : 0) + " reports" + color: root.theme.muted + font.pixelSize: 8 + } + Label { + text: (root.backend ? root.backend.dataSetCount : 0) + " data sets" + color: root.theme.muted + font.pixelSize: 8 + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + + StackLayout { + anchors.fill: parent + currentIndex: root.activeSection === "DataModel" ? 0 : 1 + + Item { + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 56 + color: root.theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 10 + spacing: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: (root.backend && root.backend.selectedIed.name || "IED") + " › Data Model › " + + (navigationModel.selectedLogicalDevice || "—") + " › " + + (navigationModel.selectedLogicalNode || "—") + color: root.theme.text + font.pixelSize: 11 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + text: signalModel.visibleRowCount + " visible rows" + + (searchField.text.length ? " · filtered" : "") + color: root.theme.muted + font.pixelSize: 9 + } + } + TextField { + id: searchField + Layout.preferredWidth: Math.min(360, Math.max(250, root.width * 0.24)) + Layout.preferredHeight: 32 + placeholderText: "Search object, value, FC or reference" + selectByMouse: true + font.pixelSize: 10 + enabled: root.backend && root.backend.imported + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: root.theme.surfaceRaised + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + Label { Layout.fillWidth: true; text: "Object / attribute"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 54; text: "FC"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 96; text: "Type"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 88; text: "Quality"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 150; text: "Value"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; horizontalAlignment: Text.AlignRight } + } + } + + ListView { + id: signalList + objectName: "iedLiveSignalTableV2" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: signalModel + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: signalRow + required property int index + required property string kind + required property string name + required property int depth + required property var value + required property var fc + required property var type + required property var quality + required property bool writable + required property bool changed + required property bool selected + required property var reference + width: signalList.width + height: signalRow.kind === "DO" ? 32 : 29 + color: signalRow.selected ? root.theme.accentSoft + : signalRow.kind === "DO" ? root.theme.chrome + : signalMouse.containsMouse ? root.theme.surfaceRaised : root.theme.surface + + Rectangle { + visible: signalRow.selected + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + color: root.theme.accent + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + RowLayout { + Layout.fillWidth: true + spacing: 6 + Item { Layout.preferredWidth: signalRow.depth * 18 } + Rectangle { + width: 22 + height: 16 + radius: 3 + color: signalRow.kind === "DO" ? root.theme.accentSoft : root.theme.surfaceRaised + Label { + anchors.centerIn: parent + text: signalRow.kind + color: signalRow.kind === "DO" ? root.theme.accent : root.theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + Label { + Layout.fillWidth: true + text: signalRow.name + color: signalRow.kind === "DO" ? root.theme.text : root.theme.textSoft + font.pixelSize: 10 + font.weight: signalRow.kind === "DO" ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + Label { visible: signalRow.changed; text: "●"; color: root.theme.amber; font.pixelSize: 8 } + } + Label { Layout.preferredWidth: 54; text: signalRow.fc ? "[" + signalRow.fc + "]" : ""; color: root.theme.muted; font.pixelSize: 9 } + Label { Layout.preferredWidth: 96; text: signalRow.type || ""; color: root.theme.muted; font.pixelSize: 9; elide: Text.ElideRight } + Label { + Layout.preferredWidth: 88 + text: signalRow.kind === "DA" && signalRow.quality ? String(signalRow.quality) : "" + color: String(signalRow.quality).toLowerCase() === "good" ? root.theme.green : root.theme.muted + font.pixelSize: 9 + elide: Text.ElideRight + } + Label { + Layout.preferredWidth: 150 + text: signalRow.value === undefined ? "" : String(signalRow.value) + color: signalRow.writable && root.backend && root.backend.running ? root.theme.accent : root.theme.textSoft + font.pixelSize: 10 + font.weight: signalRow.kind === "DA" ? Font.DemiBold : Font.Normal + elide: Text.ElideLeft + horizontalAlignment: Text.AlignRight + } + } + Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: root.theme.lineSoft } + MouseArea { + id: signalMouse + anchors.fill: parent + hoverEnabled: true + onClicked: signalModel.activate(signalRow.index) + onDoubleClicked: signalModel.activate(signalRow.index) + } + } + + Label { + anchors.centerIn: parent + visible: signalModel.visibleRowCount === 0 + text: root.backend && root.backend.imported ? "No matching data attributes" : "Open an IEC 61850 model" + color: root.theme.muted + font.pixelSize: 11 + } + } + } + } + + Item { + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 62 + color: root.theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + Layout.fillWidth: true + text: (root.backend && root.backend.selectedIed.name || "IED") + " › " + root.serviceTitle() + + (root.activeService.name ? " › " + root.activeService.name : "") + color: root.theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: root.activeService.summary || root.serviceEmptyText() + color: root.theme.muted + font.pixelSize: 9 + elide: Text.ElideRight + } + } + Rectangle { + visible: root.activeService.status !== undefined + implicitWidth: serviceStatusLabel.implicitWidth + 16 + implicitHeight: 22 + radius: 11 + color: root.theme.accentSoft + Label { + id: serviceStatusLabel + anchors.centerIn: parent + text: root.activeService.status || "Configured" + color: root.theme.accent + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: root.activeService.name ? 116 : 0 + visible: root.activeService.name !== undefined + color: root.theme.chrome + GridLayout { + anchors.fill: parent + anchors.margins: 12 + columns: 4 + columnSpacing: 14 + rowSpacing: 6 + Label { text: "Reference"; color: root.theme.muted; font.pixelSize: 8 } + Label { Layout.columnSpan: 3; Layout.fillWidth: true; text: root.activeService.reference || "—"; color: root.theme.textSoft; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { text: "DataSet"; color: root.theme.muted; font.pixelSize: 8 } + Label { Layout.columnSpan: 3; Layout.fillWidth: true; text: root.activeService.dataSetReference || "—"; color: root.theme.textSoft; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { text: "Members"; color: root.theme.muted; font.pixelSize: 8 } + Label { text: String(root.activeMemberCount); color: root.theme.text; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { text: root.activeSection === "GOOSE" ? "APPID" : root.activeSection === "Reports" ? "ConfRev" : "Logical Device"; color: root.theme.muted; font.pixelSize: 8 } + Label { + Layout.fillWidth: true + text: root.activeSection === "GOOSE" ? (root.activeService.appId || "—") + : root.activeSection === "Reports" ? String(root.activeService.confRev === undefined ? "—" : root.activeService.confRev) + : (root.activeService.logicalDevice || "—") + color: root.theme.textSoft + font.pixelSize: 9 + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: root.theme.surfaceRaised + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 0 + Label { Layout.fillWidth: true; text: "Member / setting reference"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 56; text: "FC"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 92; text: "CDC"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 128; text: "Configured"; color: root.theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; horizontalAlignment: Text.AlignRight } + } + } + + ListView { + id: memberList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.activeMemberCount + reuseItems: true + cacheBuffer: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + required property int index + property var memberData: { + var model = root.catalogFor(root.activeSection) + return model ? model.member(index) : ({}) + } + width: memberList.width + height: 31 + color: memberMouse.containsMouse ? root.theme.surfaceRaised : root.theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 0 + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Label { + Layout.fillWidth: true + text: memberData.reference || memberData.mmsItem || memberData.dataObject || "Member" + color: root.theme.textSoft + font.pixelSize: 9 + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: (memberData.logicalDevice || "") + (memberData.logicalNode ? " / " + memberData.logicalNode : "") + color: root.theme.muted + font.pixelSize: 7 + elide: Text.ElideRight + } + } + Label { Layout.preferredWidth: 56; text: memberData.fc || ""; color: root.theme.muted; font.pixelSize: 9 } + Label { Layout.preferredWidth: 92; text: memberData.cdc || memberData.type || ""; color: root.theme.muted; font.pixelSize: 9; elide: Text.ElideRight } + Label { Layout.preferredWidth: 128; text: memberData.configuredValue || ""; color: root.theme.textSoft; font.pixelSize: 9; elide: Text.ElideLeft; horizontalAlignment: Text.AlignRight } + } + Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 1; color: root.theme.lineSoft } + MouseArea { id: memberMouse; anchors.fill: parent; hoverEnabled: true; acceptedButtons: Qt.NoButton } + } + + Column { + anchors.centerIn: parent + visible: root.activeMemberCount === 0 + spacing: 5 + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: root.activeService.name ? "No members exposed for this object" : root.serviceEmptyText() + color: root.theme.textSoft + font.pixelSize: 11 + font.weight: Font.DemiBold + } + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: root.activeSection === "SettingGroups" + ? "Setting-group entries are derived only from real FC=SE / SGCB model evidence." + : "The loaded SCL does not expose additional member detail." + color: root.theme.muted + font.pixelSize: 9 + } + } + } + } + } + } + } + + Rectangle { + visible: root.showInspector + Layout.preferredWidth: visible ? 296 : 0 + Layout.minimumWidth: visible ? 276 : 0 + Layout.fillHeight: true + color: root.theme.chrome + border.width: visible ? 1 : 0 + border.color: root.theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Label { Layout.fillWidth: true; text: "INSPECTOR"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Rectangle { + visible: root.backend && root.backend.selectedValue.reference !== undefined + implicitWidth: accessLabel.implicitWidth + 14 + implicitHeight: 18 + radius: 9 + color: root.backend && root.backend.selectedValue.writable === true ? root.theme.accentSoft : root.theme.surfaceRaised + Label { + id: accessLabel + anchors.centerIn: parent + text: root.backend && root.backend.selectedValue.writable === true ? "WRITABLE" : "READ ONLY" + color: root.backend && root.backend.selectedValue.writable === true ? root.theme.accent : root.theme.muted + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + ColumnLayout { + width: parent.width + spacing: 9 + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 12 + spacing: 4 + Label { + Layout.fillWidth: true + text: root.backend ? (root.backend.selectedValue.dataAttribute || root.backend.selectedValue.dataObject || "No value selected") : "No value selected" + color: root.theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: root.backend ? (root.backend.selectedValue.reference || "Select a data attribute to inspect it.") : "" + color: root.theme.muted + font.pixelSize: 9 + wrapMode: Text.WrapAnywhere + } + } + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.preferredHeight: 76 + radius: 7 + color: root.theme.surface + border.width: 1 + border.color: root.theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 2 + Label { text: "CURRENT VALUE"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { + Layout.fillWidth: true + text: root.backend && root.backend.selectedValue.value !== undefined ? String(root.backend.selectedValue.value) : "—" + color: root.backend && root.backend.selectedValue.writable === true && root.backend.running ? root.theme.accent : root.theme.text + font.pixelSize: 20 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 5 + MetaRow { title: "FC"; value: root.backend && root.backend.selectedValue.fc ? String(root.backend.selectedValue.fc) : "" } + MetaRow { title: "Type"; value: root.backend && root.backend.selectedValue.type ? String(root.backend.selectedValue.type) : "" } + MetaRow { title: "Quality"; value: root.backend && root.backend.selectedValue.quality ? String(root.backend.selectedValue.quality) : "" } + MetaRow { title: "Unit"; value: root.backend && root.backend.selectedValue.unit ? String(root.backend.selectedValue.unit) : "" } + MetaRow { title: "Origin"; value: root.backend && root.backend.selectedValue.origin ? String(root.backend.selectedValue.origin) : "" } + MetaRow { title: "Updated"; value: root.backend && root.backend.selectedValue.updated ? String(root.backend.selectedValue.updated) : "" } + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: root.theme.lineSoft } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.bottomMargin: 12 + spacing: 5 + Label { text: "MODEL SUMMARY"; color: root.theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + MetaRow { title: "Endpoint"; value: root.backend && root.backend.imported ? (root.backend.listenAddress + ":" + root.backend.port) : "" } + MetaRow { title: "DataSets"; value: root.backend && root.backend.imported ? String(root.backend.dataSetCount) : "" } + MetaRow { title: "Reports"; value: root.backend && root.backend.imported ? String(root.backend.reportCount) : "" } + MetaRow { title: "GOOSE"; value: root.backend && root.backend.imported ? String(root.backend.gooseCount) : "" } + MetaRow { title: "Setting LDs"; value: String(settingGroupCatalog.itemCount) } + Label { + Layout.fillWidth: true + text: "Use the top Set value command or Ctrl+E for writable simulator points." + color: root.theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + } + } + } + } + } + } + + Component.onCompleted: refreshService() +} diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 298151c44..1da5e88fd 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -7,70 +7,436 @@ import ARStack.IedSimulator 1.0 ApplicationWindow { id: root - width: 1480 - height: 1000 - minimumWidth: 1180 - minimumHeight: 820 + width: 1360 + height: 860 + minimumWidth: 1024 + minimumHeight: 680 visible: true - title: "ARStack IED Simulator" + title: "ARStack IEC 61850 Workbench" color: appTheme.background font.family: interFont.status === FontLoader.Ready ? interFont.name : "Segoe UI" + property int workspaceIndex: 0 + property bool persistenceReady: false + property bool mmsWasConnected: false + property bool simulatorAutoSelectPending: true + + onWorkspaceIndexChanged: { + if (root.persistenceReady) hardening.workspaceIndex = root.workspaceIndex + } + AppTheme { id: appTheme } - IedSimulatorController { + ProductHardeningController { + id: hardening + objectName: "productHardeningBackend" + } + IedFleetController { id: simulator objectName: "simulatorBackend" } + MmsClientController { + id: mmsClient + objectName: "mmsClientBackend" + } + MmsReportController { + id: reports + objectName: "mmsReportBackend" + } + MmsFileSettingsController { + id: utilities + objectName: "mmsFileSettingsBackend" + } + SclWorkspaceController { + id: sclWorkspace + objectName: "sclWorkspaceBackend" + } + GooseMonitorController { + id: gooseMonitor + objectName: "gooseMonitorBackend" + } + + Component.onCompleted: { + if (hardening.lastHost.length > 0) { + mmsClient.host = hardening.lastHost + mmsClient.port = hardening.lastPort + } + root.workspaceIndex = hardening.workspaceIndex + root.mmsWasConnected = mmsClient.connected + root.persistenceReady = true + } + + Connections { + target: mmsClient + function onStateChanged() { + if (mmsClient.connected && !root.mmsWasConnected) + hardening.rememberEndpoint(mmsClient.host, mmsClient.port) + root.mmsWasConnected = mmsClient.connected + } + } + + Connections { + target: sclWorkspace + function onWorkspaceChanged() { + // Arm the SCL-assisted online path only after the bounded parser has + // accepted the engineering file. Failed/unfinished imports never + // become a trusted connection source. + mmsClient.trustedSclPath = sclWorkspace.loaded ? sclWorkspace.sourcePath : "" + } + } + + Connections { + target: simulator + function onModelChanged() { + if (root.simulatorAutoSelectPending && simulator.imported) { + root.workspaceIndex = 5 + root.simulatorAutoSelectPending = false + } + } + } FontLoader { id: interFont source: "qrc:/iedsim/assets/InterVariable.ttf" } - property bool appendImport: false - FileDialog { id: sclDialog - title: appendImport ? "Add an IED engineering file" : "Import an engineering file" + title: "Open IEC 61850 engineering model" nameFilters: ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] - onAccepted: appendImport ? simulator.addFile(selectedFile) : simulator.loadFile(selectedFile) + onAccepted: { + simulator.loadFileAsync(selectedFile) + sclWorkspace.openFile(selectedFile) + } } - FolderDialog { - id: folderDialog - title: "Choose the file-service folder" - onAccepted: simulator.fileFolder = selectedFolder.toString().replace("file:///", "") + function importModel() { sclDialog.open() } + + Shortcut { sequence: "Ctrl+1"; onActivated: root.workspaceIndex = 0 } + Shortcut { sequence: "Ctrl+2"; onActivated: root.workspaceIndex = 1 } + Shortcut { sequence: "Ctrl+3"; onActivated: root.workspaceIndex = 2 } + Shortcut { sequence: "Ctrl+4"; onActivated: root.workspaceIndex = 3 } + Shortcut { sequence: "Ctrl+5"; onActivated: root.workspaceIndex = 4 } + Shortcut { sequence: "Ctrl+6"; onActivated: root.workspaceIndex = 5 } + Shortcut { sequence: "Ctrl+7"; onActivated: root.workspaceIndex = 6 } + Shortcut { + sequence: "Ctrl+Shift+A" + enabled: root.workspaceIndex === 5 + onActivated: activityMonitor.opened ? activityMonitor.close() : activityMonitor.open() + } + Shortcut { + sequence: "Ctrl+Shift+C" + enabled: root.workspaceIndex === 5 && simulator.imported + onActivated: commissioningWorkspace.opened ? commissioningWorkspace.close() : commissioningWorkspace.open() } - function importModel(append) { - appendImport = append === true - sclDialog.open() + component WorkspaceButton: Button { + id: control + required property int workspace + implicitHeight: 28 + implicitWidth: Math.max(96, label.implicitWidth + 20) + checkable: true + checked: root.workspaceIndex === workspace + onClicked: root.workspaceIndex = workspace + contentItem: Label { + id: label + text: control.text + color: control.checked ? "#ffffff" : appTheme.navigationMuted + font.pixelSize: 9 + font.weight: control.checked ? Font.DemiBold : Font.Normal + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: control.checked ? appTheme.accent : control.hovered ? "#263833" : "transparent" + border.width: 0 + } } - Loader { + ColumnLayout { anchors.fill: parent - sourceComponent: simulator.running ? runtimeComponent : launchComponent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 38 + color: appTheme.navigationDark + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 4 + + Image { + source: "qrc:/iedsim/assets/app-icon.png" + sourceSize.width: 22 + sourceSize.height: 22 + Layout.preferredWidth: 22 + Layout.preferredHeight: 22 + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + Layout.rightMargin: 3 + } + + Label { + text: "ARStack IEC 61850" + color: appTheme.navigationText + font.pixelSize: 11 + font.weight: Font.DemiBold + Layout.rightMargin: 8 + } + + WorkspaceButton { workspace: 0; text: "IED Connection" } + WorkspaceButton { workspace: 1; text: "Reports" } + WorkspaceButton { workspace: 2; text: "Files" } + WorkspaceButton { workspace: 3; text: "Settings" } + WorkspaceButton { workspace: 4; text: "SCL" } + WorkspaceButton { workspace: 5; text: "Simulator" } + WorkspaceButton { workspace: 6; text: "GOOSE" } + Item { Layout.fillWidth: true } + + ComboBox { + id: recentConnectionPicker + visible: root.workspaceIndex === 0 && hardening.recentEndpoints.length > 0 + Layout.preferredWidth: 160 + model: hardening.recentEndpoints + enabled: !mmsClient.connected && !mmsClient.busy + onActivated: { + const recentHost = hardening.recentHost(currentIndex) + const recentPort = hardening.recentPort(currentIndex) + if (recentHost.length > 0 && recentPort > 0) { + mmsClient.host = recentHost + mmsClient.port = recentPort + } + } + ToolTip.visible: hovered + ToolTip.text: "Recent MMS endpoints · restored without auto-connect" + } + + Label { + visible: !hardening.settingsHealthy + text: "STATE RESET" + color: appTheme.amber + font.pixelSize: 8 + font.weight: Font.Bold + ToolTip.visible: stateResetMouse.containsMouse + ToolTip.text: hardening.settingsStatus + MouseArea { id: stateResetMouse; anchors.fill: parent; hoverEnabled: true } + } + + Label { + visible: hardening.npcapRequired && !hardening.npcapAvailable + text: "NPCAP REQUIRED" + color: appTheme.amber + font.pixelSize: 8 + font.weight: Font.Bold + ToolTip.visible: npcapMouse.containsMouse + ToolTip.text: hardening.npcapStatus + MouseArea { id: npcapMouse; anchors.fill: parent; hoverEnabled: true } + } + + Label { + text: root.workspaceIndex === 0 + ? mmsClient.stateText + : root.workspaceIndex === 1 + ? reports.stateText + : (root.workspaceIndex === 2 || root.workspaceIndex === 3) + ? utilities.stateText + : root.workspaceIndex === 4 + ? sclWorkspace.stateText + : root.workspaceIndex === 6 + ? (gooseMonitor.capturing ? "GOOSE MONITOR LIVE" : "GOOSE") + : (simulator.running ? "SIMULATOR LIVE" : "SIMULATOR") + color: root.workspaceIndex === 0 && mmsClient.connected + ? "#9ff0c1" + : root.workspaceIndex === 1 && reports.active + ? "#9ff0c1" + : root.workspaceIndex === 1 && reports.cleanupRequired + ? "#ff9ca5" + : (root.workspaceIndex === 2 || root.workspaceIndex === 3) && utilities.connected + ? "#9ff0c1" + : root.workspaceIndex === 4 && sclWorkspace.loaded && !sclWorkspace.lastError.length + ? "#9ff0c1" + : root.workspaceIndex === 6 && gooseMonitor.capturing + ? "#9ff0c1" : appTheme.navigationMuted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: root.workspaceIndex + + MmsClientWorkspace { + theme: appTheme + client: mmsClient + } + + ReportsWorkspace { + theme: appTheme + reports: reports + } + + FilesWorkspace { + theme: appTheme + files: utilities + } + + SettingsWorkspace { + theme: appTheme + settings: utilities + } + + SclWorkspace { + theme: appTheme + workspace: sclWorkspace + } + + Item { + Loader { + id: simulatorWorkspaceLoader + anchors.fill: parent + source: "qrc:/iedsim/InteropWorkspaceV2.qml" + onLoaded: { + item.theme = appTheme + item.backend = simulator + } + } + Connections { + target: simulatorWorkspaceLoader.item + function onOpenSclRequested() { root.importModel() } + } + } + + GooseWorkspace { + theme: appTheme + simulator: simulator + monitor: gooseMonitor + onOpenSimulatorRequested: root.workspaceIndex = 5 + } + } } - Component { - id: launchComponent - LaunchWorkspace { + Rectangle { + id: commissioningLauncher + z: 20 + visible: root.workspaceIndex === 5 && simulator.imported && !commissioningWorkspace.opened + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 12 + anchors.bottomMargin: 72 + width: 142 + height: 27 + radius: 5 + color: commissioningMouse.containsMouse ? appTheme.surfaceRaised : appTheme.statusChrome + border.width: 1 + border.color: appTheme.line + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 8 + spacing: 6 + Rectangle { + width: 6 + height: 6 + radius: 3 + color: simulator.reportCount > 0 || simulator.gooseCount > 0 ? appTheme.green : appTheme.accent + } + Label { + Layout.fillWidth: true + text: "Commissioning" + color: appTheme.statusText + font.pixelSize: 9 + font.weight: Font.DemiBold + } + Label { + text: String(simulator.dataSetCount + simulator.reportCount + simulator.gooseCount) + color: appTheme.navigationMuted + font.pixelSize: 8 + } + } + + MouseArea { + id: commissioningMouse anchors.fill: parent - theme: appTheme - backend: simulator - onImportRequested: root.importModel(false) - onFolderRequested: folderDialog.open() + hoverEnabled: true + onClicked: commissioningWorkspace.open() } + + ToolTip.visible: commissioningMouse.containsMouse + ToolTip.text: "Commissioning Explorer · Ctrl+Shift+C" } - Component { - id: runtimeComponent - RuntimeWorkspace { + Rectangle { + id: activityLauncher + z: 20 + visible: root.workspaceIndex === 5 && !activityMonitor.opened + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 12 + anchors.bottomMargin: 38 + width: 116 + height: 27 + radius: 5 + color: launcherMouse.containsMouse ? appTheme.surfaceRaised : appTheme.statusChrome + border.width: 1 + border.color: appTheme.line + + RowLayout { anchors.fill: parent - theme: appTheme - backend: simulator - onAddIedRequested: root.importModel(true) - onFolderRequested: folderDialog.open() + anchors.leftMargin: 9 + anchors.rightMargin: 8 + spacing: 6 + Rectangle { + width: 6 + height: 6 + radius: 3 + color: simulator.running ? appTheme.green + : simulator.fatalError.length ? appTheme.red + : appTheme.accent + } + Label { + Layout.fillWidth: true + text: "Activity" + color: appTheme.statusText + font.pixelSize: 9 + font.weight: Font.DemiBold + } + Label { + text: String(simulator.activityModel.retainedCount) + color: appTheme.navigationMuted + font.pixelSize: 8 + } } + + MouseArea { + id: launcherMouse + anchors.fill: parent + hoverEnabled: true + onClicked: activityMonitor.open() + } + + ToolTip.visible: launcherMouse.containsMouse + ToolTip.text: "Activity Monitor · Ctrl+Shift+A" + } + + CommissioningWorkspace { + id: commissioningWorkspace + z: 45 + theme: appTheme + backend: simulator + } + + ActivityMonitor { + id: activityMonitor + z: 50 + theme: appTheme + backend: simulator } -} +} \ No newline at end of file diff --git a/apps/ied_simulator/qml/MmsClientWorkspace.qml b/apps/ied_simulator/qml/MmsClientWorkspace.qml new file mode 100644 index 000000000..839d39a78 --- /dev/null +++ b/apps/ied_simulator/qml/MmsClientWorkspace.qml @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.IedSimulator 1.0 + +Item { + id: root + required property var theme + required property var client + + property var selected: client.treeModel.selectedNode + property string pendingWrite: "" + + function text(value) { + return value === undefined || value === null || String(value).length === 0 ? "—" : String(value) + } + + function sclHealthLabel() { + if (client.trustedSclHealth === "matched") return "SCL MATCHED" + if (client.trustedSclHealth === "degraded") return "SCL DEGRADED" + if (client.trustedSclHealth === "incompatible") return "SCL INCOMPATIBLE" + return "" + } + + function sclHealthColor() { + if (client.trustedSclHealth === "matched") return theme.green + if (client.trustedSclHealth === "degraded") return theme.amber + if (client.trustedSclHealth === "incompatible") return theme.red + return theme.muted + } + + function firstVisibleRow() { + var index = tree.indexAt(4, tree.contentY + 4) + return index >= 0 ? index : 0 + } + + function lastVisibleRow() { + var index = tree.indexAt(4, tree.contentY + tree.height - 4) + return index >= 0 ? index : Math.max(0, tree.count - 1) + } + + Rectangle { anchors.fill: parent; color: theme.background } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 54 + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 8 + + TextField { + id: hostField + Layout.preferredWidth: 190 + text: client.host + placeholderText: "IED IP / hostname" + selectByMouse: true + enabled: !client.connected && !client.busy + onEditingFinished: client.host = text + } + SpinBox { + id: portField + Layout.preferredWidth: 100 + from: 1 + to: 65535 + value: client.port + editable: true + enabled: !client.connected && !client.busy + onValueModified: client.port = value + } + Button { + text: client.connected ? "Reconnect" : "Connect" + enabled: !client.busy + onClicked: { + client.host = hostField.text + client.port = portField.value + if (client.connected) client.reconnect() + else client.connectToIed() + } + } + Button { + text: "Disconnect" + enabled: client.connected || client.busy + onClicked: client.disconnectFromIed() + } + + Rectangle { + width: 8; height: 8; radius: 4 + color: client.trustedSclIncompatible ? theme.red + : client.trustedSclDegraded ? theme.amber + : client.connected ? theme.green + : client.busy ? theme.amber + : client.lastError.length ? theme.red : theme.muted + } + Label { + text: client.stateText + color: theme.textSoft + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + Rectangle { + visible: client.trustedSclHealth.length > 0 + implicitWidth: healthLabel.implicitWidth + 14 + implicitHeight: 20 + radius: 10 + color: Qt.rgba(root.sclHealthColor().r, + root.sclHealthColor().g, + root.sclHealthColor().b, + 0.12) + border.width: 1 + border.color: root.sclHealthColor() + Label { + id: healthLabel + anchors.centerIn: parent + text: root.sclHealthLabel() + color: root.sclHealthColor() + font.pixelSize: 8 + font.weight: Font.DemiBold + } + } + + Item { Layout.fillWidth: true } + + ColumnLayout { + visible: client.connected + spacing: 0 + Label { + Layout.alignment: Qt.AlignRight + text: client.iedName.length ? client.iedName : client.endpoint + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + Layout.alignment: Qt.AlignRight + text: client.logicalDeviceCount + " LD · " + client.logicalNodeCount + " LN · " + client.dataAttributeCount + " DA" + color: theme.muted + font.pixelSize: 8 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Rectangle { + Layout.preferredWidth: Math.max(390, root.width * 0.44) + Layout.fillHeight: true + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 42 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + spacing: 6 + TextField { + id: searchField + Layout.fillWidth: true + placeholderText: "Search reference, FC, type or value" + enabled: client.connected + onTextChanged: searchDebounce.restart() + } + Button { + text: "Refresh visible" + enabled: client.connected && !client.operationBusy && tree.count > 0 + onClicked: client.refreshVisible(root.firstVisibleRow(), root.lastVisibleRow()) + } + Timer { + id: searchDebounce + interval: 100 + repeat: false + onTriggered: client.treeModel.filterText = searchField.text + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + ListView { + id: tree + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + cacheBuffer: 0 + model: client.treeModel + ScrollBar.vertical: ScrollBar { } + + delegate: Rectangle { + id: row + width: ListView.view.width + height: 30 + color: model.selected ? theme.accentSoft + : mouse.containsMouse ? theme.surfaceRaised + : "transparent" + border.width: model.selected ? 1 : 0 + border.color: theme.accent + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + model.depth * 14 + anchors.rightMargin: 10 + spacing: 5 + Label { + Layout.preferredWidth: 12 + text: model.hasChildren ? (model.expanded ? "▾" : "▸") : "" + color: theme.muted + font.pixelSize: 10 + } + Label { + Layout.preferredWidth: 24 + text: model.kind + color: model.kind === "DA" ? theme.accent : theme.muted + font.pixelSize: 7 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: model.label + color: theme.textSoft + font.pixelSize: 9 + elide: Text.ElideMiddle + } + Label { + visible: model.functionalConstraint.length > 0 + text: model.functionalConstraint + color: theme.muted + font.pixelSize: 8 + } + Label { + visible: model.value.length > 0 + Layout.maximumWidth: 116 + text: model.value + color: theme.text + font.pixelSize: 8 + elide: Text.ElideRight + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + onClicked: function(event) { + client.treeModel.selectRow(index) + if (model.hasChildren && event.x < 38 + model.depth * 14) client.treeModel.toggle(index) + } + onDoubleClicked: { + if (model.hasChildren) client.treeModel.toggle(index) + else client.readSelected() + } + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: theme.background + + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + ColumnLayout { + width: Math.max(0, parent.width - 36) + x: 18 + spacing: 10 + + Label { + text: root.text(root.selected.kind) + " " + root.text(root.selected.label) + color: theme.text + font.pixelSize: 15 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: root.text(root.selected.reference) + color: theme.muted + font.pixelSize: 9 + wrapMode: Text.WrapAnywhere + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 16 + rowSpacing: 8 + Label { text: "MMS item"; color: theme.muted; font.pixelSize: 9 } + Label { Layout.fillWidth: true; text: root.text(root.selected.mmsDomain) + " / " + root.text(root.selected.mmsItem); color: theme.textSoft; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { text: "FC"; color: theme.muted; font.pixelSize: 9 } + Label { text: root.text(root.selected.functionalConstraint); color: theme.textSoft; font.pixelSize: 9 } + Label { text: "MMS type"; color: theme.muted; font.pixelSize: 9 } + Label { text: root.text(root.selected.mmsType); color: theme.textSoft; font.pixelSize: 9 } + Label { text: "SCL type"; color: theme.muted; font.pixelSize: 9 } + Label { text: root.text(root.selected.sclType); color: theme.textSoft; font.pixelSize: 9 } + Label { text: "Type evidence"; color: theme.muted; font.pixelSize: 9 } + Label { text: root.text(root.selected.typeStatus); color: root.selected.typeStatus === "Exact" ? theme.green : theme.amber; font.pixelSize: 9 } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 16 + rowSpacing: 8 + Label { text: "Value"; color: theme.muted; font.pixelSize: 9 } + Label { text: root.text(root.selected.value); color: theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + Label { text: "Quality"; color: theme.muted; font.pixelSize: 9 } + Label { text: root.text(root.selected.quality); color: theme.textSoft; font.pixelSize: 9 } + Label { text: "Timestamp"; color: theme.muted; font.pixelSize: 9 } + Label { text: root.text(root.selected.timestamp); color: theme.textSoft; font.pixelSize: 9 } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Button { + text: client.operationBusy ? "Reading…" : "Read" + enabled: client.connected && !client.operationBusy && root.selected.readable === true + onClicked: client.readSelected() + } + TextField { + id: writeValue + Layout.fillWidth: true + placeholderText: root.selected.writable === true ? "New scalar value" : "Read-only / unsupported Write type" + enabled: client.connected && !client.operationBusy && root.selected.writable === true + onTextChanged: root.pendingWrite = text + } + Button { + text: "Write…" + enabled: writeValue.enabled && writeValue.text.length > 0 + onClicked: writeConfirm.open() + } + } + + Label { + Layout.fillWidth: true + visible: root.selected.writable !== true && root.selected.kind === "DA" + text: "Write is fail-closed: only exact scalar MMS types in FC SP/CF/DC/SE are enabled. ST/MX/CO and unknown/structured types are never written through this generic path." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + Label { + text: "SESSION" + color: theme.muted + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Label { text: client.endpoint; color: theme.textSoft; font.pixelSize: 9 } + Label { text: "Association: " + root.text(client.associationProfile); color: theme.muted; font.pixelSize: 8 } + Label { + visible: client.trustedSclHealth.length > 0 + text: "Trusted SCL health: " + client.trustedSclHealth.toUpperCase() + color: root.sclHealthColor() + font.pixelSize: 8 + font.weight: Font.DemiBold + } + Label { Layout.fillWidth: true; text: root.text(client.modelSummary); color: theme.muted; font.pixelSize: 8; wrapMode: Text.WordWrap } + Label { + Layout.fillWidth: true + text: client.lastError.length ? client.lastError : client.lastDiagnostic + color: client.lastError.length ? theme.red : theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + Item { Layout.fillHeight: true } + } + } + } + } + } + + Dialog { + id: writeConfirm + title: "Confirm guarded MMS Write" + modal: true + anchors.centerIn: Overlay.overlay + width: Math.min(480, Math.max(320, root.width - 32)) + implicitWidth: 480 + standardButtons: Dialog.Ok | Dialog.Cancel + onAccepted: client.writeSelected(root.pendingWrite) + contentItem: ColumnLayout { + spacing: 8 + Label { Layout.fillWidth: true; text: root.text(root.selected.reference); color: theme.text; font.pixelSize: 10; wrapMode: Text.WrapAnywhere } + Label { Layout.fillWidth: true; text: "FC " + root.text(root.selected.functionalConstraint) + " · " + root.text(root.selected.mmsType); color: theme.muted; font.pixelSize: 9 } + Label { Layout.fillWidth: true; text: "Write value: " + root.pendingWrite; color: theme.textSoft; font.pixelSize: 10 } + Label { Layout.fillWidth: true; text: "No automatic retry is performed."; color: theme.muted; font.pixelSize: 8 } + } + } +} \ No newline at end of file diff --git a/apps/ied_simulator/qml/ReportsWorkspace.qml b/apps/ied_simulator/qml/ReportsWorkspace.qml new file mode 100644 index 000000000..ec1c46a94 --- /dev/null +++ b/apps/ied_simulator/qml/ReportsWorkspace.qml @@ -0,0 +1,627 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.IedSimulator 1.0 + +Item { + id: root + required property var theme + required property var reports + + function text(value) { + return value === undefined || value === null || String(value).length === 0 ? "—" : String(value) + } + + Rectangle { anchors.fill: parent; color: theme.background } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 54 + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 8 + + TextField { + id: hostField + Layout.preferredWidth: 190 + text: reports.host + placeholderText: "IED IP / hostname" + selectByMouse: true + enabled: !reports.connected && !reports.busy + onEditingFinished: reports.host = text + } + SpinBox { + id: portField + Layout.preferredWidth: 100 + from: 1 + to: 65535 + value: reports.port + editable: true + enabled: !reports.connected && !reports.busy + onValueModified: reports.port = value + } + Button { + text: reports.connected ? "Reconnect" : "Connect" + enabled: !reports.busy + onClicked: { + reports.host = hostField.text + reports.port = portField.value + if (reports.connected) reports.reconnect() + else reports.connectToIed() + } + } + Button { + text: "Disconnect" + enabled: reports.connected || reports.busy + onClicked: reports.disconnectFromIed() + } + + Rectangle { + width: 8; height: 8; radius: 4 + color: reports.active ? theme.green + : reports.cleanupRequired ? theme.red + : reports.busy ? theme.amber + : reports.connected ? theme.accent + : reports.lastError.length ? theme.red : theme.muted + } + Label { + text: reports.stateText + color: reports.cleanupRequired ? theme.red : theme.textSoft + font.pixelSize: 10 + font.weight: Font.DemiBold + } + + Item { Layout.fillWidth: true } + + ColumnLayout { + spacing: 0 + visible: reports.connected + Label { + Layout.alignment: Qt.AlignRight + text: reports.reportControls.length + " RCB · " + reports.dataSets.length + " DataSet" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + Layout.alignment: Qt.AlignRight + text: reports.associationProfile.length ? reports.associationProfile : reports.host + ":" + reports.port + color: theme.muted + font.pixelSize: 8 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Rectangle { + Layout.preferredWidth: Math.max(310, root.width * 0.26) + Layout.fillHeight: true + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Label { + Layout.fillWidth: true + Layout.preferredHeight: 34 + Layout.leftMargin: 10 + verticalAlignment: Text.AlignVCenter + text: "URCB / BRCB inventory" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + ListView { + id: rcbList + Layout.fillWidth: true + Layout.preferredHeight: Math.max(150, root.height * 0.27) + clip: true + reuseItems: true + cacheBuffer: 0 + model: reports.reportControls + ScrollBar.vertical: ScrollBar { } + delegate: Rectangle { + required property int index + required property var modelData + width: ListView.view.width + height: 48 + color: reports.selectedRcbIndex === index ? theme.accentSoft + : mouse.containsMouse ? theme.surfaceRaised : "transparent" + border.width: reports.selectedRcbIndex === index ? 1 : 0 + border.color: theme.accent + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 8 + anchors.topMargin: 5 + anchors.bottomMargin: 5 + spacing: 1 + RowLayout { + Layout.fillWidth: true + Label { + text: root.text(modelData.mode) + color: modelData.buffered ? theme.amber : theme.accent + font.pixelSize: 8 + font.weight: Font.Bold + } + Label { + Layout.fillWidth: true + text: root.text(modelData.reference) + color: theme.text + font.pixelSize: 9 + elide: Text.ElideMiddle + } + Label { + text: root.text(modelData.availability) + color: modelData.availability === "available" ? theme.green : theme.muted + font.pixelSize: 7 + } + } + Label { + Layout.fillWidth: true + text: root.text(modelData.dataSet) + color: theme.muted + font.pixelSize: 8 + elide: Text.ElideMiddle + } + } + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + onClicked: reports.selectRcb(index) + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + Label { + Layout.fillWidth: true + Layout.preferredHeight: 32 + Layout.leftMargin: 10 + verticalAlignment: Text.AlignVCenter + text: "DataSets" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: Math.max(100, root.height * 0.18) + clip: true + reuseItems: true + cacheBuffer: 0 + model: reports.dataSets + ScrollBar.vertical: ScrollBar { } + delegate: Rectangle { + required property int index + required property var modelData + width: ListView.view.width + height: 38 + color: reports.selectedDataSetIndex === index ? theme.accentSoft + : dsMouse.containsMouse ? theme.surfaceRaised : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 8 + spacing: 5 + Label { + Layout.fillWidth: true + text: root.text(modelData.reference) + color: theme.textSoft + font.pixelSize: 8 + elide: Text.ElideMiddle + } + Label { + text: modelData.memberCount + " members" + color: theme.muted + font.pixelSize: 7 + } + } + MouseArea { + id: dsMouse + anchors.fill: parent + hoverEnabled: true + onClicked: reports.selectDataSet(index) + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + + Label { + Layout.fillWidth: true + Layout.preferredHeight: 30 + Layout.leftMargin: 10 + verticalAlignment: Text.AlignVCenter + text: "Ordered members" + color: theme.text + font.pixelSize: 9 + font.weight: Font.DemiBold + } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + cacheBuffer: 0 + model: reports.selectedDataSetMembers + ScrollBar.vertical: ScrollBar { } + delegate: Label { + required property int index + required property string modelData + width: ListView.view.width + height: 28 + leftPadding: 10 + rightPadding: 8 + verticalAlignment: Text.AlignVCenter + text: (index + 1) + ". " + modelData + color: theme.textSoft + font.pixelSize: 8 + elide: Text.ElideMiddle + } + } + } + } + + Rectangle { + Layout.preferredWidth: Math.max(390, root.width * 0.34) + Layout.fillHeight: true + color: theme.background + border.width: 1 + border.color: theme.lineSoft + + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + ColumnLayout { + width: Math.max(0, parent.width - 30) + x: 15 + spacing: 9 + + Item { Layout.preferredHeight: 4 } + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: root.text(reports.selectedRcb.mode) + " " + root.text(reports.selectedRcb.reference) + color: theme.text + font.pixelSize: 14 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Rectangle { + width: 8; height: 8; radius: 4 + color: reports.active ? theme.green : reports.cleanupRequired ? theme.red : theme.muted + } + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 10 + rowSpacing: 5 + Repeater { + model: [ + ["DataSet", reports.selectedRcb.dataSet], + ["RptID", reports.selectedRcb.reportId], + ["ConfRev", reports.selectedRcb.confRev], + ["BufTm", reports.selectedRcb.bufTm], + ["IntgPd", reports.selectedRcb.intgPd], + ["SqNum", reports.selectedRcb.sqNum], + ["RptEna", reports.selectedRcb.enabled], + ["Reserved", reports.selectedRcb.reserved], + ["ResvTms", reports.selectedRcb.resvTms], + ["Owner", reports.selectedRcb.owner] + ] + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: 46 + radius: 5 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 7 + spacing: 1 + Label { text: modelData[0]; color: theme.muted; font.pixelSize: 7 } + Label { + Layout.fillWidth: true + text: root.text(modelData[1]) + color: theme.text + font.pixelSize: 8 + elide: Text.ElideMiddle + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: detailsColumn.implicitHeight + 16 + radius: 6 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + ColumnLayout { + id: detailsColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 4 + Label { text: "Trigger options"; color: theme.muted; font.pixelSize: 7 } + Label { Layout.fillWidth: true; text: root.text(reports.selectedRcb.triggerOptions); color: theme.textSoft; font.pixelSize: 8; wrapMode: Text.Wrap } + Label { text: "Optional fields"; color: theme.muted; font.pixelSize: 7 } + Label { Layout.fillWidth: true; text: root.text(reports.selectedRcb.optionalFields); color: theme.textSoft; font.pixelSize: 8; wrapMode: Text.Wrap } + Label { text: "EntryID"; color: theme.muted; font.pixelSize: 7 } + Label { Layout.fillWidth: true; text: root.text(reports.selectedRcb.entryId); color: theme.textSoft; font.pixelSize: 8; elide: Text.ElideMiddle } + Label { + Layout.fillWidth: true + text: "BRCB capability: " + + (reports.selectedRcb.supportsEntryId ? "EntryID" : "no EntryID") + + " · " + (reports.selectedRcb.supportsPurgeBuf ? "PurgeBuf" : "no PurgeBuf") + color: theme.muted + font.pixelSize: 7 + } + } + } + + RowLayout { + Layout.fillWidth: true + Button { + text: "Enable + GI" + enabled: reports.connected && !reports.busy && !reports.active && !reports.cleanupRequired && reports.selectedRcb.reference !== undefined + onClicked: reports.enableSelected(true) + } + Button { + text: "Enable" + enabled: reports.connected && !reports.busy && !reports.active && !reports.cleanupRequired && reports.selectedRcb.reference !== undefined + onClicked: reports.enableSelected(false) + } + Button { + text: "Disable / Release" + enabled: reports.active && !reports.busy + onClicked: reports.disableSelected() + } + Button { + text: "Retry cleanup" + visible: reports.cleanupRequired + enabled: !reports.busy + onClicked: reports.retryCleanup() + } + } + + Rectangle { + visible: reports.cleanupRequired || reports.lastError.length > 0 + Layout.fillWidth: true + implicitHeight: warningText.implicitHeight + 16 + radius: 5 + color: theme.redSoft + border.width: 1 + border.color: theme.red + Label { + id: warningText + anchors.fill: parent + anchors.margins: 8 + text: reports.cleanupRequired + ? "Cleanup is not confirmed. Do not assume the RCB is free; retry only on the same live association or verify the IED explicitly." + : reports.lastError + color: theme.red + font.pixelSize: 8 + wrapMode: Text.Wrap + } + } + + Label { + text: "Candidate paths" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: "Static candidates: " + reports.staticCandidates.length + + " · Dynamic candidates: " + reports.dynamicCandidates.length + color: theme.textSoft + font.pixelSize: 8 + } + Repeater { + model: reports.staticCandidates.slice(0, 6) + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: pathText.implicitHeight + 12 + radius: 4 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + Label { + id: pathText + anchors.fill: parent + anchors.margins: 6 + text: "STATIC · " + root.text(modelData.reference) + " · " + root.text(modelData.decision) + + "\n" + root.text(modelData.reason) + color: theme.textSoft + font.pixelSize: 7 + wrapMode: Text.Wrap + } + } + } + Repeater { + model: reports.dynamicCandidates.slice(0, 4) + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: dynamicPathText.implicitHeight + 12 + radius: 4 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + Label { + id: dynamicPathText + anchors.fill: parent + anchors.margins: 6 + text: "DYNAMIC · " + root.text(modelData.reference) + " · " + root.text(modelData.decision) + + "\n" + root.text(modelData.recommendedAction) + color: theme.textSoft + font.pixelSize: 7 + wrapMode: Text.Wrap + } + } + } + Item { Layout.preferredHeight: 10 } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 38 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Label { + Layout.fillWidth: true + text: "Received reports" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + text: String(reports.receivedReportCount) + color: reports.receivedReportCount > 0 ? theme.green : theme.muted + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + cacheBuffer: 0 + model: reports.receivedReports + ScrollBar.vertical: ScrollBar { } + delegate: Rectangle { + required property var modelData + width: ListView.view.width + height: 78 + color: modelData.overflow ? theme.redSoft : "transparent" + border.width: 0 + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 9 + anchors.topMargin: 5 + anchors.bottomMargin: 5 + spacing: 2 + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: root.text(modelData.reportId) + color: theme.text + font.pixelSize: 8 + font.weight: Font.DemiBold + elide: Text.ElideMiddle + } + Label { text: "sq " + root.text(modelData.sequence); color: theme.muted; font.pixelSize: 7 } + Label { visible: modelData.overflow; text: "OVERFLOW"; color: theme.red; font.pixelSize: 7; font.weight: Font.Bold } + } + Label { + Layout.fillWidth: true + text: root.text(modelData.dataSet) + " · EntryID " + root.text(modelData.entryId) + color: theme.muted + font.pixelSize: 7 + elide: Text.ElideMiddle + } + Label { + Layout.fillWidth: true + text: root.text(modelData.valuesSummary) + color: theme.textSoft + font.pixelSize: 7 + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: "duplicate " + modelData.duplicateCount + " · gap " + modelData.gapCount + + " · reset " + modelData.resetCount + " · overflow " + modelData.overflowCount + color: theme.muted + font.pixelSize: 7 + } + } + } + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + Label { + Layout.fillWidth: true + Layout.preferredHeight: 30 + Layout.leftMargin: 10 + verticalAlignment: Text.AlignVCenter + text: "Subscription events" + color: theme.text + font.pixelSize: 9 + font.weight: Font.DemiBold + } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: Math.max(100, root.height * 0.23) + clip: true + reuseItems: true + cacheBuffer: 0 + model: reports.events + ScrollBar.vertical: ScrollBar { } + delegate: Label { + required property string modelData + width: ListView.view.width + height: Math.max(26, implicitHeight + 8) + leftPadding: 9 + rightPadding: 9 + topPadding: 4 + bottomPadding: 4 + text: modelData + color: theme.textSoft + font.pixelSize: 7 + wrapMode: Text.Wrap + } + } + } + } + } + } +} diff --git a/apps/ied_simulator/qml/RuntimeWorkspace.qml b/apps/ied_simulator/qml/RuntimeWorkspace.qml index 7f447fd5b..18dca01d9 100644 --- a/apps/ied_simulator/qml/RuntimeWorkspace.qml +++ b/apps/ied_simulator/qml/RuntimeWorkspace.qml @@ -7,13 +7,22 @@ Item { id: root required property var theme required property var backend + signal openSclRequested() signal addIedRequested() signal folderRequested() + property bool navigationVisible: true + property bool detailsVisible: true + property bool statusVisible: true + property string selectedLd: "" + property string selectedLn: "" + property var logicalDevices: [] + property var logicalNodes: [] + property var tableRows: [] + property var collapsedObjects: ({}) property string pendingValue: backend.selectedValue.value || "" property string pendingQuality: backend.selectedValue.quality || "Good" property string pendingOrigin: backend.selectedValue.origin || "Simulator" - property bool activityExpanded: true function severityColor(severity) { if (severity === "Error") return theme.red @@ -22,59 +31,375 @@ Item { return theme.accent } + function hasString(values, needle) { + for (var i = 0; i < values.length; ++i) { + if (values[i] === needle) return true + } + return false + } + function refreshEditor() { pendingValue = backend.selectedValue.value || "" pendingQuality = backend.selectedValue.quality || "Good" pendingOrigin = backend.selectedValue.origin || "Simulator" } + function refreshNavigation() { + var values = backend.values || [] + var devices = [] + for (var i = 0; i < values.length; ++i) { + var ld = values[i].logicalDevice || "" + if (ld.length && !hasString(devices, ld)) devices.push(ld) + } + logicalDevices = devices + if (!hasString(devices, selectedLd)) selectedLd = devices.length ? devices[0] : "" + rebuildLogicalNodes() + } + + function rebuildLogicalNodes() { + var values = backend.values || [] + var nodes = [] + for (var i = 0; i < values.length; ++i) { + if ((values[i].logicalDevice || "") !== selectedLd) continue + var ln = values[i].logicalNode || "" + if (ln.length && !hasString(nodes, ln)) nodes.push(ln) + } + logicalNodes = nodes + if (!hasString(nodes, selectedLn)) selectedLn = nodes.length ? nodes[0] : "" + rebuildTable() + } + + function rebuildTable() { + var values = backend.values || [] + var order = [] + var groups = ({}) + for (var i = 0; i < values.length; ++i) { + var point = values[i] + if ((point.logicalDevice || "") !== selectedLd || + (point.logicalNode || "") !== selectedLn) continue + var objectName = point.dataObject || "(unnamed)" + if (groups[objectName] === undefined) { + groups[objectName] = { rows: [], preferred: -1 } + order.push(objectName) + } + var attribute = point.dataAttribute || "" + if (groups[objectName].preferred < 0 || attribute === "stVal" || + attribute.endsWith(".stVal") || attribute === "mag.f") { + groups[objectName].preferred = i + } + groups[objectName].rows.push({ + kind: "DA", + name: attribute.length ? attribute : objectName, + value: point.value || "", + fc: point.fc || "", + type: point.type || "", + quality: point.quality || "", + sourceIndex: i + }) + } + + var result = [] + for (var j = 0; j < order.length; ++j) { + var name = order[j] + var group = groups[name] + var preferredIndex = group.preferred >= 0 ? group.preferred : group.rows[0].sourceIndex + var preferredPoint = values[preferredIndex] || ({}) + result.push({ + kind: "DO", + name: name, + value: preferredPoint.value || "", + fc: "", + type: preferredPoint.cdc || "", + quality: preferredPoint.quality || "", + sourceIndex: preferredIndex + }) + if (collapsedObjects[name] === true) continue + for (var k = 0; k < group.rows.length; ++k) result.push(group.rows[k]) + } + tableRows = result + } + + function chooseLogicalDevice(name) { + if (selectedLd === name) return + selectedLd = name + rebuildLogicalNodes() + } + + function chooseLogicalNode(name) { + if (selectedLn === name) return + selectedLn = name + rebuildTable() + } + + function toggleObject(name) { + var next = ({}) + for (var key in collapsedObjects) next[key] = collapsedObjects[key] + next[name] = !(collapsedObjects[name] === true) + collapsedObjects = next + rebuildTable() + } + + function activityCount(severity) { + var items = backend.activity || [] + var count = 0 + for (var i = 0; i < items.length; ++i) { + if ((items[i].severity || "Info") === severity) ++count + } + return count + } + Connections { target: backend + function onValuesChanged() { root.refreshNavigation() } function onSelectionChanged() { root.refreshEditor() } + function onModelChanged() { root.refreshNavigation() } } + Component.onCompleted: refreshNavigation() + Rectangle { anchors.fill: parent; color: theme.background } ColumnLayout { anchors.fill: parent spacing: 0 + // Compact tab strip: familiar engineering-tool navigation without + // copying vendor branding or proprietary assets. + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: theme.navigationDark + + RowLayout { + anchors.fill: parent + spacing: 0 + Repeater { + model: ["File", "Browser", "Simulator", "Diagnostics"] + delegate: Rectangle { + required property string modelData + required property int index + Layout.preferredWidth: index === 3 ? 96 : 82 + Layout.fillHeight: true + color: modelData === "Simulator" ? theme.navigation : "transparent" + border.width: modelData === "Simulator" ? 1 : 0 + border.color: "#5e9ed1" + Label { + anchors.centerIn: parent + text: modelData + color: theme.navigationText + font.pixelSize: 11 + font.weight: modelData === "Simulator" ? Font.DemiBold : Font.Normal + } + } + } + Item { Layout.fillWidth: true } + Label { + text: "ARStack IEC 61850" + color: theme.navigationMuted + font.pixelSize: 10 + Layout.rightMargin: 14 + } + } + } + + // Ribbon actions keep the primary workflow one click away: + // Open SCL -> configure endpoint -> Start -> select a leaf -> Set value. Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 66 + Layout.preferredHeight: 92 color: theme.chrome border.width: 1 border.color: theme.lineSoft RowLayout { anchors.fill: parent - anchors.leftMargin: 20 - anchors.rightMargin: 20 - spacing: 10 - Rectangle { - width: 30; height: 30; radius: 7 - color: theme.accentSoft; border.width: 1; border.color: theme.accent - Image { anchors.centerIn: parent; width: 17; height: 17; source: "qrc:/iedsim/assets/radio-tower.svg" } + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + + RowLayout { + Layout.preferredWidth: 330 + Layout.fillHeight: true + spacing: 6 + + Button { + id: openButton + Layout.preferredWidth: 78; Layout.preferredHeight: 66 + onClicked: root.openSclRequested() + contentItem: Column { + anchors.centerIn: parent; spacing: 5 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "▰"; color: theme.textSoft; font.pixelSize: 24 } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "Open SCL"; color: theme.text; font.pixelSize: 10 } + } + background: Rectangle { color: openButton.hovered ? theme.surfaceRaised : "transparent"; border.width: 0 } + } + Button { + id: startButton + Layout.preferredWidth: 70; Layout.preferredHeight: 66 + enabled: backend.imported && !backend.running && !backend.starting + onClicked: serverSettings.open() + contentItem: Column { + anchors.centerIn: parent; spacing: 5 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "▶"; color: startButton.enabled ? theme.green : theme.line; font.pixelSize: 24 } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: backend.starting ? "Starting" : "Start"; color: startButton.enabled ? theme.text : theme.muted; font.pixelSize: 10 } + } + background: Rectangle { color: startButton.hovered ? theme.surfaceRaised : "transparent"; border.width: 0 } + } + Button { + id: stopButton + Layout.preferredWidth: 70; Layout.preferredHeight: 66 + enabled: backend.running || backend.starting + onClicked: backend.stopSimulation() + contentItem: Column { + anchors.centerIn: parent; spacing: 5 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "■"; color: stopButton.enabled ? theme.red : theme.line; font.pixelSize: 21 } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "Stop"; color: stopButton.enabled ? theme.text : theme.muted; font.pixelSize: 10 } + } + background: Rectangle { color: stopButton.hovered ? theme.surfaceRaised : "transparent"; border.width: 0 } + } + Button { + id: closeButton + Layout.preferredWidth: 84; Layout.preferredHeight: 66 + enabled: backend.imported && !backend.running && !backend.starting + onClicked: backend.clear() + contentItem: Column { + anchors.centerIn: parent; spacing: 5 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "⊠"; color: closeButton.enabled ? theme.textSoft : theme.line; font.pixelSize: 23 } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "Close IED"; color: closeButton.enabled ? theme.text : theme.muted; font.pixelSize: 10 } + } + background: Rectangle { color: closeButton.hovered ? theme.surfaceRaised : "transparent"; border.width: 0 } + } } - Label { - text: "ARSTACK61850 / IED Simulator" - color: theme.text - font.pixelSize: theme.subtitleSize - font.weight: Font.Medium - Layout.alignment: Qt.AlignVCenter + + Rectangle { width: 1; Layout.fillHeight: true; Layout.topMargin: 8; Layout.bottomMargin: 18; color: theme.line } + + ColumnLayout { + Layout.preferredWidth: 205 + Layout.fillHeight: true + spacing: 0 + RowLayout { + Layout.fillWidth: true; Layout.fillHeight: true + Button { + id: setValueButton + Layout.preferredWidth: 96; Layout.preferredHeight: 64 + enabled: backend.running && backend.selectedValue.writable + onClicked: { root.refreshEditor(); valueEditor.open() } + contentItem: Column { + anchors.centerIn: parent; spacing: 5 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "✎"; color: setValueButton.enabled ? theme.accent : theme.line; font.pixelSize: 23 } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "Set value"; color: setValueButton.enabled ? theme.text : theme.muted; font.pixelSize: 10 } + } + background: Rectangle { color: setValueButton.hovered ? theme.surfaceRaised : "transparent"; border.width: 0 } + } + Button { + id: clearButton + Layout.preferredWidth: 100; Layout.preferredHeight: 64 + enabled: backend.activity.length > 0 + onClicked: backend.clearActivity() + contentItem: Column { + anchors.centerIn: parent; spacing: 5 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "⌫"; color: clearButton.enabled ? theme.textSoft : theme.line; font.pixelSize: 23 } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "Clear status"; color: clearButton.enabled ? theme.text : theme.muted; font.pixelSize: 10 } + } + background: Rectangle { color: clearButton.hovered ? theme.surfaceRaised : "transparent"; border.width: 0 } + } + } + Label { text: "Data"; color: theme.muted; font.pixelSize: 9; Layout.alignment: Qt.AlignHCenter; Layout.bottomMargin: 4 } } - Rectangle { width: 1; height: 26; color: theme.line; Layout.leftMargin: 6; Layout.rightMargin: 6 } - ActionButton { theme: root.theme; text: "Add IED"; iconSource: "qrc:/iedsim/assets/upload.svg"; onClicked: root.addIedRequested() } - Item { Layout.fillWidth: true } - Label { - text: backend.ieds.length + " IED" + (backend.ieds.length === 1 ? "" : "s") + " · " + backend.listenAddress + ":" + backend.port - color: theme.textSoft; font.pixelSize: theme.captionSize; font.features: { "tnum": 1 } + + Rectangle { width: 1; Layout.fillHeight: true; Layout.topMargin: 8; Layout.bottomMargin: 18; color: theme.line } + + ColumnLayout { + Layout.preferredWidth: 186 + Layout.fillHeight: true + spacing: 2 + Item { Layout.fillHeight: true } + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + Column { + spacing: 4 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: backend.reportCount.toString(); color: theme.accent; font.pixelSize: 20; font.weight: Font.DemiBold } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "Reports"; color: theme.textSoft; font.pixelSize: 10 } + } + Column { + spacing: 4 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: backend.dataSetCount.toString(); color: theme.accent; font.pixelSize: 20; font.weight: Font.DemiBold } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "DataSets"; color: theme.textSoft; font.pixelSize: 10 } + } + Column { + spacing: 4 + Label { anchors.horizontalCenter: parent.horizontalCenter; text: backend.gooseCount.toString(); color: theme.muted; font.pixelSize: 20; font.weight: Font.DemiBold } + Label { anchors.horizontalCenter: parent.horizontalCenter; text: "GOOSE"; color: theme.textSoft; font.pixelSize: 10 } + } + } + Item { Layout.fillHeight: true } + Label { text: "Services"; color: theme.muted; font.pixelSize: 9; Layout.alignment: Qt.AlignHCenter; Layout.bottomMargin: 4 } + } + + Rectangle { width: 1; Layout.fillHeight: true; Layout.topMargin: 8; Layout.bottomMargin: 18; color: theme.line } + + ColumnLayout { + Layout.preferredWidth: 250 + Layout.fillHeight: true + spacing: 1 + RowLayout { + Layout.alignment: Qt.AlignHCenter + CheckBox { + checked: root.navigationVisible + onToggled: root.navigationVisible = checked + text: "Navigation" + font.pixelSize: 10 + } + CheckBox { + checked: root.detailsVisible + onToggled: root.detailsVisible = checked + text: "Details" + font.pixelSize: 10 + } + } + RowLayout { + Layout.alignment: Qt.AlignHCenter + CheckBox { + checked: root.statusVisible + onToggled: root.statusVisible = checked + text: "Status history" + font.pixelSize: 10 + } + } + Item { Layout.fillHeight: true } + Label { text: "Show"; color: theme.muted; font.pixelSize: 9; Layout.alignment: Qt.AlignHCenter; Layout.bottomMargin: 4 } } - StatusPill { theme: root.theme; text: "Running"; tone: theme.green; fill: theme.greenSoft } - ActionButton { - theme: root.theme; text: "Stop"; danger: true - iconSource: "qrc:/iedsim/assets/square.svg" - onClicked: backend.stopSimulation() + + Item { Layout.fillWidth: true } + + ColumnLayout { + Layout.preferredWidth: 210 + Layout.fillHeight: true + spacing: 2 + Item { Layout.fillHeight: true } + StatusPill { + theme: root.theme + Layout.alignment: Qt.AlignRight + text: backend.running ? "MMS online" : (backend.starting ? "Starting" : (backend.imported ? "Ready" : "No model")) + tone: backend.running ? theme.green : (backend.starting ? theme.accent : theme.muted) + fill: backend.running ? theme.greenSoft : (backend.starting ? theme.accentSoft : theme.surfaceRaised) + } + Label { + Layout.alignment: Qt.AlignRight + text: backend.listenAddress + ":" + backend.port + color: theme.textSoft + font.pixelSize: 10 + } + Label { + Layout.alignment: Qt.AlignRight + text: backend.imported ? backend.sourceName : "Open an SCL/CID/SCD/IID file" + color: theme.muted + font.pixelSize: 9 + elide: Text.ElideMiddle + Layout.maximumWidth: 200 + } + Item { Layout.fillHeight: true } } } } @@ -82,312 +407,738 @@ Item { RowLayout { Layout.fillWidth: true Layout.fillHeight: true - Layout.margins: 12 - Layout.bottomMargin: 8 - spacing: 10 + spacing: 3 - SurfaceCard { - theme: root.theme + // external-client-inspired navigation rail: service groups and full LD/LN + // hierarchy live together so the operator never loses model context. + Rectangle { + visible: root.navigationVisible + Layout.preferredWidth: root.navigationVisible ? 305 : 0 Layout.fillHeight: true - Layout.preferredWidth: 252 - Layout.minimumWidth: 220 + color: theme.navigation + border.width: 1 + border.color: theme.navigationDark + ColumnLayout { - anchors.fill: parent; anchors.margins: 12; spacing: 10 - RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 7 + + Label { + text: "IEDs" + color: theme.navigationText + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Rectangle { Layout.fillWidth: true; height: 1; color: "#5a91bd" } + + ComboBox { + id: iedBox Layout.fillWidth: true - Label { text: "IED Fleet"; color: theme.text; font.pixelSize: theme.subtitleSize; font.weight: Font.DemiBold; Layout.fillWidth: true } - StatusPill { theme: root.theme; text: backend.ieds.length.toString(); tone: theme.accent; fill: theme.accentSoft } + implicitHeight: 34 + model: backend.ieds + textRole: "name" + currentIndex: backend.selectedIedIndex + onActivated: backend.selectIed(currentIndex) + contentItem: Label { + leftPadding: 10; rightPadding: 28 + text: iedBox.displayText + color: theme.navigationText + verticalAlignment: Text.AlignVCenter + font.pixelSize: 11 + elide: Text.ElideRight + } + background: Rectangle { + color: theme.navigationDark + border.width: 1 + border.color: "#6fa1c8" + } } - TextField { - Layout.fillWidth: true; implicitHeight: 34 - placeholderText: "Search IEDs" - color: theme.text; placeholderTextColor: theme.muted; font.pixelSize: theme.captionSize - leftPadding: 10 - background: Rectangle { radius: theme.controlRadius; color: theme.surfaceRaised; border.width: 1; border.color: parent.activeFocus ? theme.accent : theme.line } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 6 + rowSpacing: 3 + Label { text: "IP address:"; color: theme.navigationMuted; font.pixelSize: 9 } + Label { text: backend.listenAddress; color: theme.navigationText; font.pixelSize: 9; Layout.fillWidth: true; elide: Text.ElideRight } + Label { text: "Port:"; color: theme.navigationMuted; font.pixelSize: 9 } + Label { text: backend.port.toString(); color: theme.navigationText; font.pixelSize: 9 } + Label { text: "SCL path:"; color: theme.navigationMuted; font.pixelSize: 9 } + Label { text: backend.sourcePath || "—"; color: theme.navigationText; font.pixelSize: 9; Layout.fillWidth: true; elide: Text.ElideMiddle } } - ListView { - Layout.fillWidth: true; Layout.fillHeight: true - clip: true; spacing: 7; model: backend.ieds - delegate: Rectangle { - required property var modelData - required property int index - width: ListView.view.width; height: 84; radius: 7 - color: index === backend.selectedIedIndex ? theme.accentSoft : theme.surfaceRaised - border.width: 1 - border.color: index === backend.selectedIedIndex ? theme.accent : theme.lineSoft - ColumnLayout { - anchors.fill: parent; anchors.margins: 10; spacing: 3 - RowLayout { - Layout.fillWidth: true - Rectangle { width: 8; height: 8; radius: 4; color: modelData.status === "Running" ? theme.green : theme.amber } - Label { text: modelData.name || "Unnamed IED"; color: theme.text; font.pixelSize: theme.labelSize; font.weight: Font.DemiBold; Layout.fillWidth: true; elide: Text.ElideRight } - Label { text: modelData.status; color: modelData.status === "Running" ? theme.green : theme.amber; font.pixelSize: 10 } + + Rectangle { Layout.fillWidth: true; height: 1; color: "#5a91bd" } + + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: width + contentHeight: navigationColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { } + + Column { + id: navigationColumn + width: parent.width + spacing: 1 + + Rectangle { + width: parent.width; height: 28; color: "transparent" + Row { + anchors.verticalCenter: parent.verticalCenter; spacing: 7 + Label { text: "▸"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: "GOOSE"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: backend.gooseCount > 0 ? "(" + backend.gooseCount + ")" : ""; color: theme.navigationMuted; font.pixelSize: 9 } + } + } + Rectangle { + width: parent.width; height: 28; color: "transparent" + Row { + anchors.verticalCenter: parent.verticalCenter; spacing: 7 + Label { text: "▸"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: "Reports"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: backend.reportCount > 0 ? "(" + backend.reportCount + ")" : ""; color: theme.navigationMuted; font.pixelSize: 9 } + } + } + Rectangle { + width: parent.width; height: 28; color: "transparent" + Row { + anchors.verticalCenter: parent.verticalCenter; spacing: 7 + Label { text: "▸"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: "Setting Groups"; color: theme.navigationText; font.pixelSize: 10 } + } + } + Rectangle { + width: parent.width; height: 28; color: "transparent" + Row { + anchors.verticalCenter: parent.verticalCenter; spacing: 7 + Label { text: "▸"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: "DataSets"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: backend.dataSetCount > 0 ? "(" + backend.dataSetCount + ")" : ""; color: theme.navigationMuted; font.pixelSize: 9 } + } + } + Rectangle { + width: parent.width; height: 30; color: theme.navigationDark + Row { + anchors.verticalCenter: parent.verticalCenter; spacing: 7 + Label { text: "▾"; color: theme.navigationText; font.pixelSize: 10 } + Label { text: "Data Model"; color: theme.navigationText; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { text: backend.dataAttributeCount > 0 ? "(" + backend.dataAttributeCount + ")" : ""; color: theme.navigationMuted; font.pixelSize: 9 } + } + } + + Repeater { + model: root.logicalDevices + delegate: Column { + required property string modelData + width: navigationColumn.width + spacing: 0 + + Rectangle { + width: parent.width; height: 29 + color: root.selectedLd === modelData ? "#0e548d" : "transparent" + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 16 + spacing: 7 + Label { text: root.selectedLd === modelData ? "▾" : "▸"; color: theme.navigationText; font.pixelSize: 9 } + Rectangle { + width: 18; height: 16; color: "#2c80bd"; border.width: 1; border.color: "#87b8dc" + Label { anchors.centerIn: parent; text: "LD"; color: "white"; font.pixelSize: 8; font.weight: Font.DemiBold } + } + Label { text: modelData; color: theme.navigationText; font.pixelSize: 10; width: 205; elide: Text.ElideRight } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.chooseLogicalDevice(modelData) + } + } + + Column { + width: parent.width + visible: root.selectedLd === modelData + Repeater { + model: root.logicalNodes + delegate: Rectangle { + required property string modelData + width: navigationColumn.width; height: 27 + color: root.selectedLn === modelData ? "#2875ad" : "transparent" + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 43 + spacing: 7 + Rectangle { + width: 18; height: 16; color: "#317fc0"; border.width: 1; border.color: "#93c1e3" + Label { anchors.centerIn: parent; text: "LN"; color: "white"; font.pixelSize: 8; font.weight: Font.DemiBold } + } + Label { text: modelData; color: theme.navigationText; font.pixelSize: 10; width: 177; elide: Text.ElideRight } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.chooseLogicalNode(modelData) + } + } + } + } } - Label { text: modelData.manufacturer || "Engineering model"; color: theme.muted; font.pixelSize: 10; Layout.fillWidth: true; elide: Text.ElideRight } - Label { text: modelData.endpoint; color: theme.textSoft; font.pixelSize: 10; font.features: { "tnum": 1 } } } - MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: backend.selectedIedIndex = index } } } - ActionButton { theme: root.theme; Layout.fillWidth: true; text: "Add another IED"; iconSource: "qrc:/iedsim/assets/upload.svg"; onClicked: root.addIedRequested() } } } - SurfaceCard { - theme: root.theme + Rectangle { Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredWidth: 2.2 + color: theme.surface + border.width: 1 + border.color: theme.line + ColumnLayout { - anchors.fill: parent; spacing: 0 + anchors.fill: parent + spacing: 0 - RowLayout { - Layout.fillWidth: true; Layout.preferredHeight: 58 - Layout.leftMargin: 16; Layout.rightMargin: 16; spacing: 8 - ColumnLayout { - Layout.fillWidth: true; spacing: 2 - Label { text: "Model Explorer"; color: theme.text; font.pixelSize: theme.subtitleSize; font.weight: Font.DemiBold } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 27 + color: theme.navigation + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + Label { + text: (backend.selectedIed.name || "IED") + " • Data Model • " + + (root.selectedLd || "—") + " • " + (root.selectedLn || "—") + color: theme.navigationText + font.pixelSize: 10 + font.weight: Font.DemiBold + Layout.fillWidth: true + elide: Text.ElideMiddle + } Label { - text: (backend.selectedIed.name || "IED") + " / " + (backend.selectedValue.logicalDevice || "Model") + " / " + (backend.selectedValue.logicalNode || "Values") - color: theme.muted; font.pixelSize: 10; Layout.fillWidth: true; elide: Text.ElideMiddle + text: backend.running ? "● ONLINE" : "● OFFLINE" + color: backend.running ? "#b8f0c8" : "#d5e3ef" + font.pixelSize: 9 + font.weight: Font.DemiBold } } - Label { text: backend.values.length + " attributes"; color: theme.muted; font.pixelSize: theme.captionSize } } - Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } - RowLayout { - Layout.fillWidth: true; Layout.preferredHeight: 48 - Layout.leftMargin: 12; Layout.rightMargin: 12; spacing: 8 - TextField { - Layout.fillWidth: true; implicitHeight: 34 - placeholderText: "Search by object, reference, FC, or value" - color: theme.text; placeholderTextColor: theme.muted; font.pixelSize: theme.captionSize - background: Rectangle { radius: theme.controlRadius; color: theme.surfaceRaised; border.width: 1; border.color: parent.activeFocus ? theme.accent : theme.line } - } - ComboBox { - implicitWidth: 118; implicitHeight: 34 - model: ["All FC", "ST", "MX", "CF", "CO", "OR"] - contentItem: Label { leftPadding: 9; text: parent.displayText; color: theme.textSoft; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } - background: Rectangle { radius: theme.controlRadius; color: theme.surfaceRaised; border.width: 1; border.color: theme.line } + + Rectangle { + visible: root.detailsVisible + Layout.fillWidth: true + Layout.preferredHeight: root.detailsVisible ? 42 : 0 + color: "#fbfcfd" + border.width: 1 + border.color: theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 10 + Rectangle { + width: 20; height: 18; color: "#2b81c2"; border.width: 1; border.color: "#1d6396" + Label { anchors.centerIn: parent; text: "LN"; color: "white"; font.pixelSize: 8; font.weight: Font.DemiBold } + } + Label { text: root.selectedLn || "Select a logical node"; color: theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + Label { text: "IEC 61850 logical node"; color: theme.muted; font.pixelSize: 10 } + Item { Layout.fillWidth: true } + Label { text: root.tableRows.length + " rows"; color: theme.muted; font.pixelSize: 9 } } } + Rectangle { - Layout.fillWidth: true; Layout.preferredHeight: 34 - color: theme.chrome + Layout.fillWidth: true + Layout.preferredHeight: 28 + color: "#e7eaed" + border.width: 1 + border.color: theme.line RowLayout { - anchors.fill: parent; anchors.leftMargin: 12; anchors.rightMargin: 12; spacing: 8 - Label { text: "OBJECT"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; Layout.preferredWidth: 220; Layout.minimumWidth: 220; Layout.maximumWidth: 220 } - Label { text: "FC"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; Layout.preferredWidth: 42; Layout.minimumWidth: 42; Layout.maximumWidth: 42 } - Label { text: "TYPE"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; Layout.preferredWidth: 94; Layout.minimumWidth: 94; Layout.maximumWidth: 94 } - Label { text: "CURRENT VALUE"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; Layout.fillWidth: true } - Label { text: "QUALITY"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; Layout.preferredWidth: 72 } + anchors.fill: parent + spacing: 0 + Label { + text: "Name" + color: theme.textSoft + font.pixelSize: 10 + font.weight: Font.DemiBold + leftPadding: 8 + Layout.preferredWidth: parent.width * 0.68 + } + Rectangle { width: 1; Layout.fillHeight: true; color: theme.line } + Label { + text: "Value" + color: theme.textSoft + font.pixelSize: 10 + font.weight: Font.DemiBold + leftPadding: 8 + Layout.fillWidth: true + } } } + ListView { - Layout.fillWidth: true; Layout.fillHeight: true - clip: true; model: backend.values + id: modelTable + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.tableRows + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { } + delegate: Rectangle { required property var modelData required property int index - width: ListView.view.width; height: 46 - color: index === backend.selectedValueIndex ? theme.accentSoft : (index % 2 ? theme.surface : theme.surfaceRaised) - border.width: index === backend.selectedValueIndex ? 1 : 0 - border.color: theme.accent + width: ListView.view.width + height: modelData.kind === "DO" ? 29 : 27 + color: { + if (modelData.sourceIndex === backend.selectedValueIndex) return "#d8e9f7" + if (modelData.kind === "DO") return "#f1f3f5" + return index % 2 ? "#ffffff" : "#f8f9fa" + } + border.width: 0 + RowLayout { - anchors.fill: parent; anchors.leftMargin: 12; anchors.rightMargin: 12; spacing: 8 - ColumnLayout { - Layout.preferredWidth: 220; Layout.minimumWidth: 220; Layout.maximumWidth: 220; spacing: 1 - Label { text: modelData.name || modelData.dataObject; color: theme.text; font.pixelSize: theme.captionSize; font.weight: Font.Medium; width: 220; elide: Text.ElideRight } - Label { text: modelData.logicalNode + " · " + modelData.dataAttribute; color: theme.muted; font.pixelSize: 9; width: 220; elide: Text.ElideRight } + anchors.fill: parent + spacing: 0 + Item { + Layout.preferredWidth: parent.width * 0.68 + Layout.fillHeight: true + RowLayout { + anchors.fill: parent + anchors.leftMargin: modelData.kind === "DO" ? 7 : 28 + anchors.rightMargin: 6 + spacing: 6 + Label { + visible: modelData.kind === "DO" + text: root.collapsedObjects[modelData.name] === true ? "▸" : "▾" + color: theme.textSoft + font.pixelSize: 9 + } + Rectangle { + width: 18; height: 16 + color: modelData.kind === "DO" ? "#2c7fbd" : "#4f94c7" + border.width: 1; border.color: "#2a70a4" + Label { anchors.centerIn: parent; text: modelData.kind; color: "white"; font.pixelSize: 8; font.weight: Font.DemiBold } + } + Label { + text: modelData.name + color: theme.text + font.pixelSize: 10 + font.weight: modelData.kind === "DO" ? Font.DemiBold : Font.Normal + Layout.fillWidth: true + elide: Text.ElideRight + } + Label { + text: modelData.fc.length ? "[" + modelData.fc + "]" : "" + color: theme.muted + font.pixelSize: 9 + } + Label { + text: modelData.type.length ? modelData.type : "" + color: theme.muted + font.pixelSize: 8 + Layout.maximumWidth: 84 + elide: Text.ElideRight + } + } + } + Rectangle { width: 1; Layout.fillHeight: true; color: theme.lineSoft } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 7 + Label { + text: modelData.value + color: modelData.sourceIndex === backend.selectedValueIndex ? theme.accent : theme.text + font.pixelSize: 10 + Layout.fillWidth: true + elide: Text.ElideRight + } + Rectangle { + visible: modelData.kind === "DA" && modelData.quality.length > 0 + width: 6; height: 6; radius: 3 + color: modelData.quality === "Good" ? theme.green : theme.amber + } + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData.kind === "DO") root.toggleObject(modelData.name) + if (modelData.sourceIndex >= 0) backend.selectValue(modelData.sourceIndex) } - Label { text: modelData.fc || "—"; color: theme.textSoft; font.pixelSize: 10; Layout.preferredWidth: 42; Layout.minimumWidth: 42; Layout.maximumWidth: 42 } - Label { text: modelData.type; color: theme.muted; font.pixelSize: 10; Layout.preferredWidth: 94; Layout.minimumWidth: 94; Layout.maximumWidth: 94; elide: Text.ElideRight } - Label { text: modelData.value; color: modelData.changed ? theme.accent : theme.text; font.pixelSize: theme.captionSize; font.weight: modelData.changed ? Font.DemiBold : Font.Normal; Layout.fillWidth: true; elide: Text.ElideRight } - RowLayout { - Layout.preferredWidth: 72; spacing: 5 - Rectangle { width: 7; height: 7; radius: 4; color: modelData.quality === "Good" ? theme.green : theme.amber } - Label { text: modelData.quality; color: modelData.quality === "Good" ? theme.green : theme.amber; font.pixelSize: 9; elide: Text.ElideRight } + onDoubleClicked: { + if (modelData.sourceIndex >= 0) backend.selectValue(modelData.sourceIndex) + if (backend.running && backend.selectedValue.writable) { + root.refreshEditor() + valueEditor.open() + } } } - MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: backend.selectedValueIndex = index } } + Label { - anchors.centerIn: parent; visible: backend.values.length === 0 - text: "No DataSet, report, or GOOSE members were resolved for this IED." - color: theme.muted; font.pixelSize: theme.captionSize + anchors.centerIn: parent + visible: root.tableRows.length === 0 + text: backend.imported ? "Select a logical device and logical node." : "Open an SCL engineering file to begin." + color: theme.muted + font.pixelSize: 12 } } + Rectangle { - Layout.fillWidth: true; Layout.preferredHeight: 32 - color: theme.chrome + Layout.fillWidth: true + Layout.preferredHeight: 24 + color: "#eef0f2" + border.width: 1 + border.color: theme.lineSoft RowLayout { - anchors.fill: parent; anchors.leftMargin: 12; anchors.rightMargin: 12 - Label { text: backend.values.length + " resolved attributes"; color: theme.muted; font.pixelSize: 9; Layout.fillWidth: true } - Label { text: "Select a row to edit"; color: theme.muted; font.pixelSize: 9 } + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + Label { + text: backend.selectedValue.reference || "No data attribute selected" + color: theme.muted + font.pixelSize: 9 + Layout.fillWidth: true + elide: Text.ElideMiddle + } + Label { + text: backend.selectedValue.fc ? "FC " + backend.selectedValue.fc : "" + color: theme.muted + font.pixelSize: 9 + } } } } } + } - SurfaceCard { - theme: root.theme - Layout.fillHeight: true - Layout.preferredWidth: 350 - Layout.minimumWidth: 320 - ColumnLayout { - anchors.fill: parent; anchors.margins: 14; spacing: 11 + Rectangle { + visible: root.statusVisible + Layout.fillWidth: true + Layout.preferredHeight: root.statusVisible ? 118 : 0 + color: theme.surface + border.width: 1 + border.color: theme.line + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 25 + color: theme.statusChrome RowLayout { - Layout.fillWidth: true - ColumnLayout { - Layout.fillWidth: true; spacing: 2 - Label { text: "Smart Value Editor"; color: theme.text; font.pixelSize: theme.subtitleSize; font.weight: Font.DemiBold } - Label { text: "Type-aware, validated, and reversible"; color: theme.muted; font.pixelSize: 10 } + anchors.fill: parent + anchors.leftMargin: 7 + anchors.rightMargin: 7 + Label { text: "Status History"; color: theme.statusText; font.pixelSize: 10; font.weight: Font.DemiBold; Layout.fillWidth: true } + Label { text: "ⓘ " + root.activityCount("Info"); color: "#dce9f4"; font.pixelSize: 9 } + Label { text: "✓ " + root.activityCount("Success"); color: "#bde7c9"; font.pixelSize: 9 } + Label { text: "⚠ " + root.activityCount("Warning"); color: "#ffe49c"; font.pixelSize: 9 } + Label { text: "✖ " + root.activityCount("Error"); color: "#ffc2c7"; font.pixelSize: 9 } + Button { + text: "Copy" + implicitWidth: 48; implicitHeight: 20 + onClicked: backend.copyDiagnostics() + contentItem: Label { text: parent.text; color: theme.text; font.pixelSize: 9; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: "#f5f5f5"; border.width: 1; border.color: "#8c8c8c" } } - StatusPill { - theme: root.theme - text: backend.selectedValue.writable ? "Writable" : "Read only" - tone: backend.selectedValue.writable ? theme.green : theme.muted - fill: backend.selectedValue.writable ? theme.greenSoft : theme.surfaceRaised + Button { + text: "Clear" + implicitWidth: 48; implicitHeight: 20 + onClicked: backend.clearActivity() + contentItem: Label { text: parent.text; color: theme.text; font.pixelSize: 9; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: "#f5f5f5"; border.width: 1; border.color: "#8c8c8c" } } } - Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } - Label { - Layout.fillWidth: true - text: backend.selectedValue.reference || "Select a model value" - color: theme.text; font.pixelSize: theme.labelSize; font.weight: Font.DemiBold - wrapMode: Text.WrapAnywhere + } + + Rectangle { + Layout.fillWidth: true; Layout.preferredHeight: 22; color: "#f1f2f3" + RowLayout { + anchors.fill: parent; spacing: 0 + Label { text: "Time"; color: theme.textSoft; font.pixelSize: 9; leftPadding: 8; Layout.preferredWidth: 92 } + Rectangle { width: 1; Layout.fillHeight: true; color: theme.lineSoft } + Label { text: "Description"; color: theme.textSoft; font.pixelSize: 9; leftPadding: 8; Layout.fillWidth: true } + Rectangle { width: 1; Layout.fillHeight: true; color: theme.lineSoft } + Label { text: "Category"; color: theme.textSoft; font.pixelSize: 9; leftPadding: 8; Layout.preferredWidth: 100 } } - GridLayout { - Layout.fillWidth: true; columns: 2; columnSpacing: 12; rowSpacing: 5 - Label { text: "Type"; color: theme.muted; font.pixelSize: 10 } - Label { text: backend.selectedValue.type || "—"; color: theme.textSoft; font.pixelSize: 10; Layout.fillWidth: true } - Label { text: "Functional constraint"; color: theme.muted; font.pixelSize: 10 } - Label { text: backend.selectedValue.fc || "—"; color: theme.textSoft; font.pixelSize: 10; Layout.fillWidth: true } - Label { text: "Current"; color: theme.muted; font.pixelSize: 10 } - Label { text: backend.selectedValue.value || "—"; color: theme.accent; font.pixelSize: 10; Layout.fillWidth: true; elide: Text.ElideRight } + } + + ListView { + Layout.fillWidth: true; Layout.fillHeight: true + clip: true + model: backend.activity + delegate: Rectangle { + required property var modelData + required property int index + width: ListView.view.width; height: 23 + color: index % 2 ? "#ffffff" : "#f8f9fa" + RowLayout { + anchors.fill: parent; spacing: 0 + Label { text: modelData.time; color: theme.muted; font.pixelSize: 9; leftPadding: 8; Layout.preferredWidth: 92 } + Rectangle { width: 1; Layout.fillHeight: true; color: theme.lineSoft } + RowLayout { + Layout.fillWidth: true; spacing: 6 + Layout.leftMargin: 7; Layout.rightMargin: 7 + Rectangle { width: 7; height: 7; radius: 4; color: root.severityColor(modelData.severity) } + Label { text: modelData.message; color: theme.textSoft; font.pixelSize: 9; Layout.fillWidth: true; elide: Text.ElideRight } + } + Rectangle { width: 1; Layout.fillHeight: true; color: theme.lineSoft } + Label { text: modelData.category; color: root.severityColor(modelData.severity); font.pixelSize: 9; leftPadding: 8; Layout.preferredWidth: 100; elide: Text.ElideRight } + } } - Label { text: "CHOOSE NEW VALUE"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + } + } + } + } + + Popup { + id: serverSettings + parent: Overlay.overlay + modal: true + focus: true + width: 430 + height: 455 + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + closePolicy: Popup.CloseOnEscape + padding: 0 + + background: Rectangle { + color: theme.surface + border.width: 2 + border.color: theme.navigation + } + + contentItem: ColumnLayout { + spacing: 0 + + Rectangle { + Layout.fillWidth: true; Layout.preferredHeight: 34; color: theme.navigation + RowLayout { + anchors.fill: parent; anchors.leftMargin: 9; anchors.rightMargin: 9 + Label { text: "Simulate IED '" + (backend.selectedIed.name || "IED") + "'"; color: theme.navigationText; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.fillWidth: true; elide: Text.ElideRight } + Label { text: "?"; color: theme.navigationText; font.pixelSize: 12; font.weight: Font.Bold } + } + } + + ColumnLayout { + Layout.fillWidth: true; Layout.fillHeight: true + Layout.margins: 12 + spacing: 8 + + Label { text: "Server settings"; color: theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.textSoft } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 10 + rowSpacing: 7 + Label { text: "Enable server:"; color: theme.textSoft; font.pixelSize: 10 } + CheckBox { checked: true; enabled: false } + Label { text: "Listening on:"; color: theme.textSoft; font.pixelSize: 10 } ComboBox { - id: optionEditor - Layout.fillWidth: true; implicitHeight: 40 - visible: (backend.selectedValue.options || []).length > 0 - model: backend.selectedValue.options || [] - currentIndex: Math.max(0, model.indexOf(root.pendingValue)) - onActivated: root.pendingValue = currentText - contentItem: Label { leftPadding: 10; rightPadding: 28; text: optionEditor.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: theme.labelSize } - background: Rectangle { radius: theme.controlRadius; color: theme.surfaceRaised; border.width: 1; border.color: optionEditor.activeFocus ? theme.accent : theme.line } + id: settingsAddressBox + Layout.fillWidth: true; implicitHeight: 30 + model: backend.availableAddresses + currentIndex: Math.max(0, backend.availableAddresses.indexOf(backend.listenAddress)) + onActivated: backend.listenAddress = currentText + contentItem: Label { leftPadding: 8; rightPadding: 24; text: parent.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } + background: Rectangle { color: "#f7f8f9"; border.width: 1; border.color: theme.line } } + Label { text: "Port:"; color: theme.textSoft; font.pixelSize: 10 } TextField { - Layout.fillWidth: true; implicitHeight: 40 - visible: (backend.selectedValue.options || []).length === 0 - text: root.pendingValue - onTextEdited: root.pendingValue = text - readOnly: !backend.selectedValue.writable - color: theme.text; font.pixelSize: theme.labelSize - placeholderText: "Enter a value"; placeholderTextColor: theme.muted - background: Rectangle { radius: theme.controlRadius; color: theme.surfaceRaised; border.width: 1; border.color: parent.activeFocus ? theme.accent : theme.line } + id: settingsPort + Layout.fillWidth: true; implicitHeight: 30 + text: backend.port.toString() + validator: IntValidator { bottom: 1; top: 65535 } + color: theme.text; font.pixelSize: 10 + background: Rectangle { color: "#ffffff"; border.width: 1; border.color: parent.activeFocus ? theme.accent : theme.line } } - Label { text: "OPTIONAL ATTRIBUTES"; color: theme.muted; font.pixelSize: 9; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Label { text: "File transfer folder:"; color: theme.textSoft; font.pixelSize: 10 } RowLayout { - Layout.fillWidth: true - Label { text: "Quality"; color: theme.textSoft; font.pixelSize: theme.captionSize; Layout.preferredWidth: 74 } - ComboBox { - id: qualityBox - Layout.fillWidth: true; implicitHeight: 34 - model: ["Good", "Invalid", "Questionable", "Reserved"] - currentIndex: Math.max(0, model.indexOf(root.pendingQuality)) - onActivated: root.pendingQuality = currentText - contentItem: Label { leftPadding: 9; rightPadding: 26; text: qualityBox.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } - background: Rectangle { radius: theme.controlRadius; color: theme.surfaceRaised; border.width: 1; border.color: theme.line } + Layout.fillWidth: true; spacing: 4 + TextField { + Layout.fillWidth: true; implicitHeight: 30 + enabled: backend.fileServiceEnabled + text: backend.fileFolder + placeholderText: backend.fileServiceEnabled ? "Choose folder" : "File service disabled" + placeholderTextColor: theme.muted + color: theme.text; font.pixelSize: 9 + onEditingFinished: backend.fileFolder = text + background: Rectangle { color: enabled ? "#ffffff" : "#f0f1f2"; border.width: 1; border.color: theme.line } } - } - RowLayout { - Layout.fillWidth: true - Label { text: "Origin"; color: theme.textSoft; font.pixelSize: theme.captionSize; Layout.preferredWidth: 74 } - ComboBox { - id: originBox - Layout.fillWidth: true; implicitHeight: 34 - model: ["Simulator", "Process", "Operator", "Test"] - currentIndex: Math.max(0, model.indexOf(root.pendingOrigin)) - onActivated: root.pendingOrigin = currentText - contentItem: Label { leftPadding: 9; rightPadding: 26; text: originBox.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } - background: Rectangle { radius: theme.controlRadius; color: theme.surfaceRaised; border.width: 1; border.color: theme.line } + Button { + text: "…"; implicitWidth: 34; implicitHeight: 30 + enabled: backend.fileServiceEnabled + onClicked: root.folderRequested() + contentItem: Label { text: parent.text; color: theme.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: "#f2f3f4"; border.width: 1; border.color: theme.line } } } - Rectangle { - Layout.fillWidth: true; Layout.preferredHeight: 118; radius: 7 - color: backend.selectedValue.writable ? theme.greenSoft : theme.surfaceRaised - border.width: 1 - border.color: backend.selectedValue.writable ? Qt.rgba(theme.green.r, theme.green.g, theme.green.b, 0.48) : theme.line - ColumnLayout { - anchors.fill: parent; anchors.margins: 11; spacing: 5 - RowLayout { - Image { width: 16; height: 16; source: backend.selectedValue.writable ? "qrc:/iedsim/assets/circle-check.svg" : "qrc:/iedsim/assets/settings-2.svg" } - Label { text: backend.selectedValue.writable ? "Change is valid" : "Read-only attribute"; color: backend.selectedValue.writable ? theme.green : theme.textSoft; font.pixelSize: theme.captionSize; font.weight: Font.DemiBold } - } - Label { text: "✓ Value matches the resolved SCL type"; color: theme.textSoft; font.pixelSize: 9 } - Label { text: "✓ Functional constraint: " + (backend.selectedValue.fc || "not specified"); color: theme.textSoft; font.pixelSize: 9 } - Label { text: "✓ DataSet/report consumers refresh after apply"; color: theme.textSoft; font.pixelSize: 9 } - Label { text: "✓ Previous value can be restored with Undo"; color: theme.textSoft; font.pixelSize: 9 } - } + } + + Item { Layout.preferredHeight: 4 } + Label { text: "GOOSE publishing settings"; color: theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.textSoft } + GridLayout { + Layout.fillWidth: true; columns: 2; columnSpacing: 10; rowSpacing: 4 + Label { text: "Enable GOOSE:"; color: theme.textSoft; font.pixelSize: 10 } + CheckBox { checked: false; enabled: false } + Label { text: "Simulation/Test:"; color: theme.textSoft; font.pixelSize: 10 } + CheckBox { checked: true; enabled: false } + } + Label { text: "GOOSE publishing is intentionally disabled until the native publisher milestone is wired to this GUI."; color: theme.muted; font.pixelSize: 9; wrapMode: Text.WordWrap; Layout.fillWidth: true } + + Item { Layout.preferredHeight: 4 } + Label { text: "General settings"; color: theme.text; font.pixelSize: 11; font.weight: Font.DemiBold } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.textSoft } + RowLayout { + Layout.fillWidth: true + Label { text: "Mode/Behavior:"; color: theme.textSoft; font.pixelSize: 10; Layout.preferredWidth: 126 } + ComboBox { + Layout.fillWidth: true; implicitHeight: 30 + model: ["on"] + contentItem: Label { leftPadding: 8; text: parent.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } + background: Rectangle { color: "#f7f8f9"; border.width: 1; border.color: theme.line } } - Item { Layout.preferredHeight: 4 } - RowLayout { - Layout.fillWidth: true; spacing: 8 - ActionButton { theme: root.theme; Layout.fillWidth: true; text: "Undo"; enabled: backend.running; onClicked: backend.undoLastChange() } - ActionButton { - theme: root.theme; Layout.fillWidth: true; text: "Apply Value"; primary: true - enabled: backend.running && backend.selectedValue.writable && root.pendingValue.length > 0 - onClicked: backend.applySelectedValue(root.pendingValue, root.pendingQuality, root.pendingOrigin) + } + + Item { Layout.fillHeight: true } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + ActionButton { + theme: root.theme + text: "Start" + primary: true + Layout.preferredWidth: 96 + onClicked: { + var requestedPort = Number(settingsPort.text) + if (requestedPort >= 1 && requestedPort <= 65535) backend.port = requestedPort + serverSettings.close() + backend.startSimulation() } } - Label { - Layout.fillWidth: true - text: "All changes are recorded in the activity log." - color: theme.muted; font.pixelSize: 9; horizontalAlignment: Text.AlignHCenter + ActionButton { + theme: root.theme + text: "Cancel" + Layout.preferredWidth: 96 + onClicked: serverSettings.close() } } } } + } - SurfaceCard { - theme: root.theme - Layout.fillWidth: true - Layout.preferredHeight: root.activityExpanded ? 176 : 42 - Layout.leftMargin: 12; Layout.rightMargin: 12; Layout.bottomMargin: 12 - Behavior on Layout.preferredHeight { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + Popup { + id: valueEditor + parent: Overlay.overlay + modal: true + focus: true + width: 470 + height: 390 + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + closePolicy: Popup.CloseOnEscape + padding: 0 + + background: Rectangle { + color: theme.surface + border.width: 2 + border.color: theme.navigation + } + + onOpened: root.refreshEditor() + + contentItem: ColumnLayout { + spacing: 0 + Rectangle { + Layout.fillWidth: true; Layout.preferredHeight: 34; color: theme.navigation + Label { anchors.fill: parent; leftPadding: 10; text: "Set simulated value"; color: theme.navigationText; font.pixelSize: 11; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + } ColumnLayout { - anchors.fill: parent; spacing: 0 - Rectangle { - Layout.fillWidth: true; Layout.preferredHeight: 42; color: "transparent" - RowLayout { - anchors.fill: parent; anchors.leftMargin: 12; anchors.rightMargin: 12; spacing: 8 - Image { width: 15; height: 15; source: "qrc:/iedsim/assets/activity.svg" } - Label { text: "Live Activity"; color: theme.textSoft; font.pixelSize: theme.labelSize; font.weight: Font.DemiBold } - StatusPill { theme: root.theme; text: "MMS online"; tone: theme.green; fill: theme.greenSoft } - Item { Layout.fillWidth: true } - Label { text: backend.activity.length + " events"; color: theme.muted; font.pixelSize: 10 } - ActionButton { theme: root.theme; text: "Copy diagnostics"; implicitHeight: 28; onClicked: backend.copyDiagnostics() } - ActionButton { theme: root.theme; text: "Clear"; implicitHeight: 28; onClicked: backend.clearActivity() } - Image { width: 15; height: 15; source: root.activityExpanded ? "qrc:/iedsim/assets/chevron-down.svg" : "qrc:/iedsim/assets/chevron-up.svg" } - } - TapHandler { onTapped: root.activityExpanded = !root.activityExpanded } + Layout.fillWidth: true; Layout.fillHeight: true + Layout.margins: 14; spacing: 9 + Label { text: backend.selectedValue.reference || "No attribute selected"; color: theme.text; font.pixelSize: 11; font.weight: Font.DemiBold; Layout.fillWidth: true; wrapMode: Text.WrapAnywhere } + RowLayout { + Layout.fillWidth: true + Label { text: "Type"; color: theme.muted; font.pixelSize: 9; Layout.preferredWidth: 70 } + Label { text: backend.selectedValue.type || "—"; color: theme.textSoft; font.pixelSize: 9; Layout.fillWidth: true } + Label { text: backend.selectedValue.fc ? "[" + backend.selectedValue.fc + "]" : ""; color: theme.muted; font.pixelSize: 9 } } - Rectangle { visible: root.activityExpanded; Layout.fillWidth: true; height: 1; color: theme.lineSoft } - ListView { - visible: root.activityExpanded - Layout.fillWidth: true; Layout.fillHeight: true; clip: true - model: backend.activity - delegate: RowLayout { - required property var modelData - width: ListView.view.width; height: 31; spacing: 10 - Item { width: 10 } - Label { text: modelData.time; color: theme.muted; font.pixelSize: 9; Layout.preferredWidth: 82 } - Rectangle { width: 7; height: 7; radius: 4; color: root.severityColor(modelData.severity) } - Label { text: modelData.category; color: theme.textSoft; font.pixelSize: 9; font.weight: Font.DemiBold; Layout.preferredWidth: 72 } - Label { text: modelData.message; color: theme.textSoft; font.pixelSize: 10; Layout.fillWidth: true; elide: Text.ElideRight } - Label { text: modelData.severity; color: root.severityColor(modelData.severity); font.pixelSize: 9; Layout.preferredWidth: 58 } - Item { width: 8 } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + Label { text: "Value"; color: theme.textSoft; font.pixelSize: 10 } + ComboBox { + id: valueOptions + Layout.fillWidth: true; implicitHeight: 32 + visible: (backend.selectedValue.options || []).length > 0 + model: backend.selectedValue.options || [] + currentIndex: Math.max(0, model.indexOf(root.pendingValue)) + onActivated: root.pendingValue = currentText + contentItem: Label { leftPadding: 8; rightPadding: 24; text: parent.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } + background: Rectangle { color: "#ffffff"; border.width: 1; border.color: theme.line } + } + TextField { + Layout.fillWidth: true; implicitHeight: 32 + visible: (backend.selectedValue.options || []).length === 0 + text: root.pendingValue + onTextEdited: root.pendingValue = text + color: theme.text; font.pixelSize: 10 + background: Rectangle { color: "#ffffff"; border.width: 1; border.color: parent.activeFocus ? theme.accent : theme.line } + } + Label { text: "Quality"; color: theme.textSoft; font.pixelSize: 10 } + ComboBox { + id: editQuality + Layout.fillWidth: true; implicitHeight: 32 + model: ["Good", "Invalid", "Questionable", "Reserved"] + currentIndex: Math.max(0, model.indexOf(root.pendingQuality)) + onActivated: root.pendingQuality = currentText + contentItem: Label { leftPadding: 8; rightPadding: 24; text: parent.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } + background: Rectangle { color: "#ffffff"; border.width: 1; border.color: theme.line } + } + Label { text: "Origin"; color: theme.textSoft; font.pixelSize: 10 } + ComboBox { + id: editOrigin + Layout.fillWidth: true; implicitHeight: 32 + model: ["Simulator", "Process", "Operator", "Test"] + currentIndex: Math.max(0, model.indexOf(root.pendingOrigin)) + onActivated: root.pendingOrigin = currentText + contentItem: Label { leftPadding: 8; rightPadding: 24; text: parent.displayText; color: theme.text; verticalAlignment: Text.AlignVCenter; font.pixelSize: 10 } + background: Rectangle { color: "#ffffff"; border.width: 1; border.color: theme.line } + } + Item { Layout.fillHeight: true } + RowLayout { + Layout.fillWidth: true + ActionButton { theme: root.theme; text: "Undo last"; enabled: backend.running; onClicked: backend.undoLastChange() } + Item { Layout.fillWidth: true } + ActionButton { theme: root.theme; text: "Cancel"; onClicked: valueEditor.close() } + ActionButton { + theme: root.theme; text: "Apply"; primary: true + enabled: backend.running && backend.selectedValue.writable && root.pendingValue.length > 0 + onClicked: { + if (backend.applySelectedValue(root.pendingValue, root.pendingQuality, root.pendingOrigin)) { + root.rebuildTable() + valueEditor.close() + } + } } } } diff --git a/apps/ied_simulator/qml/SclWorkspace.qml b/apps/ied_simulator/qml/SclWorkspace.qml new file mode 100644 index 000000000..c132b3dcf --- /dev/null +++ b/apps/ied_simulator/qml/SclWorkspace.qml @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts + +Item { + id: root + required property var theme + required property var workspace + + function targetEditionKey(index) { + if (index === 1) return "ed1" + if (index === 2) return "ed2" + if (index === 3) return "ed2.1" + return "preserve" + } + + FileDialog { + id: openDialog + title: "Open SCL engineering source" + fileMode: FileDialog.OpenFile + nameFilters: ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] + onAccepted: workspace.openFile(selectedFile) + } + + FileDialog { + id: saveDialog + property bool canonicalRequested: false + title: canonicalRequested ? "Export canonical SCL" : "Save exact SCL copy" + fileMode: FileDialog.SaveFile + nameFilters: canonicalRequested + ? ["Canonical SCL (*.scd *.icd *.cid)", "All files (*)"] + : ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] + onAccepted: { + if (canonicalRequested) + workspace.exportCanonical(selectedFile, root.targetEditionKey(editionPicker.currentIndex)) + else + workspace.saveAs(selectedFile, "preserve") + } + } + + Rectangle { anchors.fill: parent; color: theme.background } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + + SurfaceCard { + Layout.fillWidth: true + Layout.preferredHeight: 78 + theme: root.theme + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: "SCL WORKSPACE" + color: theme.text + font.pixelSize: theme.subtitleSize + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: workspace.loaded + ? workspace.sourceName + " · " + workspace.editionText + : "Exact source preservation + verified canonical reconstruction/export" + color: theme.textSoft + font.pixelSize: theme.captionSize + elide: Text.ElideMiddle + } + } + + ActionButton { + theme: root.theme + text: "Open SCL" + primary: !workspace.loaded + enabled: !workspace.busy + onClicked: openDialog.open() + } + ActionButton { + theme: root.theme + text: "Cancel" + danger: true + enabled: workspace.busy + onClicked: workspace.cancelOperation() + } + ColumnLayout { + Layout.preferredWidth: 160 + spacing: 1 + Label { + Layout.alignment: Qt.AlignRight + text: workspace.stateText + color: workspace.lastError.length ? theme.red : (workspace.loaded ? theme.green : theme.textSoft) + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Label { + Layout.alignment: Qt.AlignRight + text: workspace.sourceFormat.length ? workspace.sourceFormat.toUpperCase() : "NO SOURCE" + color: theme.muted + font.pixelSize: 9 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + SurfaceCard { + Layout.fillWidth: true + Layout.preferredHeight: 190 + theme: root.theme + + GridLayout { + anchors.fill: parent + anchors.margins: 12 + columns: 4 + columnSpacing: 14 + rowSpacing: 8 + + Label { text: "Edition"; color: theme.muted; font.pixelSize: theme.captionSize } + Label { text: workspace.editionText; color: theme.text; font.pixelSize: theme.labelSize; font.weight: Font.DemiBold } + Label { text: "Header ID"; color: theme.muted; font.pixelSize: theme.captionSize } + Label { text: workspace.headerId || "—"; color: theme.text; font.pixelSize: theme.labelSize; elide: Text.ElideRight; Layout.fillWidth: true } + + Label { text: "Namespace"; color: theme.muted; font.pixelSize: theme.captionSize } + Label { + text: workspace.namespaceUri || "—" + color: theme.textSoft + font.pixelSize: 9 + elide: Text.ElideMiddle + Layout.columnSpan: 3 + Layout.fillWidth: true + } + + Label { text: "Header version"; color: theme.muted; font.pixelSize: theme.captionSize } + Label { text: workspace.headerVersion || "—"; color: theme.text; font.pixelSize: theme.labelSize } + Label { text: "Header revision"; color: theme.muted; font.pixelSize: theme.captionSize } + Label { text: workspace.headerRevision || "—"; color: theme.text; font.pixelSize: theme.labelSize } + + Repeater { + model: [ + {label: "IED", value: workspace.iedCount}, + {label: "Logical nodes", value: workspace.logicalNodeCount}, + {label: "Model leaves", value: workspace.modelLeafCount}, + {label: "DataSets", value: workspace.dataSetCount}, + {label: "Reports", value: workspace.reportCount}, + {label: "GOOSE", value: workspace.gooseCount}, + {label: "SMV", value: workspace.smvCount} + ] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 1 + Label { text: modelData.label; color: theme.muted; font.pixelSize: 9 } + Label { + text: String(modelData.value) + color: theme.text + font.pixelSize: theme.subtitleSize + font.weight: Font.DemiBold + } + } + } + } + } + + SurfaceCard { + Layout.fillWidth: true + Layout.fillHeight: true + theme: root.theme + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + Label { + text: "Preservation / reconstruction report" + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: workspace.reconstructionSupported ? "CANONICAL READY" : "CANONICAL GUARDED" + color: workspace.reconstructionSupported ? theme.green : theme.amber + font.pixelSize: 9 + font.weight: Font.Bold + } + } + + ListView { + id: reportList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 4 + model: workspace.preservationReport + delegate: Rectangle { + required property string modelData + width: reportList.width + height: reportText.implicitHeight + 14 + radius: 5 + color: modelData.indexOf("fail-closed") >= 0 || modelData.indexOf("not vendor-lossless") >= 0 + ? theme.amberSoft : theme.chrome + border.width: 1 + border.color: theme.lineSoft + Label { + id: reportText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 8 + anchors.rightMargin: 8 + text: modelData + wrapMode: Text.Wrap + color: theme.textSoft + font.pixelSize: theme.captionSize + } + } + ScrollBar.vertical: ScrollBar {} + } + } + } + } + + SurfaceCard { + Layout.preferredWidth: 410 + Layout.fillHeight: true + theme: root.theme + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Label { + text: "Export policy" + color: theme.text + font.pixelSize: theme.subtitleSize + font.weight: Font.DemiBold + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 82 + radius: 6 + color: workspace.exactSourceSaveSupported ? theme.greenSoft : theme.surfaceSoft + border.width: 1 + border.color: workspace.exactSourceSaveSupported ? theme.green : theme.line + ColumnLayout { + anchors.fill: parent + anchors.margins: 9 + spacing: 2 + Label { + text: "EXACT SOURCE SAVE" + color: workspace.exactSourceSaveSupported ? theme.green : theme.textSoft + font.pixelSize: 9 + font.weight: Font.Bold + } + Label { + Layout.fillWidth: true + text: "Byte-identical source copy. Same edition and same extension/profile only; vendor XML and unmodeled content remain untouched." + wrapMode: Text.Wrap + color: theme.textSoft + font.pixelSize: theme.captionSize + } + } + } + + ActionButton { + Layout.fillWidth: true + theme: root.theme + text: "Save exact source copy" + enabled: workspace.exactSourceSaveSupported && !workspace.busy + onClicked: { + saveDialog.canonicalRequested = false + saveDialog.open() + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 108 + radius: 6 + color: workspace.reconstructionSupported ? theme.greenSoft : theme.amberSoft + border.width: 1 + border.color: workspace.reconstructionSupported ? theme.green : theme.amber + ColumnLayout { + anchors.fill: parent + anchors.margins: 9 + spacing: 2 + Label { + text: "CANONICAL RECONSTRUCTION" + color: workspace.reconstructionSupported ? theme.green : theme.amber + font.pixelSize: 9 + font.weight: Font.Bold + } + Label { + Layout.fillWidth: true + text: workspace.reconstructionSupported + ? "Rebuilds modeled DTT/references/DataSets/Reports/GOOSE/SMV and Communication. Output is reparsed + semantic-verified; normalized, not vendor-lossless." + : "This source contains a model that cannot be reconstructed safely. Exact source preservation remains available." + wrapMode: Text.Wrap + color: theme.textSoft + font.pixelSize: theme.captionSize + } + } + } + + Label { text: "Canonical target edition"; color: theme.textSoft; font.pixelSize: theme.captionSize } + ComboBox { + id: editionPicker + Layout.fillWidth: true + model: ["Preserve source edition", "Edition 1", "Edition 2", "Edition 2.1"] + enabled: workspace.loaded && workspace.editionConversionSupported && !workspace.busy + } + + ActionButton { + Layout.fillWidth: true + theme: root.theme + text: "Export canonical SCL / ICD / CID" + primary: true + enabled: workspace.reconstructionSupported && !workspace.busy + onClicked: { + saveDialog.canonicalRequested = true + saveDialog.open() + } + } + + Label { + Layout.fillWidth: true + text: workspace.profileConversionSupported + ? "Profile conversion: SCD + single-IED ICD/CID enabled." + : "ICD/CID requires exactly one modeled IED; SCD remains the canonical multi-IED target." + wrapMode: Text.Wrap + color: workspace.profileConversionSupported ? theme.green : theme.textSoft + font.pixelSize: 9 + } + + Rectangle { Layout.fillWidth: true; height: 1; color: theme.lineSoft } + + Label { + visible: workspace.lastExportVerified + Layout.fillWidth: true + text: "VERIFIED · " + workspace.lastExportMode + " · " + workspace.lastExportPath + wrapMode: Text.WrapAnywhere + color: theme.green + font.pixelSize: theme.captionSize + font.weight: Font.DemiBold + } + Label { + visible: workspace.lastError.length > 0 + Layout.fillWidth: true + text: workspace.lastError + wrapMode: Text.Wrap + color: theme.red + font.pixelSize: theme.captionSize + } + + Item { Layout.fillHeight: true } + + Label { + Layout.fillWidth: true + text: "Safety boundary: generic Enum ordinal domains and ambiguous nested-SDO parent typing fail closed instead of inventing engineering values. Exact source preservation is the fidelity path for vendor-specific XML." + wrapMode: Text.Wrap + color: theme.muted + font.pixelSize: 9 + } + } + } + } + } +} diff --git a/apps/ied_simulator/qml/SettingsWorkspace.qml b/apps/ied_simulator/qml/SettingsWorkspace.qml new file mode 100644 index 000000000..f994526f9 --- /dev/null +++ b/apps/ied_simulator/qml/SettingsWorkspace.qml @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: root + required property var theme + required property var settings + + function valueOrDash(name) { + var value = settings.selectedSettingGroup[name] + if (value === undefined || value === null || value === "") + return "—" + return String(value) + } + + function activationReady() { + return settings.selectedSettingGroup.canActivate === true + } + + Rectangle { + anchors.fill: parent + color: theme.background + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + + SurfaceCard { + Layout.fillWidth: true + Layout.preferredHeight: 72 + theme: root.theme + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + ColumnLayout { + Layout.preferredWidth: 235 + spacing: 2 + Label { + text: "SETTING GROUPS" + color: theme.text + font.pixelSize: theme.subtitleSize + font.weight: Font.DemiBold + } + Label { + text: "SGCB inspection + guarded ActSG activation" + color: theme.muted + font.pixelSize: theme.captionSize + } + } + + TextField { + Layout.preferredWidth: 210 + text: settings.host + placeholderText: "IED host" + enabled: !settings.busy && !settings.connected + onEditingFinished: settings.host = text + } + SpinBox { + Layout.preferredWidth: 105 + from: 1 + to: 65535 + editable: true + value: settings.port + enabled: !settings.busy && !settings.connected + onValueModified: settings.port = value + } + ActionButton { + theme: root.theme + text: settings.connected ? "Connected" : "Connect" + primary: !settings.connected + enabled: !settings.busy && !settings.connected + onClicked: settings.connectToIed() + } + ActionButton { + theme: root.theme + text: "Refresh" + enabled: settings.connected && !settings.operationBusy + onClicked: settings.refreshSettingGroups() + } + ActionButton { + theme: root.theme + text: "Disconnect" + danger: true + enabled: settings.connected + onClicked: settings.disconnectFromIed() + } + Item { Layout.fillWidth: true } + ColumnLayout { + spacing: 1 + Label { + Layout.alignment: Qt.AlignRight + text: settings.stateText + color: settings.connected ? theme.green : (settings.lastError.length ? theme.red : theme.textSoft) + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Label { + Layout.alignment: Qt.AlignRight + text: settings.associationProfile.length ? settings.associationProfile : settings.endpoint + color: theme.muted + font.pixelSize: 9 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 10 + + SurfaceCard { + Layout.preferredWidth: 340 + Layout.fillHeight: true + theme: root.theme + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + Label { + text: "SGCB inventory" + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: settings.settingGroupCount + " block(s)" + color: theme.muted + font.pixelSize: theme.captionSize + } + } + + ListView { + id: groupList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: settings.settingGroups + spacing: 3 + + delegate: Rectangle { + id: groupRow + required property var modelData + required property int index + width: groupList.width + height: 60 + radius: 6 + color: settings.selectedSettingGroupIndex === index + ? theme.accentSoft + : groupMouse.containsMouse ? theme.surfaceRaised : theme.surface + border.width: 1 + border.color: settings.selectedSettingGroupIndex === index ? theme.accent : theme.lineSoft + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + Label { + Layout.fillWidth: true + text: groupRow.modelData.reference || "SGCB" + elide: Text.ElideMiddle + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + RowLayout { + Layout.fillWidth: true + Label { + text: "ActSG " + (groupRow.modelData.actSG === undefined ? "—" : groupRow.modelData.actSG) + color: theme.textSoft + font.pixelSize: theme.captionSize + } + Label { + text: "Num " + (groupRow.modelData.numOfSG === undefined ? "—" : groupRow.modelData.numOfSG) + color: theme.textSoft + font.pixelSize: theme.captionSize + } + Item { Layout.fillWidth: true } + Label { + text: groupRow.modelData.canActivate === true ? "ACTIVATE READY" : "READ ONLY" + color: groupRow.modelData.canActivate === true ? theme.green : theme.amber + font.pixelSize: 9 + font.weight: Font.Bold + } + } + } + + MouseArea { + id: groupMouse + anchors.fill: parent + hoverEnabled: true + onClicked: settings.selectSettingGroup(groupRow.index) + } + } + + ScrollBar.vertical: ScrollBar {} + } + } + } + + SurfaceCard { + Layout.fillWidth: true + Layout.fillHeight: true + theme: root.theme + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 9 + + Label { + text: settings.selectedSettingGroup.reference || "Select a Setting Group" + color: theme.text + font.pixelSize: theme.subtitleSize + font.weight: Font.DemiBold + } + Label { + text: settings.selectedSettingGroup.functionalConstraints + ? "Exact FC paths: " + settings.selectedSettingGroup.functionalConstraints + : "" + color: theme.muted + font.pixelSize: theme.captionSize + } + + GridLayout { + Layout.fillWidth: true + columns: 5 + columnSpacing: 8 + rowSpacing: 5 + + Repeater { + model: [ + {label: "NumOfSG", key: "numOfSG"}, + {label: "ActSG", key: "actSG"}, + {label: "EditSG", key: "editSG"}, + {label: "CnfEdit", key: "cnfEdit"}, + {label: "LActTm", key: "lActTm"} + ] + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: 58 + radius: 6 + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 7 + spacing: 2 + Label { + text: modelData.label + color: theme.muted + font.pixelSize: 9 + } + Label { + Layout.fillWidth: true + text: root.valueOrDash(modelData.key) + elide: Text.ElideRight + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Activate group" + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + SpinBox { + id: groupTarget + from: 1 + to: Math.max(1, Number(settings.selectedSettingGroup.numOfSG || 1)) + value: Math.max(1, Number(settings.selectedSettingGroup.actSG || 1)) + editable: true + enabled: root.activationReady() && !settings.operationBusy + } + ActionButton { + theme: root.theme + text: "Activate + verify" + primary: true + enabled: settings.connected && root.activationReady() && !settings.operationBusy + onClicked: settings.activateSelectedSettingGroup(groupTarget.value) + } + ActionButton { + theme: root.theme + text: "Cancel" + danger: true + enabled: settings.operationBusy + onClicked: settings.cancelOperation() + } + Item { Layout.fillWidth: true } + Label { + text: settings.selectedSettingGroup.complete === true ? "5/5 READ PATH READY" : "PARTIAL READ" + color: settings.selectedSettingGroup.complete === true ? theme.green : theme.amber + font.pixelSize: 9 + font.weight: Font.Bold + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: root.activationReady() ? 50 : 66 + radius: 6 + color: root.activationReady() ? theme.greenSoft : theme.amberSoft + border.width: 1 + border.color: root.activationReady() ? theme.green : theme.amber + Label { + anchors.fill: parent + anchors.margins: 8 + text: root.activationReady() + ? "Guard: one exact-type ActSG Write followed by verification Read. No automatic retry." + : (settings.selectedSettingGroup.activationBlockedReason || + "Activation unavailable until SGCB values are read safely.") + wrapMode: Text.Wrap + color: theme.textSoft + font.pixelSize: theme.captionSize + } + } + + Label { + text: "Exact attributes" + color: theme.text + font.pixelSize: theme.labelSize + font.weight: Font.DemiBold + } + + ListView { + id: attributeList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: settings.selectedSettingGroup.attributes || [] + spacing: 2 + delegate: Rectangle { + required property var modelData + width: attributeList.width + height: 43 + radius: 5 + color: theme.chrome + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + Label { + text: modelData.path || "" + color: theme.text + font.pixelSize: theme.captionSize + font.weight: Font.DemiBold + Layout.preferredWidth: 90 + } + Label { + Layout.fillWidth: true + text: modelData.reference || "" + elide: Text.ElideMiddle + color: theme.muted + font.pixelSize: 9 + } + Label { + text: modelData.success === true ? modelData.value : "READ FAILED" + color: modelData.success === true ? theme.textSoft : theme.red + font.pixelSize: theme.captionSize + Layout.preferredWidth: 135 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + } + } + ScrollBar.vertical: ScrollBar {} + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 58 + radius: 6 + color: theme.surfaceSoft + border.width: 1 + border.color: theme.line + Label { + anchors.fill: parent + anchors.margins: 8 + text: "Full Setting Group editing is NOT claimed in Milestone Q. EditSG → SE value mutation → CnfEdit remains disabled until that complete transaction is implemented and proven." + wrapMode: Text.Wrap + color: theme.textSoft + font.pixelSize: theme.captionSize + } + } + + Label { + Layout.fillWidth: true + visible: settings.lastError.length > 0 + text: settings.lastError + wrapMode: Text.Wrap + color: theme.red + font.pixelSize: theme.captionSize + } + } + } + } + } +} diff --git a/apps/ied_simulator/resources.qrc b/apps/ied_simulator/resources.qrc index fa7f0afb7..ad3c92f68 100644 --- a/apps/ied_simulator/resources.qrc +++ b/apps/ied_simulator/resources.qrc @@ -1,6 +1,9 @@ ../arstack_studio/assets/InterVariable.ttf + ../arstack_studio/assets/LUCIDE_LICENSE.txt + assets/black-fascia-ied.svg + ../../assets/favicon-96x96.png ../arstack_studio/assets/lucide/activity.svg ../arstack_studio/assets/lucide/chevron-down.svg ../arstack_studio/assets/lucide/chevron-up.svg @@ -14,4 +17,7 @@ ../arstack_studio/assets/lucide/upload.svg ../arstack_studio/assets/lucide/x.svg + + qml/InteropWorkspaceV2.qml + diff --git a/apps/ied_simulator/src/GooseMonitorController.cpp b/apps/ied_simulator/src/GooseMonitorController.cpp new file mode 100644 index 000000000..928aded79 --- /dev/null +++ b/apps/ied_simulator/src/GooseMonitorController.cpp @@ -0,0 +1,573 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "GooseMonitorController.hpp" + +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/mms/data_value.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using ar::iec61850::goose::GooseSequenceStatus; +using ar::iec61850::mms::MmsDataKind; +using ar::iec61850::mms::MmsDataValue; + +QString appIdText(const std::uint16_t appId) { + return QStringLiteral("0x%1").arg( + QString::number(appId, 16).rightJustified(4, QLatin1Char('0')).toUpper()); +} + +QString rawHex(const std::vector& bytes) { + if (bytes.empty()) return QStringLiteral("—"); + const auto raw = QByteArray( + reinterpret_cast(bytes.data()), + static_cast(std::min( + bytes.size(), static_cast(std::numeric_limits::max())))); + return QString::fromLatin1(raw.toHex(' ')).toUpper(); +} + +bool sequenceAnomaly(const GooseSequenceStatus status) noexcept { + return status == GooseSequenceStatus::duplicate || + status == GooseSequenceStatus::sequence_gap || + status == GooseSequenceStatus::sequence_regression || + status == GooseSequenceStatus::state_jump || + status == GooseSequenceStatus::state_regression; +} + +bool sequenceIssue(const GooseSequenceStatus status) noexcept { + return status == GooseSequenceStatus::sequence_gap || + status == GooseSequenceStatus::sequence_regression || + status == GooseSequenceStatus::state_jump || + status == GooseSequenceStatus::state_regression; +} +} // namespace + +GooseMonitorController::GooseMonitorController(QObject* parent) + : QAbstractListModel(parent) { + monitorClock_.start(); + captureTimer_ = new QTimer(this); + captureTimer_->setInterval(kPollIntervalMilliseconds); + captureTimer_->setTimerType(Qt::PreciseTimer); + connect(captureTimer_, &QTimer::timeout, this, &GooseMonitorController::pollCapture); +} + +GooseMonitorController::~GooseMonitorController() { + stopCapture(); +} + +int GooseMonitorController::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(streams_.size()); +} + +QVariant GooseMonitorController::data(const QModelIndex& index, const int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) return {}; + const auto& slot = streams_[static_cast(index.row())]; + const auto& frame = slot.frame; + switch (role) { + case SourceMacRole: return QString::fromStdString(frame.source.to_string()); + case DestinationMacRole: return QString::fromStdString(frame.destination.to_string()); + case AppIdRole: return static_cast(frame.app_id); + case AppIdTextRole: return appIdText(frame.app_id); + case VlanIdRole: return frame.vlan.has_value() ? QVariant{static_cast(frame.vlan->vlan_id)} : QVariant{}; + case VlanPriorityRole: + return frame.vlan.has_value() ? QVariant{static_cast(frame.vlan->priority_code_point)} : QVariant{}; + case GoIdRole: return QString::fromStdString(frame.pdu.go_id); + case GoCbRefRole: return QString::fromStdString(frame.pdu.go_cb_ref); + case DataSetReferenceRole: return QString::fromStdString(frame.pdu.data_set_reference); + case ConfRevRole: return static_cast(frame.pdu.configuration_revision); + case StateNumberRole: return static_cast(frame.pdu.state_number); + case SequenceNumberRole: return static_cast(frame.pdu.sequence_number); + case TtlRole: return static_cast(frame.pdu.time_allowed_to_live_milliseconds); + case StatusRole: return slot.status; + case TimedOutRole: return slot.timedOut; + case LateRole: return slot.late; + case AnomalyRole: return slot.anomaly; + case PacketCountRole: return static_cast(slot.packetCount); + case LastSeenMsRole: return slot.lastSeenMilliseconds; + case ValuesSummaryRole: return slot.valuesSummary; + case ValueCountRole: return static_cast(slot.values.size()); + default: return {}; + } +} + +QHash GooseMonitorController::roleNames() const { + return { + {SourceMacRole, "sourceMac"}, + {DestinationMacRole, "destinationMac"}, + {AppIdRole, "appId"}, + {AppIdTextRole, "appIdText"}, + {VlanIdRole, "vlanId"}, + {VlanPriorityRole, "vlanPriority"}, + {GoIdRole, "goId"}, + {GoCbRefRole, "goCbRef"}, + {DataSetReferenceRole, "dataSetReference"}, + {ConfRevRole, "confRev"}, + {StateNumberRole, "stNum"}, + {SequenceNumberRole, "sqNum"}, + {TtlRole, "ttlMs"}, + {StatusRole, "status"}, + {TimedOutRole, "timedOut"}, + {LateRole, "late"}, + {AnomalyRole, "anomaly"}, + {PacketCountRole, "packets"}, + {LastSeenMsRole, "lastSeenMs"}, + {ValuesSummaryRole, "valuesSummary"}, + {ValueCountRole, "valueCount"}, + }; +} + +QStringList GooseMonitorController::interfaces() const { + QStringList result; + for (const auto& networkInterface : QNetworkInterface::allInterfaces()) { + const auto flags = networkInterface.flags(); + if (!flags.testFlag(QNetworkInterface::IsUp) || + !flags.testFlag(QNetworkInterface::IsRunning) || + flags.testFlag(QNetworkInterface::IsLoopBack)) { + continue; + } + const auto name = networkInterface.name().trimmed(); + if (!name.isEmpty() && !result.contains(name)) result.push_back(name); + } + std::sort(result.begin(), result.end()); + return result; +} + +void GooseMonitorController::setInterfaceName(const QString& value) { + const auto normalized = value.trimmed(); + if (normalized == interfaceName_) return; + if (capturing_) { + appendEvent(QStringLiteral("Stop GOOSE monitoring before changing the Ethernet interface.")); + emit monitorChanged(); + return; + } + interfaceName_ = normalized; + statusText_ = normalized.isEmpty() + ? QStringLiteral("Select an Ethernet interface") + : QStringLiteral("Ready on %1").arg(normalized); + emit monitorChanged(); +} + +bool GooseMonitorController::startCapture() { + if (capturing_) return true; + if (interfaceName_.isEmpty()) { + statusText_ = QStringLiteral("Interface required"); + appendEvent(QStringLiteral("GOOSE monitoring rejected: select an explicit Ethernet interface.")); + emit monitorChanged(); + return false; + } + std::string error; + if (!transport_.open(interfaceName_.toStdString(), error)) { + statusText_ = QStringLiteral("Capture failed: %1").arg(QString::fromStdString(error)); + appendEvent(statusText_); + emit monitorChanged(); + return false; + } + capturing_ = true; + statusText_ = QStringLiteral("Capturing GOOSE on %1").arg(interfaceName_); + lastExpiryScanMilliseconds_ = monitorClock_.elapsed(); + captureTimer_->start(); + appendEvent(statusText_); + emit monitorChanged(); + return true; +} + +void GooseMonitorController::stopCapture() { + if (captureTimer_ != nullptr) captureTimer_->stop(); + transport_.close(); + if (!capturing_) return; + capturing_ = false; + statusText_ = QStringLiteral("Stopped"); + appendEvent(QStringLiteral("GOOSE monitoring stopped.")); + emit monitorChanged(); +} + +void GooseMonitorController::clear() { + beginResetModel(); + streams_.clear(); + endResetModel(); + retainedPackets_.clear(); + recentEvents_.clear(); + selectedRow_ = -1; + packetCount_ = 0; + decodeErrorCount_ = 0; + rejectedFrameCount_ = 0; + droppedStreamCount_ = 0; + duplicateCount_ = 0; + sequenceIssueCount_ = 0; + timeoutCount_ = 0; + statusText_ = capturing_ + ? QStringLiteral("Capturing GOOSE on %1").arg(interfaceName_) + : QStringLiteral("Stopped"); + emit selectionChanged(); + emit monitorChanged(); +} + +void GooseMonitorController::select(const int row) { + if (row < 0 || row >= rowCount() || row == selectedRow_) return; + selectedRow_ = row; + emit selectionChanged(); +} + +QVariantMap GooseMonitorController::selectedStream() const { + if (selectedRow_ < 0 || selectedRow_ >= rowCount()) return {}; + return streamMap(streams_[static_cast(selectedRow_)]); +} + +QStringList GooseMonitorController::selectedValues() const { + if (selectedRow_ < 0 || selectedRow_ >= rowCount()) return {}; + return streams_[static_cast(selectedRow_)].values; +} + +QVariantMap GooseMonitorController::streamMap(const StreamSlot& slot) const { + QVariantMap result; + const auto& frame = slot.frame; + result.insert(QStringLiteral("sourceMac"), QString::fromStdString(frame.source.to_string())); + result.insert(QStringLiteral("destinationMac"), QString::fromStdString(frame.destination.to_string())); + result.insert(QStringLiteral("appId"), static_cast(frame.app_id)); + result.insert(QStringLiteral("appIdText"), appIdText(frame.app_id)); + result.insert( + QStringLiteral("vlanId"), + frame.vlan.has_value() ? QVariant{static_cast(frame.vlan->vlan_id)} : QVariant{}); + result.insert( + QStringLiteral("vlanPriority"), + frame.vlan.has_value() ? QVariant{static_cast(frame.vlan->priority_code_point)} : QVariant{}); + result.insert(QStringLiteral("goId"), QString::fromStdString(frame.pdu.go_id)); + result.insert(QStringLiteral("goCbRef"), QString::fromStdString(frame.pdu.go_cb_ref)); + result.insert(QStringLiteral("dataSetReference"), QString::fromStdString(frame.pdu.data_set_reference)); + result.insert(QStringLiteral("confRev"), static_cast(frame.pdu.configuration_revision)); + result.insert(QStringLiteral("stNum"), static_cast(frame.pdu.state_number)); + result.insert(QStringLiteral("sqNum"), static_cast(frame.pdu.sequence_number)); + result.insert(QStringLiteral("ttlMs"), static_cast(frame.pdu.time_allowed_to_live_milliseconds)); + result.insert(QStringLiteral("status"), slot.status); + result.insert(QStringLiteral("timedOut"), slot.timedOut); + result.insert(QStringLiteral("late"), slot.late); + result.insert(QStringLiteral("anomaly"), slot.anomaly); + result.insert(QStringLiteral("packets"), static_cast(slot.packetCount)); + result.insert(QStringLiteral("lastSeenMs"), slot.lastSeenMilliseconds); + result.insert(QStringLiteral("valueCount"), static_cast(slot.values.size())); + result.insert(QStringLiteral("valuesSummary"), slot.valuesSummary); + return result; +} + +QString GooseMonitorController::sequenceStatusText(const GooseSequenceStatus status) { + switch (status) { + case GooseSequenceStatus::first: return QStringLiteral("First frame"); + case GooseSequenceStatus::retransmission: return QStringLiteral("Retransmission"); + case GooseSequenceStatus::state_change: return QStringLiteral("State change"); + case GooseSequenceStatus::duplicate: return QStringLiteral("Duplicate"); + case GooseSequenceStatus::sequence_gap: return QStringLiteral("Sequence gap"); + case GooseSequenceStatus::sequence_regression: return QStringLiteral("Out of order"); + case GooseSequenceStatus::state_jump: return QStringLiteral("State jump"); + case GooseSequenceStatus::state_regression: return QStringLiteral("Stale state"); + case GooseSequenceStatus::identity_mismatch: return QStringLiteral("Identity mismatch"); + } + return QStringLiteral("Unknown"); +} + +QString GooseMonitorController::formatValue(const MmsDataValue& value, const int depth) { + if (depth > 4) return QStringLiteral("…"); + const auto& scalar = value.value(); + switch (value.kind()) { + case MmsDataKind::boolean: + if (const auto* v = std::get_if(&scalar)) return *v ? QStringLiteral("true") : QStringLiteral("false"); + break; + case MmsDataKind::integer: + if (const auto* v = std::get_if(&scalar)) return QString::number(*v); + break; + case MmsDataKind::unsigned_integer: + if (const auto* v = std::get_if(&scalar)) return QString::number(*v); + break; + case MmsDataKind::floating_point: + if (const auto* v = std::get_if(&scalar)) return QString::number(*v, 'g', 8); + if (const auto* v = std::get_if(&scalar)) return QString::number(*v, 'g', 12); + break; + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: + if (const auto* v = std::get_if(&scalar)) return QString::fromStdString(*v); + break; + case MmsDataKind::utc_time: + if (const auto* v = std::get_if(&scalar)) { + const auto milliseconds = std::chrono::duration_cast( + v->value.time_since_epoch()).count(); + return QStringLiteral("UTC %1 ms q=0x%2") + .arg(milliseconds) + .arg(QString::number(v->quality, 16).rightJustified(2, QLatin1Char('0')).toUpper()); + } + break; + case MmsDataKind::array: + case MmsDataKind::structure: { + QStringList children; + const auto& values = value.children(); + const auto limit = std::min(values.size(), 12U); + for (std::size_t index = 0; index < limit; ++index) { + children.push_back(formatValue(values[index], depth + 1)); + } + if (values.size() > limit) { + children.push_back(QStringLiteral("… +%1").arg( + static_cast(values.size() - limit))); + } + const bool array = value.kind() == MmsDataKind::array; + return (array ? QStringLiteral("[") : QStringLiteral("{")) + + children.join(QStringLiteral(", ")) + + (array ? QStringLiteral("]") : QStringLiteral("}")); + } + case MmsDataKind::bit_string: return QStringLiteral("bits %1").arg(rawHex(value.raw_value())); + case MmsDataKind::octet_string: return QStringLiteral("octets %1").arg(rawHex(value.raw_value())); + case MmsDataKind::binary_time: return QStringLiteral("binary-time %1").arg(rawHex(value.raw_value())); + case MmsDataKind::bcd: return QStringLiteral("bcd %1").arg(rawHex(value.raw_value())); + case MmsDataKind::boolean_array: return QStringLiteral("bool-array %1").arg(rawHex(value.raw_value())); + case MmsDataKind::object_id: return QStringLiteral("object-id %1").arg(rawHex(value.raw_value())); + case MmsDataKind::unknown: return QStringLiteral("unknown %1").arg(rawHex(value.raw_value())); + } + return QStringLiteral("—"); +} + +QString GooseMonitorController::streamKey(const ar::iec61850::goose::GooseFrame& frame) { + return QString::fromStdString(frame.source.to_string()) + QLatin1Char('|') + + QString::number(frame.app_id) + QLatin1Char('|') + QString::fromStdString(frame.pdu.go_cb_ref); +} + +int GooseMonitorController::indexForKey(const QString& key) const noexcept { + for (int index = 0; index < rowCount(); ++index) { + if (streams_[static_cast(index)].key == key) return index; + } + return -1; +} + +bool GooseMonitorController::ingestFrameBytes( + const std::span frameBytes, + const clock::time_point arrivalTime) { + return ingestFrameInternal(frameBytes, arrivalTime, std::chrono::system_clock::now()); +} + +bool GooseMonitorController::ingestFrameInternal( + const std::span frameBytes, + const clock::time_point arrivalTime, + const std::chrono::system_clock::time_point captureTimestamp) { + ar::iec61850::goose::GooseFrame decoded; + if (!ar::iec61850::goose::GooseFrameCodec::try_decode(frameBytes, decoded)) { + ++decodeErrorCount_; + appendEvent(QStringLiteral("Rejected malformed/non-GOOSE Ethernet frame.")); + emit monitorChanged(); + return false; + } + if (decoded.pdu.values.size() > static_cast(kMemberCapacity)) { + ++rejectedFrameCount_; + appendEvent(QStringLiteral("Rejected GOOSE frame: allData exceeds 256 members.")); + emit monitorChanged(); + return false; + } + + const auto key = streamKey(decoded); + int row = indexForKey(key); + if (row < 0) { + if (rowCount() >= kStreamCapacity) { + ++droppedStreamCount_; + appendEvent(QStringLiteral("Dropped new GOOSE identity: stream table capacity is 256.")); + emit monitorChanged(); + return false; + } + row = rowCount(); + beginInsertRows(QModelIndex{}, row, row); + streams_.emplace_back(); + streams_.back().key = key; + endInsertRows(); + if (selectedRow_ < 0) { + selectedRow_ = row; + emit selectionChanged(); + } + } + + auto& slot = streams_[static_cast(row)]; + const bool wasTimedOut = slot.timedOut; + ar::iec61850::goose::GooseSupervisionResult supervision; + try { + supervision = slot.supervisor.observe(decoded.pdu, arrivalTime); + } catch (const std::exception& error) { + ++rejectedFrameCount_; + appendEvent(QStringLiteral("Rejected GOOSE frame: %1").arg(QString::fromUtf8(error.what()))); + emit monitorChanged(); + return false; + } + if (!supervision.accepted) { + ++rejectedFrameCount_; + appendEvent(QStringLiteral("Rejected GOOSE identity mismatch.")); + emit monitorChanged(); + return false; + } + + if (supervision.status == GooseSequenceStatus::duplicate) ++duplicateCount_; + bool issue = sequenceIssue(supervision.status); + if (supervision.state_change_sequence_not_zero || + supervision.value_changed_without_state_increment || + supervision.configuration_revision_changed) { + issue = true; + } + if (issue) ++sequenceIssueCount_; + if (supervision.expired_before_arrival && !wasTimedOut) ++timeoutCount_; + + slot.frame = std::move(decoded); + slot.packetCount++; + slot.lastSeenMilliseconds = monitorClock_.elapsed(); + slot.timedOut = false; + slot.late = supervision.expired_before_arrival; + slot.anomaly = sequenceAnomaly(supervision.status) || issue || slot.late; + slot.status = sequenceStatusText(supervision.status); + if (slot.late) slot.status.prepend(QStringLiteral("Late after timeout · ")); + if (supervision.missed_sequence_count > 0U) { + slot.status += QStringLiteral(" · missed %1").arg(supervision.missed_sequence_count); + } + if (supervision.state_change_sequence_not_zero) slot.status += QStringLiteral(" · sqNum not zero"); + if (supervision.value_changed_without_state_increment) { + slot.status += QStringLiteral(" · value changed without stNum"); + } + if (supervision.configuration_revision_changed) slot.status += QStringLiteral(" · ConfRev changed"); + + slot.values.clear(); + slot.values.reserve(static_cast(slot.frame.pdu.values.size())); + for (std::size_t index = 0; index < slot.frame.pdu.values.size(); ++index) { + slot.values.push_back( + QStringLiteral("[%1] %2").arg(static_cast(index)).arg(formatValue(slot.frame.pdu.values[index]))); + } + QStringList summary; + const int summaryCount = std::min(3, static_cast(slot.values.size())); + for (int index = 0; index < summaryCount; ++index) summary.push_back(slot.values.at(index)); + if (slot.values.size() > summaryCount) { + summary.push_back(QStringLiteral("… +%1").arg( + static_cast(slot.values.size() - summaryCount))); + } + slot.valuesSummary = summary.join(QStringLiteral(" ")); + + ++packetCount_; + retainPacket(frameBytes, captureTimestamp); + appendEvent( + QStringLiteral("%1 · %2 · st=%3 sq=%4 · %5") + .arg(appIdText(slot.frame.app_id), QString::fromStdString(slot.frame.pdu.go_cb_ref)) + .arg(slot.frame.pdu.state_number) + .arg(slot.frame.pdu.sequence_number) + .arg(slot.status)); + + const auto modelIndex = index(row, 0); + emit dataChanged(modelIndex, modelIndex); + if (selectedRow_ == row) emit selectionChanged(); + emit monitorChanged(); + return true; +} + +void GooseMonitorController::checkTimeoutsAt(const clock::time_point now) { + bool changed = false; + for (int row = 0; row < rowCount(); ++row) { + auto& slot = streams_[static_cast(row)]; + const auto expiry = slot.supervisor.check_expiry(now); + if (!expiry.has_value()) continue; + slot.timedOut = true; + slot.anomaly = true; + slot.status = QStringLiteral("Timeout"); + ++timeoutCount_; + appendEvent( + QStringLiteral("TIMEOUT · %1 · st=%2 sq=%3 ttl=%4 ms") + .arg(QString::fromStdString(slot.frame.pdu.go_cb_ref)) + .arg(expiry->state_number) + .arg(expiry->sequence_number) + .arg(expiry->time_allowed_to_live_milliseconds)); + const auto modelIndex = index(row, 0); + emit dataChanged(modelIndex, modelIndex); + if (selectedRow_ == row) emit selectionChanged(); + changed = true; + } + if (changed) emit monitorChanged(); +} + +void GooseMonitorController::pollCapture() { + if (!capturing_) return; + for (int count = 0; count < kPollFrameBudget; ++count) { + std::vector frame; + std::string error; + const auto result = transport_.receive(frame, error); + if (result == ar::iec61850::goose::RawEthernetReceiveResult::none) break; + if (result == ar::iec61850::goose::RawEthernetReceiveResult::failure) { + failCapture(QString::fromStdString(error)); + return; + } + static_cast(ingestFrameInternal( + frame, + clock::now(), + std::chrono::system_clock::now())); + } + + const qint64 now = monitorClock_.elapsed(); + if (now - lastExpiryScanMilliseconds_ >= kExpiryScanIntervalMilliseconds) { + lastExpiryScanMilliseconds_ = now; + checkTimeoutsAt(clock::now()); + } +} + +void GooseMonitorController::appendEvent(QString event) { + if (event.isEmpty()) return; + while (recentEvents_.size() >= kEventCapacity) recentEvents_.removeFirst(); + recentEvents_.push_back(std::move(event)); +} + +void GooseMonitorController::retainPacket( + const std::span frameBytes, + const std::chrono::system_clock::time_point timestamp) { + while (static_cast(retainedPackets_.size()) >= kRetainedPacketCapacity) { + retainedPackets_.pop_front(); + } + retainedPackets_.push_back(ar::iec61850::capture::PcapPacket{ + timestamp, + std::vector{frameBytes.begin(), frameBytes.end()}}); +} + +bool GooseMonitorController::exportPcap(const QUrl& fileUrl) { + const auto localFile = fileUrl.toLocalFile(); + if (localFile.isEmpty()) { + statusText_ = QStringLiteral("PCAP export requires a local file path."); + emit monitorChanged(); + return false; + } + if (retainedPackets_.empty()) { + statusText_ = QStringLiteral("No retained GOOSE packets to export."); + emit monitorChanged(); + return false; + } + try { + std::vector packets; + packets.reserve(retainedPackets_.size()); + for (const auto& packet : retainedPackets_) packets.push_back(packet); + ar::iec61850::capture::PcapWriter::write_all( + std::filesystem::path{localFile.toStdString()}, packets); + statusText_ = QStringLiteral("Saved %1 GOOSE packets to %2") + .arg(static_cast(packets.size())) + .arg(localFile); + appendEvent(statusText_); + emit monitorChanged(); + return true; + } catch (const std::exception& error) { + statusText_ = QStringLiteral("PCAP export failed: %1").arg(QString::fromUtf8(error.what())); + appendEvent(statusText_); + emit monitorChanged(); + return false; + } +} + +void GooseMonitorController::failCapture(const QString& reason) { + if (captureTimer_ != nullptr) captureTimer_->stop(); + transport_.close(); + capturing_ = false; + statusText_ = QStringLiteral("Capture stopped: %1").arg(reason); + appendEvent(statusText_); + emit monitorChanged(); +} diff --git a/apps/ied_simulator/src/GooseMonitorController.hpp b/apps/ied_simulator/src/GooseMonitorController.hpp new file mode 100644 index 000000000..23a3ef4f2 --- /dev/null +++ b/apps/ied_simulator/src/GooseMonitorController.hpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/goose/frame.hpp" +#include "ariec61850/goose/raw_ethernet_subscriber.hpp" +#include "ariec61850/goose/subscriber_supervisor.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +class QTimer; + +class GooseMonitorController : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY monitorChanged) + Q_PROPERTY(QString interfaceName READ interfaceName WRITE setInterfaceName NOTIFY monitorChanged) + Q_PROPERTY(bool capturing READ capturing NOTIFY monitorChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY monitorChanged) + Q_PROPERTY(int streamCount READ streamCount NOTIFY monitorChanged) + Q_PROPERTY(quint64 packetCount READ packetCount NOTIFY monitorChanged) + Q_PROPERTY(quint64 decodeErrorCount READ decodeErrorCount NOTIFY monitorChanged) + Q_PROPERTY(quint64 rejectedFrameCount READ rejectedFrameCount NOTIFY monitorChanged) + Q_PROPERTY(quint64 droppedStreamCount READ droppedStreamCount NOTIFY monitorChanged) + Q_PROPERTY(quint64 duplicateCount READ duplicateCount NOTIFY monitorChanged) + Q_PROPERTY(quint64 sequenceIssueCount READ sequenceIssueCount NOTIFY monitorChanged) + Q_PROPERTY(quint64 timeoutCount READ timeoutCount NOTIFY monitorChanged) + Q_PROPERTY(int selectedRow READ selectedRow NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedStream READ selectedStream NOTIFY selectionChanged) + Q_PROPERTY(QStringList selectedValues READ selectedValues NOTIFY selectionChanged) + Q_PROPERTY(QStringList recentEvents READ recentEvents NOTIFY monitorChanged) + Q_PROPERTY(int retainedPacketCount READ retainedPacketCount NOTIFY monitorChanged) + Q_PROPERTY(int streamCapacity READ streamCapacity CONSTANT) + Q_PROPERTY(int memberCapacity READ memberCapacity CONSTANT) + Q_PROPERTY(int eventCapacity READ eventCapacity CONSTANT) + Q_PROPERTY(int retainedPacketCapacity READ retainedPacketCapacity CONSTANT) + +public: + using clock = ar::iec61850::goose::GooseSubscriberSupervisor::clock; + + enum Role { + SourceMacRole = Qt::UserRole + 1, + DestinationMacRole, + AppIdRole, + AppIdTextRole, + VlanIdRole, + VlanPriorityRole, + GoIdRole, + GoCbRefRole, + DataSetReferenceRole, + ConfRevRole, + StateNumberRole, + SequenceNumberRole, + TtlRole, + StatusRole, + TimedOutRole, + LateRole, + AnomalyRole, + PacketCountRole, + LastSeenMsRole, + ValuesSummaryRole, + ValueCountRole, + }; + + explicit GooseMonitorController(QObject* parent = nullptr); + ~GooseMonitorController() override; + + [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex{}) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] QStringList interfaces() const; + [[nodiscard]] QString interfaceName() const { return interfaceName_; } + void setInterfaceName(const QString& value); + [[nodiscard]] bool capturing() const noexcept { return capturing_; } + [[nodiscard]] QString statusText() const { return statusText_; } + [[nodiscard]] int streamCount() const noexcept { return static_cast(streams_.size()); } + [[nodiscard]] quint64 packetCount() const noexcept { return packetCount_; } + [[nodiscard]] quint64 decodeErrorCount() const noexcept { return decodeErrorCount_; } + [[nodiscard]] quint64 rejectedFrameCount() const noexcept { return rejectedFrameCount_; } + [[nodiscard]] quint64 droppedStreamCount() const noexcept { return droppedStreamCount_; } + [[nodiscard]] quint64 duplicateCount() const noexcept { return duplicateCount_; } + [[nodiscard]] quint64 sequenceIssueCount() const noexcept { return sequenceIssueCount_; } + [[nodiscard]] quint64 timeoutCount() const noexcept { return timeoutCount_; } + [[nodiscard]] int selectedRow() const noexcept { return selectedRow_; } + [[nodiscard]] QVariantMap selectedStream() const; + [[nodiscard]] QStringList selectedValues() const; + [[nodiscard]] QStringList recentEvents() const { return recentEvents_; } + [[nodiscard]] int retainedPacketCount() const noexcept { + return static_cast(retainedPackets_.size()); + } + [[nodiscard]] int streamCapacity() const noexcept { return kStreamCapacity; } + [[nodiscard]] int memberCapacity() const noexcept { return kMemberCapacity; } + [[nodiscard]] int eventCapacity() const noexcept { return kEventCapacity; } + [[nodiscard]] int retainedPacketCapacity() const noexcept { return kRetainedPacketCapacity; } + + Q_INVOKABLE bool startCapture(); + Q_INVOKABLE void stopCapture(); + Q_INVOKABLE void clear(); + Q_INVOKABLE void select(int row); + Q_INVOKABLE bool exportPcap(const QUrl& fileUrl); + + // Deterministic QA/offline evidence path. This uses the same decoder, + // supervisor, bounded model and retention path as live raw-Ethernet capture. + [[nodiscard]] bool ingestFrameBytes( + std::span frameBytes, + clock::time_point arrivalTime); + void checkTimeoutsAt(clock::time_point now); + +signals: + void monitorChanged(); + void selectionChanged(); + +private: + struct StreamSlot final { + QString key; + ar::iec61850::goose::GooseFrame frame; + ar::iec61850::goose::GooseSubscriberSupervisor supervisor; + QString status; + QStringList values; + QString valuesSummary; + quint64 packetCount{}; + qint64 lastSeenMilliseconds{}; + bool timedOut{}; + bool late{}; + bool anomaly{}; + }; + + static constexpr int kStreamCapacity = 256; + static constexpr int kMemberCapacity = 256; + static constexpr int kEventCapacity = 256; + static constexpr int kRetainedPacketCapacity = 4096; + static constexpr int kPollFrameBudget = 64; + static constexpr int kPollIntervalMilliseconds = 10; + static constexpr int kExpiryScanIntervalMilliseconds = 100; + + [[nodiscard]] QVariantMap streamMap(const StreamSlot& slot) const; + [[nodiscard]] static QString sequenceStatusText(ar::iec61850::goose::GooseSequenceStatus status); + [[nodiscard]] static QString formatValue(const ar::iec61850::mms::MmsDataValue& value, int depth = 0); + [[nodiscard]] static QString streamKey(const ar::iec61850::goose::GooseFrame& frame); + [[nodiscard]] int indexForKey(const QString& key) const noexcept; + bool ingestFrameInternal( + std::span frameBytes, + clock::time_point arrivalTime, + std::chrono::system_clock::time_point captureTimestamp); + void pollCapture(); + void appendEvent(QString event); + void retainPacket( + std::span frameBytes, + std::chrono::system_clock::time_point timestamp); + void failCapture(const QString& reason); + + std::vector streams_; + std::deque retainedPackets_; + QStringList recentEvents_; + QString interfaceName_; + QString statusText_{QStringLiteral("Stopped")}; + ar::iec61850::goose::RawEthernetSubscriber transport_; + QTimer* captureTimer_{}; + QElapsedTimer monitorClock_; + qint64 lastExpiryScanMilliseconds_{}; + int selectedRow_{-1}; + bool capturing_{}; + quint64 packetCount_{}; + quint64 decodeErrorCount_{}; + quint64 rejectedFrameCount_{}; + quint64 droppedStreamCount_{}; + quint64 duplicateCount_{}; + quint64 sequenceIssueCount_{}; + quint64 timeoutCount_{}; +}; diff --git a/apps/ied_simulator/src/IedActivityModel.cpp b/apps/ied_simulator/src/IedActivityModel.cpp new file mode 100644 index 000000000..46d1a8ac6 --- /dev/null +++ b/apps/ied_simulator/src/IedActivityModel.cpp @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedActivityModel.hpp" + +#include +#include +#include +#include + +#include + +IedActivityModel::IedActivityModel(QObject* parent) + : QAbstractListModel(parent) { + publishTimer_.setSingleShot(true); + publishTimer_.setInterval(16); + connect(&publishTimer_, &QTimer::timeout, this, &IedActivityModel::publishPending); + + filterTimer_.setSingleShot(true); + filterTimer_.setInterval(80); + connect(&filterTimer_, &QTimer::timeout, this, &IedActivityModel::rebuildVisible); +} + +QString IedActivityModel::filterText() const { return filterText_; } +QString IedActivityModel::severityFilter() const { return severityFilter_; } +int IedActivityModel::retainedCount() const noexcept { + const qsizetype stagedCount = + events_.size() + pendingEvents_.size() + (droppedPendingEvents_ > 0 ? 1 : 0); + return static_cast(std::min(maxRetainedEvents_, stagedCount)); +} +int IedActivityModel::visibleCount() const noexcept { return visibleIndices_.size(); } + +void IedActivityModel::setFilterText(const QString& value) { + if (filterText_ == value) return; + filterText_ = value; + emit filterTextChanged(); + scheduleFilterRebuild(); +} + +void IedActivityModel::setSeverityFilter(const QString& value) { + const auto normalized = value.trimmed().isEmpty() ? QStringLiteral("All") : value.trimmed(); + if (severityFilter_ == normalized) return; + severityFilter_ = normalized; + emit severityFilterChanged(); + scheduleFilterRebuild(); +} + +int IedActivityModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : visibleIndices_.size(); +} + +QVariant IedActivityModel::data(const QModelIndex& index, const int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= visibleIndices_.size()) return {}; + const int sourceIndex = visibleIndices_.at(index.row()); + if (sourceIndex < 0 || sourceIndex >= events_.size()) return {}; + const auto event = events_.at(sourceIndex).toMap(); + switch (role) { + case TimeRole: return event.value(QStringLiteral("time")); + case CategoryRole: return event.value(QStringLiteral("category")); + case MessageRole: return event.value(QStringLiteral("message")); + case SeverityRole: return event.value(QStringLiteral("severity")); + case IedRole: return event.value(QStringLiteral("ied")); + default: return {}; + } +} + +QHash IedActivityModel::roleNames() const { + return { + {TimeRole, "time"}, + {CategoryRole, "category"}, + {MessageRole, "message"}, + {SeverityRole, "severity"}, + {IedRole, "ied"}, + }; +} + +void IedActivityModel::push_front(const QVariant& value) { + if (pendingEvents_.size() >= maxPendingEvents_) { + pendingEvents_.removeFirst(); + ++droppedPendingEvents_; + } + pendingEvents_.push_back(value); + schedulePublish(); +} + +void IedActivityModel::removeLast() { + // The compatibility caller still performs a legacy >300 trim loop. size() + // is already capped, so this is only used by older direct callers. + if (!pendingEvents_.isEmpty()) { + pendingEvents_.removeFirst(); + schedulePublish(); + return; + } + if (events_.isEmpty()) return; + events_.removeLast(); + rebuildVisible(); +} + +void IedActivityModel::clear() { + publishTimer_.stop(); + filterTimer_.stop(); + if (events_.isEmpty() && pendingEvents_.isEmpty() && droppedPendingEvents_ == 0) return; + const int previousRetained = retainedCount(); + const int previousVisible = visibleCount(); + beginResetModel(); + events_.clear(); + pendingEvents_.clear(); + visibleIndices_.clear(); + droppedPendingEvents_ = 0; + endResetModel(); + if (previousRetained != 0) emit retainedCountChanged(); + if (previousVisible != 0) emit visibleCountChanged(); +} + +int IedActivityModel::size() const noexcept { return retainedCount(); } + +QVariantList IedActivityModel::snapshot() const { + QVariantList result; + result.reserve(retainedCount()); + + if (droppedPendingEvents_ > 0) { + QVariantMap summary; + summary.insert( + QStringLiteral("time"), + QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz"))); + summary.insert(QStringLiteral("category"), QStringLiteral("Diagnostics")); + summary.insert( + QStringLiteral("message"), + QStringLiteral("%1 high-rate activity events were coalesced to protect the UI event loop.") + .arg(droppedPendingEvents_)); + summary.insert(QStringLiteral("severity"), QStringLiteral("Warning")); + summary.insert(QStringLiteral("ied"), QString{}); + result.push_back(summary); + } + for (auto it = pendingEvents_.crbegin(); it != pendingEvents_.crend(); ++it) { + if (result.size() >= maxRetainedEvents_) break; + result.push_back(*it); + } + for (const auto& event : events_) { + if (result.size() >= maxRetainedEvents_) break; + result.push_back(event); + } + return result; +} + +bool IedActivityModel::exportDiagnostics(const QUrl& fileUrl, const QString& text) const { + const auto path = fileUrl.toLocalFile(); + if (path.isEmpty()) return false; + const auto payload = text.toUtf8(); + if (payload.isEmpty() || payload.size() > maxDiagnosticsBytes_) return false; + + QSaveFile output(path); + if (!output.open(QIODevice::WriteOnly)) return false; + if (output.write(payload) != payload.size()) { + output.cancelWriting(); + return false; + } + return output.commit(); +} + +bool IedActivityModel::matches(const QVariantMap& event) const { + if (severityFilter_ != QStringLiteral("All") && + event.value(QStringLiteral("severity"), QStringLiteral("Info")).toString() != severityFilter_) { + return false; + } + + const auto query = filterText_.trimmed(); + if (query.isEmpty()) return true; + const QStringList fields{ + event.value(QStringLiteral("time")).toString(), + event.value(QStringLiteral("severity")).toString(), + event.value(QStringLiteral("ied")).toString(), + event.value(QStringLiteral("category")).toString(), + event.value(QStringLiteral("message")).toString(), + }; + for (const auto& field : fields) { + if (field.contains(query, Qt::CaseInsensitive)) return true; + } + return false; +} + +QVector IedActivityModel::buildVisibleIndices(const QVariantList& events) const { + QVector result; + result.reserve(events.size()); + for (int index = 0; index < events.size(); ++index) { + if (matches(events.at(index).toMap())) result.push_back(index); + } + return result; +} + +void IedActivityModel::schedulePublish() { + if (!publishTimer_.isActive()) publishTimer_.start(); +} + +void IedActivityModel::publishPending() { + if (pendingEvents_.isEmpty() && droppedPendingEvents_ == 0) return; + const int previousRetained = events_.size(); + const int previousVisible = visibleIndices_.size(); + + auto next = snapshot(); + auto nextVisible = buildVisibleIndices(next); + beginResetModel(); + events_ = std::move(next); + pendingEvents_.clear(); + droppedPendingEvents_ = 0; + visibleIndices_ = std::move(nextVisible); + endResetModel(); + + if (previousRetained != events_.size()) emit retainedCountChanged(); + if (previousVisible != visibleIndices_.size()) emit visibleCountChanged(); +} + +void IedActivityModel::scheduleFilterRebuild() { + if (!filterTimer_.isActive()) filterTimer_.start(); +} + +void IedActivityModel::rebuildVisible() { + // Publish staged events first so a filter change never reveals a stale row + // mapping. publishPending() already performs the reset in that case. + if (!pendingEvents_.isEmpty() || droppedPendingEvents_ > 0) { + publishTimer_.stop(); + publishPending(); + return; + } + + const auto nextVisible = buildVisibleIndices(events_); + if (nextVisible == visibleIndices_) return; + const int previousVisible = visibleIndices_.size(); + beginResetModel(); + visibleIndices_ = nextVisible; + endResetModel(); + if (previousVisible != visibleIndices_.size()) emit visibleCountChanged(); +} diff --git a/apps/ied_simulator/src/IedActivityModel.hpp b/apps/ied_simulator/src/IedActivityModel.hpp new file mode 100644 index 000000000..043ab28ed --- /dev/null +++ b/apps/ied_simulator/src/IedActivityModel.hpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +class IedActivityModel : public QAbstractListModel { + Q_OBJECT + QML_ANONYMOUS + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) + Q_PROPERTY(QString severityFilter READ severityFilter WRITE setSeverityFilter NOTIFY severityFilterChanged) + Q_PROPERTY(int retainedCount READ retainedCount NOTIFY retainedCountChanged) + Q_PROPERTY(int visibleCount READ visibleCount NOTIFY visibleCountChanged) + +public: + enum Role { + TimeRole = Qt::UserRole + 1, + CategoryRole, + MessageRole, + SeverityRole, + IedRole, + }; + Q_ENUM(Role) + + explicit IedActivityModel(QObject* parent = nullptr); + + [[nodiscard]] QString filterText() const; + [[nodiscard]] QString severityFilter() const; + [[nodiscard]] int retainedCount() const noexcept; + [[nodiscard]] int visibleCount() const noexcept; + + void setFilterText(const QString& value); + void setSeverityFilter(const QString& value); + + [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex{}) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + // Compatibility surface for the controller's existing bounded activity + // append path. Events are staged for at most one UI frame, then published + // as one model reset instead of one QML list rebuild per child-process line. + void push_front(const QVariant& value); + void removeLast(); + void clear(); + [[nodiscard]] int size() const noexcept; + [[nodiscard]] QVariantList snapshot() const; + operator QVariantList() const { return snapshot(); } + + // Diagnostics are written through QSaveFile so an interrupted export never + // leaves a partially-written operator evidence file behind. + Q_INVOKABLE bool exportDiagnostics(const QUrl& fileUrl, const QString& text) const; + + [[nodiscard]] QVariantList::const_iterator begin() const noexcept { return events_.cbegin(); } + [[nodiscard]] QVariantList::const_iterator end() const noexcept { return events_.cend(); } + +signals: + void filterTextChanged(); + void severityFilterChanged(); + void retainedCountChanged(); + void visibleCountChanged(); + +private: + static constexpr int maxRetainedEvents_ = 300; + static constexpr int maxPendingEvents_ = 128; + static constexpr qsizetype maxDiagnosticsBytes_ = 2 * 1024 * 1024; + + [[nodiscard]] bool matches(const QVariantMap& event) const; + [[nodiscard]] QVector buildVisibleIndices(const QVariantList& events) const; + void schedulePublish(); + void publishPending(); + void scheduleFilterRebuild(); + void rebuildVisible(); + + QVariantList events_; + QVariantList pendingEvents_; + QVector visibleIndices_; + QTimer publishTimer_; + QTimer filterTimer_; + QString filterText_; + QString severityFilter_{QStringLiteral("All")}; + int droppedPendingEvents_{}; +}; diff --git a/apps/ied_simulator/src/IedCommissioningModel.cpp b/apps/ied_simulator/src/IedCommissioningModel.cpp new file mode 100644 index 000000000..ff4650743 --- /dev/null +++ b/apps/ied_simulator/src/IedCommissioningModel.cpp @@ -0,0 +1,620 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedCommissioningModel.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace { +QString qstring(const std::string& value) { + return QString::fromStdString(value); +} + +QString logicalNodeName(const ar::iec61850::scl::SclDataSetEntry& entry) { + return qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst); +} + +QString objectReference(const ar::iec61850::scl::SclDataSetEntry& entry) { + return qstring(entry.ied_name) + qstring(entry.ld_inst) + QLatin1Char('/') + + logicalNodeName(entry) + QLatin1Char('.') + qstring(entry.do_name); +} + +QString mmsItem(const ar::iec61850::scl::SclDataSetEntry& entry) { + auto dataObject = qstring(entry.do_name); + dataObject.replace(QLatin1Char('.'), QLatin1Char('$')); + auto dataAttribute = qstring(entry.da_name); + dataAttribute.replace(QLatin1Char('.'), QLatin1Char('$')); + QStringList parts{ + logicalNodeName(entry), + qstring(entry.functional_constraint), + dataObject}; + if (!dataAttribute.isEmpty()) parts.push_back(dataAttribute); + parts.removeAll(QString{}); + return parts.join(QLatin1Char('$')); +} + +QString reportBindingStatus(const ar::iec61850::scl::SclDataSetBindingStatus status) { + using Status = ar::iec61850::scl::SclDataSetBindingStatus; + switch (status) { + case Status::resolved: return QStringLiteral("Bound"); + case Status::resolved_empty: return QStringLiteral("Empty DataSet"); + case Status::unresolved: return QStringLiteral("Unresolved"); + case Status::not_specified: return QStringLiteral("No DataSet"); + } + return QStringLiteral("Unknown"); +} + +QString normalizedKind(QString value) { + value = value.trimmed(); + if (value.compare(QStringLiteral("DataSet"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("DataSet"); + } + if (value.compare(QStringLiteral("Report"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("Report"); + } + if (value.compare(QStringLiteral("GOOSE"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("GOOSE"); + } + if (value.compare(QStringLiteral("SettingGroup"), Qt::CaseInsensitive) == 0 || + value.compare(QStringLiteral("Setting Group"), Qt::CaseInsensitive) == 0 || + value.compare(QStringLiteral("Setting Groups"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("SettingGroup"); + } + if (value.compare(QStringLiteral("Control"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("Control"); + } + return QStringLiteral("All"); +} + +int boundedSize(const std::size_t size) { + return static_cast(std::min( + size, static_cast(std::numeric_limits::max()))); +} + +bool isSettingGroupEntry(const ar::iec61850::scl::SclDataSetEntry& entry) { + const auto fc = qstring(entry.functional_constraint); + if (fc.compare(QStringLiteral("SE"), Qt::CaseInsensitive) == 0) return true; + const auto dataObject = qstring(entry.do_name); + return dataObject.compare(QStringLiteral("SGCB"), Qt::CaseInsensitive) == 0 || + dataObject.startsWith(QStringLiteral("SGCB."), Qt::CaseInsensitive); +} +} // namespace + +IedCommissioningModel::IedCommissioningModel(QObject* parent) + : QObject(parent) {} + +void IedCommissioningModel::setBackend(IedFleetController* backend) { + if (backend_ == backend) return; + if (backend_ != nullptr) disconnect(backend_, nullptr, this, nullptr); + backend_ = backend; + if (backend_ != nullptr) { + connect(backend_, &IedFleetController::modelChanged, this, &IedCommissioningModel::rebuild); + connect( + backend_, + &IedFleetController::selectionChanged, + this, + &IedCommissioningModel::synchronize); + // Runtime state is the ownership boundary for commissioning behaviors. + // Prune synchronously on a Running -> Stopping/Ready/Failed transition so + // behavior-owned values are restored before a restart can snapshot them + // into the next startup manifest. + connect( + backend_, + &IedFleetController::runtimeChanged, + this, + &IedCommissioningModel::pruneBehaviors); + } + synchronizedSessionKey_.clear(); + synchronizedDocumentCount_ = -1; + rebuild(); + emit backendChanged(); +} + +void IedCommissioningModel::setKindFilter(const QString& filter) { + const auto normalized = normalizedKind(filter); + if (kindFilter_ == normalized) return; + kindFilter_ = normalized; + applyFilter(); + emit filterChanged(); +} + +void IedCommissioningModel::setFilterText(const QString& filter) { + const auto normalized = filter.trimmed(); + if (filterText_ == normalized) return; + filterText_ = normalized; + applyFilter(); + emit filterChanged(); +} + +void IedCommissioningModel::synchronize() { + QString sessionKey; + if (backend_ != nullptr && backend_->selectedIedIndex_ >= 0 && + backend_->selectedIedIndex_ < backend_->ieds_.size()) { + sessionKey = backend_->ieds_.at(backend_->selectedIedIndex_) + .toMap() + .value(QStringLiteral("sessionKey")) + .toString(); + } + const auto documentCount = backend_ == nullptr + ? qsizetype{0} + : static_cast(backend_->documents_.size()); + if (sessionKey == synchronizedSessionKey_ && documentCount == synchronizedDocumentCount_) return; + rebuild(); +} + +void IedCommissioningModel::rebuild() { + const QString previousIdentity = + selectedHandleIndex_ >= 0 && selectedHandleIndex_ < handles_.size() + ? handles_.at(selectedHandleIndex_).identity + : QString{}; + + handles_.clear(); + visibleHandles_.clear(); + selectedHandleIndex_ = -1; + dataSetCount_ = 0; + reportCount_ = 0; + gooseCount_ = 0; + settingGroupCount_ = 0; + controlCount_ = 0; + synchronizedSessionKey_.clear(); + synchronizedDocumentCount_ = backend_ == nullptr + ? qsizetype{0} + : static_cast(backend_->documents_.size()); + + if (backend_ == nullptr || backend_->selectedIedIndex_ < 0 || + backend_->selectedIedIndex_ >= backend_->ieds_.size()) { + ++revision_; + emit modelChanged(); + emit selectionChanged(); + return; + } + + const auto ied = backend_->ieds_.at(backend_->selectedIedIndex_).toMap(); + const auto iedName = ied.value(QStringLiteral("name")).toString(); + const int documentIndex = ied.value(QStringLiteral("documentIndex"), -1).toInt(); + synchronizedSessionKey_ = ied.value(QStringLiteral("sessionKey")).toString(); + if (iedName.isEmpty() || documentIndex < 0 || + documentIndex >= static_cast(backend_->documents_.size())) { + ++revision_; + emit modelChanged(); + emit selectionChanged(); + return; + } + + const auto& document = backend_->documents_[static_cast(documentIndex)].document; + const auto append = [this, documentIndex]( + const Kind kind, + const int sourceIndex, + QString identity) { + Handle handle; + handle.kind = kind; + handle.documentIndex = documentIndex; + handle.sourceIndex = sourceIndex; + handle.identity = kindName(kind) + QLatin1Char('\x1f') + std::move(identity); + handles_.push_back(std::move(handle)); + }; + + for (int index = 0; index < boundedSize(document.data_sets.size()); ++index) { + const auto& dataSet = document.data_sets[static_cast(index)]; + if (qstring(dataSet.ied_name) != iedName) continue; + auto identity = qstring(dataSet.reference); + if (identity.isEmpty()) identity = qstring(dataSet.name); + append(Kind::dataSet, index, identity); + ++dataSetCount_; + } + for (int index = 0; index < boundedSize(document.report_controls.size()); ++index) { + const auto& report = document.report_controls[static_cast(index)]; + if (qstring(report.ied_name) != iedName) continue; + auto identity = qstring(report.control_block_reference); + if (identity.isEmpty()) identity = qstring(report.name); + append(Kind::report, index, identity); + ++reportCount_; + } + for (int index = 0; index < boundedSize(document.goose_streams.size()); ++index) { + const auto& goose = document.goose_streams[static_cast(index)]; + if (qstring(goose.ied_name) != iedName) continue; + auto identity = qstring(goose.control_block_reference); + if (identity.isEmpty()) identity = qstring(goose.control_name); + append(Kind::goose, index, identity); + ++gooseCount_; + } + + // Setting-group navigation mirrors the external-client concept without inventing + // SGCB metadata that the bounded SCL parser does not expose separately. + // A Logical Device is included only when the parsed model contains either + // FC=SE setting values or the standard SGCB data object. + QSet settingGroupLogicalDevices; + for (int index = 0; index < boundedSize(document.model_entries.size()); ++index) { + const auto& entry = document.model_entries[static_cast(index)]; + if (qstring(entry.ied_name) != iedName || !isSettingGroupEntry(entry)) continue; + const auto logicalDevice = qstring(entry.ld_inst); + if (logicalDevice.isEmpty() || settingGroupLogicalDevices.contains(logicalDevice)) continue; + settingGroupLogicalDevices.insert(logicalDevice); + append(Kind::settingGroup, index, logicalDevice); + ++settingGroupCount_; + } + + QSet controls; + for (int index = 0; index < boundedSize(document.model_entries.size()); ++index) { + const auto& entry = document.model_entries[static_cast(index)]; + if (qstring(entry.ied_name) != iedName || + qstring(entry.functional_constraint).compare(QStringLiteral("CF"), Qt::CaseInsensitive) != 0 || + qstring(entry.da_name).compare(QStringLiteral("ctlModel"), Qt::CaseInsensitive) != 0 || + qstring(entry.configured_value).trimmed().isEmpty()) { + continue; + } + const auto identity = objectReference(entry); + if (identity.isEmpty() || controls.contains(identity)) continue; + controls.insert(identity); + append(Kind::control, index, identity); + ++controlCount_; + } + + if (!previousIdentity.isEmpty()) { + for (int index = 0; index < handles_.size(); ++index) { + if (handles_.at(index).identity == previousIdentity) { + selectedHandleIndex_ = index; + break; + } + } + } + applyFilter(true); +} + +void IedCommissioningModel::applyFilter(const bool preserveSelection) { + visibleHandles_.clear(); + visibleHandles_.reserve(handles_.size()); + for (int index = 0; index < handles_.size(); ++index) { + const auto& handle = handles_.at(index); + if (kindFilter_ != QStringLiteral("All") && kindName(handle.kind) != kindFilter_) continue; + if (!matches(handle)) continue; + visibleHandles_.push_back(index); + } + + if (!preserveSelection || !visibleHandles_.contains(selectedHandleIndex_)) { + selectedHandleIndex_ = visibleHandles_.isEmpty() ? -1 : visibleHandles_.constFirst(); + } + ++revision_; + emit modelChanged(); + emit selectionChanged(); +} + +QString IedCommissioningModel::kindName(const Kind kind) { + switch (kind) { + case Kind::dataSet: return QStringLiteral("DataSet"); + case Kind::report: return QStringLiteral("Report"); + case Kind::goose: return QStringLiteral("GOOSE"); + case Kind::settingGroup: return QStringLiteral("SettingGroup"); + case Kind::control: return QStringLiteral("Control"); + } + return QStringLiteral("Unknown"); +} + +QVariantMap IedCommissioningModel::mapForHandle( + const Handle& handle, + const int catalogIndex) const { + QVariantMap item; + if (backend_ == nullptr || handle.documentIndex < 0 || + handle.documentIndex >= static_cast(backend_->documents_.size())) { + return item; + } + const auto& document = + backend_->documents_[static_cast(handle.documentIndex)].document; + item.insert(QStringLiteral("catalogIndex"), catalogIndex); + item.insert(QStringLiteral("kind"), kindName(handle.kind)); + item.insert(QStringLiteral("selected"), catalogIndex == selectedHandleIndex_); + + switch (handle.kind) { + case Kind::dataSet: { + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.data_sets.size())) return {}; + const auto& dataSet = document.data_sets[static_cast(handle.sourceIndex)]; + item.insert(QStringLiteral("name"), qstring(dataSet.name)); + item.insert(QStringLiteral("reference"), qstring(dataSet.reference)); + item.insert(QStringLiteral("dataSetReference"), qstring(dataSet.reference)); + item.insert(QStringLiteral("memberCount"), boundedSize(dataSet.entries.size())); + item.insert(QStringLiteral("status"), QStringLiteral("Configured")); + item.insert(QStringLiteral("logicalNode"), qstring(dataSet.logical_node_path)); + item.insert(QStringLiteral("logicalDevice"), qstring(dataSet.ld_inst)); + item.insert( + QStringLiteral("summary"), + QStringLiteral("%1 canonical FCDA member%2") + .arg(static_cast(dataSet.entries.size())) + .arg(dataSet.entries.size() == 1 ? QString{} : QStringLiteral("s"))); + break; + } + case Kind::report: { + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.report_controls.size())) return {}; + const auto& report = document.report_controls[static_cast(handle.sourceIndex)]; + item.insert(QStringLiteral("name"), qstring(report.name)); + item.insert(QStringLiteral("reference"), qstring(report.control_block_reference)); + item.insert(QStringLiteral("dataSetReference"), qstring(report.data_set_reference)); + item.insert(QStringLiteral("memberCount"), boundedSize(report.entries.size())); + item.insert(QStringLiteral("status"), reportBindingStatus(report.data_set_binding_status)); + item.insert(QStringLiteral("buffered"), report.buffered); + item.insert(QStringLiteral("reportId"), qstring(report.report_id)); + item.insert(QStringLiteral("confRev"), static_cast(report.configuration_revision)); + item.insert(QStringLiteral("bufferTimeMs"), static_cast(report.buffer_time_milliseconds)); + item.insert(QStringLiteral("integrityMs"), static_cast(report.integrity_period_milliseconds)); + item.insert(QStringLiteral("logicalNode"), qstring(report.logical_node_path)); + item.insert( + QStringLiteral("summary"), + QStringLiteral("%1 · ConfRev %2 · %3 member%4") + .arg(report.buffered ? QStringLiteral("BRCB") : QStringLiteral("URCB")) + .arg(static_cast(report.configuration_revision)) + .arg(static_cast(report.entries.size())) + .arg(report.entries.size() == 1 ? QString{} : QStringLiteral("s"))); + break; + } + case Kind::goose: { + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.goose_streams.size())) return {}; + const auto& goose = document.goose_streams[static_cast(handle.sourceIndex)]; + const bool addressed = goose.address.app_id.has_value() && goose.address.destination_mac.has_value(); + item.insert(QStringLiteral("name"), qstring(goose.control_name)); + item.insert(QStringLiteral("reference"), qstring(goose.control_block_reference)); + item.insert(QStringLiteral("dataSetReference"), qstring(goose.data_set_reference)); + item.insert(QStringLiteral("memberCount"), boundedSize(goose.entries.size())); + item.insert( + QStringLiteral("status"), + goose.entries.empty() + ? QStringLiteral("No members") + : addressed ? QStringLiteral("Bound + addressed") : QStringLiteral("Bound")); + item.insert(QStringLiteral("goId"), qstring(goose.go_id)); + item.insert(QStringLiteral("confRev"), static_cast(goose.configuration_revision)); + item.insert(QStringLiteral("minTimeMs"), static_cast(goose.min_time_milliseconds)); + item.insert(QStringLiteral("maxTimeMs"), static_cast(goose.max_time_milliseconds)); + item.insert(QStringLiteral("appId"), qstring(goose.address.app_id_text)); + item.insert(QStringLiteral("mac"), qstring(goose.address.destination_mac_text)); + item.insert( + QStringLiteral("vlanId"), + goose.address.vlan_id.has_value() + ? QVariant{static_cast(*goose.address.vlan_id)} : QVariant{}); + item.insert( + QStringLiteral("vlanPriority"), + goose.address.vlan_priority.has_value() + ? QVariant{static_cast(*goose.address.vlan_priority)} : QVariant{}); + item.insert( + QStringLiteral("summary"), + QStringLiteral("APPID %1 · %2 member%3") + .arg(qstring(goose.address.app_id_text).isEmpty() + ? QStringLiteral("—") : qstring(goose.address.app_id_text)) + .arg(static_cast(goose.entries.size())) + .arg(goose.entries.size() == 1 ? QString{} : QStringLiteral("s"))); + break; + } + case Kind::settingGroup: { + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.model_entries.size())) return {}; + const auto& seed = document.model_entries[static_cast(handle.sourceIndex)]; + const auto iedName = qstring(seed.ied_name); + const auto logicalDevice = qstring(seed.ld_inst); + int memberCount{}; + QSet logicalNodes; + bool hasSgcb{}; + for (const auto& entry : document.model_entries) { + if (qstring(entry.ied_name) != iedName || qstring(entry.ld_inst) != logicalDevice || + !isSettingGroupEntry(entry)) { + continue; + } + ++memberCount; + logicalNodes.insert(logicalNodeName(entry)); + const auto dataObject = qstring(entry.do_name); + if (dataObject.compare(QStringLiteral("SGCB"), Qt::CaseInsensitive) == 0 || + dataObject.startsWith(QStringLiteral("SGCB."), Qt::CaseInsensitive)) { + hasSgcb = true; + } + } + item.insert(QStringLiteral("name"), QStringLiteral("SG %1").arg(logicalDevice)); + item.insert(QStringLiteral("reference"), QStringLiteral("%1%2").arg(iedName, logicalDevice)); + item.insert(QStringLiteral("dataSetReference"), QString{}); + item.insert(QStringLiteral("memberCount"), memberCount); + item.insert(QStringLiteral("status"), hasSgcb ? QStringLiteral("SGCB + SE") : QStringLiteral("SE values")); + item.insert(QStringLiteral("logicalDevice"), logicalDevice); + item.insert(QStringLiteral("logicalNode"), QStringLiteral("LLN0")); + item.insert(QStringLiteral("hasSgcb"), hasSgcb); + item.insert( + QStringLiteral("summary"), + QStringLiteral("%1 setting-group value%2 · %3 logical node%4") + .arg(memberCount) + .arg(memberCount == 1 ? QString{} : QStringLiteral("s")) + .arg(logicalNodes.size()) + .arg(logicalNodes.size() == 1 ? QString{} : QStringLiteral("s"))); + break; + } + case Kind::control: { + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.model_entries.size())) return {}; + const auto& entry = document.model_entries[static_cast(handle.sourceIndex)]; + item.insert(QStringLiteral("name"), qstring(entry.do_name)); + item.insert(QStringLiteral("reference"), objectReference(entry)); + item.insert(QStringLiteral("dataSetReference"), QString{}); + item.insert(QStringLiteral("memberCount"), 0); + item.insert(QStringLiteral("status"), QStringLiteral("Configured")); + item.insert(QStringLiteral("controlModel"), qstring(entry.configured_value)); + item.insert(QStringLiteral("cdc"), qstring(entry.cdc)); + item.insert(QStringLiteral("logicalNode"), logicalNodeName(entry)); + item.insert(QStringLiteral("logicalDevice"), qstring(entry.ld_inst)); + item.insert( + QStringLiteral("summary"), + QStringLiteral("%1 · %2") + .arg(qstring(entry.cdc), qstring(entry.configured_value))); + break; + } + } + return item; +} + +QVariantMap IedCommissioningModel::item(const int visibleRow) const { + if (visibleRow < 0 || visibleRow >= visibleHandles_.size()) return {}; + const int catalogIndex = visibleHandles_.at(visibleRow); + if (catalogIndex < 0 || catalogIndex >= handles_.size()) return {}; + return mapForHandle(handles_.at(catalogIndex), catalogIndex); +} + +QVariantMap IedCommissioningModel::selectedItem() const { + if (selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) return {}; + return mapForHandle(handles_.at(selectedHandleIndex_), selectedHandleIndex_); +} + +const std::vector* IedCommissioningModel::entriesFor( + const Handle& handle) const noexcept { + if (backend_ == nullptr || handle.documentIndex < 0 || + handle.documentIndex >= static_cast(backend_->documents_.size())) return nullptr; + const auto& document = + backend_->documents_[static_cast(handle.documentIndex)].document; + switch (handle.kind) { + case Kind::dataSet: + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.data_sets.size())) return nullptr; + return &document.data_sets[static_cast(handle.sourceIndex)].entries; + case Kind::report: + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.report_controls.size())) return nullptr; + return &document.report_controls[static_cast(handle.sourceIndex)].entries; + case Kind::goose: + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.goose_streams.size())) return nullptr; + return &document.goose_streams[static_cast(handle.sourceIndex)].entries; + case Kind::settingGroup: + case Kind::control: + return nullptr; + } + return nullptr; +} + +int IedCommissioningModel::memberCount() const noexcept { + if (selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) return 0; + const auto& handle = handles_.at(selectedHandleIndex_); + if (handle.kind == Kind::settingGroup) { + if (backend_ == nullptr || handle.documentIndex < 0 || + handle.documentIndex >= static_cast(backend_->documents_.size())) return 0; + const auto& document = backend_->documents_[static_cast(handle.documentIndex)].document; + if (handle.sourceIndex < 0 || handle.sourceIndex >= static_cast(document.model_entries.size())) return 0; + const auto& seed = document.model_entries[static_cast(handle.sourceIndex)]; + int count{}; + for (const auto& entry : document.model_entries) { + if (entry.ied_name == seed.ied_name && entry.ld_inst == seed.ld_inst && isSettingGroupEntry(entry)) ++count; + } + return count; + } + const auto* entries = entriesFor(handle); + return entries == nullptr ? 0 : boundedSize(entries->size()); +} + +QVariantMap IedCommissioningModel::member(const int row) const { + if (selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) return {}; + const auto& handle = handles_.at(selectedHandleIndex_); + + const auto toMap = [](const ar::iec61850::scl::SclDataSetEntry& entry, const int index) { + QVariantMap item; + item.insert(QStringLiteral("index"), index); + item.insert(QStringLiteral("reference"), qstring(entry.signal_reference)); + item.insert(QStringLiteral("logicalDevice"), qstring(entry.ld_inst)); + item.insert(QStringLiteral("logicalNode"), logicalNodeName(entry)); + item.insert(QStringLiteral("dataObject"), qstring(entry.do_name)); + item.insert(QStringLiteral("dataAttribute"), qstring(entry.da_name)); + item.insert(QStringLiteral("fc"), qstring(entry.functional_constraint)); + item.insert(QStringLiteral("cdc"), qstring(entry.cdc)); + item.insert(QStringLiteral("type"), qstring(entry.basic_type)); + item.insert(QStringLiteral("configuredValue"), qstring(entry.configured_value)); + item.insert(QStringLiteral("quality"), entry.is_quality); + item.insert(QStringLiteral("timestamp"), entry.is_timestamp); + item.insert(QStringLiteral("mmsDomain"), qstring(entry.ied_name) + qstring(entry.ld_inst)); + item.insert(QStringLiteral("mmsItem"), mmsItem(entry)); + return item; + }; + + if (handle.kind == Kind::settingGroup) { + if (backend_ == nullptr || handle.documentIndex < 0 || + handle.documentIndex >= static_cast(backend_->documents_.size()) || row < 0) return {}; + const auto& document = backend_->documents_[static_cast(handle.documentIndex)].document; + if (handle.sourceIndex < 0 || handle.sourceIndex >= static_cast(document.model_entries.size())) return {}; + const auto& seed = document.model_entries[static_cast(handle.sourceIndex)]; + int current{}; + for (const auto& entry : document.model_entries) { + if (entry.ied_name != seed.ied_name || entry.ld_inst != seed.ld_inst || !isSettingGroupEntry(entry)) continue; + if (current == row) return toMap(entry, row); + ++current; + } + return {}; + } + + const auto* entries = entriesFor(handle); + if (entries == nullptr || row < 0 || row >= boundedSize(entries->size())) return {}; + return toMap(entries->at(static_cast(row)), row); +} + +void IedCommissioningModel::select(const int visibleRow) { + if (visibleRow < 0 || visibleRow >= visibleHandles_.size()) return; + const int catalogIndex = visibleHandles_.at(visibleRow); + if (selectedHandleIndex_ == catalogIndex) return; + selectedHandleIndex_ = catalogIndex; + ++revision_; + emit modelChanged(); + emit selectionChanged(); +} + +QString IedCommissioningModel::dataSetReferenceFor(const Handle& handle) const { + const auto item = mapForHandle(handle, -1); + return item.value(QStringLiteral("dataSetReference")).toString(); +} + +bool IedCommissioningModel::focusBoundDataSet() { + if (selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) return false; + const auto& selected = handles_.at(selectedHandleIndex_); + if (selected.kind != Kind::report && selected.kind != Kind::goose) return false; + const auto target = dataSetReferenceFor(selected); + if (target.isEmpty()) return false; + + int targetIndex = -1; + for (int index = 0; index < handles_.size(); ++index) { + const auto& candidate = handles_.at(index); + if (candidate.kind != Kind::dataSet) continue; + if (dataSetReferenceFor(candidate) == target) { + targetIndex = index; + break; + } + } + if (targetIndex < 0) return false; + + const bool filterStateChanged = kindFilter_ != QStringLiteral("DataSet") || !filterText_.isEmpty(); + kindFilter_ = QStringLiteral("DataSet"); + filterText_.clear(); + selectedHandleIndex_ = targetIndex; + applyFilter(true); + if (filterStateChanged) emit filterChanged(); + return true; +} + +bool IedCommissioningModel::matches(const Handle& handle) const { + if (filterText_.isEmpty()) return true; + const auto item = mapForHandle(handle, -1); + QString haystack; + static const QStringList keys{ + QStringLiteral("kind"), + QStringLiteral("name"), + QStringLiteral("reference"), + QStringLiteral("dataSetReference"), + QStringLiteral("status"), + QStringLiteral("summary"), + QStringLiteral("reportId"), + QStringLiteral("goId"), + QStringLiteral("appId"), + QStringLiteral("mac"), + QStringLiteral("logicalDevice"), + QStringLiteral("logicalNode"), + QStringLiteral("controlModel"), + QStringLiteral("cdc")}; + for (const auto& key : keys) { + haystack += item.value(key).toString(); + haystack += QLatin1Char('\n'); + } + return haystack.contains(filterText_, Qt::CaseInsensitive); +} diff --git a/apps/ied_simulator/src/IedCommissioningModel.hpp b/apps/ied_simulator/src/IedCommissioningModel.hpp new file mode 100644 index 000000000..54d4009bb --- /dev/null +++ b/apps/ied_simulator/src/IedCommissioningModel.hpp @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IedFleetController.hpp" + +#include "ariec61850/goose/publisher_runtime.hpp" +#include "ariec61850/goose/raw_ethernet_publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class QTimer; + +class IedCommissioningModel : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(IedFleetController* backend READ backend WRITE setBackend NOTIFY backendChanged) + Q_PROPERTY(QString kindFilter READ kindFilter WRITE setKindFilter NOTIFY filterChanged) + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterChanged) + Q_PROPERTY(int itemCount READ itemCount NOTIFY modelChanged) + Q_PROPERTY(int memberCount READ memberCount NOTIFY modelChanged) + Q_PROPERTY(int selectedCatalogIndex READ selectedCatalogIndex NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedItem READ selectedItem NOTIFY selectionChanged) + Q_PROPERTY(int dataSetCount READ dataSetCount NOTIFY modelChanged) + Q_PROPERTY(int reportCount READ reportCount NOTIFY modelChanged) + Q_PROPERTY(int gooseCount READ gooseCount NOTIFY modelChanged) + Q_PROPERTY(int settingGroupCount READ settingGroupCount NOTIFY modelChanged) + Q_PROPERTY(int controlCount READ controlCount NOTIFY modelChanged) + Q_PROPERTY(quint64 revision READ revision NOTIFY modelChanged) + Q_PROPERTY(int behaviorCount READ behaviorCount NOTIFY behaviorChanged) + Q_PROPERTY(int behaviorCapacity READ behaviorCapacity CONSTANT) + Q_PROPERTY(quint64 behaviorTickCount READ behaviorTickCount NOTIFY behaviorChanged) + Q_PROPERTY(quint64 behaviorRejectedCount READ behaviorRejectedCount NOTIFY behaviorChanged) + Q_PROPERTY(QStringList gooseInterfaces READ gooseInterfaces NOTIFY goosePublicationChanged) + Q_PROPERTY(QString gooseInterfaceName READ gooseInterfaceName WRITE setGooseInterfaceName NOTIFY goosePublicationChanged) + Q_PROPERTY(bool goosePublishing READ goosePublishing NOTIFY goosePublicationChanged) + Q_PROPERTY(int goosePublisherCount READ goosePublisherCount NOTIFY goosePublicationChanged) + Q_PROPERTY(quint64 gooseTransmitCount READ gooseTransmitCount NOTIFY goosePublicationChanged) + Q_PROPERTY(quint64 gooseStateChangeCount READ gooseStateChangeCount NOTIFY goosePublicationChanged) + Q_PROPERTY(QString goosePublicationStatus READ goosePublicationStatus NOTIFY goosePublicationChanged) + Q_PROPERTY(QVariantMap selectedGooseRuntime READ selectedGooseRuntime NOTIFY goosePublicationChanged) + +public: + explicit IedCommissioningModel(QObject* parent = nullptr); + + [[nodiscard]] IedFleetController* backend() const noexcept { return backend_; } + void setBackend(IedFleetController* backend); + + [[nodiscard]] QString kindFilter() const { return kindFilter_; } + void setKindFilter(const QString& filter); + [[nodiscard]] QString filterText() const { return filterText_; } + void setFilterText(const QString& filter); + + [[nodiscard]] int itemCount() const noexcept { return visibleHandles_.size(); } + [[nodiscard]] int memberCount() const noexcept; + [[nodiscard]] int selectedCatalogIndex() const noexcept { return selectedHandleIndex_; } + [[nodiscard]] QVariantMap selectedItem() const; + [[nodiscard]] int dataSetCount() const noexcept { return dataSetCount_; } + [[nodiscard]] int reportCount() const noexcept { return reportCount_; } + [[nodiscard]] int gooseCount() const noexcept { return gooseCount_; } + [[nodiscard]] int settingGroupCount() const noexcept { return settingGroupCount_; } + [[nodiscard]] int controlCount() const noexcept { return controlCount_; } + [[nodiscard]] quint64 revision() const noexcept { return revision_; } + [[nodiscard]] int behaviorCount() const noexcept { return behaviors_.size(); } + [[nodiscard]] int behaviorCapacity() const noexcept { return kBehaviorCapacity; } + [[nodiscard]] quint64 behaviorTickCount() const noexcept { return behaviorTickCount_; } + [[nodiscard]] quint64 behaviorRejectedCount() const noexcept { return behaviorRejectedCount_; } + + [[nodiscard]] QStringList gooseInterfaces() const; + [[nodiscard]] QString gooseInterfaceName() const { return gooseInterfaceName_; } + void setGooseInterfaceName(const QString& interfaceName); + [[nodiscard]] bool goosePublishing() const noexcept { return !goosePublishers_.empty(); } + [[nodiscard]] int goosePublisherCount() const noexcept { + return static_cast(goosePublishers_.size()); + } + [[nodiscard]] quint64 gooseTransmitCount() const noexcept { return gooseTransmitCount_; } + [[nodiscard]] quint64 gooseStateChangeCount() const noexcept { return gooseStateChangeCount_; } + [[nodiscard]] QString goosePublicationStatus() const { return goosePublicationStatus_; } + [[nodiscard]] QVariantMap selectedGooseRuntime() const; + + Q_INVOKABLE QVariantMap item(int visibleRow) const; + Q_INVOKABLE QVariantMap member(int row) const; + Q_INVOKABLE void select(int visibleRow); + Q_INVOKABLE bool focusBoundDataSet(); + + // Runtime commissioning actions. These deliberately reuse the controller's + // bounded live-delta path instead of creating a second simulator state path. + Q_INVOKABLE int firstDrivableMember() const; + Q_INVOKABLE bool canDriveMember(int row) const; + Q_INVOKABLE bool focusMemberValue(int row); + Q_INVOKABLE bool focusControlStatus(); + Q_INVOKABLE QVariantMap memberBehavior(int row) const; + Q_INVOKABLE bool startMemberBehavior( + int row, + const QString& mode, + int intervalMilliseconds, + double step = 1.0); + Q_INVOKABLE bool pulseSelectedService(int intervalMilliseconds = 500); + Q_INVOKABLE bool stopMemberBehavior(int row, bool restore = true); + Q_INVOKABLE void stopAllBehaviors(bool restore = true); + + // Milestone M: standards-compliant GOOSE Ethernet publication for the + // currently selected configured GSEControl. Publication is explicit: an + // interface must be selected and the MMS simulator runtime must be online. + Q_INVOKABLE bool startSelectedGoosePublication(); + Q_INVOKABLE bool stopSelectedGoosePublication(); + Q_INVOKABLE void stopAllGoosePublication(); + +signals: + void backendChanged(); + void filterChanged(); + void modelChanged(); + void selectionChanged(); + void behaviorChanged(); + void goosePublicationChanged(); + +private: + enum class Kind { + dataSet, + report, + goose, + settingGroup, + control, + }; + + struct Handle final { + Kind kind{Kind::dataSet}; + int documentIndex{-1}; + int sourceIndex{-1}; + QString identity; + }; + + struct PointBinding final { + int iedIndex{-1}; + int sourceIndex{-1}; + int pointStoreIndex{-1}; + QString sessionKey; + QString reference; + }; + + struct BehaviorSlot final { + int iedIndex{-1}; + int pointStoreIndex{-1}; + QString sessionKey; + QString reference; + QString mode; + QString originalValue; + QString originalQuality; + QString originalOrigin; + QString originalUpdated; + QString lastAppliedValue; + double step{1.0}; + int intervalMilliseconds{1'000}; + qint64 nextDueMilliseconds{}; + quint64 ticks{}; + bool originalChanged{}; + bool pulseRestorePending{}; + bool rejectionReported{}; + }; + + struct GoosePublisherSlot final { + int iedIndex{-1}; + QString sessionKey; + QString identity; + QString controlReference; + QString dataSetReference; + QVector pointStoreIndices; + QVector lastValues; + std::unique_ptr runtime; + quint64 transmitCount{}; + quint64 stateChangeCount{}; + qint64 lastTransmitMilliseconds{}; + std::uint32_t minTimeMilliseconds{}; + }; + + static constexpr int kBehaviorCapacity = 16; + static constexpr int kBehaviorMinimumIntervalMilliseconds = 100; + static constexpr int kBehaviorMaximumIntervalMilliseconds = 60'000; + static constexpr int kGoosePublisherCapacity = 16; + static constexpr int kGooseMemberCapacity = 256; + + void synchronize(); + void rebuild(); + void applyFilter(bool preserveSelection = true); + [[nodiscard]] QVariantMap mapForHandle(const Handle& handle, int catalogIndex) const; + [[nodiscard]] bool matches(const Handle& handle) const; + [[nodiscard]] const std::vector* entriesFor( + const Handle& handle) const noexcept; + [[nodiscard]] QString dataSetReferenceFor(const Handle& handle) const; + [[nodiscard]] static QString kindName(Kind kind); + + [[nodiscard]] std::optional pointBindingForMember(int row) const; + [[nodiscard]] std::optional controlStatusBinding() const; + [[nodiscard]] QString iedSessionKey(int iedIndex) const; + [[nodiscard]] bool drivablePoint(const IedPointStore::PointRecord& point) const; + [[nodiscard]] QString normalizedBehaviorMode(const QString& mode) const; + [[nodiscard]] QString nextBehaviorValue( + const IedPointStore::PointRecord& point, + const BehaviorSlot& slot, + bool* ok) const; + [[nodiscard]] int behaviorIndexFor(const PointBinding& binding) const noexcept; + [[nodiscard]] bool applyBehaviorValue( + BehaviorSlot& slot, + const QString& value, + bool restoreOriginal = false); + [[nodiscard]] bool restoreOwnedPointLocally(BehaviorSlot& slot); + void ensureBehaviorTimer(); + void advanceBehaviors(); + void pruneBehaviors(); + + [[nodiscard]] std::optional goosePointIndexFor( + int iedIndex, + const ar::iec61850::scl::SclDataSetEntry& entry) const; + [[nodiscard]] std::optional gooseValueForPoint( + const IedPointStore::PointRecord& point) const; + [[nodiscard]] bool buildGooseValues( + const GoosePublisherSlot& slot, + std::vector& values, + QVector* valueSnapshot = nullptr) const; + [[nodiscard]] int goosePublisherIndexForIdentity(const QString& identity) const noexcept; + [[nodiscard]] QString selectedGooseIdentity() const; + void ensureGooseTimer(); + void markGooseValuesDirty(); + void advanceGoosePublication(); + void pruneGoosePublishers(); + [[nodiscard]] bool transmitGoosePublication( + GoosePublisherSlot& slot, + const ar::iec61850::goose::GoosePublication& publication, + bool stateChange); + void failGoosePublication(const QString& reason); + + IedFleetController* backend_{}; + QVector handles_; + QVector visibleHandles_; + QVector behaviors_; + std::vector goosePublishers_; + QString kindFilter_{QStringLiteral("All")}; + QString filterText_; + QString synchronizedSessionKey_; + QString gooseInterfaceName_; + QString goosePublicationStatus_{QStringLiteral("Stopped")}; + qsizetype synchronizedDocumentCount_{-1}; + int selectedHandleIndex_{-1}; + int dataSetCount_{}; + int reportCount_{}; + int gooseCount_{}; + int settingGroupCount_{}; + int controlCount_{}; + quint64 revision_{}; + quint64 behaviorTickCount_{}; + quint64 behaviorRejectedCount_{}; + quint64 gooseTransmitCount_{}; + quint64 gooseStateChangeCount_{}; + qint64 gooseLastUiNotifyMilliseconds_{}; + QTimer* behaviorTimer_{}; + QTimer* gooseTimer_{}; + QElapsedTimer behaviorClock_; + ar::iec61850::goose::RawEthernetPublisher gooseTransport_; + bool gooseValuesDirty_{}; +}; diff --git a/apps/ied_simulator/src/IedCommissioningRuntime.cpp b/apps/ied_simulator/src/IedCommissioningRuntime.cpp new file mode 100644 index 000000000..1f30ef5f7 --- /dev/null +++ b/apps/ied_simulator/src/IedCommissioningRuntime.cpp @@ -0,0 +1,632 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedCommissioningModel.hpp" + +#include +#include + +#include +#include +#include + +namespace { +QString qstringRuntime(const std::string& value) { + return QString::fromStdString(value); +} + +QString logicalNodeRuntime(const ar::iec61850::scl::SclDataSetEntry& entry) { + return qstringRuntime(entry.prefix) + qstringRuntime(entry.ln_class) + qstringRuntime(entry.ln_inst); +} + +bool textEquals(const QString& left, const QString& right) { + return left.compare(right, Qt::CaseInsensitive) == 0; +} + +bool optionContains(const QStringList& options, const QString& value) { + return std::any_of(options.cbegin(), options.cend(), [&value](const QString& option) { + return option.compare(value, Qt::CaseInsensitive) == 0; + }); +} + +QString normalizedDataObject(QString value) { + value.replace(QLatin1Char('$'), QLatin1Char('.')); + return value; +} +} // namespace + +QString IedCommissioningModel::iedSessionKey(const int iedIndex) const { + if (backend_ == nullptr || iedIndex < 0 || iedIndex >= backend_->ieds_.size()) return {}; + return backend_->ieds_.at(iedIndex).toMap().value(QStringLiteral("sessionKey")).toString(); +} + +std::optional +IedCommissioningModel::pointBindingForMember(const int row) const { + if (backend_ == nullptr || backend_->selectedIedIndex_ < 0 || + selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) { + return std::nullopt; + } + const auto* entries = entriesFor(handles_.at(selectedHandleIndex_)); + if (entries == nullptr || row < 0 || row >= static_cast(entries->size())) { + return std::nullopt; + } + const auto& entry = entries->at(static_cast(row)); + const int iedIndex = backend_->selectedIedIndex_; + const auto ied = backend_->ieds_.at(iedIndex).toMap(); + const auto iedName = ied.value(QStringLiteral("name")).toString(); + if (iedName.isEmpty()) return std::nullopt; + + const auto bindAbsolute = [this, iedIndex](const int absoluteIndex) + -> std::optional { + if (absoluteIndex < 0) return std::nullopt; + const int sourceIndex = backend_->selectedPointIndices_.indexOf(absoluteIndex); + if (sourceIndex < 0) return std::nullopt; + const auto* point = backend_->pointStore_.at(absoluteIndex); + if (point == nullptr) return std::nullopt; + return PointBinding{ + iedIndex, + sourceIndex, + absoluteIndex, + iedSessionKey(iedIndex), + point->reference}; + }; + + const auto exactReference = qstringRuntime(entry.signal_reference); + if (!exactReference.isEmpty()) { + if (auto exact = bindAbsolute(backend_->pointStore_.indexOf(iedName, exactReference)); + exact.has_value()) { + return exact; + } + } + + const auto expectedDomain = qstringRuntime(entry.ied_name) + qstringRuntime(entry.ld_inst); + const auto expectedNode = logicalNodeRuntime(entry); + const auto expectedObject = normalizedDataObject(qstringRuntime(entry.do_name)); + const auto expectedAttribute = normalizedDataObject(qstringRuntime(entry.da_name)); + const auto expectedFc = qstringRuntime(entry.functional_constraint); + + for (int sourceIndex = 0; sourceIndex < backend_->selectedPointIndices_.size(); ++sourceIndex) { + const int absoluteIndex = backend_->selectedPointIndices_.at(sourceIndex); + const auto* point = backend_->pointStore_.at(absoluteIndex); + if (point == nullptr || point->iedName != iedName) continue; + if (!expectedDomain.isEmpty() && point->mmsDomain != expectedDomain) continue; + if (!expectedNode.isEmpty() && point->logicalNode != expectedNode) continue; + if (!expectedFc.isEmpty() && + point->functionalConstraint.compare(expectedFc, Qt::CaseInsensitive) != 0) continue; + if (!expectedObject.isEmpty() && + normalizedDataObject(point->dataObject) != expectedObject) continue; + if (!expectedAttribute.isEmpty() && + normalizedDataObject(point->dataAttribute) != expectedAttribute) continue; + return PointBinding{ + iedIndex, + sourceIndex, + absoluteIndex, + iedSessionKey(iedIndex), + point->reference}; + } + return std::nullopt; +} + +std::optional +IedCommissioningModel::controlStatusBinding() const { + if (backend_ == nullptr || backend_->selectedIedIndex_ < 0 || + selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) { + return std::nullopt; + } + const auto& handle = handles_.at(selectedHandleIndex_); + if (handle.kind != Kind::control || handle.documentIndex < 0 || + handle.documentIndex >= static_cast(backend_->documents_.size())) { + return std::nullopt; + } + const auto& document = backend_->documents_[static_cast(handle.documentIndex)].document; + if (handle.sourceIndex < 0 || + handle.sourceIndex >= static_cast(document.model_entries.size())) { + return std::nullopt; + } + const auto& entry = document.model_entries[static_cast(handle.sourceIndex)]; + const auto iedName = qstringRuntime(entry.ied_name); + const auto expectedDomain = iedName + qstringRuntime(entry.ld_inst); + const auto expectedNode = logicalNodeRuntime(entry); + const auto expectedObject = normalizedDataObject(qstringRuntime(entry.do_name)); + const int iedIndex = backend_->selectedIedIndex_; + + for (int sourceIndex = 0; sourceIndex < backend_->selectedPointIndices_.size(); ++sourceIndex) { + const int absoluteIndex = backend_->selectedPointIndices_.at(sourceIndex); + const auto* point = backend_->pointStore_.at(absoluteIndex); + if (point == nullptr || point->iedName != iedName || point->mmsDomain != expectedDomain || + point->logicalNode != expectedNode || + normalizedDataObject(point->dataObject) != expectedObject || + point->functionalConstraint.compare(QStringLiteral("ST"), Qt::CaseInsensitive) != 0 || + point->dataAttribute.compare(QStringLiteral("stVal"), Qt::CaseInsensitive) != 0) { + continue; + } + return PointBinding{ + iedIndex, + sourceIndex, + absoluteIndex, + iedSessionKey(iedIndex), + point->reference}; + } + return std::nullopt; +} + +bool IedCommissioningModel::drivablePoint(const IedPointStore::PointRecord& point) const { + if (!point.writable) return false; + const auto fc = point.functionalConstraint.trimmed().toUpper(); + if (fc != QStringLiteral("ST") && fc != QStringLiteral("MX")) return false; + if (point.dataAttribute.compare(QStringLiteral("q"), Qt::CaseInsensitive) == 0 || + point.dataAttribute.compare(QStringLiteral("t"), Qt::CaseInsensitive) == 0 || + point.type.contains(QStringLiteral("Quality"), Qt::CaseInsensitive) || + point.type.contains(QStringLiteral("Timestamp"), Qt::CaseInsensitive)) { + return false; + } + if ((optionContains(point.options, QStringLiteral("false")) && + optionContains(point.options, QStringLiteral("true"))) || + (optionContains(point.options, QStringLiteral("off")) && + optionContains(point.options, QStringLiteral("on"))) || + point.type.contains(QStringLiteral("Boolean"), Qt::CaseInsensitive)) { + return true; + } + bool numeric{}; + static_cast(point.value.toDouble(&numeric)); + return numeric; +} + +QString IedCommissioningModel::normalizedBehaviorMode(const QString& mode) const { + QString token; + token.reserve(mode.size()); + for (const auto character : mode.toLower()) { + if (character.isLetterOrNumber()) token.append(character); + } + if (token == QStringLiteral("pulse")) return QStringLiteral("Pulse"); + if (token == QStringLiteral("toggle")) return QStringLiteral("Toggle"); + if (token == QStringLiteral("ramp") || token == QStringLiteral("ramp1")) { + return QStringLiteral("Ramp"); + } + return {}; +} + +QString IedCommissioningModel::nextBehaviorValue( + const IedPointStore::PointRecord& point, + const BehaviorSlot& slot, + bool* const ok) const { + if (ok != nullptr) *ok = false; + const auto current = point.value.trimmed(); + + if (optionContains(point.options, QStringLiteral("false")) && + optionContains(point.options, QStringLiteral("true"))) { + if (slot.mode == QStringLiteral("Ramp")) return {}; + const bool active = textEquals(current, QStringLiteral("true")) || current == QStringLiteral("1"); + if (ok != nullptr) *ok = true; + return active ? QStringLiteral("false") : QStringLiteral("true"); + } + if (optionContains(point.options, QStringLiteral("off")) && + optionContains(point.options, QStringLiteral("on"))) { + if (slot.mode == QStringLiteral("Ramp")) return {}; + if (ok != nullptr) *ok = true; + return textEquals(current, QStringLiteral("on")) + ? QStringLiteral("off") : QStringLiteral("on"); + } + if (point.type.contains(QStringLiteral("Boolean"), Qt::CaseInsensitive)) { + if (slot.mode == QStringLiteral("Ramp")) return {}; + const bool active = textEquals(current, QStringLiteral("true")) || current == QStringLiteral("1"); + if (ok != nullptr) *ok = true; + return active ? QStringLiteral("false") : QStringLiteral("true"); + } + + bool currentOk{}; + const double currentNumber = current.toDouble(¤tOk); + if (!currentOk || !std::isfinite(currentNumber)) return {}; + + double next = currentNumber + slot.step; + if (slot.mode == QStringLiteral("Toggle")) { + bool originalOk{}; + const double original = slot.originalValue.toDouble(&originalOk); + if (!originalOk || !std::isfinite(original)) return {}; + const double epsilon = std::max(1.0, std::abs(original)) * 1e-9; + next = std::abs(currentNumber - original) <= epsilon ? original + slot.step : original; + } + if (!std::isfinite(next) || std::abs(next) > 1.0e12) return {}; + + const bool floating = point.type.contains(QStringLiteral("Float"), Qt::CaseInsensitive) || + point.rawType.contains(QStringLiteral("FLOAT"), Qt::CaseInsensitive); + if (ok != nullptr) *ok = true; + return floating + ? QString::number(next, 'g', 12) + : QString::number(static_cast(std::llround(next))); +} + +int IedCommissioningModel::behaviorIndexFor(const PointBinding& binding) const noexcept { + for (int index = 0; index < behaviors_.size(); ++index) { + const auto& slot = behaviors_.at(index); + if (slot.iedIndex == binding.iedIndex && + slot.pointStoreIndex == binding.pointStoreIndex && + slot.sessionKey == binding.sessionKey) { + return index; + } + } + return -1; +} + +bool IedCommissioningModel::applyBehaviorValue( + BehaviorSlot& slot, + const QString& value, + const bool restoreOriginal) { + if (backend_ == nullptr || slot.iedIndex < 0 || slot.pointStoreIndex < 0 || + iedSessionKey(slot.iedIndex) != slot.sessionKey) { + return false; + } + auto* runtime = backend_->runtimeAt(slot.iedIndex); + auto* point = backend_->pointStore_.atMutable(slot.pointStoreIndex); + if (runtime == nullptr || runtime->state != IedFleetController::RuntimeState::running || + point == nullptr || !point->writable) { + return false; + } + + const auto before = *point; + point->value = value; + if (restoreOriginal) { + point->quality = slot.originalQuality; + point->origin = slot.originalOrigin; + point->changed = slot.originalChanged; + point->updated = slot.originalUpdated; + } else { + point->origin = QStringLiteral("Commissioning %1").arg(slot.mode); + point->changed = true; + point->updated = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")); + } + + if (!backend_->enqueueLiveUpdate(slot.iedIndex, *point)) { + *point = before; + ++behaviorRejectedCount_; + if (!slot.rejectionReported) { + slot.rejectionReported = true; + backend_->appendActivity( + QStringLiteral("Commissioning"), + QStringLiteral("Behavior update for %1 was deferred because the bounded live channel rejected it.") + .arg(slot.reference), + QStringLiteral("Warning"), + point->iedName); + } + return false; + } + + slot.rejectionReported = false; + slot.lastAppliedValue = value; + ++slot.ticks; + ++behaviorTickCount_; + if (slot.iedIndex == backend_->selectedIedIndex_) { + emit backend_->valuesChanged(); + emit backend_->selectionChanged(); + } + return true; +} + +bool IedCommissioningModel::restoreOwnedPointLocally(BehaviorSlot& slot) { + if (backend_ == nullptr || slot.iedIndex < 0 || slot.pointStoreIndex < 0 || + iedSessionKey(slot.iedIndex) != slot.sessionKey) { + return false; + } + auto* point = backend_->pointStore_.atMutable(slot.pointStoreIndex); + // If another operator/editor has replaced the behavior-owned value, never + // overwrite that newer state during teardown. + if (point == nullptr || point->value != slot.lastAppliedValue) return false; + + point->value = slot.originalValue; + point->quality = slot.originalQuality; + point->origin = slot.originalOrigin; + point->changed = slot.originalChanged; + point->updated = slot.originalUpdated; + slot.lastAppliedValue = slot.originalValue; + if (slot.iedIndex == backend_->selectedIedIndex_) { + emit backend_->valuesChanged(); + emit backend_->selectionChanged(); + } + return true; +} + +void IedCommissioningModel::ensureBehaviorTimer() { + if (!behaviorClock_.isValid()) behaviorClock_.start(); + if (behaviorTimer_ != nullptr) return; + behaviorTimer_ = new QTimer{this}; + behaviorTimer_->setTimerType(Qt::CoarseTimer); + behaviorTimer_->setInterval(kBehaviorMinimumIntervalMilliseconds); + connect(behaviorTimer_, &QTimer::timeout, this, &IedCommissioningModel::advanceBehaviors); +} + +void IedCommissioningModel::pruneBehaviors() { + if (behaviors_.isEmpty()) return; + bool changed{}; + for (int index = behaviors_.size() - 1; index >= 0; --index) { + auto& slot = behaviors_[index]; + const auto* runtime = backend_ == nullptr ? nullptr : backend_->runtimeAt(slot.iedIndex); + const auto* point = backend_ == nullptr ? nullptr : backend_->pointStore_.at(slot.pointStoreIndex); + if (backend_ != nullptr && point != nullptr && runtime != nullptr && + runtime->state == IedFleetController::RuntimeState::running && + iedSessionKey(slot.iedIndex) == slot.sessionKey) { + continue; + } + if (backend_ != nullptr && point != nullptr && + iedSessionKey(slot.iedIndex) == slot.sessionKey) { + static_cast(restoreOwnedPointLocally(slot)); + } + behaviors_.removeAt(index); + changed = true; + } + if (behaviors_.isEmpty() && behaviorTimer_ != nullptr) behaviorTimer_->stop(); + if (changed) emit behaviorChanged(); +} + +void IedCommissioningModel::advanceBehaviors() { + if (behaviors_.isEmpty()) { + if (behaviorTimer_ != nullptr) behaviorTimer_->stop(); + return; + } + if (!behaviorClock_.isValid()) behaviorClock_.start(); + const qint64 now = behaviorClock_.elapsed(); + bool changed{}; + + for (int index = behaviors_.size() - 1; index >= 0; --index) { + auto& slot = behaviors_[index]; + if (backend_ == nullptr || iedSessionKey(slot.iedIndex) != slot.sessionKey) { + behaviors_.removeAt(index); + changed = true; + continue; + } + auto* runtime = backend_->runtimeAt(slot.iedIndex); + auto* point = backend_->pointStore_.atMutable(slot.pointStoreIndex); + if (runtime == nullptr || runtime->state != IedFleetController::RuntimeState::running || + point == nullptr) { + const auto iedName = point == nullptr ? QString{} : point->iedName; + const bool restored = point != nullptr && restoreOwnedPointLocally(slot); + if (backend_ != nullptr && point != nullptr) { + backend_->appendActivity( + QStringLiteral("Commissioning"), + restored + ? QStringLiteral("Runtime stopped; behavior on %1 was removed and its owned value restored locally before restart.") + .arg(slot.reference) + : QStringLiteral("Runtime stopped; behavior on %1 was removed without overwriting newer external state.") + .arg(slot.reference), + QStringLiteral("Info"), + iedName); + } + behaviors_.removeAt(index); + changed = true; + continue; + } + if (point->value != slot.lastAppliedValue) { + backend_->appendActivity( + QStringLiteral("Commissioning"), + QStringLiteral("Behavior on %1 stopped because another edit changed the owned value.") + .arg(slot.reference), + QStringLiteral("Warning"), + point->iedName); + behaviors_.removeAt(index); + changed = true; + continue; + } + if (now < slot.nextDueMilliseconds) continue; + + if (slot.pulseRestorePending) { + if (applyBehaviorValue(slot, slot.originalValue, true)) { + backend_->appendActivity( + QStringLiteral("Commissioning"), + QStringLiteral("Pulse on %1 restored its original value.").arg(slot.reference), + QStringLiteral("Success"), + point->iedName); + behaviors_.removeAt(index); + changed = true; + } else { + slot.nextDueMilliseconds = now + slot.intervalMilliseconds; + changed = true; + } + continue; + } + + bool nextOk{}; + const auto next = nextBehaviorValue(*point, slot, &nextOk); + if (!nextOk || !applyBehaviorValue(slot, next, false)) { + slot.nextDueMilliseconds = now + slot.intervalMilliseconds; + changed = true; + continue; + } + // Do not catch up missed intervals in a burst. One scheduler turn emits + // at most one update per bounded slot, then advances from 'now'. + slot.nextDueMilliseconds = now + slot.intervalMilliseconds; + changed = true; + } + + if (behaviors_.isEmpty() && behaviorTimer_ != nullptr) behaviorTimer_->stop(); + if (changed) emit behaviorChanged(); +} + +int IedCommissioningModel::firstDrivableMember() const { + for (int row = 0; row < memberCount(); ++row) { + if (canDriveMember(row)) return row; + } + return -1; +} + +bool IedCommissioningModel::canDriveMember(const int row) const { + const auto binding = pointBindingForMember(row); + if (!binding.has_value() || backend_ == nullptr) return false; + const auto* point = backend_->pointStore_.at(binding->pointStoreIndex); + return point != nullptr && drivablePoint(*point); +} + +bool IedCommissioningModel::focusMemberValue(const int row) { + const auto binding = pointBindingForMember(row); + if (!binding.has_value() || backend_ == nullptr || + binding->iedIndex != backend_->selectedIedIndex_) { + return false; + } + backend_->selectValue(binding->sourceIndex); + return backend_->selectedValueIndex_ == binding->sourceIndex; +} + +bool IedCommissioningModel::focusControlStatus() { + const auto binding = controlStatusBinding(); + if (!binding.has_value() || backend_ == nullptr || + binding->iedIndex != backend_->selectedIedIndex_) { + return false; + } + backend_->selectValue(binding->sourceIndex); + return backend_->selectedValueIndex_ == binding->sourceIndex; +} + +QVariantMap IedCommissioningModel::memberBehavior(const int row) const { + QVariantMap result; + const auto binding = pointBindingForMember(row); + if (!binding.has_value()) { + result.insert(QStringLiteral("active"), false); + return result; + } + const int index = behaviorIndexFor(*binding); + if (index < 0) { + result.insert(QStringLiteral("active"), false); + return result; + } + const auto& slot = behaviors_.at(index); + result.insert(QStringLiteral("active"), true); + result.insert(QStringLiteral("mode"), slot.mode); + result.insert(QStringLiteral("intervalMs"), slot.intervalMilliseconds); + result.insert(QStringLiteral("step"), slot.step); + result.insert(QStringLiteral("ticks"), static_cast(slot.ticks)); + result.insert(QStringLiteral("reference"), slot.reference); + result.insert(QStringLiteral("originalValue"), slot.originalValue); + result.insert(QStringLiteral("lastValue"), slot.lastAppliedValue); + return result; +} + +bool IedCommissioningModel::startMemberBehavior( + const int row, + const QString& requestedMode, + const int intervalMilliseconds, + const double step) { + const auto mode = normalizedBehaviorMode(requestedMode); + if (backend_ == nullptr || mode.isEmpty() || + intervalMilliseconds < kBehaviorMinimumIntervalMilliseconds || + intervalMilliseconds > kBehaviorMaximumIntervalMilliseconds || + !std::isfinite(step) || step == 0.0 || std::abs(step) > 1.0e9) { + return false; + } + const auto binding = pointBindingForMember(row); + if (!binding.has_value()) return false; + auto* runtime = backend_->runtimeAt(binding->iedIndex); + auto* point = backend_->pointStore_.atMutable(binding->pointStoreIndex); + if (runtime == nullptr || runtime->state != IedFleetController::RuntimeState::running || + point == nullptr || !drivablePoint(*point) || behaviorIndexFor(*binding) >= 0 || + behaviors_.size() >= kBehaviorCapacity) { + return false; + } + + BehaviorSlot slot; + slot.iedIndex = binding->iedIndex; + slot.pointStoreIndex = binding->pointStoreIndex; + slot.sessionKey = binding->sessionKey; + slot.reference = binding->reference; + slot.mode = mode; + slot.originalValue = point->value; + slot.originalQuality = point->quality; + slot.originalOrigin = point->origin; + slot.originalUpdated = point->updated; + slot.originalChanged = point->changed; + slot.lastAppliedValue = point->value; + slot.step = step; + slot.intervalMilliseconds = intervalMilliseconds; + slot.pulseRestorePending = mode == QStringLiteral("Pulse"); + + bool nextOk{}; + const auto next = nextBehaviorValue(*point, slot, &nextOk); + if (!nextOk) return false; + + ensureBehaviorTimer(); + if (!applyBehaviorValue(slot, next, false)) return false; + slot.nextDueMilliseconds = behaviorClock_.elapsed() + intervalMilliseconds; + behaviors_.push_back(std::move(slot)); + if (!behaviorTimer_->isActive()) behaviorTimer_->start(); + + const auto& stored = behaviors_.constLast(); + backend_->appendActivity( + QStringLiteral("Commissioning"), + stored.mode == QStringLiteral("Pulse") + ? QStringLiteral("Pulsed %1; restore is scheduled in %2 ms.") + .arg(stored.reference) + .arg(stored.intervalMilliseconds) + : QStringLiteral("Started %1 behavior on %2 every %3 ms (%4/%5 slots active).") + .arg(stored.mode, stored.reference) + .arg(stored.intervalMilliseconds) + .arg(behaviors_.size()) + .arg(kBehaviorCapacity), + QStringLiteral("Success"), + point->iedName); + emit behaviorChanged(); + return true; +} + +bool IedCommissioningModel::pulseSelectedService(const int intervalMilliseconds) { + if (selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) return false; + const auto kind = handles_.at(selectedHandleIndex_).kind; + if (kind == Kind::control) return false; + const int row = firstDrivableMember(); + return row >= 0 && startMemberBehavior( + row, QStringLiteral("Pulse"), intervalMilliseconds, 1.0); +} + +bool IedCommissioningModel::stopMemberBehavior(const int row, const bool restore) { + const auto binding = pointBindingForMember(row); + if (!binding.has_value()) return false; + const int index = behaviorIndexFor(*binding); + if (index < 0) return false; + + auto& slot = behaviors_[index]; + auto* point = backend_ == nullptr ? nullptr : backend_->pointStore_.atMutable(slot.pointStoreIndex); + const bool externallyOverridden = point != nullptr && point->value != slot.lastAppliedValue; + bool restored = !restore || externallyOverridden; + if (restore && !externallyOverridden) { + restored = applyBehaviorValue(slot, slot.originalValue, true); + if (!restored) restored = restoreOwnedPointLocally(slot); + } + const auto reference = slot.reference; + const auto iedName = point == nullptr ? QString{} : point->iedName; + behaviors_.removeAt(index); + if (behaviors_.isEmpty() && behaviorTimer_ != nullptr) behaviorTimer_->stop(); + if (backend_ != nullptr) { + backend_->appendActivity( + QStringLiteral("Commissioning"), + restored + ? QStringLiteral("Stopped behavior on %1%2.") + .arg(reference, restore && !externallyOverridden + ? QStringLiteral(" and restored the original value") : QString{}) + : QStringLiteral("Stopped behavior on %1; the runtime was unavailable for restore.") + .arg(reference), + restored ? QStringLiteral("Info") : QStringLiteral("Warning"), + iedName); + } + emit behaviorChanged(); + return restored; +} + +void IedCommissioningModel::stopAllBehaviors(const bool restore) { + if (behaviors_.isEmpty()) return; + for (int index = behaviors_.size() - 1; index >= 0; --index) { + auto& slot = behaviors_[index]; + auto* point = backend_ == nullptr ? nullptr : backend_->pointStore_.atMutable(slot.pointStoreIndex); + const bool externallyOverridden = point != nullptr && point->value != slot.lastAppliedValue; + if (restore && !externallyOverridden) { + if (!applyBehaviorValue(slot, slot.originalValue, true)) { + static_cast(restoreOwnedPointLocally(slot)); + } + } + } + behaviors_.clear(); + if (behaviorTimer_ != nullptr) behaviorTimer_->stop(); + if (backend_ != nullptr) { + backend_->appendActivity( + QStringLiteral("Commissioning"), + restore + ? QStringLiteral("Stopped all commissioning behaviors; owned values were restored where possible.") + : QStringLiteral("Stopped all commissioning behaviors."), + QStringLiteral("Info")); + } + emit behaviorChanged(); +} diff --git a/apps/ied_simulator/src/IedFleetController.cpp b/apps/ied_simulator/src/IedFleetController.cpp new file mode 100644 index 000000000..21cbacde1 --- /dev/null +++ b/apps/ied_simulator/src/IedFleetController.cpp @@ -0,0 +1,2106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedFleetController.hpp" +#include "IedReportControlManifest.hpp" +#include "IedSettingControlManifest.hpp" +#include "IedRuntimeGuardrails.hpp" + +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { +QString qstring(const std::string& value) { + return QString::fromStdString(value); +} + +std::optional controlModelCode(QString value) { + value = value.trimmed(); + bool numericOk{}; + const auto numeric = value.toInt(&numericOk); + if (numericOk && numeric >= 0 && numeric <= 4) return numeric; + + QString token; + token.reserve(value.size()); + for (const auto character : value.toLower()) { + if (character.isLetterOrNumber()) token.append(character); + } + if (token == QStringLiteral("statusonly")) return 0; + if (token == QStringLiteral("directwithnormalsecurity") || + token == QStringLiteral("directnormal")) return 1; + if (token == QStringLiteral("sbowithnormalsecurity") || + token == QStringLiteral("selectbeforeoperatewithnormalsecurity") || + token == QStringLiteral("sbonormal")) return 2; + if (token == QStringLiteral("directwithenhancedsecurity") || + token == QStringLiteral("directenhanced")) return 3; + if (token == QStringLiteral("sbowithenhancedsecurity") || + token == QStringLiteral("selectbeforeoperatewithenhancedsecurity") || + token == QStringLiteral("sboenhanced")) return 4; + return std::nullopt; +} + +QString mmsDomainFor(const ar::iec61850::scl::SclDataSetEntry& entry) { + return qstring(entry.ied_name) + qstring(entry.ld_inst); +} + +QString mmsItemFor(const ar::iec61850::scl::SclDataSetEntry& entry) { + auto dataObject = qstring(entry.do_name); + dataObject.replace(QLatin1Char('.'), QLatin1Char('$')); + auto attribute = qstring(entry.da_name); + attribute.replace(QLatin1Char('.'), QLatin1Char('$')); + QStringList parts{ + qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst), + qstring(entry.functional_constraint), + dataObject}; + if (!attribute.isEmpty()) parts.push_back(attribute); + parts.removeAll(QString{}); + return parts.join(QLatin1Char('$')); +} + +QByteArray manifestField(QString value) { + value.replace(QLatin1Char('\t'), QLatin1Char(' ')); + value.replace(QLatin1Char('\r'), QLatin1Char(' ')); + value.replace(QLatin1Char('\n'), QLatin1Char(' ')); + return value.toUtf8(); +} + +bool isActiveState(const IedFleetController::RuntimeState state) { + return state == IedFleetController::RuntimeState::starting || + state == IedFleetController::RuntimeState::running || + state == IedFleetController::RuntimeState::stopping; +} + +constexpr int kLivePendingLimit = 256; +constexpr int kLiveInflightLimit = 256; +constexpr int kLiveFlushBudget = 64; +constexpr qint64 kLiveProcessBufferLimit = 64 * 1024; +constexpr qsizetype kLivePayloadBudget = 32 * 1024; +constexpr qsizetype kLiveValueByteLimit = 4 * 1024; +} // namespace + +IedFleetController::IedFleetController(QObject* parent) + : QObject(parent) { + connect( + QCoreApplication::instance(), + &QCoreApplication::aboutToQuit, + this, + [this] { stopAllProcessesBlocking(); }); + + appendActivity( + QStringLiteral("Workspace"), + QStringLiteral("Ready. Import an IEC 61850 engineering model to begin.")); +} + +IedFleetController::~IedFleetController() { + stopAllProcessesBlocking(); + removeModelManifests(); +} + +bool IedFleetController::imported() const noexcept { return !documents_.empty(); } + +bool IedFleetController::running() const noexcept { + const auto* runtime = runtimeAt(selectedIedIndex_); + return runtime != nullptr && runtime->state == RuntimeState::running; +} + +bool IedFleetController::starting() const noexcept { + const auto* runtime = runtimeAt(selectedIedIndex_); + return runtime != nullptr && runtime->state == RuntimeState::starting; +} + +bool IedFleetController::anyRunning() const noexcept { + for (const auto& runtime : runtimes_) { + if (runtime != nullptr && isActiveState(runtime->state)) return true; + } + return false; +} + +int IedFleetController::runningCount() const noexcept { + int count{}; + for (const auto& runtime : runtimes_) { + if (runtime != nullptr && runtime->state == RuntimeState::running) ++count; + } + return count; +} + +QString IedFleetController::sourceName() const { return sourceName_; } +QString IedFleetController::sourcePath() const { return sourcePath_; } +QString IedFleetController::fatalError() const { return fatalError_; } + +QString IedFleetController::modelStatus() const { + if (!fatalError_.isEmpty()) return fatalError_; + if (!imported()) return QStringLiteral("No engineering model loaded"); + return QStringLiteral("%1 IED%2 · %3 running · %4 local IPv4 endpoint%5 available") + .arg(ieds_.size()) + .arg(ieds_.size() == 1 ? QString{} : QStringLiteral("s")) + .arg(runningCount()) + .arg(bindableIpv4Addresses().size()) + .arg(bindableIpv4Addresses().size() == 1 ? QString{} : QStringLiteral("s")); +} + +QString IedFleetController::listenAddress() const { + const auto* runtime = runtimeAt(selectedIedIndex_); + return runtime != nullptr ? runtime->listenAddress : defaultListenAddress_; +} + +QStringList IedFleetController::availableAddresses() const { + QStringList result{QStringLiteral("0.0.0.0")}; + const auto addresses = bindableIpv4Addresses(); + for (const auto& address : addresses) { + if (!result.contains(address)) result.push_back(address); + } + if (!result.contains(QStringLiteral("127.0.0.1"))) { + result.push_back(QStringLiteral("127.0.0.1")); + } + return result; +} + +QVariantList IedFleetController::networkAddresses() const { + QVariantList result; + const auto interfaces = QNetworkInterface::allInterfaces(); + for (const auto& networkInterface : interfaces) { + if (!networkInterface.flags().testFlag(QNetworkInterface::IsUp)) continue; + for (const auto& entry : networkInterface.addressEntries()) { + if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol) continue; + QVariantMap item; + item.insert(QStringLiteral("address"), entry.ip().toString()); + item.insert(QStringLiteral("prefixLength"), entry.prefixLength()); + item.insert(QStringLiteral("interface"), networkInterface.humanReadableName()); + item.insert(QStringLiteral("interfaceName"), networkInterface.name()); + item.insert(QStringLiteral("loopback"), entry.ip().isLoopback()); + item.insert( + QStringLiteral("label"), + QStringLiteral("%1 · %2/%3") + .arg(networkInterface.humanReadableName(), entry.ip().toString()) + .arg(entry.prefixLength())); + result.push_back(item); + } + } + return result; +} + +int IedFleetController::port() const noexcept { + const auto* runtime = runtimeAt(selectedIedIndex_); + return runtime != nullptr ? runtime->port : defaultPort_; +} + +bool IedFleetController::gooseEnabled() const noexcept { return gooseEnabled_; } +bool IedFleetController::fileServiceEnabled() const noexcept { return fileServiceEnabled_; } +QString IedFleetController::fileFolder() const { return fileFolder_; } +QVariantList IedFleetController::ieds() const { return ieds_; } +int IedFleetController::selectedIedIndex() const noexcept { return selectedIedIndex_; } +QVariantList IedFleetController::values() const { + return pointStore_.toVariantList(selectedPointIndices_); +} +int IedFleetController::selectedValueIndex() const noexcept { return selectedValueIndex_; } +QVariantList IedFleetController::activity() const { return activity_.snapshot(); } +int IedFleetController::logicalDeviceCount() const noexcept { return logicalDeviceCount_; } +int IedFleetController::dataObjectCount() const noexcept { return dataObjectCount_; } +int IedFleetController::dataAttributeCount() const noexcept { return dataAttributeCount_; } +int IedFleetController::dataSetCount() const noexcept { return dataSetCount_; } +int IedFleetController::reportCount() const noexcept { return reportCount_; } +int IedFleetController::gooseCount() const noexcept { return gooseCount_; } + +QVariantMap IedFleetController::selectedIed() const { + if (selectedIedIndex_ < 0 || selectedIedIndex_ >= ieds_.size()) return {}; + return ieds_.at(selectedIedIndex_).toMap(); +} + +QVariantMap IedFleetController::selectedValue() const { + const auto* point = valueRecord(selectedValueIndex_); + return point == nullptr ? QVariantMap{} : IedPointStore::toVariantMap(*point); +} + +QString IedFleetController::endpointConflict() const { + return endpointConflictFor(selectedIedIndex_, true); +} + +void IedFleetController::setListenAddress(const QString& value) { + const auto normalized = value.trimmed(); + auto* runtime = runtimeAt(selectedIedIndex_); + if (runtime == nullptr) { + if (defaultListenAddress_ == normalized) return; + defaultListenAddress_ = normalized; + emit configurationChanged(); + return; + } + if (isActiveState(runtime->state) || runtime->listenAddress == normalized) return; + runtime->listenAddress = normalized; + updateIedRuntimePresentation(selectedIedIndex_); + emit configurationChanged(); +} + +void IedFleetController::setPort(const int value) { + if (value < 1 || value > 65'535) return; + auto* runtime = runtimeAt(selectedIedIndex_); + if (runtime == nullptr) { + if (defaultPort_ == value) return; + defaultPort_ = value; + emit configurationChanged(); + return; + } + if (isActiveState(runtime->state) || runtime->port == value) return; + runtime->port = value; + updateIedRuntimePresentation(selectedIedIndex_); + emit configurationChanged(); +} + +void IedFleetController::setGooseEnabled(const bool value) { + if (gooseEnabled_ == value) return; + gooseEnabled_ = value; + emit configurationChanged(); +} + +void IedFleetController::setFileServiceEnabled(const bool value) { + if (fileServiceEnabled_ == value) return; + fileServiceEnabled_ = value; + emit configurationChanged(); +} + +void IedFleetController::setFileFolder(const QString& value) { + const auto normalized = QDir::cleanPath(value.trimmed()); + if (fileFolder_ == normalized) return; + fileFolder_ = normalized == QStringLiteral(".") && value.trimmed().isEmpty() + ? QString{} + : normalized; + emit configurationChanged(); +} + +bool IedFleetController::loadFile(const QUrl& fileUrl) { + return importFile(fileUrl, false); +} + +bool IedFleetController::addFile(const QUrl& fileUrl) { + return importFile(fileUrl, true); +} + +bool IedFleetController::importFile(const QUrl& fileUrl, const bool append) { + const auto path = fileUrl.toLocalFile(); + if (path.isEmpty()) { + fatalError_ = QStringLiteral("Choose a local SCL, CID, SCD, IID, or ICD file."); + emit modelChanged(); + return false; + } + + if (anyRunning()) { + appendActivity( + QStringLiteral("Workspace"), + QStringLiteral("Stopping active IEDs before changing the engineering model."), + QStringLiteral("Info")); + stopAllProcessesBlocking(); + } + + try { + auto document = ar::iec61850::scl::SclParser{}.load( + std::filesystem::path{path.toStdWString()}); + if (!append) { + removeModelManifests(); + runtimes_.clear(); + ieds_.clear(); + documents_.clear(); + pointStore_.clear(); + selectedPointIndices_.clear(); + } + preparedIeds_.clear(); + seededRuntimeIeds_.clear(); + fleetStartPending_ = false; + fleetStartMembers_.clear(); + fleetStartReady_.clear(); + fleetStartRollbackCount_ = 0; + navigationIndex_.clear(); + valueScopeIndex_.clear(); + preparedValueIndexIed_ = -1; + preparedPointCount_ = 0; + documents_.push_back(LoadedDocument{path, std::move(document)}); + sourcePath_ = path; + sourceName_ = QFileInfo(path).fileName(); + fatalError_.clear(); + previousValue_.reset(); + rebuildPresentation(); + appendActivity( + QStringLiteral("Importer"), + QStringLiteral("%1 imported and validated successfully.").arg(sourceName_), + QStringLiteral("Success")); + return true; + } catch (const std::exception& error) { + fatalError_ = QString::fromUtf8(error.what()); + appendActivity(QStringLiteral("Importer"), fatalError_, QStringLiteral("Error")); + emit modelChanged(); + return false; + } +} + +void IedFleetController::clear() { + if (anyRunning()) { + if (clearPending_) return; + clearPending_ = true; + appendActivity( + QStringLiteral("Workspace"), + QStringLiteral("Stopping active IEDs before clearing the engineering model."), + QStringLiteral("Info")); + stopAllSimulations(); + emit modelChanged(); + return; + } + performClear(); +} + +void IedFleetController::performClear() { + clearPending_ = false; + removeModelManifests(); + runtimes_.clear(); + documents_.clear(); + ieds_.clear(); + pointStore_.clear(); + selectedPointIndices_.clear(); + navigationIndex_.clear(); + valueScopeIndex_.clear(); + preparedIeds_.clear(); + seededRuntimeIeds_.clear(); + fleetStartPending_ = false; + fleetStartMembers_.clear(); + fleetStartReady_.clear(); + fleetStartRollbackCount_ = 0; + previousValue_.reset(); + sourceName_.clear(); + sourcePath_.clear(); + fatalError_.clear(); + selectedIedIndex_ = -1; + selectedValueIndex_ = -1; + preparedValueIndexIed_ = -1; + preparedPointCount_ = 0; + logicalDeviceCount_ = 0; + dataObjectCount_ = 0; + dataAttributeCount_ = 0; + dataSetCount_ = 0; + reportCount_ = 0; + gooseCount_ = 0; + emit modelChanged(); + emit selectionChanged(); + emit valuesChanged(); + emit configurationChanged(); + emit runtimeChanged(); +} + +void IedFleetController::selectIed(const int index) { + const int normalized = index >= 0 && index < ieds_.size() ? index : -1; + if (selectedIedIndex_ == normalized) return; + selectedIedIndex_ = normalized; + previousValue_.reset(); + if (!adoptPreparedIed(normalized)) rebuildValues(); + emit valuesChanged(); + emit selectionChanged(); + emit configurationChanged(); + emit runtimeChanged(); +} + +void IedFleetController::selectValue(const int index) { + const int normalized = index >= 0 && index < selectedPointIndices_.size() ? index : -1; + if (selectedValueIndex_ == normalized) return; + selectedValueIndex_ = normalized; + emit selectionChanged(); +} + +bool IedFleetController::startSimulation() { + return startIed(selectedIedIndex_); +} + +void IedFleetController::stopSimulation() { + stopIed(selectedIedIndex_); +} + +bool IedFleetController::startIed(const int index) { + if (!imported() || index < 0 || index >= ieds_.size()) return false; + auto* runtime = runtimeAt(index); + if (runtime == nullptr || !runtime->enabled || isActiveState(runtime->state)) return false; + + const auto iedName = ieds_.at(index).toMap().value(QStringLiteral("name")).toString(); + if (runtime->listenAddress.trimmed().isEmpty()) { + appendActivity( + QStringLiteral("Network"), + QStringLiteral("Assign a local IPv4 address before starting this IED."), + QStringLiteral("Error"), + iedName); + return false; + } + if (!isLocalAddress(runtime->listenAddress)) { + appendActivity( + QStringLiteral("Network"), + QStringLiteral("%1 is not currently assigned to this computer.") + .arg(runtime->listenAddress), + QStringLiteral("Error"), + iedName); + return false; + } + const auto conflict = endpointConflictFor(index, false); + if (!conflict.isEmpty()) { + appendActivity( + QStringLiteral("Network"), conflict, QStringLiteral("Error"), iedName); + return false; + } + + QString fileRoot; + if (fileServiceEnabled_) { + const QFileInfo rootInfo{fileFolder_.trimmed()}; + if (fileFolder_.trimmed().isEmpty() || !rootInfo.exists() || !rootInfo.isDir()) { + appendActivity( + QStringLiteral("Files"), + QStringLiteral("Choose an existing File transfer folder before starting the IED."), + QStringLiteral("Error"), + iedName); + return false; + } + fileRoot = QDir::cleanPath(rootInfo.absoluteFilePath()); + } + + const auto executable = serverExecutable(); + if (executable.isEmpty()) { + appendActivity( + QStringLiteral("Server"), + QStringLiteral("ariec61850_ied_simulator_server was not found beside the GUI."), + QStringLiteral("Error"), + iedName); + return false; + } + if (!writeModelManifest(index)) return false; + + if (runtime->process == nullptr) { + runtime->process = std::make_unique(); + connectRuntimeSignals(index); + } + runtime->standardOutputBuffer.clear(); + runtime->standardErrorBuffer.clear(); + runtime->standardOutputDrainScheduled = false; + runtime->standardErrorDrainScheduled = false; + runtime->standardOutputOverflowReported = false; + runtime->standardErrorOverflowReported = false; + ++runtime->startGeneration; + const auto generation = runtime->startGeneration; + runtime->liveGeneration = generation; + runtime->nextLiveRevision = 0; + runtime->lastLiveAckRevision = 0; + runtime->liveRequested = 0; + runtime->liveSent = 0; + runtime->liveCoalesced = 0; + runtime->liveRejected = 0; + runtime->liveLastAckLatencyMilliseconds = 0; + runtime->liveMaxAckLatencyMilliseconds = 0; + runtime->liveMaxPending = 0; + runtime->liveFlushScheduled = false; + runtime->pendingLiveUpdates.clear(); + runtime->liveSentAtMilliseconds.clear(); + setRuntimeState(index, RuntimeState::starting); + runtime->process->setProgram(executable); + QStringList arguments{ + QStringLiteral("--host"), runtime->listenAddress, + QStringLiteral("--port"), QString::number(runtime->port), + QStringLiteral("--model-manifest"), runtime->modelManifestPath, + QStringLiteral("--live-stdin"), + QStringLiteral("--live-generation"), QString::number(runtime->liveGeneration)}; + if (!fileRoot.isEmpty()) { + arguments << QStringLiteral("--file-root") << fileRoot; + } + runtime->process->setArguments(arguments); + runtime->process->start(); + + appendActivity( + QStringLiteral("Server"), + QStringLiteral("Starting IEC 61850 MMS endpoint on %1:%2%3.") + .arg(runtime->listenAddress) + .arg(runtime->port) + .arg(fileRoot.isEmpty() + ? QString{} + : QStringLiteral(" · file root %1").arg(fileRoot)), + QStringLiteral("Info"), + iedName); + + QTimer::singleShot(5'000, this, [this, index, generation] { + auto* current = runtimeAt(index); + if (current == nullptr || current->startGeneration != generation || + current->state != RuntimeState::starting || current->process == nullptr || + current->process->state() == QProcess::NotRunning) { + return; + } + appendActivity( + QStringLiteral("Server"), + QStringLiteral("Listener readiness was not confirmed within 5 seconds."), + QStringLiteral("Error"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + stopIed(index); + }); + return true; +} + +void IedFleetController::stopIed(const int index) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || runtime->process == nullptr || + runtime->process->state() == QProcess::NotRunning) { + if (runtime != nullptr) setRuntimeState(index, RuntimeState::ready); + return; + } + + setRuntimeState(index, RuntimeState::stopping); + appendActivity( + QStringLiteral("Server"), + QStringLiteral("Stopping MMS endpoint…"), + QStringLiteral("Info"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + const auto stopGeneration = ++runtime->startGeneration; + auto* const process = runtime->process.get(); + process->terminate(); + QTimer::singleShot(900, this, [this, index, process, stopGeneration] { + auto* current = runtimeAt(index); + if (current == nullptr || current->process.get() != process || + current->startGeneration != stopGeneration || + current->state != RuntimeState::stopping) { + return; + } + if (process->state() != QProcess::NotRunning) process->kill(); + }); +} + +int IedFleetController::startAllSimulations() { + if (!imported()) return 0; + const auto fleetConflict = fleetEndpointConflict(); + if (!fleetConflict.isEmpty()) { + appendActivity(QStringLiteral("Network"), fleetConflict, QStringLiteral("Error")); + return 0; + } + if (fleetStartPending_) { + appendActivity( + QStringLiteral("Fleet"), + QStringLiteral("A fleet start is already in progress."), + QStringLiteral("Warning")); + return 0; + } + + QVector candidates; + for (int index = 0; index < ieds_.size(); ++index) { + const auto* runtime = runtimeAt(index); + if (runtime == nullptr || !runtime->enabled) continue; + if (!isLocalAddress(runtime->listenAddress)) { + appendActivity( + QStringLiteral("Network"), + QStringLiteral("%1 is not currently assigned to this computer.") + .arg(runtime->listenAddress), + QStringLiteral("Error"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + return 0; + } + if (!isActiveState(runtime->state)) candidates.push_back(index); + } + + if (candidates.isEmpty()) return 0; + fleetStartPending_ = true; + fleetStartMembers_.clear(); + fleetStartReady_.clear(); + for (const int index : candidates) fleetStartMembers_.insert(index); + + int started{}; + for (const int index : candidates) { + if (startIed(index)) { + ++started; + continue; + } + handleFleetStartFailure(index, QStringLiteral("An enabled IED could not be launched.")); + return 0; + } + appendActivity( + QStringLiteral("Fleet"), + QStringLiteral("Starting %1 IED endpoint%2.") + .arg(started) + .arg(started == 1 ? QString{} : QStringLiteral("s")), + QStringLiteral("Info")); + return started; +} + +void IedFleetController::stopAllSimulations() { + for (int index = 0; index < static_cast(runtimes_.size()); ++index) { + if (const auto* runtime = runtimeAt(index); runtime != nullptr && isActiveState(runtime->state)) { + stopIed(index); + } + } +} + +bool IedFleetController::configureIedEndpoint( + const int index, + const QString& address, + const int requestedPort) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || isActiveState(runtime->state) || requestedPort < 1 || + requestedPort > 65'535) { + return false; + } + runtime->listenAddress = address.trimmed(); + runtime->port = requestedPort; + updateIedRuntimePresentation(index); + emit configurationChanged(); + if (index == selectedIedIndex_) emit selectionChanged(); + return true; +} + +void IedFleetController::setIedEnabled(const int index, const bool enabled) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || isActiveState(runtime->state) || runtime->enabled == enabled) return; + runtime->enabled = enabled; + updateIedRuntimePresentation(index); + emit configurationChanged(); +} + +bool IedFleetController::autoAssignIedAddresses() { + if (anyRunning()) { + appendActivity( + QStringLiteral("Network"), + QStringLiteral("Stop active IEDs before changing fleet IP assignments."), + QStringLiteral("Warning")); + return false; + } + + const auto addresses = bindableIpv4Addresses(); + int enabledCount{}; + for (const auto& runtime : runtimes_) { + if (runtime != nullptr && runtime->enabled) ++enabledCount; + } + if (enabledCount == 0) return true; + + int addressIndex{}; + int assigned{}; + for (int index = 0; index < static_cast(runtimes_.size()); ++index) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || !runtime->enabled) continue; + if (addressIndex < addresses.size()) { + runtime->listenAddress = addresses.at(addressIndex++); + ++assigned; + } else if (enabledCount == 1) { + runtime->listenAddress = QStringLiteral("127.0.0.1"); + ++assigned; + } else { + runtime->listenAddress.clear(); + } + updateIedRuntimePresentation(index); + } + emit configurationChanged(); + emit selectionChanged(); + + if (assigned < enabledCount) { + appendActivity( + QStringLiteral("Network"), + QStringLiteral( + "Assigned %1 of %2 enabled IEDs. Add more IPv4 addresses to the Ethernet adapter, " + "then refresh interfaces and run Auto assign again.") + .arg(assigned) + .arg(enabledCount), + QStringLiteral("Warning")); + return false; + } + + appendActivity( + QStringLiteral("Network"), + QStringLiteral("Assigned a distinct local IPv4 address to each enabled IED."), + QStringLiteral("Success")); + return true; +} + +void IedFleetController::refreshNetworkInterfaces() { + emit networkInterfacesChanged(); + emit modelChanged(); + appendActivity( + QStringLiteral("Network"), + QStringLiteral("Local network interfaces refreshed.")); +} + +bool IedFleetController::applySelectedValue( + const QString& value, + const QString& quality, + const QString& origin) { + if (!running() || selectedValueIndex_ < 0 || + selectedValueIndex_ >= selectedPointIndices_.size()) { + return false; + } + const auto pointIndex = selectedPointIndices_.at(selectedValueIndex_); + auto* point = pointStore_.atMutable(pointIndex); + if (point == nullptr || !point->writable) return false; + + previousValue_ = ValueSnapshot{ + selectedIedIndex_, selectedValueIndex_, IedPointStore::toVariantMap(*point)}; + const auto before = point->value; + point->value = value; + point->quality = quality; + point->origin = origin; + point->changed = true; + point->updated = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")); + + if (!enqueueLiveUpdate(selectedIedIndex_, *point)) { + *point = IedPointStore::fromVariantMap(previousValue_->value); + previousValue_.reset(); + emit valuesChanged(); + emit selectionChanged(); + return false; + } + emit valuesChanged(); + emit selectionChanged(); + appendActivity( + QStringLiteral("Value"), + QStringLiteral("%1 changed from %2 to %3 · quality %4 · origin %5") + .arg(point->reference, before, value, quality, origin), + QStringLiteral("Success"), + point->iedName); + return true; +} + +bool IedFleetController::undoLastChange() { + if (!previousValue_.has_value() || previousValue_->iedIndex != selectedIedIndex_ || + previousValue_->valueIndex < 0 || + previousValue_->valueIndex >= selectedPointIndices_.size()) { + return false; + } + const int index = previousValue_->valueIndex; + const auto pointIndex = selectedPointIndices_.at(index); + auto* point = pointStore_.atMutable(pointIndex); + if (point == nullptr) return false; + const auto current = *point; + const auto previous = IedPointStore::fromVariantMap(previousValue_->value); + *point = previous; + if (running() && !enqueueLiveUpdate(selectedIedIndex_, *point)) { + *point = current; + return false; + } + selectedValueIndex_ = index; + previousValue_.reset(); + emit valuesChanged(); + emit selectionChanged(); + appendActivity( + QStringLiteral("Value"), + QStringLiteral("Last change to %1 was reverted.").arg(previous.reference), + QStringLiteral("Info"), + previous.iedName); + return true; +} + +int IedFleetController::qaBurstValues(const int updates, const int distinctPoints) { + if (!qEnvironmentVariableIsSet("ARSTACK_IEDSIM_QA") || updates <= 0 || + selectedPointIndices_.isEmpty()) { + return 0; + } + const int boundedUpdates = std::min(updates, 100'000); + const int boundedDistinct = std::clamp( + distinctPoints, 1, std::min(4'096, static_cast(selectedPointIndices_.size()))); + const auto updated = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")); + int applied{}; + for (int iteration = 0; iteration < boundedUpdates; ++iteration) { + const int sourceIndex = iteration % boundedDistinct; + auto* point = pointStore_.atMutable(selectedPointIndices_.at(sourceIndex)); + if (point == nullptr) continue; + point->value = QString::number(iteration); + point->origin = QStringLiteral("Responsiveness QA"); + point->changed = true; + point->updated = updated; + ++applied; + emit valuesChanged(); + } + return applied; +} + +bool IedFleetController::enqueueLiveUpdate( + const int index, + const IedPointStore::PointRecord& point) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || runtime->state != RuntimeState::running || + runtime->process == nullptr || runtime->process->state() != QProcess::Running || + point.mmsDomain.isEmpty() || point.mmsItem.isEmpty()) { + return false; + } + + const auto valueBytes = point.value.toUtf8(); + if (valueBytes.size() > kLiveValueByteLimit) { + ++runtime->liveRejected; + appendActivity( + QStringLiteral("Live data"), + QStringLiteral("Value update exceeds the bounded 4 KiB live payload limit."), + QStringLiteral("Error"), + point.iedName); + return false; + } + + ++runtime->liveRequested; + const auto revision = ++runtime->nextLiveRevision; + const auto key = point.mmsDomain + QLatin1Char('\x1f') + point.mmsItem; + const auto queuedAt = QDateTime::currentMSecsSinceEpoch(); + auto existing = runtime->pendingLiveUpdates.find(key); + if (existing != runtime->pendingLiveUpdates.end()) { + existing->domain = point.mmsDomain; + existing->item = point.mmsItem; + existing->value = point.value; + existing->revision = revision; + existing->queuedAtMilliseconds = queuedAt; + ++runtime->liveCoalesced; + } else { + if (runtime->pendingLiveUpdates.size() >= kLivePendingLimit) { + ++runtime->liveRejected; + appendActivity( + QStringLiteral("Live data"), + QStringLiteral("Live update queue is saturated; the edit was rejected to keep memory bounded."), + QStringLiteral("Error"), + point.iedName); + return false; + } + PendingLiveUpdate update; + update.key = key; + update.domain = point.mmsDomain; + update.item = point.mmsItem; + update.value = point.value; + update.revision = revision; + update.queuedAtMilliseconds = queuedAt; + runtime->pendingLiveUpdates.insert(key, std::move(update)); + runtime->liveMaxPending = std::max( + runtime->liveMaxPending, + static_cast(runtime->pendingLiveUpdates.size())); + } + scheduleLiveFlush(index); + return true; +} + +void IedFleetController::scheduleLiveFlush(const int index) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || runtime->liveFlushScheduled || + runtime->pendingLiveUpdates.isEmpty()) { + return; + } + runtime->liveFlushScheduled = true; + const auto generation = runtime->liveGeneration; + QTimer::singleShot(4, this, [this, index, generation] { + auto* current = runtimeAt(index); + if (current == nullptr || current->liveGeneration != generation) return; + current->liveFlushScheduled = false; + flushLiveUpdates(index, generation); + }); +} + +void IedFleetController::flushLiveUpdates(const int index, const quint64 generation) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || runtime->liveGeneration != generation || + runtime->state != RuntimeState::running || runtime->process == nullptr || + runtime->process->state() != QProcess::Running) { + return; + } + if (runtime->pendingLiveUpdates.isEmpty()) return; + if (runtime->process->bytesToWrite() >= kLiveProcessBufferLimit || + runtime->liveSentAtMilliseconds.size() >= kLiveInflightLimit) { + scheduleLiveFlush(index); + return; + } + + QVector candidates; + candidates.reserve(runtime->pendingLiveUpdates.size()); + for (auto it = runtime->pendingLiveUpdates.cbegin(); + it != runtime->pendingLiveUpdates.cend(); ++it) { + candidates.push_back(it.value()); + } + std::sort( + candidates.begin(), candidates.end(), + [](const PendingLiveUpdate& left, const PendingLiveUpdate& right) { + return left.revision < right.revision; + }); + + QByteArray payload; + QVector sending; + sending.reserve(std::min(kLiveFlushBudget, static_cast(candidates.size()))); + for (const auto& update : candidates) { + if (sending.size() >= kLiveFlushBudget || + runtime->liveSentAtMilliseconds.size() + sending.size() >= kLiveInflightLimit) { + break; + } + const auto line = QByteArrayLiteral("ARSTACK_LIVE\t1\t") + + QByteArray::number(generation) + '\t' + + QByteArray::number(update.revision) + '\t' + + update.domain.toUtf8().toHex() + '\t' + + update.item.toUtf8().toHex() + '\t' + + update.value.toUtf8().toHex() + '\n'; + if (!payload.isEmpty() && payload.size() + line.size() > kLivePayloadBudget) break; + payload += line; + sending.push_back(update); + } + if (sending.isEmpty()) { + scheduleLiveFlush(index); + return; + } + + const auto written = runtime->process->write(payload); + if (written != payload.size()) { + ++runtime->liveRejected; + appendActivity( + QStringLiteral("Live data"), + QStringLiteral("Could not write the bounded live-update batch to the simulator process."), + QStringLiteral("Error"), + index >= 0 && index < ieds_.size() + ? ieds_.at(index).toMap().value(QStringLiteral("name")).toString() + : QString{}); + stopIed(index); + return; + } + + for (const auto& update : sending) { + runtime->pendingLiveUpdates.remove(update.key); + runtime->liveSentAtMilliseconds.insert(update.revision, update.queuedAtMilliseconds); + } + runtime->liveSent += static_cast(sending.size()); + if (!runtime->pendingLiveUpdates.isEmpty()) scheduleLiveFlush(index); +} + +void IedFleetController::handleLiveUpdateAck(const int index, const QVariantMap& fields) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr) return; + bool generationOk{}; + bool revisionOk{}; + const auto generation = fields.value(QStringLiteral("generation")).toString().toULongLong(&generationOk); + const auto revision = fields.value(QStringLiteral("revision")).toString().toULongLong(&revisionOk); + if (!generationOk || !revisionOk || generation != runtime->liveGeneration) return; + + const bool accepted = fields.value(QStringLiteral("accepted")).toString() == QStringLiteral("true"); + const auto sent = runtime->liveSentAtMilliseconds.find(revision); + qint64 latency{}; + if (sent != runtime->liveSentAtMilliseconds.end()) { + latency = std::max(0, QDateTime::currentMSecsSinceEpoch() - sent.value()); + runtime->liveSentAtMilliseconds.erase(sent); + runtime->liveLastAckLatencyMilliseconds = latency; + runtime->liveMaxAckLatencyMilliseconds = std::max( + runtime->liveMaxAckLatencyMilliseconds, latency); + } + if (accepted) { + runtime->lastLiveAckRevision = std::max(runtime->lastLiveAckRevision, revision); + qInfo().noquote() << QStringLiteral( + "IEDSIM_LIVE_ACK generation=%1 revision=%2 latency_ms=%3 pending=%4 inflight=%5") + .arg(generation).arg(revision).arg(latency) + .arg(runtime->pendingLiveUpdates.size()) + .arg(runtime->liveSentAtMilliseconds.size()); + } else { + ++runtime->liveRejected; + appendActivity( + QStringLiteral("Live data"), + QStringLiteral("Server rejected live update revision %1 (%2).") + .arg(revision) + .arg(fields.value(QStringLiteral("reason")).toString()), + QStringLiteral("Error"), + index >= 0 && index < ieds_.size() + ? ieds_.at(index).toMap().value(QStringLiteral("name")).toString() + : QString{}); + } + if (!runtime->pendingLiveUpdates.isEmpty()) scheduleLiveFlush(index); +} + +int IedFleetController::qaBurstLiveValues(const int updates) { + if (!qEnvironmentVariableIsSet("ARSTACK_IEDSIM_QA") || updates <= 0 || + !running() || selectedPointIndices_.isEmpty()) { + return 0; + } + const int bounded = std::min(updates, 100'000); + auto* runtime = runtimeAt(selectedIedIndex_); + auto* point = pointStore_.atMutable(selectedPointIndices_.constFirst()); + if (runtime == nullptr || point == nullptr) return 0; + + int accepted{}; + const auto normalizedType = point->type.trimmed().toLower(); + for (int iteration = 0; iteration < bounded; ++iteration) { + if (normalizedType.contains(QStringLiteral("bool"))) { + point->value = (iteration & 1) != 0 ? QStringLiteral("true") : QStringLiteral("false"); + } else { + point->value = QString::number(iteration); + } + point->origin = QStringLiteral("Live data-plane QA"); + point->changed = true; + point->updated = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")); + if (!enqueueLiveUpdate(selectedIedIndex_, *point)) break; + ++accepted; + emit valuesChanged(); + } + qInfo().noquote() << QStringLiteral( + "IEDSIM_LIVE_BURST requested=%1 accepted=%2 coalesced=%3 rejected=%4 pending=%5 pending_max=%6 final=%7") + .arg(bounded).arg(accepted).arg(runtime->liveCoalesced).arg(runtime->liveRejected) + .arg(runtime->pendingLiveUpdates.size()).arg(runtime->liveMaxPending).arg(point->value); + return accepted; +} + +void IedFleetController::clearActivity() { + activity_.clear(); + emit activityChanged(); +} + +QString IedFleetController::diagnosticsText() const { + QString text; + text += QStringLiteral("ARStack IED Simulator diagnostics\n"); + text += QStringLiteral("Captured: %1\n") + .arg(QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + text += QStringLiteral("Model: %1\nSource: %2\n") + .arg(sourceName_.isEmpty() ? QStringLiteral("") : sourceName_) + .arg(sourcePath_.isEmpty() ? QStringLiteral("") : sourcePath_); + text += QStringLiteral("File service: %1%2\n") + .arg(fileServiceEnabled_ ? QStringLiteral("enabled") : QStringLiteral("disabled")) + .arg(fileServiceEnabled_ && !fileFolder_.isEmpty() + ? QStringLiteral(" · root=") + fileFolder_ + : QString{}); + text += QStringLiteral("Fleet: %1 IEDs; %2 running\n") + .arg(ieds_.size()) + .arg(runningCount()); + for (int index = 0; index < ieds_.size(); ++index) { + const auto ied = ieds_.at(index).toMap(); + const auto* runtime = runtimeAt(index); + text += QStringLiteral(" %1 | %2 | %3 | pid %4\n") + .arg(ied.value(QStringLiteral("name")).toString()) + .arg(ied.value(QStringLiteral("endpoint")).toString()) + .arg(ied.value(QStringLiteral("status")).toString()) + .arg(runtime != nullptr && runtime->process != nullptr ? runtime->process->processId() : 0); + } + text += QStringLiteral( + "MMS association profile: Authentication=None; AP-title=1,1,1,999,1; " + "AE-qualifier=12; P-selector=00 00 00 01; S-selector=00 01; T-selector=00 01\n"); + text += QStringLiteral( + "Counts: IED=%1; LD=%2; DO=%3; DA/BDA=%4; DataSet=%5; Report=%6; GOOSE=%7; TypedPoints=%8\n") + .arg(ieds_.size()) + .arg(logicalDeviceCount_) + .arg(dataObjectCount_) + .arg(dataAttributeCount_) + .arg(dataSetCount_) + .arg(reportCount_) + .arg(gooseCount_) + .arg(pointStore_.size()); + text += QStringLiteral("Prepared IED profiles: %1; fleet rollbacks: %2\n") + .arg(preparedIeds_.size()).arg(fleetStartRollbackCount_); + for (int index = 0; index < static_cast(runtimes_.size()); ++index) { + const auto* runtime = runtimeAt(index); + if (runtime == nullptr || runtime->liveGeneration == 0) continue; + text += QStringLiteral(" Live[%1]: gen=%2 requested=%3 sent=%4 coalesced=%5 rejected=%6 ack=%7 pending=%8 inflight=%9 maxPending=%10 maxAckMs=%11\n") + .arg(index).arg(runtime->liveGeneration).arg(runtime->liveRequested) + .arg(runtime->liveSent).arg(runtime->liveCoalesced).arg(runtime->liveRejected) + .arg(runtime->lastLiveAckRevision).arg(runtime->pendingLiveUpdates.size()) + .arg(runtime->liveSentAtMilliseconds.size()).arg(runtime->liveMaxPending) + .arg(runtime->liveMaxAckLatencyMilliseconds); + } + text += QStringLiteral("\nRecent activity (newest first):\n"); + for (const auto& item : activity_.snapshot()) { + const auto event = item.toMap(); + text += QStringLiteral("%1 | %2 | %3 | %4 | %5\n") + .arg( + event.value(QStringLiteral("time")).toString(), + event.value(QStringLiteral("severity")).toString(), + event.value(QStringLiteral("ied")).toString(), + event.value(QStringLiteral("category")).toString(), + event.value(QStringLiteral("message")).toString()); + } + return text; +} + +void IedFleetController::copyDiagnostics() { + if (auto* clipboard = QGuiApplication::clipboard(); clipboard != nullptr) { + clipboard->setText(diagnosticsText()); + appendActivity( + QStringLiteral("Diagnostics"), + QStringLiteral("Fleet diagnostics copied to the clipboard."), + QStringLiteral("Success")); + } +} + +void IedFleetController::rebuildPresentation() { + QHash previousConfigurations; + for (int index = 0; index < ieds_.size(); ++index) { + const auto ied = ieds_.at(index).toMap(); + const auto* runtime = runtimeAt(index); + if (runtime == nullptr) continue; + QVariantMap configuration; + configuration.insert(QStringLiteral("address"), runtime->listenAddress); + configuration.insert(QStringLiteral("port"), runtime->port); + configuration.insert(QStringLiteral("enabled"), runtime->enabled); + previousConfigurations.insert( + ied.value(QStringLiteral("sessionKey")).toString(), configuration); + } + + ieds_.clear(); + logicalDeviceCount_ = 0; + dataObjectCount_ = 0; + dataAttributeCount_ = 0; + dataSetCount_ = 0; + reportCount_ = 0; + gooseCount_ = 0; + + std::set logicalDevices; + std::set dataObjects; + std::set dataAttributes; + for (std::size_t documentIndex = 0; documentIndex < documents_.size(); ++documentIndex) { + const auto& loaded = documents_[documentIndex]; + const auto& document = loaded.document; + dataSetCount_ += static_cast(document.data_sets.size()); + reportCount_ += static_cast(document.report_controls.size()); + gooseCount_ += static_cast(document.goose_streams.size()); + + for (const auto& ied : document.ieds) { + const auto name = qstring(ied.name); + QVariantMap item; + item.insert(QStringLiteral("name"), name); + item.insert(QStringLiteral("manufacturer"), qstring(ied.manufacturer)); + item.insert(QStringLiteral("type"), qstring(ied.type)); + item.insert(QStringLiteral("configVersion"), qstring(ied.config_version)); + item.insert(QStringLiteral("documentIndex"), static_cast(documentIndex)); + item.insert(QStringLiteral("sourcePath"), loaded.path); + item.insert(QStringLiteral("sessionKey"), loaded.path + QLatin1Char('\x1f') + name); + ieds_.push_back(item); + } + + const auto collectEntry = [&](const ar::iec61850::scl::SclDataSetEntry& entry) { + const auto ld = qstring(entry.ied_name) + QLatin1Char('/') + qstring(entry.ld_inst); + logicalDevices.insert(ld); + const auto logicalNode = qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst); + const auto object = ld + QLatin1Char('/') + logicalNode + + QLatin1Char('/') + qstring(entry.do_name); + dataObjects.insert(object); + dataAttributes.insert(object + QLatin1Char('/') + qstring(entry.da_name)); + }; + if (!document.model_entries.empty()) { + for (const auto& entry : document.model_entries) collectEntry(entry); + } else { + for (const auto& dataSet : document.data_sets) { + for (const auto& entry : dataSet.entries) collectEntry(entry); + } + for (const auto& stream : document.goose_streams) { + for (const auto& entry : stream.entries) collectEntry(entry); + } + for (const auto& report : document.report_controls) { + for (const auto& entry : report.entries) collectEntry(entry); + } + } + } + + if (ieds_.isEmpty() && !documents_.empty()) { + QVariantMap fallback; + const auto name = QFileInfo(sourceName_).completeBaseName(); + fallback.insert(QStringLiteral("name"), name); + fallback.insert(QStringLiteral("manufacturer"), QStringLiteral("SCL model")); + fallback.insert(QStringLiteral("type"), QStringLiteral("IED")); + fallback.insert(QStringLiteral("configVersion"), QString{}); + fallback.insert(QStringLiteral("documentIndex"), 0); + fallback.insert(QStringLiteral("sourcePath"), documents_.front().path); + fallback.insert( + QStringLiteral("sessionKey"), + documents_.front().path + QLatin1Char('\x1f') + name); + ieds_.push_back(fallback); + } + + logicalDeviceCount_ = static_cast(logicalDevices.size()); + dataObjectCount_ = static_cast(dataObjects.size()); + dataAttributeCount_ = static_cast(dataAttributes.size()); + + const auto maximumIedIndex = static_cast(ieds_.size()) - 1; + selectedIedIndex_ = ieds_.isEmpty() + ? -1 + : std::clamp(selectedIedIndex_, 0, maximumIedIndex); + if (selectedIedIndex_ < 0 && !ieds_.isEmpty()) selectedIedIndex_ = 0; + + rebuildRuntimeInstances(previousConfigurations); + rebuildValues(); + emit modelChanged(); + emit selectionChanged(); + emit configurationChanged(); + emit runtimeChanged(); +} + +void IedFleetController::rebuildRuntimeInstances( + const QHash& previousConfigurations) { + removeModelManifests(); + runtimes_.clear(); + runtimes_.reserve(static_cast(ieds_.size())); + + const auto candidates = bindableIpv4Addresses(); + QSet reservedAddresses; + int nextCandidate{}; + const bool multiIed = ieds_.size() > 1; + + for (int index = 0; index < ieds_.size(); ++index) { + const auto ied = ieds_.at(index).toMap(); + const auto key = ied.value(QStringLiteral("sessionKey")).toString(); + const auto previous = previousConfigurations.value(key); + + auto runtime = std::make_unique(); + runtime->key = key; + runtime->port = previous.isEmpty() + ? defaultPort_ + : previous.value(QStringLiteral("port"), defaultPort_).toInt(); + runtime->enabled = previous.isEmpty() + ? true + : previous.value(QStringLiteral("enabled"), true).toBool(); + runtime->listenAddress = previous.isEmpty() + ? (multiIed ? QString{} : defaultListenAddress_) + : previous.value(QStringLiteral("address")).toString(); + + if (multiIed && runtime->listenAddress == QStringLiteral("0.0.0.0")) { + runtime->listenAddress.clear(); + } + + if (multiIed && runtime->listenAddress.isEmpty()) { + while (nextCandidate < candidates.size() && + reservedAddresses.contains(candidates.at(nextCandidate))) { + ++nextCandidate; + } + if (nextCandidate < candidates.size()) { + runtime->listenAddress = candidates.at(nextCandidate++); + } + } + if (!runtime->listenAddress.isEmpty() && + runtime->listenAddress != QStringLiteral("0.0.0.0")) { + reservedAddresses.insert(runtime->listenAddress); + } + + runtime->modelManifestPath = manifestPathFor(index); + runtime->process = std::make_unique(); + runtimes_.push_back(std::move(runtime)); + connectRuntimeSignals(index); + updateIedRuntimePresentation(index); + } + + if (multiIed) { + int unassigned{}; + for (const auto& runtime : runtimes_) { + if (runtime != nullptr && runtime->enabled && runtime->listenAddress.isEmpty()) ++unassigned; + } + if (unassigned > 0) { + appendActivity( + QStringLiteral("Network"), + QStringLiteral( + "%1 IED%2 still need a unique local IPv4 address. Add secondary IPv4 addresses " + "to the Ethernet adapter, refresh interfaces, then use Auto assign.") + .arg(unassigned) + .arg(unassigned == 1 ? QString{} : QStringLiteral("s")), + QStringLiteral("Warning")); + } + } +} + +bool IedFleetController::adoptPreparedIed(const int iedIndex) { + if (iedIndex < 0 || iedIndex >= preparedIeds_.size()) return false; + const auto& projection = preparedIeds_.at(iedIndex); + selectedPointIndices_ = projection.pointIndices; + navigationIndex_ = projection.navigationIndex; + valueScopeIndex_ = projection.valueScopeIndex; + preparedValueIndexIed_ = iedIndex; + preparedPointCount_ = static_cast(selectedPointIndices_.size()); + selectedValueIndex_ = selectedPointIndices_.isEmpty() ? -1 : 0; + return true; +} + +void IedFleetController::rebuildValues() { + selectedPointIndices_.clear(); + navigationIndex_.clear(); + valueScopeIndex_.clear(); + preparedValueIndexIed_ = -1; + preparedPointCount_ = 0; + selectedValueIndex_ = -1; + if (selectedIedIndex_ < 0 || selectedIedIndex_ >= ieds_.size()) { + emit valuesChanged(); + return; + } + const auto ied = ieds_.at(selectedIedIndex_).toMap(); + const int documentIndex = ied.value(QStringLiteral("documentIndex")).toInt(); + if (documentIndex < 0 || documentIndex >= static_cast(documents_.size())) { + emit valuesChanged(); + return; + } + + ar::iec61850::simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = ied.value(QStringLiteral("name")).toString().toStdString(); + options.runtime_ied_name = options.ied_name; + const auto built = ar::iec61850::simulation::IedSimulatorProfileBuilder::build( + documents_[static_cast(documentIndex)].document, + options); + + std::vector points; + points.reserve(built.profile.point_count()); + for (const auto& device : built.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + for (const auto& point : node.points) points.push_back(&point); + } + } + std::stable_sort( + points.begin(), points.end(), + [](const auto* left, const auto* right) { + return left->source_order < right->source_order; + }); + + selectedPointIndices_.reserve(static_cast(points.size())); + for (const auto* point : points) { + const int pointIndex = pointStore_.insertIfMissing( + IedPointStore::fromSimulatorPoint(*point)); + selectedPointIndices_.push_back(pointIndex); + } + seededRuntimeIeds_.insert(ied.value(QStringLiteral("sessionKey")).toString()); + + selectedValueIndex_ = selectedPointIndices_.isEmpty() ? -1 : 0; + emit valuesChanged(); +} + +void IedFleetController::seedRuntimeValues(const int iedIndex) { + if (iedIndex < 0 || iedIndex >= ieds_.size()) return; + const auto ied = ieds_.at(iedIndex).toMap(); + const auto sessionKey = ied.value(QStringLiteral("sessionKey")).toString(); + if (!sessionKey.isEmpty() && seededRuntimeIeds_.contains(sessionKey)) return; + + if (iedIndex >= 0 && iedIndex < preparedIeds_.size()) { + if (!sessionKey.isEmpty()) seededRuntimeIeds_.insert(sessionKey); + return; + } + + const int documentIndex = ied.value(QStringLiteral("documentIndex")).toInt(); + if (documentIndex < 0 || documentIndex >= static_cast(documents_.size())) return; + + ar::iec61850::simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = ied.value(QStringLiteral("name")).toString().toStdString(); + options.runtime_ied_name = options.ied_name; + const auto built = ar::iec61850::simulation::IedSimulatorProfileBuilder::build( + documents_[static_cast(documentIndex)].document, + options); + pointStore_.reserve(pointStore_.size() + static_cast(built.profile.point_count())); + for (const auto& device : built.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + for (const auto& point : node.points) { + pointStore_.insertIfMissing(IedPointStore::fromSimulatorPoint(point)); + } + } + } + if (!sessionKey.isEmpty()) seededRuntimeIeds_.insert(sessionKey); +} + +void IedFleetController::updateIedRuntimePresentation(const int index) { + if (index < 0 || index >= ieds_.size()) return; + const auto* runtime = runtimeAt(index); + if (runtime == nullptr) return; + auto item = ieds_.at(index).toMap(); + item.insert(QStringLiteral("status"), runtimeStateText(runtime->state)); + item.insert(QStringLiteral("running"), runtime->state == RuntimeState::running); + item.insert(QStringLiteral("starting"), runtime->state == RuntimeState::starting); + item.insert(QStringLiteral("enabled"), runtime->enabled); + item.insert(QStringLiteral("listenAddress"), runtime->listenAddress); + item.insert(QStringLiteral("port"), runtime->port); + item.insert( + QStringLiteral("endpoint"), + runtime->listenAddress.isEmpty() + ? QStringLiteral("Assign IP") + : QStringLiteral("%1:%2").arg(runtime->listenAddress).arg(runtime->port)); + ieds_[index] = item; +} + +void IedFleetController::connectRuntimeSignals(const int index) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || runtime->process == nullptr) return; + auto* const process = runtime->process.get(); + + connect(process, &QProcess::started, this, [this, index] { + if (index < 0 || index >= ieds_.size()) return; + appendActivity( + QStringLiteral("Server"), + QStringLiteral("Server process launched; waiting for listener confirmation."), + QStringLiteral("Info"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + }); + + connect(process, &QProcess::errorOccurred, this, [this, index](const QProcess::ProcessError error) { + auto* current = runtimeAt(index); + if (current == nullptr || current->process == nullptr) return; + const bool failedDuringFleetStart = current->state == RuntimeState::starting; + const bool expectedStop = current->state == RuntimeState::stopping; + setRuntimeState(index, expectedStop ? RuntimeState::ready : RuntimeState::failed); + if (!expectedStop) { + const auto reason = QStringLiteral("MMS server process error %1: %2") + .arg(static_cast(error)) + .arg(current->process->errorString()); + appendActivity( + QStringLiteral("Server"), + reason, + QStringLiteral("Error"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + if (failedDuringFleetStart) handleFleetStartFailure(index, reason); + } + }); + + connect( + process, + qOverload(&QProcess::finished), + this, + [this, index](const int exitCode, const QProcess::ExitStatus) { + auto* current = runtimeAt(index); + if (current == nullptr || current->process == nullptr) return; + consumeServerOutput( + index, + current->standardOutputBuffer, + current->process->readAllStandardOutput(), + false); + consumeServerOutput( + index, + current->standardErrorBuffer, + current->process->readAllStandardError(), + true); + if (!current->standardOutputBuffer.isEmpty()) { + bool truncated{}; + const auto tail = ar::iedsim::runtime_guardrails::boundedTail( + current->standardOutputBuffer, &truncated); + processServerLine(index, QString::fromUtf8(tail).trimmed(), false); + if (truncated) { + appendActivity( + QStringLiteral("Diagnostics"), + QStringLiteral("Final child-process stdout line was truncated at the safety limit."), + QStringLiteral("Warning"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + } + current->standardOutputBuffer.clear(); + } + if (!current->standardErrorBuffer.isEmpty()) { + bool truncated{}; + const auto tail = ar::iedsim::runtime_guardrails::boundedTail( + current->standardErrorBuffer, &truncated); + processServerLine(index, QString::fromUtf8(tail).trimmed(), true); + if (truncated) { + appendActivity( + QStringLiteral("Diagnostics"), + QStringLiteral("Final child-process stderr line was truncated at the safety limit."), + QStringLiteral("Warning"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + } + current->standardErrorBuffer.clear(); + } + const bool failedDuringFleetStart = current->state == RuntimeState::starting; + const bool wasActive = isActiveState(current->state); + setRuntimeState(index, RuntimeState::ready); + if (wasActive) { + appendActivity( + QStringLiteral("Server"), + QStringLiteral("MMS endpoint stopped (exit %1).").arg(exitCode), + exitCode == 0 ? QStringLiteral("Info") : QStringLiteral("Warning"), + ieds_.at(index).toMap().value(QStringLiteral("name")).toString()); + } + if (failedDuringFleetStart) { + handleFleetStartFailure( + index, QStringLiteral("Endpoint exited before listener readiness was confirmed.")); + } + }); + + connect(process, &QProcess::readyReadStandardOutput, this, [this, index] { + auto* current = runtimeAt(index); + if (current == nullptr || current->process == nullptr) return; + consumeServerOutput( + index, + current->standardOutputBuffer, + current->process->readAllStandardOutput(), + false); + }); + connect(process, &QProcess::readyReadStandardError, this, [this, index] { + auto* current = runtimeAt(index); + if (current == nullptr || current->process == nullptr) return; + consumeServerOutput( + index, + current->standardErrorBuffer, + current->process->readAllStandardError(), + true); + }); +} + +void IedFleetController::setRuntimeState(const int index, const RuntimeState state) { + auto* runtime = runtimeAt(index); + if (runtime == nullptr || runtime->state == state) return; + runtime->state = state; + updateIedRuntimePresentation(index); + emit runtimeChanged(); + emit modelChanged(); + if (index == selectedIedIndex_) emit selectionChanged(); + + if (clearPending_ && !anyRunning()) { + QTimer::singleShot(0, this, [this] { + if (clearPending_ && !anyRunning()) performClear(); + }); + } +} + +void IedFleetController::handleFleetStartReady(const int index) { + if (!fleetStartPending_ || !fleetStartMembers_.contains(index)) return; + fleetStartReady_.insert(index); + if (fleetStartReady_.size() != fleetStartMembers_.size()) return; + + const int readyCount = fleetStartReady_.size(); + fleetStartPending_ = false; + fleetStartMembers_.clear(); + fleetStartReady_.clear(); + appendActivity( + QStringLiteral("Fleet"), + QStringLiteral("%1 IED endpoint%2 reached listener-ready state.") + .arg(readyCount) + .arg(readyCount == 1 ? QString{} : QStringLiteral("s")), + QStringLiteral("Success")); +} + +void IedFleetController::handleFleetStartFailure(const int index, const QString& reason) { + if (!fleetStartPending_ || !fleetStartMembers_.contains(index)) return; + + const auto failedName = index >= 0 && index < ieds_.size() + ? ieds_.at(index).toMap().value(QStringLiteral("name")).toString() + : QStringLiteral("IED"); + const auto members = fleetStartMembers_.values(); + fleetStartPending_ = false; + fleetStartMembers_.clear(); + fleetStartReady_.clear(); + ++fleetStartRollbackCount_; + appendActivity( + QStringLiteral("Fleet"), + QStringLiteral("Fleet start rolled back after %1 failed: %2").arg(failedName, reason), + QStringLiteral("Error"), + failedName); + for (const int member : members) { + const auto* runtime = runtimeAt(member); + if (runtime != nullptr && isActiveState(runtime->state)) stopIed(member); + } + emit runtimeChanged(); +} + +void IedFleetController::stopAllProcessesBlocking() { + for (auto& runtime : runtimes_) { + if (runtime != nullptr && runtime->process != nullptr && + runtime->process->state() != QProcess::NotRunning) { + runtime->process->terminate(); + } + } + + QElapsedTimer gracefulDeadline; + gracefulDeadline.start(); + for (auto& runtime : runtimes_) { + if (runtime == nullptr || runtime->process == nullptr || + runtime->process->state() == QProcess::NotRunning) { + continue; + } + const int remaining = std::max(0, 1'200 - static_cast(gracefulDeadline.elapsed())); + if (remaining > 0) runtime->process->waitForFinished(remaining); + } + + for (auto& runtime : runtimes_) { + if (runtime != nullptr && runtime->process != nullptr && + runtime->process->state() != QProcess::NotRunning) { + runtime->process->kill(); + } + } + + QElapsedTimer killDeadline; + killDeadline.start(); + for (auto& runtime : runtimes_) { + if (runtime == nullptr || runtime->process == nullptr || + runtime->process->state() == QProcess::NotRunning) { + continue; + } + const int remaining = std::max(0, 500 - static_cast(killDeadline.elapsed())); + if (remaining > 0) runtime->process->waitForFinished(remaining); + } + + for (int index = 0; index < static_cast(runtimes_.size()); ++index) { + if (auto* runtime = runtimeAt(index); runtime != nullptr) { + runtime->state = RuntimeState::ready; + updateIedRuntimePresentation(index); + } + } + emit runtimeChanged(); + emit modelChanged(); +} + +void IedFleetController::appendActivity( + const QString& category, + const QString& message, + const QString& severity, + const QString& iedName) { + QVariantMap event; + event.insert( + QStringLiteral("time"), + QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz"))); + event.insert(QStringLiteral("category"), category); + event.insert(QStringLiteral("message"), message); + event.insert(QStringLiteral("severity"), severity); + event.insert(QStringLiteral("ied"), iedName); + activity_.push_front(event); + emit activityChanged(); +} + +void IedFleetController::consumeServerOutput( + const int index, + QByteArray& buffer, + const QByteArray& bytes, + const bool standardError) { + const auto drained = ar::iedsim::runtime_guardrails::appendAndDrain(buffer, bytes); + auto* current = runtimeAt(index); + + if (drained.droppedBytes > 0 && current != nullptr) { + bool& reported = standardError + ? current->standardErrorOverflowReported + : current->standardOutputOverflowReported; + if (!reported) { + reported = true; + appendActivity( + QStringLiteral("Diagnostics"), + QStringLiteral( + "Child-process %1 exceeded the %2 KiB framing buffer; excess bytes are discarded " + "to keep simulator memory bounded.") + .arg(standardError ? QStringLiteral("stderr") : QStringLiteral("stdout")) + .arg(ar::iedsim::runtime_guardrails::kMaxBufferedProcessBytes / 1024), + QStringLiteral("Warning"), + index >= 0 && index < ieds_.size() + ? ieds_.at(index).toMap().value(QStringLiteral("name")).toString() + : QString{}); + } + } + + for (const auto& rawLine : drained.lines) { + const auto line = QString::fromUtf8(rawLine).trimmed(); + if (!line.isEmpty()) processServerLine(index, line, standardError); + } + if (drained.truncatedLines > 0 && current != nullptr) { + appendActivity( + QStringLiteral("Diagnostics"), + QStringLiteral("%1 child-process line%2 truncated at %3 KiB.") + .arg(drained.truncatedLines) + .arg(drained.truncatedLines == 1 ? QString{} : QStringLiteral("s")) + .arg(ar::iedsim::runtime_guardrails::kMaxProcessLineBytes / 1024), + QStringLiteral("Warning"), + index >= 0 && index < ieds_.size() + ? ieds_.at(index).toMap().value(QStringLiteral("name")).toString() + : QString{}); + } + + if (!drained.moreCompleteLines || current == nullptr) return; + bool& scheduled = standardError + ? current->standardErrorDrainScheduled + : current->standardOutputDrainScheduled; + if (scheduled) return; + scheduled = true; + QTimer::singleShot(0, this, [this, index, standardError] { + auto* runtime = runtimeAt(index); + if (runtime == nullptr) return; + bool& pending = standardError + ? runtime->standardErrorDrainScheduled + : runtime->standardOutputDrainScheduled; + pending = false; + auto& pendingBuffer = standardError + ? runtime->standardErrorBuffer + : runtime->standardOutputBuffer; + consumeServerOutput(index, pendingBuffer, QByteArray{}, standardError); + }); +} + +void IedFleetController::processServerLine( + const int index, + const QString& line, + const bool standardError) { + if (index < 0 || index >= ieds_.size()) return; + const auto iedName = ieds_.at(index).toMap().value(QStringLiteral("name")).toString(); + + if (qEnvironmentVariableIsSet("ARSTACK_IEDSIM_TRACE_SERVER")) { + if (standardError) { + qWarning().noquote() << '[' + iedName + ']' << line; + } else { + qInfo().noquote() << '[' + iedName + ']' << line; + } + } + if (!line.startsWith(QStringLiteral("IEDSIM_EVENT "))) { + appendActivity( + QStringLiteral("MMS"), + line, + standardError ? QStringLiteral("Error") : QStringLiteral("Info"), + iedName); + return; + } + + QVariantMap fields; + const auto tokens = line.mid(13).split(QLatin1Char(' '), Qt::SkipEmptyParts); + for (const auto& token : tokens) { + const auto separator = token.indexOf(QLatin1Char('=')); + if (separator > 0) fields.insert(token.left(separator), token.mid(separator + 1)); + } + const auto kind = fields.value(QStringLiteral("kind")).toString(); + if (kind == QStringLiteral("live_update_ack")) { + handleLiveUpdateAck(index, fields); + return; + } + if (kind == QStringLiteral("server_ready")) { + setRuntimeState(index, RuntimeState::running); + handleFleetStartReady(index); + appendActivity( + QStringLiteral("Server"), + QStringLiteral("MMS listener ready on %1:%2 · %3 domains · %4 objects.") + .arg(fields.value(QStringLiteral("bind")).toString()) + .arg(fields.value(QStringLiteral("port")).toString()) + .arg(fields.value(QStringLiteral("domains")).toString()) + .arg(fields.value(QStringLiteral("objects")).toString()), + QStringLiteral("Success"), + iedName); + const auto truncated = fields.value(QStringLiteral("truncated")).toInt(); + if (truncated > 0) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("%1 model objects exceeded the bounded host profile and were omitted.") + .arg(truncated), + QStringLiteral("Warning"), + iedName); + } + return; + } + if (kind == QStringLiteral("client_connected")) { + appendActivity( + QStringLiteral("TCP"), + QStringLiteral("Client %1 connected · association %2.") + .arg(fields.value(QStringLiteral("remote")).toString()) + .arg(fields.value(QStringLiteral("association")).toString()), + QStringLiteral("Success"), + iedName); + return; + } + if (kind == QStringLiteral("protocol_stage")) { + appendActivity( + QStringLiteral("Protocol"), + QStringLiteral("Association %1 reached %2 stage.") + .arg(fields.value(QStringLiteral("association")).toString()) + .arg(fields.value(QStringLiteral("stage")).toString().toUpper()), + QStringLiteral("Info"), + iedName); + return; + } + if (kind == QStringLiteral("mms_service")) { + const bool accepted = fields.value(QStringLiteral("accepted")).toString() != + QStringLiteral("false"); + appendActivity( + QStringLiteral("MMS"), + QStringLiteral("%1 invoke %2 %3.") + .arg(fields.value(QStringLiteral("service")).toString()) + .arg(fields.value(QStringLiteral("invoke")).toString()) + .arg(accepted ? QStringLiteral("answered") : QStringLiteral("rejected")), + accepted ? QStringLiteral("Info") : QStringLiteral("Warning"), + iedName); + return; + } + if (kind == QStringLiteral("client_closed")) { + appendActivity( + QStringLiteral("TCP"), + QStringLiteral("Client %1 closed · RX %2 · TX %3 · %4.") + .arg(fields.value(QStringLiteral("remote")).toString()) + .arg(fields.value(QStringLiteral("rx")).toString()) + .arg(fields.value(QStringLiteral("tx")).toString()) + .arg(fields.value(QStringLiteral("state")).toString()), + QStringLiteral("Info"), + iedName); + return; + } + if (kind == QStringLiteral("report_sent")) { + appendActivity( + QStringLiteral("Report"), + QStringLiteral("Report %1 sent · SqNum %2 · reason %3.") + .arg(fields.value(QStringLiteral("rcb")).toString()) + .arg(fields.value(QStringLiteral("sqnum")).toString()) + .arg(fields.value(QStringLiteral("reason")).toString()), + QStringLiteral("Success"), + iedName); + return; + } + if (kind != QStringLiteral("server_stopped")) { + appendActivity(QStringLiteral("MMS"), line, QStringLiteral("Info"), iedName); + } +} + +bool IedFleetController::writeModelManifest(const int iedIndex) { + if (iedIndex < 0 || iedIndex >= ieds_.size()) return false; + auto* runtime = runtimeAt(iedIndex); + if (runtime == nullptr) return false; + if (runtime->modelManifestPath.isEmpty()) runtime->modelManifestPath = manifestPathFor(iedIndex); + + const auto activeIed = ieds_.at(iedIndex).toMap(); + const auto activeIedName = activeIed.value(QStringLiteral("name")).toString(); + const auto activeDocumentIndex = activeIed.value(QStringLiteral("documentIndex"), -1).toInt(); + if (activeIedName.isEmpty() || activeDocumentIndex < 0 || + activeDocumentIndex >= static_cast(documents_.size())) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("Select a valid IED before starting the simulator."), + QStringLiteral("Error"), + activeIedName); + return false; + } + + // For an interactive async import the selected IED was already seeded on + // the worker, so this is a constant-time guard instead of a second profile + // build on Start. Other IEDs are lazily seeded for compatibility. + seedRuntimeValues(iedIndex); + ++runtime->modelRevision; + QByteArray manifest = "ARSTACK_IED_MODEL\t2\t" + + QByteArray::number(runtime->modelRevision) + "\n"; + QSet uniqueRoots; + const auto& loaded = documents_[static_cast(activeDocumentIndex)]; + for (const auto& logicalNode : loaded.document.logical_nodes) { + if (qstring(logicalNode.ied_name) != activeIedName) continue; + const auto domain = qstring(logicalNode.mms_domain()); + const auto item = qstring(logicalNode.name); + if (domain.isEmpty() || item.isEmpty()) continue; + const auto key = domain + QLatin1Char('\n') + item; + if (uniqueRoots.contains(key)) continue; + uniqueRoots.insert(key); + manifest += "LN\t" + manifestField(domain) + "\t" + manifestField(item) + "\n"; + } + + QSet emittedObjects; + for (const auto& point : pointStore_.records()) { + if (point.iedName != activeIedName) continue; + if (point.mmsDomain.isEmpty() || point.mmsItem.isEmpty()) continue; + const auto key = point.mmsDomain + QLatin1Char('\n') + point.mmsItem; + if (emittedObjects.contains(key)) continue; + emittedObjects.insert(key); + manifest += "OBJ\t" + manifestField(point.mmsDomain) + "\t" + + manifestField(point.mmsItem) + "\t" + + manifestField(point.rawType) + "\t" + + manifestField(point.type) + "\t" + + manifestField(point.value) + "\n"; + } + + QSet emittedControls; + for (const auto& entry : loaded.document.model_entries) { + if (qstring(entry.ied_name) != activeIedName || + qstring(entry.functional_constraint).compare(QStringLiteral("CF"), Qt::CaseInsensitive) != 0 || + qstring(entry.da_name).compare(QStringLiteral("ctlModel"), Qt::CaseInsensitive) != 0) { + continue; + } + const auto model = controlModelCode(qstring(entry.configured_value)); + if (!model.has_value()) continue; + const auto domain = mmsDomainFor(entry); + const auto logicalNode = qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst); + auto dataObject = qstring(entry.do_name); + dataObject.replace(QLatin1Char('.'), QLatin1Char('$')); + const auto cdc = qstring(entry.cdc).trimmed().toUpper(); + if (domain.isEmpty() || logicalNode.isEmpty() || dataObject.isEmpty() || cdc.isEmpty()) continue; + const auto key = domain + QLatin1Char('\n') + logicalNode + QLatin1Char('\n') + dataObject; + if (emittedControls.contains(key)) continue; + emittedControls.insert(key); + manifest += "CTL\t" + manifestField(domain) + "\t" + manifestField(logicalNode) + + "\t" + manifestField(dataObject) + "\t" + manifestField(cdc) + "\t" + + QByteArray::number(*model) + "\n"; + } + + QSet emittedDataSetMembers; + for (const auto& dataSet : loaded.document.data_sets) { + if (qstring(dataSet.ied_name) != activeIedName) continue; + const auto dataSetDomain = qstring(dataSet.ied_name) + qstring(dataSet.ld_inst); + auto dataSetItem = qstring(dataSet.logical_node_path) + QLatin1Char('$') + + qstring(dataSet.name); + dataSetItem.replace(QLatin1Char('.'), QLatin1Char('$')); + for (const auto& entry : dataSet.entries) { + const auto memberDomain = mmsDomainFor(entry); + const auto memberItem = mmsItemFor(entry); + const auto memberKey = dataSetDomain + QLatin1Char('\n') + dataSetItem + + QLatin1Char('\n') + memberDomain + QLatin1Char('\n') + memberItem; + if (dataSetDomain.isEmpty() || dataSetItem.isEmpty() || + memberDomain.isEmpty() || memberItem.isEmpty() || + emittedDataSetMembers.contains(memberKey)) continue; + emittedDataSetMembers.insert(memberKey); + manifest += "DS\t" + manifestField(dataSetDomain) + "\t" + + manifestField(dataSetItem) + "\t" + manifestField(memberDomain) + + "\t" + manifestField(memberItem) + "\n"; + } + } + + QSet emittedReportControls; + for (const auto& report : loaded.document.report_controls) { + manifest += arstack::iedsim::reportControlManifestLines( + report, + activeIedName, + emittedReportControls); + } + + QSet emittedSettingControls; + for (const auto& setting : loaded.document.setting_controls) { + manifest += arstack::iedsim::settingControlManifestLine( + setting, activeIedName, emittedSettingControls); + } + + if (uniqueRoots.isEmpty()) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("The selected IED contains no discoverable logical-node roots."), + QStringLiteral("Error"), + activeIedName); + return false; + } + if (emittedObjects.isEmpty()) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("The selected IED contains no typed structural data-attribute leaves."), + QStringLiteral("Error"), + activeIedName); + return false; + } + + QSaveFile output{runtime->modelManifestPath}; + if (!output.open(QIODevice::WriteOnly | QIODevice::Truncate) || + output.write(manifest) != manifest.size()) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("Could not create the temporary MMS model manifest."), + QStringLiteral("Error"), + activeIedName); + return false; + } + if (!output.commit()) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("Could not publish the MMS model manifest atomically."), + QStringLiteral("Error"), + activeIedName); + return false; + } + return true; +} + +void IedFleetController::removeModelManifests() { + for (auto& runtime : runtimes_) { + if (runtime == nullptr || runtime->modelManifestPath.isEmpty()) continue; + QFile::remove(runtime->modelManifestPath); + runtime->modelManifestPath.clear(); + } +} + +QString IedFleetController::manifestPathFor(const int iedIndex) const { + const auto pid = QCoreApplication::applicationPid(); + if (iedIndex == 0) { + return QDir::temp().filePath( + QStringLiteral("arstack-ied-simulator-%1.model").arg(pid)); + } + return QDir::temp().filePath( + QStringLiteral("arstack-ied-simulator-%1-%2.model").arg(pid).arg(iedIndex)); +} + +QString IedFleetController::serverExecutable() const { +#if defined(Q_OS_WIN) + constexpr auto executableName = "ariec61850_ied_simulator_server.exe"; +#else + constexpr auto executableName = "ariec61850_ied_simulator_server"; +#endif + const auto besideGui = QCoreApplication::applicationDirPath() + QLatin1Char('/') + + QString::fromLatin1(executableName); + if (QFileInfo::exists(besideGui)) return besideGui; + const auto currentDirectory = QCoreApplication::applicationDirPath() + + QStringLiteral("/ariec61850-core/") + QString::fromLatin1(executableName); + if (QFileInfo::exists(currentDirectory)) return currentDirectory; + return {}; +} + +bool IedFleetController::isLocalAddress(const QString& addressText) const { + const auto text = addressText.trimmed(); + if (text == QStringLiteral("0.0.0.0")) return true; + QHostAddress requested; + if (!requested.setAddress(text) || requested.protocol() != QAbstractSocket::IPv4Protocol) return false; + if (requested.isLoopback()) return true; + const auto localAddresses = QNetworkInterface::allAddresses(); + return std::any_of( + localAddresses.cbegin(), localAddresses.cend(), + [&requested](const QHostAddress& candidate) { + return candidate.protocol() == QAbstractSocket::IPv4Protocol && candidate == requested; + }); +} + +QString IedFleetController::endpointConflictFor( + const int index, + const bool includeReadyPeers) const { + const auto* runtime = runtimeAt(index); + if (runtime == nullptr || !runtime->enabled) return {}; + if (runtime->listenAddress.isEmpty()) return QStringLiteral("This IED has no local IP assignment."); + if (!isLocalAddress(runtime->listenAddress)) { + return QStringLiteral("%1 is not assigned to an active local interface.") + .arg(runtime->listenAddress); + } + + for (int peerIndex = 0; peerIndex < static_cast(runtimes_.size()); ++peerIndex) { + if (peerIndex == index) continue; + const auto* peer = runtimeAt(peerIndex); + if (peer == nullptr || !peer->enabled || peer->listenAddress.isEmpty()) continue; + if (!includeReadyPeers && !isActiveState(peer->state)) continue; + if (peer->port != runtime->port) continue; + if (peer->listenAddress == runtime->listenAddress || + peer->listenAddress == QStringLiteral("0.0.0.0") || + runtime->listenAddress == QStringLiteral("0.0.0.0")) { + const auto peerName = peerIndex >= 0 && peerIndex < ieds_.size() + ? ieds_.at(peerIndex).toMap().value(QStringLiteral("name")).toString() + : QStringLiteral("another IED"); + return QStringLiteral("Endpoint conflicts with %1 on port %2. Use a distinct laptop IP address.") + .arg(peerName) + .arg(runtime->port); + } + } + return {}; +} + +QString IedFleetController::fleetEndpointConflict() const { + for (int index = 0; index < static_cast(runtimes_.size()); ++index) { + const auto* runtime = runtimeAt(index); + if (runtime == nullptr || !runtime->enabled) continue; + if (runtime->listenAddress.isEmpty()) { + return QStringLiteral("One or more enabled IEDs do not have a local IP assignment."); + } + if (!isLocalAddress(runtime->listenAddress)) { + return QStringLiteral("%1 is not assigned to an active local interface.") + .arg(runtime->listenAddress); + } + const auto conflict = endpointConflictFor(index, true); + if (!conflict.isEmpty()) return conflict; + } + return {}; +} + +QStringList IedFleetController::bindableIpv4Addresses() const { + QStringList result; + const auto interfaces = QNetworkInterface::allInterfaces(); + for (const auto& networkInterface : interfaces) { + if (!networkInterface.flags().testFlag(QNetworkInterface::IsUp) || + networkInterface.flags().testFlag(QNetworkInterface::IsLoopBack)) { + continue; + } + for (const auto& entry : networkInterface.addressEntries()) { + const auto address = entry.ip(); + if (address.protocol() != QAbstractSocket::IPv4Protocol || address.isLoopback()) continue; + const auto text = address.toString(); + if (!result.contains(text)) result.push_back(text); + } + } + return result; +} + +IedFleetController::RuntimeInstance* IedFleetController::runtimeAt(const int index) noexcept { + if (index < 0 || index >= static_cast(runtimes_.size())) return nullptr; + return runtimes_[static_cast(index)].get(); +} + +const IedFleetController::RuntimeInstance* IedFleetController::runtimeAt(const int index) const noexcept { + if (index < 0 || index >= static_cast(runtimes_.size())) return nullptr; + return runtimes_[static_cast(index)].get(); +} + +QString IedFleetController::runtimeStateText(const RuntimeState state) const { + switch (state) { + case RuntimeState::ready: return QStringLiteral("Ready"); + case RuntimeState::starting: return QStringLiteral("Starting"); + case RuntimeState::running: return QStringLiteral("Running"); + case RuntimeState::stopping: return QStringLiteral("Stopping"); + case RuntimeState::failed: return QStringLiteral("Failed"); + } + return QStringLiteral("Ready"); +} diff --git a/apps/ied_simulator/src/IedFleetController.hpp b/apps/ied_simulator/src/IedFleetController.hpp new file mode 100644 index 000000000..c6ba780f0 --- /dev/null +++ b/apps/ied_simulator/src/IedFleetController.hpp @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IedActivityModel.hpp" +#include "IedPointStore.hpp" + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class IedFleetController : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(bool imported READ imported NOTIFY modelChanged) + Q_PROPERTY(bool importing READ importing NOTIFY modelChanged) + Q_PROPERTY(bool running READ running NOTIFY runtimeChanged) + Q_PROPERTY(bool starting READ starting NOTIFY runtimeChanged) + Q_PROPERTY(bool anyRunning READ anyRunning NOTIFY runtimeChanged) + Q_PROPERTY(int runningCount READ runningCount NOTIFY runtimeChanged) + Q_PROPERTY(QString sourceName READ sourceName NOTIFY modelChanged) + Q_PROPERTY(QString sourcePath READ sourcePath NOTIFY modelChanged) + Q_PROPERTY(QString modelStatus READ modelStatus NOTIFY modelChanged) + Q_PROPERTY(QString fatalError READ fatalError NOTIFY modelChanged) + Q_PROPERTY(QString listenAddress READ listenAddress WRITE setListenAddress NOTIFY configurationChanged) + Q_PROPERTY(QStringList availableAddresses READ availableAddresses NOTIFY networkInterfacesChanged) + Q_PROPERTY(QVariantList networkAddresses READ networkAddresses NOTIFY networkInterfacesChanged) + Q_PROPERTY(int port READ port WRITE setPort NOTIFY configurationChanged) + Q_PROPERTY(bool gooseEnabled READ gooseEnabled WRITE setGooseEnabled NOTIFY configurationChanged) + Q_PROPERTY(bool fileServiceEnabled READ fileServiceEnabled WRITE setFileServiceEnabled NOTIFY configurationChanged) + Q_PROPERTY(QString fileFolder READ fileFolder WRITE setFileFolder NOTIFY configurationChanged) + Q_PROPERTY(QVariantList ieds READ ieds NOTIFY modelChanged) + Q_PROPERTY(int selectedIedIndex READ selectedIedIndex WRITE selectIed NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedIed READ selectedIed NOTIFY selectionChanged) + Q_PROPERTY(QString endpointConflict READ endpointConflict NOTIFY configurationChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) + Q_PROPERTY(int selectedValueIndex READ selectedValueIndex WRITE selectValue NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedValue READ selectedValue NOTIFY selectionChanged) + Q_PROPERTY(QVariantList activity READ activity NOTIFY activityChanged) + Q_PROPERTY(IedActivityModel* activityModel READ activityModel CONSTANT) + Q_PROPERTY(int logicalDeviceCount READ logicalDeviceCount NOTIFY modelChanged) + Q_PROPERTY(int dataObjectCount READ dataObjectCount NOTIFY modelChanged) + Q_PROPERTY(int dataAttributeCount READ dataAttributeCount NOTIFY modelChanged) + Q_PROPERTY(int dataSetCount READ dataSetCount NOTIFY modelChanged) + Q_PROPERTY(int reportCount READ reportCount NOTIFY modelChanged) + Q_PROPERTY(int gooseCount READ gooseCount NOTIFY modelChanged) + Q_PROPERTY(qint64 lastImportWorkerMilliseconds READ lastImportWorkerMilliseconds NOTIFY modelChanged) + Q_PROPERTY(qint64 lastGuiApplyMilliseconds READ lastGuiApplyMilliseconds NOTIFY modelChanged) + Q_PROPERTY(int preparedPointCount READ preparedPointCount NOTIFY modelChanged) + Q_PROPERTY(int preparedIedCount READ preparedIedCount NOTIFY modelChanged) + Q_PROPERTY(int fleetStartRollbackCount READ fleetStartRollbackCount NOTIFY runtimeChanged) + Q_PROPERTY(bool clearPending READ clearPending NOTIFY modelChanged) + +public: + enum class RuntimeState { + ready, + starting, + running, + stopping, + failed, + }; + + explicit IedFleetController(QObject* parent = nullptr); + ~IedFleetController() override; + + [[nodiscard]] bool imported() const noexcept; + [[nodiscard]] bool importing() const noexcept; + [[nodiscard]] bool running() const noexcept; + [[nodiscard]] bool starting() const noexcept; + [[nodiscard]] bool anyRunning() const noexcept; + [[nodiscard]] int runningCount() const noexcept; + [[nodiscard]] QString sourceName() const; + [[nodiscard]] QString sourcePath() const; + [[nodiscard]] QString modelStatus() const; + [[nodiscard]] QString fatalError() const; + [[nodiscard]] QString listenAddress() const; + [[nodiscard]] QStringList availableAddresses() const; + [[nodiscard]] QVariantList networkAddresses() const; + [[nodiscard]] int port() const noexcept; + [[nodiscard]] bool gooseEnabled() const noexcept; + [[nodiscard]] bool fileServiceEnabled() const noexcept; + [[nodiscard]] QString fileFolder() const; + [[nodiscard]] QVariantList ieds() const; + [[nodiscard]] int selectedIedIndex() const noexcept; + [[nodiscard]] QVariantMap selectedIed() const; + [[nodiscard]] QString endpointConflict() const; + [[nodiscard]] QVariantList values() const; + + // C++ model/view fast path. The canonical high-cardinality state is typed; + // QVariant maps are materialized only for compatibility/QML boundaries. + [[nodiscard]] int valueCount() const noexcept { return selectedPointIndices_.size(); } + [[nodiscard]] const IedPointStore::PointRecord* valueRecord(const int sourceIndex) const noexcept { + if (sourceIndex < 0 || sourceIndex >= selectedPointIndices_.size()) return nullptr; + return pointStore_.at(selectedPointIndices_.at(sourceIndex)); + } + [[nodiscard]] bool hasPreparedValueIndex() const noexcept { + return preparedValueIndexIed_ == selectedIedIndex_ && + preparedPointCount_ == selectedPointIndices_.size(); + } + [[nodiscard]] const QVariantList& navigationIndexView() const noexcept { + return navigationIndex_; + } + [[nodiscard]] const QVector& valueScopeIndices( + const QString& logicalDevice, + const QString& logicalNode) const noexcept { + static const QVector empty; + if (!hasPreparedValueIndex()) return empty; + const auto found = valueScopeIndex_.constFind( + logicalDevice + QLatin1Char('\x1f') + logicalNode); + return found == valueScopeIndex_.cend() ? empty : found.value(); + } + [[nodiscard]] int selectedValueIndex() const noexcept; + [[nodiscard]] QVariantMap selectedValue() const; + [[nodiscard]] QVariantList activity() const; + [[nodiscard]] IedActivityModel* activityModel() noexcept { return &activity_; } + [[nodiscard]] const IedActivityModel* activityModel() const noexcept { return &activity_; } + [[nodiscard]] int logicalDeviceCount() const noexcept; + [[nodiscard]] int dataObjectCount() const noexcept; + [[nodiscard]] int dataAttributeCount() const noexcept; + [[nodiscard]] int dataSetCount() const noexcept; + [[nodiscard]] int reportCount() const noexcept; + [[nodiscard]] int gooseCount() const noexcept; + [[nodiscard]] qint64 lastImportWorkerMilliseconds() const noexcept { + return lastImportWorkerMilliseconds_; + } + [[nodiscard]] qint64 lastGuiApplyMilliseconds() const noexcept { + return lastGuiApplyMilliseconds_; + } + [[nodiscard]] int preparedPointCount() const noexcept { return preparedPointCount_; } + [[nodiscard]] int preparedIedCount() const noexcept { + return static_cast(preparedIeds_.size()); + } + [[nodiscard]] int fleetStartRollbackCount() const noexcept { return fleetStartRollbackCount_; } + [[nodiscard]] bool clearPending() const noexcept { return clearPending_; } + + void setListenAddress(const QString& value); + void setPort(int value); + void setGooseEnabled(bool value); + void setFileServiceEnabled(bool value); + void setFileFolder(const QString& value); + + Q_INVOKABLE bool loadFile(const QUrl& fileUrl); + Q_INVOKABLE bool loadFileAsync(const QUrl& fileUrl); + Q_INVOKABLE bool addFile(const QUrl& fileUrl); + Q_INVOKABLE void clear(); + Q_INVOKABLE void selectIed(int index); + Q_INVOKABLE void selectValue(int index); + + // Compatibility entry points operate on the currently selected IED. + Q_INVOKABLE bool startSimulation(); + Q_INVOKABLE void stopSimulation(); + + // Fleet operations keep each IED on an independent local address/port. + Q_INVOKABLE bool startIed(int index); + Q_INVOKABLE void stopIed(int index); + Q_INVOKABLE int startAllSimulations(); + Q_INVOKABLE void stopAllSimulations(); + Q_INVOKABLE bool configureIedEndpoint(int index, const QString& address, int port); + Q_INVOKABLE void setIedEnabled(int index, bool enabled); + Q_INVOKABLE bool autoAssignIedAddresses(); + Q_INVOKABLE void refreshNetworkInterfaces(); + + Q_INVOKABLE bool applySelectedValue( + const QString& value, + const QString& quality, + const QString& origin); + Q_INVOKABLE bool undoLastChange(); + Q_INVOKABLE void clearActivity(); + Q_INVOKABLE void copyDiagnostics(); + Q_INVOKABLE QString diagnosticsText() const; + Q_INVOKABLE int qaBurstValues(int updates, int distinctPoints = 64); + Q_INVOKABLE int qaBurstLiveValues(int updates); + +signals: + void modelChanged(); + void configurationChanged(); + void runtimeChanged(); + void networkInterfacesChanged(); + void selectionChanged(); + void valuesChanged(); + void activityChanged(); + +private: + // Commissioning projections inspect the already parsed SCL document in place. + // They are UI/application views only and never own a second parser or protocol state. + friend class IedCommissioningModel; + + struct LoadedDocument final { + QString path; + ar::iec61850::scl::SclDocument document; + }; + + struct PendingAsyncImport final { + QString path; + quint64 generation{}; + }; + + struct PreparedIedProjection final { + QVector pointIndices; + QVariantList navigationIndex; + QHash> valueScopeIndex; + }; + + struct AsyncImportResult final { + QString path; + quint64 generation{}; + std::optional document; + QVariantList ieds; + IedPointStore pointStore; + QVector selectedPointIndices; + QVariantList navigationIndex; + QHash> valueScopeIndex; + QVector preparedIeds; + QString error; + qint64 parserMilliseconds{}; + qint64 preparationMilliseconds{}; + qint64 elapsedMilliseconds{}; + int logicalDeviceCount{}; + int dataObjectCount{}; + int dataAttributeCount{}; + int dataSetCount{}; + int reportCount{}; + int gooseCount{}; + int preparedPointCount{}; + int preparedIedCount{}; + }; + + struct PendingLiveUpdate final { + QString key; + QString domain; + QString item; + QString value; + quint64 revision{}; + qint64 queuedAtMilliseconds{}; + }; + + struct RuntimeInstance final { + QString key; + QString listenAddress; + int port{102}; + bool enabled{true}; + RuntimeState state{RuntimeState::ready}; + std::unique_ptr process; + QByteArray standardOutputBuffer; + QByteArray standardErrorBuffer; + bool standardOutputDrainScheduled{}; + bool standardErrorDrainScheduled{}; + bool standardOutputOverflowReported{}; + bool standardErrorOverflowReported{}; + QString modelManifestPath; + quint64 startGeneration{}; + quint64 modelRevision{}; + quint64 liveGeneration{}; + quint64 nextLiveRevision{}; + quint64 lastLiveAckRevision{}; + quint64 liveRequested{}; + quint64 liveSent{}; + quint64 liveCoalesced{}; + quint64 liveRejected{}; + qint64 liveLastAckLatencyMilliseconds{}; + qint64 liveMaxAckLatencyMilliseconds{}; + int liveMaxPending{}; + bool liveFlushScheduled{}; + QHash pendingLiveUpdates; + QHash liveSentAtMilliseconds; + }; + + struct ValueSnapshot final { + int iedIndex{-1}; + int valueIndex{-1}; + QVariantMap value; + }; + + bool importFile(const QUrl& fileUrl, bool append); + void launchAsyncImport(PendingAsyncImport request); + void finishAsyncImport(const std::shared_ptr& result); + void rebuildPresentation(); + void rebuildRuntimeInstances(const QHash& previousConfigurations); + void rebuildValues(); + [[nodiscard]] bool adoptPreparedIed(int iedIndex); + void seedRuntimeValues(int iedIndex); + void updateIedRuntimePresentation(int index); + void connectRuntimeSignals(int index); + void setRuntimeState(int index, RuntimeState state); + void performClear(); + void handleFleetStartReady(int index); + void handleFleetStartFailure(int index, const QString& reason); + void stopAllProcessesBlocking(); + + void appendActivity( + const QString& category, + const QString& message, + const QString& severity = QStringLiteral("Info"), + const QString& iedName = QString{}); + void consumeServerOutput( + int index, + QByteArray& buffer, + const QByteArray& bytes, + bool standardError); + void processServerLine(int index, const QString& line, bool standardError); + [[nodiscard]] bool enqueueLiveUpdate(int index, const IedPointStore::PointRecord& point); + void scheduleLiveFlush(int index); + void flushLiveUpdates(int index, quint64 generation); + void handleLiveUpdateAck(int index, const QVariantMap& fields); + + [[nodiscard]] bool writeModelManifest(int iedIndex); + void removeModelManifests(); + [[nodiscard]] QString manifestPathFor(int iedIndex) const; + [[nodiscard]] QString serverExecutable() const; + [[nodiscard]] bool isLocalAddress(const QString& address) const; + [[nodiscard]] QString endpointConflictFor(int index, bool includeReadyPeers) const; + [[nodiscard]] QString fleetEndpointConflict() const; + [[nodiscard]] QStringList bindableIpv4Addresses() const; + [[nodiscard]] RuntimeInstance* runtimeAt(int index) noexcept; + [[nodiscard]] const RuntimeInstance* runtimeAt(int index) const noexcept; + [[nodiscard]] QString runtimeStateText(RuntimeState state) const; + + std::vector documents_; + std::vector> runtimes_; + QVariantList ieds_; + IedPointStore pointStore_; + QVector selectedPointIndices_; + QVariantList navigationIndex_; + QHash> valueScopeIndex_; + QVector preparedIeds_; + QSet seededRuntimeIeds_; + QSet fleetStartMembers_; + QSet fleetStartReady_; + IedActivityModel activity_; + std::optional previousValue_; + QThreadPool importPool_; + std::optional pendingAsyncImport_; + QString sourceName_; + QString sourcePath_; + QString fatalError_; + QString defaultListenAddress_{QStringLiteral("0.0.0.0")}; + QString fileFolder_; + quint64 asyncImportGeneration_{}; + qint64 lastImportWorkerMilliseconds_{}; + qint64 lastGuiApplyMilliseconds_{}; + int preparedPointCount_{}; + int preparedValueIndexIed_{-1}; + int fleetStartRollbackCount_{}; + int defaultPort_{102}; + int selectedIedIndex_{-1}; + int selectedValueIndex_{-1}; + int logicalDeviceCount_{}; + int dataObjectCount_{}; + int dataAttributeCount_{}; + int dataSetCount_{}; + int reportCount_{}; + int gooseCount_{}; + bool asyncImportRunning_{}; + bool gooseEnabled_{}; + bool fileServiceEnabled_{}; + bool clearPending_{}; + bool fleetStartPending_{}; +}; diff --git a/apps/ied_simulator/src/IedFleetControllerAsync.cpp b/apps/ied_simulator/src/IedFleetControllerAsync.cpp new file mode 100644 index 000000000..53c1fbd2d --- /dev/null +++ b/apps/ied_simulator/src/IedFleetControllerAsync.cpp @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedFleetController.hpp" + +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString qstring(const std::string& value) { + return QString::fromStdString(value); +} +} // namespace + +bool IedFleetController::importing() const noexcept { + return asyncImportRunning_ || pendingAsyncImport_.has_value(); +} + +bool IedFleetController::loadFileAsync(const QUrl& fileUrl) { + const auto path = fileUrl.toLocalFile(); + if (path.isEmpty()) { + fatalError_ = QStringLiteral("Choose a local SCL, CID, SCD, IID, or ICD file."); + emit modelChanged(); + return false; + } + + if (anyRunning()) { + appendActivity( + QStringLiteral("Workspace"), + QStringLiteral("Stop active IEDs before loading another engineering model."), + QStringLiteral("Warning")); + return false; + } + + const auto generation = ++asyncImportGeneration_; + PendingAsyncImport request{path, generation}; + + sourcePath_ = path; + sourceName_ = QFileInfo(path).fileName(); + fatalError_.clear(); + + if (asyncImportRunning_) { + pendingAsyncImport_ = std::move(request); + appendActivity( + QStringLiteral("Importer"), + QStringLiteral("Queued %1; the older in-flight import will be discarded when it finishes.") + .arg(sourceName_), + QStringLiteral("Info")); + emit modelChanged(); + return true; + } + + // The interactive async path is replacement-only. Clear the old model now + // so Start cannot launch stale IED state while a new SCL is still parsing. + clear(); + sourcePath_ = path; + sourceName_ = QFileInfo(path).fileName(); + fatalError_.clear(); + navigationIndex_.clear(); + valueScopeIndex_.clear(); + preparedIeds_.clear(); + preparedValueIndexIed_ = -1; + lastImportWorkerMilliseconds_ = 0; + lastGuiApplyMilliseconds_ = 0; + preparedPointCount_ = 0; + appendActivity( + QStringLiteral("Importer"), + QStringLiteral("Loading %1 on the bounded background importer.").arg(sourceName_), + QStringLiteral("Info")); + launchAsyncImport(std::move(request)); + emit modelChanged(); + return true; +} + +void IedFleetController::launchAsyncImport(PendingAsyncImport request) { + importPool_.setMaxThreadCount(1); + importPool_.setExpiryTimeout(-1); + asyncImportRunning_ = true; + + QPointer guard{this}; + importPool_.start([guard, request = std::move(request)]() mutable { + auto result = std::make_shared(); + result->path = request.path; + result->generation = request.generation; + + QElapsedTimer totalTimer; + totalTimer.start(); + try { + QElapsedTimer parserTimer; + parserTimer.start(); + result->document.emplace(ar::iec61850::scl::SclParser{}.load( + std::filesystem::path{request.path.toStdWString()})); + result->parserMilliseconds = parserTimer.elapsed(); + + // Parser output, profile expansion, typed canonical points, LD/LN + // navigation and per-LN source indexes are built on the one bounded + // worker. GUI completion only moves ownership of these containers. + QElapsedTimer preparationTimer; + preparationTimer.start(); + const auto& document = *result->document; + + result->dataSetCount = static_cast(document.data_sets.size()); + result->reportCount = static_cast(document.report_controls.size()); + result->gooseCount = static_cast(document.goose_streams.size()); + + std::set logicalDevices; + std::set dataObjects; + std::set dataAttributes; + const auto collectEntry = [&](const ar::iec61850::scl::SclDataSetEntry& entry) { + const auto ld = qstring(entry.ied_name) + QLatin1Char('/') + qstring(entry.ld_inst); + logicalDevices.insert(ld); + const auto logicalNode = + qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst); + const auto object = ld + QLatin1Char('/') + logicalNode + + QLatin1Char('/') + qstring(entry.do_name); + dataObjects.insert(object); + dataAttributes.insert(object + QLatin1Char('/') + qstring(entry.da_name)); + }; + if (!document.model_entries.empty()) { + for (const auto& entry : document.model_entries) collectEntry(entry); + } else { + for (const auto& dataSet : document.data_sets) { + for (const auto& entry : dataSet.entries) collectEntry(entry); + } + for (const auto& stream : document.goose_streams) { + for (const auto& entry : stream.entries) collectEntry(entry); + } + for (const auto& report : document.report_controls) { + for (const auto& entry : report.entries) collectEntry(entry); + } + } + result->logicalDeviceCount = static_cast(logicalDevices.size()); + result->dataObjectCount = static_cast(dataObjects.size()); + result->dataAttributeCount = static_cast(dataAttributes.size()); + + for (const auto& ied : document.ieds) { + const auto name = qstring(ied.name); + QVariantMap item; + item.insert(QStringLiteral("name"), name); + item.insert(QStringLiteral("manufacturer"), qstring(ied.manufacturer)); + item.insert(QStringLiteral("type"), qstring(ied.type)); + item.insert(QStringLiteral("configVersion"), qstring(ied.config_version)); + item.insert(QStringLiteral("documentIndex"), 0); + item.insert(QStringLiteral("sourcePath"), result->path); + item.insert( + QStringLiteral("sessionKey"), + result->path + QLatin1Char('\x1f') + name); + result->ieds.push_back(item); + } + + if (result->ieds.isEmpty()) { + QVariantMap fallback; + const auto name = QFileInfo(result->path).completeBaseName(); + fallback.insert(QStringLiteral("name"), name); + fallback.insert(QStringLiteral("manufacturer"), QStringLiteral("SCL model")); + fallback.insert(QStringLiteral("type"), QStringLiteral("IED")); + fallback.insert(QStringLiteral("configVersion"), QString{}); + fallback.insert(QStringLiteral("documentIndex"), 0); + fallback.insert(QStringLiteral("sourcePath"), result->path); + fallback.insert( + QStringLiteral("sessionKey"), + result->path + QLatin1Char('\x1f') + name); + result->ieds.push_back(fallback); + } + + result->preparedIeds.resize(result->ieds.size()); + for (int iedIndex = 0; iedIndex < result->ieds.size(); ++iedIndex) { + const auto preparedIed = result->ieds.at(iedIndex).toMap(); + const auto preparedName = preparedIed.value(QStringLiteral("name")).toString(); + + ar::iec61850::simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = preparedName.toStdString(); + options.runtime_ied_name = options.ied_name; + const auto built = ar::iec61850::simulation::IedSimulatorProfileBuilder::build( + document, options); + + auto& projection = result->preparedIeds[iedIndex]; + std::vector points; + points.reserve(built.profile.point_count()); + for (const auto& device : built.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + for (const auto& point : node.points) points.push_back(&point); + } + } + std::stable_sort( + points.begin(), points.end(), + [](const auto* left, const auto* right) { + return left->source_order < right->source_order; + }); + + result->pointStore.reserve( + result->pointStore.size() + static_cast(points.size())); + projection.pointIndices.reserve(static_cast(points.size())); + projection.valueScopeIndex.reserve( + static_cast(built.profile.logical_node_count())); + std::set seenScopes; + int sourceIndex{}; + for (const auto* point : points) { + const auto logicalDevice = qstring(point->logical_device); + const auto logicalNode = qstring(point->logical_node); + const auto scopeKey = + logicalDevice + QLatin1Char('\x1f') + logicalNode; + projection.valueScopeIndex[scopeKey].push_back(sourceIndex); + if (seenScopes.insert(scopeKey).second) { + QVariantMap navigationEntry; + navigationEntry.insert(QStringLiteral("logicalDevice"), logicalDevice); + navigationEntry.insert(QStringLiteral("logicalNode"), logicalNode); + projection.navigationIndex.push_back(std::move(navigationEntry)); + } + + const auto pointIndex = result->pointStore.insertIfMissing( + IedPointStore::fromSimulatorPoint(*point)); + projection.pointIndices.push_back(pointIndex); + ++sourceIndex; + } + if (iedIndex == 0) { + result->selectedPointIndices = projection.pointIndices; + result->navigationIndex = projection.navigationIndex; + result->valueScopeIndex = projection.valueScopeIndex; + result->preparedPointCount = static_cast(points.size()); + } + } + result->preparedIedCount = static_cast(result->preparedIeds.size()); + result->preparationMilliseconds = preparationTimer.elapsed(); + } catch (const std::exception& error) { + result->document.reset(); + result->error = QString::fromUtf8(error.what()); + } catch (...) { + result->document.reset(); + result->error = QStringLiteral("Unexpected failure while parsing the engineering model."); + } + result->elapsedMilliseconds = totalTimer.elapsed(); + + if (guard.isNull()) return; + QMetaObject::invokeMethod( + guard.data(), + [guard, result = std::move(result)] { + if (!guard.isNull()) guard->finishAsyncImport(result); + }, + Qt::QueuedConnection); + }); +} + +void IedFleetController::finishAsyncImport( + const std::shared_ptr& result) { + asyncImportRunning_ = false; + + // A clear(), synchronous load, or newer async request changes sourcePath_ + // and/or the generation. In all of those cases this completed result is + // intentionally stale and must never overwrite the newer workspace state. + const bool latest = result != nullptr && + result->generation == asyncImportGeneration_ && + sourcePath_ == result->path; + + if (latest) { + if (result->document.has_value()) { + QElapsedTimer applyTimer; + applyTimer.start(); + + documents_.push_back(LoadedDocument{result->path, std::move(*result->document)}); + fatalError_.clear(); + previousValue_.reset(); + + // These are move/adoption operations. No per-point QVariant map is + // reconstructed on the GUI thread for the interactive Open path. + ieds_ = std::move(result->ieds); + pointStore_ = std::move(result->pointStore); + selectedPointIndices_ = std::move(result->selectedPointIndices); + navigationIndex_ = std::move(result->navigationIndex); + valueScopeIndex_ = std::move(result->valueScopeIndex); + preparedIeds_ = std::move(result->preparedIeds); + logicalDeviceCount_ = result->logicalDeviceCount; + dataObjectCount_ = result->dataObjectCount; + dataAttributeCount_ = result->dataAttributeCount; + dataSetCount_ = result->dataSetCount; + reportCount_ = result->reportCount; + gooseCount_ = result->gooseCount; + selectedIedIndex_ = ieds_.isEmpty() ? -1 : 0; + selectedValueIndex_ = selectedPointIndices_.isEmpty() ? -1 : 0; + preparedValueIndexIed_ = selectedIedIndex_; + seededRuntimeIeds_.clear(); + for (int index = 0; index < preparedIeds_.size() && index < ieds_.size(); ++index) { + seededRuntimeIeds_.insert( + ieds_.at(index).toMap().value(QStringLiteral("sessionKey")).toString()); + } + rebuildRuntimeInstances({}); + + lastImportWorkerMilliseconds_ = result->elapsedMilliseconds; + preparedPointCount_ = result->preparedPointCount; + const bool selectedProjectionReady = + selectedIedIndex_ < 0 || adoptPreparedIed(selectedIedIndex_); + lastGuiApplyMilliseconds_ = applyTimer.elapsed(); + + if (!selectedProjectionReady) { + fatalError_ = QStringLiteral( + "The bounded importer returned an incomplete prepared IED projection."); + selectedIedIndex_ = -1; + selectedValueIndex_ = -1; + selectedPointIndices_.clear(); + navigationIndex_.clear(); + valueScopeIndex_.clear(); + preparedValueIndexIed_ = -1; + preparedPointCount_ = 0; + appendActivity( + QStringLiteral("Importer"), + fatalError_, + QStringLiteral("Error")); + } + + emit valuesChanged(); + emit modelChanged(); + emit selectionChanged(); + emit configurationChanged(); + emit runtimeChanged(); + + if (selectedProjectionReady) { + qInfo().noquote() << QStringLiteral( + "IEDSIM_IMPORT_PATH worker_ms=%1 parser_ms=%2 prepare_ms=%3 gui_apply_ms=%4 points=%5 scopes=%6 typed_store=1 prepared_ieds=%7") + .arg(lastImportWorkerMilliseconds_) + .arg(result->parserMilliseconds) + .arg(result->preparationMilliseconds) + .arg(lastGuiApplyMilliseconds_) + .arg(preparedPointCount_) + .arg(valueScopeIndex_.size()) + .arg(result->preparedIedCount); + appendActivity( + QStringLiteral("Importer"), + QStringLiteral( + "%1 parsed and prebuilt %4 IED profile%5 into typed point storage on the bounded worker in %2 ms; GUI adoption took %3 ms.") + .arg(sourceName_) + .arg(lastImportWorkerMilliseconds_) + .arg(lastGuiApplyMilliseconds_) + .arg(result->preparedIedCount) + .arg(result->preparedIedCount == 1 ? QString{} : QStringLiteral("s")), + QStringLiteral("Success")); + } + } else { + fatalError_ = result->error.isEmpty() + ? QStringLiteral("The engineering model could not be parsed.") + : result->error; + appendActivity( + QStringLiteral("Importer"), + fatalError_, + QStringLiteral("Error")); + emit modelChanged(); + } + } + + if (pendingAsyncImport_.has_value()) { + auto next = std::move(*pendingAsyncImport_); + pendingAsyncImport_.reset(); + + // The newest request owns the visible source identity. Keep the old + // parse result out of the UI and immediately reuse the single worker. + sourcePath_ = next.path; + sourceName_ = QFileInfo(next.path).fileName(); + fatalError_.clear(); + launchAsyncImport(std::move(next)); + emit modelChanged(); + return; + } + + emit modelChanged(); +} diff --git a/apps/ied_simulator/src/IedGoosePublication.cpp b/apps/ied_simulator/src/IedGoosePublication.cpp new file mode 100644 index 000000000..2d3b1a98d --- /dev/null +++ b/apps/ied_simulator/src/IedGoosePublication.cpp @@ -0,0 +1,623 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedCommissioningModel.hpp" + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString qstringGoose(const std::string& value) { + return QString::fromStdString(value); +} + +QString logicalNodeGoose(const ar::iec61850::scl::SclDataSetEntry& entry) { + return qstringGoose(entry.prefix) + qstringGoose(entry.ln_class) + qstringGoose(entry.ln_inst); +} + +QString normalizedObject(QString value) { + value.replace(QLatin1Char('$'), QLatin1Char('.')); + return value; +} + +bool pointMatchesBooleanText(const QString& value) { + const auto normalized = value.trimmed().toLower(); + return normalized == QStringLiteral("true") || normalized == QStringLiteral("false") || + normalized == QStringLiteral("on") || normalized == QStringLiteral("off") || + normalized == QStringLiteral("1") || normalized == QStringLiteral("0"); +} + +bool booleanValue(const QString& value) { + const auto normalized = value.trimmed().toLower(); + return normalized == QStringLiteral("true") || normalized == QStringLiteral("on") || + normalized == QStringLiteral("1"); +} + +std::optional> hexBytes(const QString& text) { + auto normalized = text.trimmed(); + normalized.remove(QLatin1Char(' ')); + normalized.remove(QLatin1Char(':')); + if (normalized.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive)) normalized.remove(0, 2); + if (normalized.isEmpty() || (normalized.size() % 2) != 0) return std::nullopt; + const auto bytes = QByteArray::fromHex(normalized.toLatin1()); + if (bytes.size() * 2 != normalized.size()) return std::nullopt; + std::vector result; + result.reserve(static_cast(bytes.size())); + for (const char byte : bytes) result.push_back(static_cast(byte)); + return result; +} + +QString transportErrorText(const std::string& error) { + return QString::fromStdString(error); +} +} // namespace + +QStringList IedCommissioningModel::gooseInterfaces() const { + QStringList result; + const auto interfaces = QNetworkInterface::allInterfaces(); + for (const auto& networkInterface : interfaces) { + const auto flags = networkInterface.flags(); + if (!flags.testFlag(QNetworkInterface::IsUp) || + !flags.testFlag(QNetworkInterface::IsRunning) || + flags.testFlag(QNetworkInterface::IsLoopBack)) { + continue; + } + const auto name = networkInterface.name().trimmed(); + if (name.isEmpty() || result.contains(name)) continue; + result.push_back(name); + } + std::sort(result.begin(), result.end()); + return result; +} + +void IedCommissioningModel::setGooseInterfaceName(const QString& interfaceName) { + const auto normalized = interfaceName.trimmed(); + if (gooseInterfaceName_ == normalized) return; + if (goosePublishing()) { + if (backend_ != nullptr) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("Stop active GOOSE publication before changing the Ethernet interface."), + QStringLiteral("Warning")); + } + return; + } + gooseInterfaceName_ = normalized; + goosePublicationStatus_ = normalized.isEmpty() + ? QStringLiteral("Select an Ethernet interface") + : QStringLiteral("Ready on %1").arg(normalized); + emit goosePublicationChanged(); +} + +QString IedCommissioningModel::selectedGooseIdentity() const { + if (selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) return {}; + const auto& handle = handles_.at(selectedHandleIndex_); + return handle.kind == Kind::goose ? handle.identity : QString{}; +} + +int IedCommissioningModel::goosePublisherIndexForIdentity(const QString& identity) const noexcept { + for (int index = 0; index < static_cast(goosePublishers_.size()); ++index) { + if (goosePublishers_[static_cast(index)].identity == identity) return index; + } + return -1; +} + +QVariantMap IedCommissioningModel::selectedGooseRuntime() const { + QVariantMap result; + result.insert(QStringLiteral("active"), false); + const auto identity = selectedGooseIdentity(); + if (identity.isEmpty()) return result; + const int index = goosePublisherIndexForIdentity(identity); + if (index < 0) return result; + const auto& slot = goosePublishers_[static_cast(index)]; + result.insert(QStringLiteral("active"), true); + result.insert(QStringLiteral("interface"), gooseInterfaceName_); + result.insert(QStringLiteral("transmits"), static_cast(slot.transmitCount)); + result.insert(QStringLiteral("stateChanges"), static_cast(slot.stateChangeCount)); + result.insert(QStringLiteral("lastTransmitMs"), slot.lastTransmitMilliseconds); + result.insert(QStringLiteral("controlReference"), slot.controlReference); + result.insert(QStringLiteral("dataSetReference"), slot.dataSetReference); + result.insert(QStringLiteral("memberCount"), slot.pointStoreIndices.size()); + if (slot.runtime != nullptr) { + result.insert( + QStringLiteral("stNum"), + static_cast(slot.runtime->session().state_number())); + result.insert( + QStringLiteral("nextSqNum"), + static_cast(slot.runtime->session().next_sequence_number())); + } + return result; +} + +std::optional IedCommissioningModel::goosePointIndexFor( + const int iedIndex, + const ar::iec61850::scl::SclDataSetEntry& entry) const { + if (backend_ == nullptr || iedIndex < 0 || iedIndex >= backend_->ieds_.size()) { + return std::nullopt; + } + const auto iedName = backend_->ieds_.at(iedIndex).toMap().value(QStringLiteral("name")).toString(); + if (iedName.isEmpty()) return std::nullopt; + + const auto exactReference = qstringGoose(entry.signal_reference); + if (!exactReference.isEmpty()) { + const int exact = backend_->pointStore_.indexOf(iedName, exactReference); + if (exact >= 0) return exact; + } + + const auto expectedDomain = qstringGoose(entry.ied_name) + qstringGoose(entry.ld_inst); + const auto expectedNode = logicalNodeGoose(entry); + const auto expectedObject = normalizedObject(qstringGoose(entry.do_name)); + const auto expectedAttribute = normalizedObject(qstringGoose(entry.da_name)); + const auto expectedFc = qstringGoose(entry.functional_constraint); + + const auto& records = backend_->pointStore_.records(); + for (int index = 0; index < records.size(); ++index) { + const auto& point = records.at(index); + if (point.iedName != iedName) continue; + if (!expectedDomain.isEmpty() && point.mmsDomain != expectedDomain) continue; + if (!expectedNode.isEmpty() && point.logicalNode != expectedNode) continue; + if (!expectedFc.isEmpty() && + point.functionalConstraint.compare(expectedFc, Qt::CaseInsensitive) != 0) continue; + if (!expectedObject.isEmpty() && normalizedObject(point.dataObject) != expectedObject) continue; + if (!expectedAttribute.isEmpty() && + normalizedObject(point.dataAttribute) != expectedAttribute) continue; + return index; + } + return std::nullopt; +} + +std::optional +IedCommissioningModel::gooseValueForPoint(const IedPointStore::PointRecord& point) const { + using ar::iec61850::mms::Iec61850UtcTime; + using ar::iec61850::mms::MmsDataValue; + + const auto type = (point.type + QLatin1Char(' ') + point.rawType).trimmed().toUpper(); + const auto attribute = point.dataAttribute.trimmed().toLower(); + + if (attribute == QStringLiteral("q") || type.contains(QStringLiteral("QUALITY"))) { + const auto bytes = hexBytes(point.value); + if (!bytes.has_value() || bytes->size() < 2U || bytes->front() > 7U) return std::nullopt; + return MmsDataValue::bit_string( + bytes->front(), + std::span{bytes->data() + 1, bytes->size() - 1U}); + } + + if (attribute == QStringLiteral("t") || type.contains(QStringLiteral("TIMESTAMP")) || + type.contains(QStringLiteral("UTC"))) { + QString timestampText = point.value.trimmed(); + const QRegularExpression expression{QStringLiteral("unix-ms\\s*=\\s*(-?\\d+)")}; + const auto match = expression.match(timestampText); + if (match.hasMatch()) timestampText = match.captured(1); + bool ok{}; + const qint64 milliseconds = timestampText.toLongLong(&ok); + if (!ok) return std::nullopt; + return MmsDataValue::utc_time(Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::milliseconds{milliseconds}}, 0U}); + } + + if (type.contains(QStringLiteral("BOOLEAN")) || + (pointMatchesBooleanText(point.value) && + (point.options.contains(QStringLiteral("true"), Qt::CaseInsensitive) || + point.options.contains(QStringLiteral("false"), Qt::CaseInsensitive)))) { + return MmsDataValue::boolean(booleanValue(point.value)); + } + + if (type.contains(QStringLiteral("FLOAT")) || type.contains(QStringLiteral("DOUBLE")) || + type.contains(QStringLiteral("REAL"))) { + bool ok{}; + const double value = point.value.toDouble(&ok); + if (!ok || !std::isfinite(value)) return std::nullopt; + return MmsDataValue::floating_point(value); + } + + if (type.contains(QStringLiteral("UINT")) || type.contains(QStringLiteral("UNSIGNED"))) { + bool ok{}; + const qulonglong value = point.value.toULongLong(&ok, 0); + if (!ok) return std::nullopt; + return MmsDataValue::unsigned_integer(static_cast(value)); + } + + if (type.contains(QStringLiteral("INT")) || type.contains(QStringLiteral("INTEGER"))) { + bool ok{}; + const qlonglong value = point.value.toLongLong(&ok, 0); + if (!ok) return std::nullopt; + return MmsDataValue::integer(static_cast(value)); + } + + if (type.contains(QStringLiteral("OCTET"))) { + const auto bytes = hexBytes(point.value); + if (!bytes.has_value()) return std::nullopt; + return MmsDataValue::octet_string(*bytes); + } + + return MmsDataValue::visible_string(point.value.toStdString()); +} + +bool IedCommissioningModel::buildGooseValues( + const GoosePublisherSlot& slot, + std::vector& values, + QVector* const valueSnapshot) const { + values.clear(); + values.reserve(static_cast(slot.pointStoreIndices.size())); + if (valueSnapshot != nullptr) { + valueSnapshot->clear(); + valueSnapshot->reserve(slot.pointStoreIndices.size()); + } + if (backend_ == nullptr) return false; + + for (const int pointIndex : slot.pointStoreIndices) { + const auto* point = backend_->pointStore_.at(pointIndex); + if (point == nullptr) return false; + auto value = gooseValueForPoint(*point); + if (!value.has_value()) return false; + values.push_back(std::move(*value)); + if (valueSnapshot != nullptr) valueSnapshot->push_back(point->value); + } + return true; +} + +bool IedCommissioningModel::startSelectedGoosePublication() { + using namespace ar::iec61850; + + if (backend_ == nullptr || backend_->selectedIedIndex_ < 0 || + selectedHandleIndex_ < 0 || selectedHandleIndex_ >= handles_.size()) { + return false; + } + const auto& handle = handles_.at(selectedHandleIndex_); + if (handle.kind != Kind::goose) return false; + if (goosePublisherIndexForIdentity(handle.identity) >= 0) return true; + if (static_cast(goosePublishers_.size()) >= kGoosePublisherCapacity) { + failGoosePublication(QStringLiteral("GOOSE publisher capacity is limited to 16 active streams.")); + return false; + } + + const int iedIndex = backend_->selectedIedIndex_; + const auto* iedRuntime = backend_->runtimeAt(iedIndex); + if (iedRuntime == nullptr || iedRuntime->state != IedFleetController::RuntimeState::running) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("Start the selected MMS simulator runtime before enabling GOOSE publication."), + QStringLiteral("Warning")); + return false; + } + if (gooseInterfaceName_.trimmed().isEmpty()) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("Select an explicit Ethernet interface before enabling GOOSE publication."), + QStringLiteral("Warning")); + goosePublicationStatus_ = QStringLiteral("Interface required"); + emit goosePublicationChanged(); + return false; + } + if (!gooseInterfaces().contains(gooseInterfaceName_)) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("The selected Ethernet interface is not currently up/running: %1") + .arg(gooseInterfaceName_), + QStringLiteral("Error")); + goosePublicationStatus_ = QStringLiteral("Interface unavailable"); + emit goosePublicationChanged(); + return false; + } + if (handle.documentIndex < 0 || handle.documentIndex >= static_cast(backend_->documents_.size())) { + return false; + } + const auto& document = backend_->documents_[static_cast(handle.documentIndex)].document; + if (handle.sourceIndex < 0 || handle.sourceIndex >= static_cast(document.goose_streams.size())) { + return false; + } + const auto& stream = document.goose_streams[static_cast(handle.sourceIndex)]; + + if (!stream.address.app_id.has_value() || !stream.address.destination_mac.has_value()) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("GOOSE publication rejected: APPID and destination MAC must both be configured in SCL."), + QStringLiteral("Error")); + return false; + } + if (stream.entries.empty() || stream.entries.size() > static_cast(kGooseMemberCapacity)) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("GOOSE publication rejected: DataSet must resolve to 1..256 canonical members."), + QStringLiteral("Error")); + return false; + } + if (stream.min_time_milliseconds == 0U || + stream.max_time_milliseconds < stream.min_time_milliseconds) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("GOOSE publication rejected: SCL MinTime/MaxTime retransmission settings are invalid."), + QStringLiteral("Error")); + return false; + } + if (stream.address.vlan_id.has_value() != stream.address.vlan_priority.has_value()) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("GOOSE publication rejected: VLAN ID and priority must be configured together."), + QStringLiteral("Error")); + return false; + } + + GoosePublisherSlot slot; + slot.iedIndex = iedIndex; + slot.sessionKey = iedSessionKey(iedIndex); + slot.identity = handle.identity; + slot.controlReference = qstringGoose(stream.control_block_reference); + slot.dataSetReference = qstringGoose(stream.data_set_reference); + slot.minTimeMilliseconds = stream.min_time_milliseconds; + slot.pointStoreIndices.reserve(static_cast(stream.entries.size())); + for (const auto& entry : stream.entries) { + const auto pointIndex = goosePointIndexFor(iedIndex, entry); + if (!pointIndex.has_value()) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("GOOSE publication rejected: one DataSet member could not be resolved to the canonical point store."), + QStringLiteral("Error")); + return false; + } + slot.pointStoreIndices.push_back(*pointIndex); + } + + std::vector values; + if (!buildGooseValues(slot, values, &slot.lastValues)) { + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("GOOSE publication rejected: one DataSet value cannot be represented on the MMS/GOOSE wire."), + QStringLiteral("Error")); + return false; + } + + const bool openingTransport = !gooseTransport_.active(); + if (openingTransport) { + std::string error; + if (!gooseTransport_.open(gooseInterfaceName_.toStdString(), error)) { + goosePublicationStatus_ = transportErrorText(error); + backend_->appendActivity( + QStringLiteral("GOOSE"), goosePublicationStatus_, QStringLiteral("Error")); + emit goosePublicationChanged(); + return false; + } + } + + goose::GooseFrame frame; + frame.destination = ethernet::MacAddress{ + std::span{stream.address.destination_mac->data(), + stream.address.destination_mac->size()}}; + frame.source = gooseTransport_.source_mac(); + frame.app_id = *stream.address.app_id; + if (stream.address.vlan_id.has_value()) { + frame.vlan = ethernet::VlanTag{ + *stream.address.vlan_priority, + *stream.address.vlan_id}; + } + frame.pdu.go_cb_ref = stream.control_block_reference; + frame.pdu.data_set_reference = stream.data_set_reference; + frame.pdu.go_id = stream.go_id; + frame.pdu.configuration_revision = stream.configuration_revision; + frame.pdu.time_allowed_to_live_milliseconds = + std::max(1U, stream.min_time_milliseconds * 2U); + + slot.runtime = std::make_unique( + goose::GoosePublisherSession{std::move(frame)}, + stream.min_time_milliseconds, + stream.max_time_milliseconds); + + const auto publication = slot.runtime->start( + values, + mms::Iec61850UtcTime{std::chrono::system_clock::now(), 0U}, + goose::GoosePublisherRuntime::clock::now()); + if (!transmitGoosePublication(slot, publication, false)) { + const auto failure = goosePublicationStatus_; + if (openingTransport && goosePublishers_.empty()) gooseTransport_.close(); + failGoosePublication(failure); + return false; + } + + goosePublishers_.push_back(std::move(slot)); + goosePublicationStatus_ = QStringLiteral("Publishing %1 stream%2 on %3") + .arg(goosePublishers_.size()) + .arg(goosePublishers_.size() == 1U ? QString{} : QStringLiteral("s")) + .arg(gooseInterfaceName_); + gooseValuesDirty_ = false; + + connect( + backend_, &IedFleetController::valuesChanged, + this, &IedCommissioningModel::markGooseValuesDirty, + Qt::UniqueConnection); + connect( + backend_, &IedFleetController::runtimeChanged, + this, &IedCommissioningModel::pruneGoosePublishers, + Qt::UniqueConnection); + connect( + backend_, &IedFleetController::selectionChanged, + this, &IedCommissioningModel::pruneGoosePublishers, + Qt::UniqueConnection); + ensureGooseTimer(); + + backend_->appendActivity( + QStringLiteral("GOOSE"), + QStringLiteral("Publishing %1 on %2 · APPID %3 · %4 member%5.") + .arg(qstringGoose(stream.control_block_reference), gooseInterfaceName_, + qstringGoose(stream.address.app_id_text)) + .arg(stream.entries.size()) + .arg(stream.entries.size() == 1U ? QString{} : QStringLiteral("s")), + QStringLiteral("Success"), + backend_->ieds_.at(iedIndex).toMap().value(QStringLiteral("name")).toString()); + emit goosePublicationChanged(); + return true; +} + +bool IedCommissioningModel::stopSelectedGoosePublication() { + const auto identity = selectedGooseIdentity(); + if (identity.isEmpty()) return false; + const int index = goosePublisherIndexForIdentity(identity); + if (index < 0) return false; + auto& slot = goosePublishers_[static_cast(index)]; + if (slot.runtime != nullptr) slot.runtime->stop(); + goosePublishers_.erase(goosePublishers_.begin() + index); + + if (goosePublishers_.empty()) { + if (gooseTimer_ != nullptr) gooseTimer_->stop(); + gooseTransport_.close(); + gooseValuesDirty_ = false; + goosePublicationStatus_ = gooseInterfaceName_.isEmpty() + ? QStringLiteral("Stopped") + : QStringLiteral("Ready on %1").arg(gooseInterfaceName_); + } else { + ensureGooseTimer(); + goosePublicationStatus_ = QStringLiteral("Publishing %1 streams on %2") + .arg(goosePublishers_.size()).arg(gooseInterfaceName_); + } + emit goosePublicationChanged(); + return true; +} + +void IedCommissioningModel::stopAllGoosePublication() { + for (auto& slot : goosePublishers_) { + if (slot.runtime != nullptr) slot.runtime->stop(); + } + goosePublishers_.clear(); + if (gooseTimer_ != nullptr) gooseTimer_->stop(); + gooseTransport_.close(); + gooseValuesDirty_ = false; + goosePublicationStatus_ = gooseInterfaceName_.isEmpty() + ? QStringLiteral("Stopped") + : QStringLiteral("Ready on %1").arg(gooseInterfaceName_); + emit goosePublicationChanged(); +} + +void IedCommissioningModel::markGooseValuesDirty() { + if (!goosePublishers_.empty()) gooseValuesDirty_ = true; +} + +void IedCommissioningModel::ensureGooseTimer() { + if (goosePublishers_.empty()) return; + if (gooseTimer_ == nullptr) { + gooseTimer_ = new QTimer(this); + gooseTimer_->setTimerType(Qt::PreciseTimer); + connect(gooseTimer_, &QTimer::timeout, this, &IedCommissioningModel::advanceGoosePublication); + } + std::uint32_t minimum = 10U; + for (const auto& slot : goosePublishers_) { + minimum = std::min(minimum, std::max(1U, slot.minTimeMilliseconds)); + } + gooseTimer_->setInterval(static_cast(std::clamp(minimum, 1U, 10U))); + if (!gooseTimer_->isActive()) gooseTimer_->start(); +} + +void IedCommissioningModel::pruneGoosePublishers() { + if (goosePublishers_.empty()) return; + if (backend_ == nullptr || backend_->selectedIedIndex_ < 0) { + stopAllGoosePublication(); + return; + } + const auto currentSession = iedSessionKey(backend_->selectedIedIndex_); + for (const auto& slot : goosePublishers_) { + const auto* runtime = backend_->runtimeAt(slot.iedIndex); + if (slot.sessionKey != currentSession || runtime == nullptr || + runtime->state != IedFleetController::RuntimeState::running) { + stopAllGoosePublication(); + return; + } + } +} + +bool IedCommissioningModel::transmitGoosePublication( + GoosePublisherSlot& slot, + const ar::iec61850::goose::GoosePublication& publication, + const bool stateChange) { + std::string error; + if (!gooseTransport_.transmit(publication.ethernet_bytes, error)) { + goosePublicationStatus_ = transportErrorText(error); + return false; + } + ++slot.transmitCount; + ++gooseTransmitCount_; + if (stateChange) { + ++slot.stateChangeCount; + ++gooseStateChangeCount_; + } + slot.lastTransmitMilliseconds = QDateTime::currentMSecsSinceEpoch(); + const auto now = slot.lastTransmitMilliseconds; + if (now - gooseLastUiNotifyMilliseconds_ >= 100) { + gooseLastUiNotifyMilliseconds_ = now; + emit goosePublicationChanged(); + } + return true; +} + +void IedCommissioningModel::advanceGoosePublication() { + if (goosePublishers_.empty()) { + if (gooseTimer_ != nullptr) gooseTimer_->stop(); + return; + } + pruneGoosePublishers(); + if (goosePublishers_.empty()) return; + + using Runtime = ar::iec61850::goose::GoosePublisherRuntime; + if (gooseValuesDirty_) { + gooseValuesDirty_ = false; + for (auto& slot : goosePublishers_) { + std::vector values; + QVector snapshot; + if (!buildGooseValues(slot, values, &snapshot)) { + failGoosePublication(QStringLiteral( + "GOOSE source value became unrepresentable; publication stopped fail-closed.")); + return; + } + if (snapshot == slot.lastValues) continue; + const auto publication = slot.runtime->state_change( + values, + ar::iec61850::mms::Iec61850UtcTime{std::chrono::system_clock::now(), 0U}, + Runtime::clock::now()); + if (!transmitGoosePublication(slot, publication, true)) { + failGoosePublication(goosePublicationStatus_); + return; + } + slot.lastValues = std::move(snapshot); + } + } + + const auto now = Runtime::clock::now(); + for (auto& slot : goosePublishers_) { + auto publication = slot.runtime->poll(now); + if (!publication.has_value()) continue; + if (!transmitGoosePublication(slot, *publication, false)) { + failGoosePublication(goosePublicationStatus_); + return; + } + } +} + +void IedCommissioningModel::failGoosePublication(const QString& reason) { + const auto failure = reason.isEmpty() + ? QStringLiteral("GOOSE publication failed closed.") : reason; + for (auto& slot : goosePublishers_) { + if (slot.runtime != nullptr) slot.runtime->stop(); + } + goosePublishers_.clear(); + if (gooseTimer_ != nullptr) gooseTimer_->stop(); + gooseTransport_.close(); + gooseValuesDirty_ = false; + goosePublicationStatus_ = failure; + if (backend_ != nullptr) { + backend_->appendActivity( + QStringLiteral("GOOSE"), failure, QStringLiteral("Error")); + } + emit goosePublicationChanged(); +} diff --git a/apps/ied_simulator/src/IedNavigationModel.cpp b/apps/ied_simulator/src/IedNavigationModel.cpp new file mode 100644 index 000000000..33250f543 --- /dev/null +++ b/apps/ied_simulator/src/IedNavigationModel.cpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedNavigationModel.hpp" + +#include +#include +#include + +namespace { +struct DeviceGroup final { + QString name; + QStringList logicalNodes; +}; +} + +IedNavigationModel::IedNavigationModel(QObject* parent) + : QAbstractListModel(parent) { + rebuildTimer_.setSingleShot(true); + rebuildTimer_.setInterval(16); + connect(&rebuildTimer_, &QTimer::timeout, this, &IedNavigationModel::rebuild); +} + +IedFleetController* IedNavigationModel::backend() const noexcept { return backend_; } + +void IedNavigationModel::setBackend(IedFleetController* backend) { + if (backend_ == backend) return; + if (backend_ != nullptr) disconnect(backend_, nullptr, this, nullptr); + backend_ = backend; + observedIedIndex_ = -1; + + if (backend_ != nullptr) { + // Value edits do not change LD/LN topology. Rebuilding navigation for + // every live valuesChanged burst used to rescan tens of thousands of + // point maps on the GUI thread for no structural benefit. + connect(backend_, &IedFleetController::modelChanged, + this, &IedNavigationModel::scheduleRebuild); + connect(backend_, &IedFleetController::selectionChanged, this, [this] { + if (backend_ != nullptr && backend_->selectedIedIndex() != observedIedIndex_) { + scheduleRebuild(); + } + }); + } + + emit backendChanged(); + scheduleRebuild(); +} + +QString IedNavigationModel::selectedLogicalDevice() const { return selectedLogicalDevice_; } +QString IedNavigationModel::selectedLogicalNode() const { return selectedLogicalNode_; } + +int IedNavigationModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(rows_.size()); +} + +QVariant IedNavigationModel::data(const QModelIndex& index, const int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) return {}; + const auto& row = rows_[static_cast(index.row())]; + switch (role) { + case KindRole: return row.kind; + case NameRole: return row.name; + case DepthRole: return row.depth; + case ExpandedRole: return row.expanded; + case ExpandableRole: return row.expandable; + case SelectedRole: + return row.kind == QStringLiteral("LN") && + row.logicalDevice == selectedLogicalDevice_ && + row.logicalNode == selectedLogicalNode_; + case LogicalDeviceRole: return row.logicalDevice; + case LogicalNodeRole: return row.logicalNode; + default: return {}; + } +} + +QHash IedNavigationModel::roleNames() const { + return { + {KindRole, "kind"}, + {NameRole, "name"}, + {DepthRole, "depth"}, + {ExpandedRole, "expanded"}, + {ExpandableRole, "expandable"}, + {SelectedRole, "selected"}, + {LogicalDeviceRole, "logicalDevice"}, + {LogicalNodeRole, "logicalNode"}, + }; +} + +void IedNavigationModel::activate(const int rowIndex) { + if (rowIndex < 0 || rowIndex >= rowCount()) return; + const auto row = rows_[static_cast(rowIndex)]; + if (row.kind == QStringLiteral("LD")) { + if (collapsedDevices_.contains(row.logicalDevice)) { + collapsedDevices_.remove(row.logicalDevice); + } else { + collapsedDevices_.insert(row.logicalDevice); + } + scheduleRebuild(); + return; + } + if (row.kind != QStringLiteral("LN")) return; + if (selectedLogicalDevice_ == row.logicalDevice && selectedLogicalNode_ == row.logicalNode) return; + + selectedLogicalDevice_ = row.logicalDevice; + selectedLogicalNode_ = row.logicalNode; + if (!rows_.empty()) { + emit dataChanged(index(0, 0), index(rowCount() - 1, 0), {SelectedRole}); + } + emit selectionChanged(); +} + +void IedNavigationModel::expandAll() { + if (collapsedDevices_.isEmpty()) return; + collapsedDevices_.clear(); + scheduleRebuild(); +} + +void IedNavigationModel::collapseAll() { + QSet next; + for (const auto& row : rows_) { + if (row.kind == QStringLiteral("LD")) next.insert(row.logicalDevice); + } + if (next == collapsedDevices_) return; + collapsedDevices_ = std::move(next); + scheduleRebuild(); +} + +void IedNavigationModel::scheduleRebuild() { + if (!rebuildTimer_.isActive()) rebuildTimer_.start(); +} + +bool IedNavigationModel::containsSelection( + const QString& logicalDevice, + const QString& logicalNode) const { + for (const auto& row : rows_) { + if (row.kind == QStringLiteral("LN") && row.logicalDevice == logicalDevice && + row.logicalNode == logicalNode) { + return true; + } + } + return false; +} + +void IedNavigationModel::rebuild() { + const auto previousDevice = selectedLogicalDevice_; + const auto previousNode = selectedLogicalNode_; + observedIedIndex_ = backend_ != nullptr ? backend_->selectedIedIndex() : -1; + + QVector groups; + QHash deviceIndex; + const auto appendScope = [&groups, &deviceIndex]( + const QString& logicalDevice, + const QString& logicalNode) { + if (logicalDevice.isEmpty() || logicalNode.isEmpty()) return; + auto found = deviceIndex.constFind(logicalDevice); + int index{}; + if (found == deviceIndex.cend()) { + index = groups.size(); + deviceIndex.insert(logicalDevice, index); + groups.push_back(DeviceGroup{logicalDevice, {}}); + } else { + index = found.value(); + } + auto& nodes = groups[index].logicalNodes; + if (nodes.isEmpty() || nodes.constLast() != logicalNode) { + if (!nodes.contains(logicalNode)) nodes.push_back(logicalNode); + } + }; + + if (backend_ != nullptr) { + if (backend_->hasPreparedValueIndex()) { + // Interactive Open SCL has already reduced tens of thousands of + // points to this tiny ordered LD/LN index on the bounded worker. + const auto& navigation = backend_->navigationIndexView(); + groups.reserve(navigation.size() > 0 ? 16 : 0); + for (const auto& value : navigation) { + const auto item = value.toMap(); + appendScope( + item.value(QStringLiteral("logicalDevice")).toString(), + item.value(QStringLiteral("logicalNode")).toString()); + } + } else { + // Synchronous/multi-IED selection fallback remains correct without + // rematerializing the typed canonical point store into QVariant maps. + groups.reserve(backend_->valueCount() > 0 ? 16 : 0); + for (int sourceIndex = 0; sourceIndex < backend_->valueCount(); ++sourceIndex) { + const auto* point = backend_->valueRecord(sourceIndex); + if (point == nullptr) continue; + appendScope(point->logicalDevice, point->logicalNode); + } + } + } + + beginResetModel(); + rows_.clear(); + if (!groups.isEmpty()) { + rows_.push_back(Row{ + QStringLiteral("SECTION"), QStringLiteral("Data Model"), {}, {}, 0, false, true}); + for (const auto& group : groups) { + const bool expanded = !collapsedDevices_.contains(group.name); + rows_.push_back(Row{ + QStringLiteral("LD"), group.name, group.name, {}, 1, true, expanded}); + if (!expanded) continue; + for (const auto& logicalNode : group.logicalNodes) { + rows_.push_back(Row{ + QStringLiteral("LN"), logicalNode, group.name, logicalNode, 2, false, true}); + } + } + } + endResetModel(); + + if (!containsSelection(selectedLogicalDevice_, selectedLogicalNode_)) { + selectedLogicalDevice_.clear(); + selectedLogicalNode_.clear(); + for (const auto& row : rows_) { + if (row.kind != QStringLiteral("LN")) continue; + selectedLogicalDevice_ = row.logicalDevice; + selectedLogicalNode_ = row.logicalNode; + break; + } + } + + if (previousDevice != selectedLogicalDevice_ || previousNode != selectedLogicalNode_) { + emit selectionChanged(); + } +} diff --git a/apps/ied_simulator/src/IedNavigationModel.hpp b/apps/ied_simulator/src/IedNavigationModel.hpp new file mode 100644 index 000000000..62fd23e36 --- /dev/null +++ b/apps/ied_simulator/src/IedNavigationModel.hpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IedFleetController.hpp" + +#include +#include +#include +#include + +#include + +class IedNavigationModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(IedFleetController* backend READ backend WRITE setBackend NOTIFY backendChanged) + Q_PROPERTY(QString selectedLogicalDevice READ selectedLogicalDevice NOTIFY selectionChanged) + Q_PROPERTY(QString selectedLogicalNode READ selectedLogicalNode NOTIFY selectionChanged) + +public: + enum Roles { + KindRole = Qt::UserRole + 1, + NameRole, + DepthRole, + ExpandedRole, + ExpandableRole, + SelectedRole, + LogicalDeviceRole, + LogicalNodeRole, + }; + + explicit IedNavigationModel(QObject* parent = nullptr); + + [[nodiscard]] IedFleetController* backend() const noexcept; + void setBackend(IedFleetController* backend); + + [[nodiscard]] QString selectedLogicalDevice() const; + [[nodiscard]] QString selectedLogicalNode() const; + + [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex{}) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + Q_INVOKABLE void activate(int row); + Q_INVOKABLE void expandAll(); + Q_INVOKABLE void collapseAll(); + +signals: + void backendChanged(); + void selectionChanged(); + +private: + struct Row final { + QString kind; + QString name; + QString logicalDevice; + QString logicalNode; + int depth{}; + bool expandable{}; + bool expanded{}; + }; + + void scheduleRebuild(); + void rebuild(); + [[nodiscard]] bool containsSelection( + const QString& logicalDevice, + const QString& logicalNode) const; + + IedFleetController* backend_{}; + QTimer rebuildTimer_; + std::vector rows_; + QSet collapsedDevices_; + QString selectedLogicalDevice_; + QString selectedLogicalNode_; + int observedIedIndex_{-1}; +}; diff --git a/apps/ied_simulator/src/IedPointStore.cpp b/apps/ied_simulator/src/IedPointStore.cpp new file mode 100644 index 000000000..6e8637b31 --- /dev/null +++ b/apps/ied_simulator/src/IedPointStore.cpp @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedPointStore.hpp" + +#include + +namespace { +QString qstring(const std::string& value) { + return QString::fromStdString(value); +} +} + +void IedPointStore::clear() { + points_.clear(); + keyIndex_.clear(); +} + +void IedPointStore::reserve(const qsizetype size) { + if (size <= 0) return; + points_.reserve(size); + keyIndex_.reserve(size); +} + +const IedPointStore::PointRecord* IedPointStore::at(const qsizetype index) const noexcept { + if (index < 0 || index >= points_.size()) return nullptr; + return &points_.at(index); +} + +IedPointStore::PointRecord* IedPointStore::atMutable(const qsizetype index) noexcept { + if (index < 0 || index >= points_.size()) return nullptr; + return &points_[index]; +} + +int IedPointStore::indexOf(const QString& iedName, const QString& reference) const noexcept { + const auto found = keyIndex_.constFind(keyFor(iedName, reference)); + return found == keyIndex_.cend() ? -1 : found.value(); +} + +int IedPointStore::upsert(PointRecord point) { + const auto key = keyFor(point.iedName, point.reference); + const auto found = keyIndex_.constFind(key); + if (found != keyIndex_.cend()) { + points_[found.value()] = std::move(point); + return found.value(); + } + const int index = points_.size(); + points_.push_back(std::move(point)); + keyIndex_.insert(key, index); + return index; +} + +int IedPointStore::insertIfMissing(PointRecord point) { + const auto key = keyFor(point.iedName, point.reference); + const auto found = keyIndex_.constFind(key); + if (found != keyIndex_.cend()) return found.value(); + const int index = points_.size(); + points_.push_back(std::move(point)); + keyIndex_.insert(key, index); + return index; +} + +QVariantMap IedPointStore::toVariantMap(const qsizetype index) const { + const auto* point = at(index); + return point == nullptr ? QVariantMap{} : toVariantMap(*point); +} + +QVariantList IedPointStore::toVariantList(const QVector& pointIndices) const { + QVariantList result; + result.reserve(pointIndices.size()); + for (const auto pointIndex : pointIndices) { + const auto* point = at(pointIndex); + if (point != nullptr) result.push_back(toVariantMap(*point)); + } + return result; +} + +IedPointStore::PointRecord IedPointStore::fromSimulatorPoint( + const ar::iec61850::simulation::IedSimulatorPoint& point) { + PointRecord record; + record.dataObject = qstring(point.data_object); + record.dataAttribute = qstring(point.data_attribute); + record.name = record.dataAttribute.isEmpty() + ? record.dataObject + : record.dataObject + QLatin1Char('.') + record.dataAttribute; + record.reference = qstring(point.reference); + record.logicalDevice = qstring(point.logical_device); + record.logicalNode = qstring(point.logical_node); + record.functionalConstraint = qstring(point.functional_constraint); + record.cdc = qstring(point.cdc); + record.type = qstring(point.display_type); + record.rawType = qstring(point.basic_type); + record.iedName = qstring(point.ied_name); + record.mmsDomain = qstring(point.mms_domain); + record.mmsItem = qstring(point.mms_item); + record.value = qstring(point.initial_value); + if (point.display_type == "Enumeration" && point.cdc == "DPC") { + record.options = { + QStringLiteral("intermediate-state"), + QStringLiteral("off"), + QStringLiteral("on"), + QStringLiteral("bad-state")}; + } else if (point.display_type == "Boolean") { + record.options = {QStringLiteral("false"), QStringLiteral("true")}; + } + return record; +} + +IedPointStore::PointRecord IedPointStore::fromVariantMap(const QVariantMap& value) { + PointRecord record; + record.name = value.value(QStringLiteral("name")).toString(); + record.reference = value.value(QStringLiteral("reference")).toString(); + record.logicalDevice = value.value(QStringLiteral("logicalDevice")).toString(); + record.logicalNode = value.value(QStringLiteral("logicalNode")).toString(); + record.dataObject = value.value(QStringLiteral("dataObject")).toString(); + record.dataAttribute = value.value(QStringLiteral("dataAttribute")).toString(); + record.functionalConstraint = value.value(QStringLiteral("fc")).toString(); + record.cdc = value.value(QStringLiteral("cdc")).toString(); + record.type = value.value(QStringLiteral("type")).toString(); + record.rawType = value.value(QStringLiteral("rawType")).toString(); + record.iedName = value.value(QStringLiteral("iedName")).toString(); + record.mmsDomain = value.value(QStringLiteral("mmsDomain")).toString(); + record.mmsItem = value.value(QStringLiteral("mmsItem")).toString(); + record.value = value.value(QStringLiteral("value")).toString(); + record.quality = value.value(QStringLiteral("quality"), QStringLiteral("Good")).toString(); + record.origin = value.value(QStringLiteral("origin"), QStringLiteral("Simulator")).toString(); + record.updated = value.value(QStringLiteral("updated"), QStringLiteral("—")).toString(); + record.options = value.value(QStringLiteral("options")).toStringList(); + record.writable = value.value(QStringLiteral("writable"), true).toBool(); + record.changed = value.value(QStringLiteral("changed"), false).toBool(); + return record; +} + +QVariantMap IedPointStore::toVariantMap(const PointRecord& point) { + QVariantMap item; + item.insert(QStringLiteral("name"), point.name); + item.insert(QStringLiteral("reference"), point.reference); + item.insert(QStringLiteral("logicalDevice"), point.logicalDevice); + item.insert(QStringLiteral("logicalNode"), point.logicalNode); + item.insert(QStringLiteral("dataObject"), point.dataObject); + item.insert(QStringLiteral("dataAttribute"), point.dataAttribute); + item.insert(QStringLiteral("fc"), point.functionalConstraint); + item.insert(QStringLiteral("cdc"), point.cdc); + item.insert(QStringLiteral("type"), point.type); + item.insert(QStringLiteral("rawType"), point.rawType); + item.insert(QStringLiteral("iedName"), point.iedName); + item.insert(QStringLiteral("mmsDomain"), point.mmsDomain); + item.insert(QStringLiteral("mmsItem"), point.mmsItem); + item.insert(QStringLiteral("value"), point.value); + item.insert(QStringLiteral("quality"), point.quality); + item.insert(QStringLiteral("origin"), point.origin); + item.insert(QStringLiteral("writable"), point.writable); + item.insert(QStringLiteral("changed"), point.changed); + item.insert(QStringLiteral("updated"), point.updated); + if (!point.options.isEmpty()) item.insert(QStringLiteral("options"), point.options); + return item; +} + +QString IedPointStore::keyFor(const QString& iedName, const QString& reference) { + return iedName + QLatin1Char('\x1f') + reference; +} diff --git a/apps/ied_simulator/src/IedPointStore.hpp b/apps/ied_simulator/src/IedPointStore.hpp new file mode 100644 index 000000000..3e68e793c --- /dev/null +++ b/apps/ied_simulator/src/IedPointStore.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/simulation/ied_simulator_profile.hpp" + +#include +#include +#include +#include +#include +#include + +class IedPointStore final { +public: + struct PointRecord final { + QString name; + QString reference; + QString logicalDevice; + QString logicalNode; + QString dataObject; + QString dataAttribute; + QString functionalConstraint; + QString cdc; + QString type; + QString rawType; + QString iedName; + QString mmsDomain; + QString mmsItem; + QString value; + QString quality{QStringLiteral("Good")}; + QString origin{QStringLiteral("Simulator")}; + QString updated{QStringLiteral("—")}; + QStringList options; + bool writable{true}; + bool changed{}; + }; + + [[nodiscard]] qsizetype size() const noexcept { return points_.size(); } + [[nodiscard]] bool isEmpty() const noexcept { return points_.isEmpty(); } + void clear(); + void reserve(qsizetype size); + + [[nodiscard]] const PointRecord* at(qsizetype index) const noexcept; + [[nodiscard]] PointRecord* atMutable(qsizetype index) noexcept; + [[nodiscard]] int indexOf(const QString& iedName, const QString& reference) const noexcept; + + int upsert(PointRecord point); + int insertIfMissing(PointRecord point); + + [[nodiscard]] const QVector& records() const noexcept { return points_; } + [[nodiscard]] QVariantMap toVariantMap(qsizetype index) const; + [[nodiscard]] QVariantList toVariantList(const QVector& pointIndices) const; + + [[nodiscard]] static PointRecord fromSimulatorPoint( + const ar::iec61850::simulation::IedSimulatorPoint& point); + [[nodiscard]] static PointRecord fromVariantMap(const QVariantMap& value); + [[nodiscard]] static QVariantMap toVariantMap(const PointRecord& point); + [[nodiscard]] static QString keyFor(const QString& iedName, const QString& reference); + +private: + QVector points_; + QHash keyIndex_; +}; diff --git a/apps/ied_simulator/src/IedReportControlManifest.hpp b/apps/ied_simulator/src/IedReportControlManifest.hpp new file mode 100644 index 000000000..510272930 --- /dev/null +++ b/apps/ied_simulator/src/IedReportControlManifest.hpp @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +namespace arstack::iedsim { + +inline QByteArray reportManifestField(QString value) { + value.replace(QLatin1Char('\t'), QLatin1Char(' ')); + value.replace(QLatin1Char('\r'), QLatin1Char(' ')); + value.replace(QLatin1Char('\n'), QLatin1Char(' ')); + return value.toUtf8(); +} + +inline QStringList concreteReportControlNames( + const QString& baseName, + const bool indexed, + const std::uint32_t maxClients) { + if (baseName.isEmpty()) return {}; + if (!indexed) return {baseName}; + + // IEC 61850 SCL keeps one ReportControl definition while RptEnabled@max + // describes the number of concrete MMS RCB instances. Engineering clients + // such as IEDScout address those instances with a two-digit suffix + // (Buffer01, Buffer02, Unbuffer01, ...). Keep the SCL model definition-level + // and expand only at the runtime-manifest boundary. + const auto count = std::max(1U, maxClients); + QStringList names; + names.reserve(static_cast(count)); + for (std::uint32_t index = 1U; index <= count; ++index) { + names.push_back( + baseName + QStringLiteral("%1").arg(index, 2, 10, QLatin1Char('0'))); + } + return names; +} + +inline QByteArray reportControlManifestLines( + const ar::iec61850::scl::SclReportControl& report, + const QString& activeIedName, + QSet& emittedReportControls) { + const auto iedName = QString::fromStdString(report.ied_name); + if (iedName != activeIedName) return {}; + + const auto domain = iedName + QString::fromStdString(report.ld_inst); + auto logicalNode = QString::fromStdString(report.logical_node_path); + logicalNode.replace(QLatin1Char('.'), QLatin1Char('$')); + const auto baseName = QString::fromStdString(report.name); + const auto dataSetName = QString::fromStdString(report.data_set_name); + QString dataSetDomain; + QString dataSetItem; + if (!dataSetName.isEmpty()) { + dataSetDomain = domain; + dataSetItem = logicalNode + QLatin1Char('$') + dataSetName; + dataSetItem.replace(QLatin1Char('.'), QLatin1Char('$')); + } + if (domain.isEmpty() || logicalNode.isEmpty() || baseName.isEmpty()) { + return {}; + } + + const auto triggerOptions = report.buffered ? 0x6CU : 0x64U; + const auto optionalFields0 = report.buffered ? 0x79U : 0x78U; + constexpr auto optionalFields1 = 0x80U; + + QByteArray lines; + for (const auto& concreteName : + concreteReportControlNames(baseName, report.indexed, report.max_clients)) { + const auto item = logicalNode + + (report.buffered ? QStringLiteral("$BR$") : QStringLiteral("$RP$")) + + concreteName; + const auto key = domain + QLatin1Char('\n') + item; + if (emittedReportControls.contains(key)) continue; + emittedReportControls.insert(key); + + auto reportId = QString::fromStdString(report.report_id); + if (reportId.isEmpty()) reportId = domain + QLatin1Char('/') + item; + + lines += "RCB\t" + reportManifestField(domain) + "\t" + + reportManifestField(item) + "\t" + + QByteArray::number(report.buffered ? 1 : 0) + "\t" + + reportManifestField(reportId) + "\t" + reportManifestField(dataSetDomain) + "\t" + + reportManifestField(dataSetItem) + "\t" + + QByteArray::number(report.configuration_revision) + "\t" + + QByteArray::number(report.buffer_time_milliseconds) + "\t" + + QByteArray::number(report.integrity_period_milliseconds) + "\t" + + QByteArray::number(triggerOptions) + "\t" + + QByteArray::number(optionalFields0) + "\t" + + QByteArray::number(optionalFields1) + "\n"; + } + return lines; +} + +} // namespace arstack::iedsim diff --git a/apps/ied_simulator/src/IedRuntimeGuardrails.cpp b/apps/ied_simulator/src/IedRuntimeGuardrails.cpp new file mode 100644 index 000000000..9bcec144b --- /dev/null +++ b/apps/ied_simulator/src/IedRuntimeGuardrails.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedRuntimeGuardrails.hpp" + +#include + +namespace ar::iedsim::runtime_guardrails { +namespace { +QByteArray boundedLine(const QByteArray& line, int& truncatedLines) { + if (line.size() <= kMaxProcessLineBytes) return line; + ++truncatedLines; + auto result = line.left(kMaxProcessLineBytes); + result += " …[truncated]"; + return result; +} +} // namespace + +DrainResult appendAndDrain(QByteArray& buffer, const QByteArray& bytes) { + DrainResult result; + + if (!bytes.isEmpty()) { + const qsizetype combinedSize = buffer.size() + bytes.size(); + if (combinedSize > kMaxBufferedProcessBytes) { + const qsizetype overflow = combinedSize - kMaxBufferedProcessBytes; + result.droppedBytes += overflow; + + if (overflow >= buffer.size()) { + const qsizetype dropFromBytes = overflow - buffer.size(); + buffer.clear(); + buffer += bytes.mid(std::min(dropFromBytes, bytes.size())); + } else { + buffer.remove(0, overflow); + buffer += bytes; + } + } else { + buffer += bytes; + } + } + + result.lines.reserve(kMaxLinesPerDrain); + for (int count = 0; count < kMaxLinesPerDrain; ++count) { + const auto newline = buffer.indexOf('\n'); + if (newline < 0) break; + auto line = buffer.left(newline); + buffer.remove(0, newline + 1); + if (!line.isEmpty() && line.endsWith('\r')) line.chop(1); + if (!line.trimmed().isEmpty()) { + result.lines.push_back(boundedLine(line, result.truncatedLines)); + } + } + result.moreCompleteLines = buffer.indexOf('\n') >= 0; + return result; +} + +QByteArray boundedTail(const QByteArray& buffer, bool* truncated) { + const bool didTruncate = buffer.size() > kMaxProcessLineBytes; + if (truncated != nullptr) *truncated = didTruncate; + if (!didTruncate) return buffer; + auto result = buffer.left(kMaxProcessLineBytes); + result += " …[truncated]"; + return result; +} + +} // namespace ar::iedsim::runtime_guardrails diff --git a/apps/ied_simulator/src/IedRuntimeGuardrails.hpp b/apps/ied_simulator/src/IedRuntimeGuardrails.hpp new file mode 100644 index 000000000..5b8561f86 --- /dev/null +++ b/apps/ied_simulator/src/IedRuntimeGuardrails.hpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace ar::iedsim::runtime_guardrails { + +inline constexpr qsizetype kMaxBufferedProcessBytes = 64 * 1024; +inline constexpr qsizetype kMaxProcessLineBytes = 8 * 1024; +inline constexpr int kMaxLinesPerDrain = 64; + +struct DrainResult final { + QVector lines; + qsizetype droppedBytes{}; + int truncatedLines{}; + bool moreCompleteLines{}; +}; + +// Appends child-process bytes into a bounded framing buffer and drains at most +// kMaxLinesPerDrain complete lines. Oversized unterminated output cannot grow +// memory without bound, and oversized individual lines are truncated before +// conversion to QString / activity events. +[[nodiscard]] DrainResult appendAndDrain(QByteArray& buffer, const QByteArray& bytes); + +// Flushes an unterminated tail at process exit while preserving the same line +// cap used by normal drains. +[[nodiscard]] QByteArray boundedTail(const QByteArray& buffer, bool* truncated = nullptr); + +} // namespace ar::iedsim::runtime_guardrails diff --git a/apps/ied_simulator/src/IedSettingControlManifest.hpp b/apps/ied_simulator/src/IedSettingControlManifest.hpp new file mode 100644 index 000000000..8ec029e7f --- /dev/null +++ b/apps/ied_simulator/src/IedSettingControlManifest.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include + +namespace arstack::iedsim { + +inline QByteArray settingManifestField(QString value) { + value.replace(QLatin1Char('\t'), QLatin1Char(' ')); + value.replace(QLatin1Char('\r'), QLatin1Char(' ')); + value.replace(QLatin1Char('\n'), QLatin1Char(' ')); + return value.toUtf8(); +} + +inline QByteArray settingControlManifestLine( + const ar::iec61850::scl::SclSettingControl& setting, + const QString& activeIedName, + QSet& emittedSettingControls) { + const auto iedName = QString::fromStdString(setting.ied_name); + if (iedName != activeIedName || !setting.valid()) return {}; + + const auto domain = iedName + QString::fromStdString(setting.ld_inst); + auto logicalNode = QString::fromStdString(setting.logical_node_path); + logicalNode.replace(QLatin1Char('.'), QLatin1Char('$')); + const auto item = logicalNode + QStringLiteral("$SP$SGCB"); + if (domain.isEmpty() || logicalNode.isEmpty()) return {}; + + const auto key = domain + QLatin1Char('\n') + item; + if (emittedSettingControls.contains(key)) return {}; + emittedSettingControls.insert(key); + + return "SGCB\t" + settingManifestField(domain) + "\t" + + settingManifestField(item) + "\t" + + QByteArray::number(*setting.number_of_setting_groups) + "\t" + + QByteArray::number(*setting.active_setting_group) + "\n"; +} + +} // namespace arstack::iedsim diff --git a/apps/ied_simulator/src/IedSignalModel.cpp b/apps/ied_simulator/src/IedSignalModel.cpp new file mode 100644 index 000000000..ad056e4dd --- /dev/null +++ b/apps/ied_simulator/src/IedSignalModel.cpp @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedSignalModel.hpp" + +#include +#include + +#include + +namespace { +struct ObjectGroup final { + QString name; + QVector members; + int preferred{-1}; + bool objectMatches{}; +}; + +QString referenceAt(const IedFleetController* backend, const int index) { + if (backend == nullptr) return {}; + const auto* point = backend->valueRecord(index); + return point == nullptr ? QString{} : point->reference; +} +} // namespace + +IedSignalModel::IedSignalModel(QObject* parent) + : QAbstractListModel(parent) { + rebuildTimer_.setSingleShot(true); + rebuildTimer_.setInterval(80); + connect(&rebuildTimer_, &QTimer::timeout, this, &IedSignalModel::rebuild); + + refreshTimer_.setSingleShot(true); + refreshTimer_.setInterval(16); + connect(&refreshTimer_, &QTimer::timeout, this, &IedSignalModel::refreshSnapshot); +} + +IedFleetController* IedSignalModel::backend() const noexcept { return backend_; } + +void IedSignalModel::setBackend(IedFleetController* backend) { + if (backend_ == backend) return; + if (backend_ != nullptr) disconnect(backend_, nullptr, this, nullptr); + backend_ = backend; + observedIedIndex_ = -1; + observedSelectedSourceIndex_ = -1; + observedSourceCount_ = 0; + + if (backend_ != nullptr) { + connect(backend_, &IedFleetController::valuesChanged, + this, &IedSignalModel::scheduleRefresh); + connect(backend_, &IedFleetController::modelChanged, + this, &IedSignalModel::scheduleRebuild); + connect(backend_, &IedFleetController::selectionChanged, this, [this] { + if (backend_ == nullptr) return; + if (backend_->selectedIedIndex() != observedIedIndex_) { + scheduleRebuild(); + } else { + refreshSelectedRole(); + } + }); + } + + emit backendChanged(); + scheduleRebuild(); +} + +QString IedSignalModel::logicalDevice() const { return logicalDevice_; } + +void IedSignalModel::setLogicalDevice(const QString& value) { + if (logicalDevice_ == value) return; + logicalDevice_ = value; + emit scopeChanged(); + scheduleRebuild(); +} + +QString IedSignalModel::logicalNode() const { return logicalNode_; } + +void IedSignalModel::setLogicalNode(const QString& value) { + if (logicalNode_ == value) return; + logicalNode_ = value; + emit scopeChanged(); + scheduleRebuild(); +} + +QString IedSignalModel::filterText() const { return filterText_; } + +void IedSignalModel::setFilterText(const QString& value) { + if (filterText_ == value) return; + filterText_ = value; + emit filterTextChanged(); + scheduleRebuild(); +} + +int IedSignalModel::visibleRowCount() const noexcept { + return static_cast(rows_.size()); +} + +int IedSignalModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(rows_.size()); +} + +IedSignalModel::Row IedSignalModel::makeRow( + const RowKind kind, + const int sourceIndex, + const QString& name, + const IedPointStore::PointRecord& item) const { + const bool objectRow = kind == RowKind::dataObject; + Row row; + row.kind = kind; + row.sourceIndex = sourceIndex; + row.name = name; + row.functionalConstraint = objectRow ? QString{} : item.functionalConstraint; + row.type = objectRow ? item.cdc : item.type; + row.reference = item.reference; + row.value = item.value; + row.quality = item.quality; + row.writable = item.writable; + row.changed = item.changed; + return row; +} + +QVariant IedSignalModel::data(const QModelIndex& index, const int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) return {}; + const auto& row = rows_[static_cast(index.row())]; + const bool objectRow = row.kind == RowKind::dataObject; + + switch (role) { + case KindRole: return objectRow ? QStringLiteral("DO") : QStringLiteral("DA"); + case NameRole: return row.name; + case DepthRole: return objectRow ? 0 : 1; + case ValueRole: return row.value; + case FunctionalConstraintRole: + return objectRow ? QVariant{} : QVariant{row.functionalConstraint}; + case TypeRole: return row.type; + case QualityRole: return row.quality; + case SourceIndexRole: return row.sourceIndex; + case WritableRole: return row.writable; + case ChangedRole: return row.changed; + case ReferenceRole: return row.reference; + case SelectedRole: + return backend_ != nullptr && backend_->selectedValueIndex() == row.sourceIndex; + default: return {}; + } +} + +QHash IedSignalModel::roleNames() const { + return { + {KindRole, "kind"}, + {NameRole, "name"}, + {DepthRole, "depth"}, + {ValueRole, "value"}, + {FunctionalConstraintRole, "fc"}, + {TypeRole, "type"}, + {QualityRole, "quality"}, + {SourceIndexRole, "sourceIndex"}, + {WritableRole, "writable"}, + {ChangedRole, "changed"}, + {ReferenceRole, "reference"}, + {SelectedRole, "selected"}, + }; +} + +void IedSignalModel::activate(const int rowIndex) { + if (backend_ == nullptr || rowIndex < 0 || rowIndex >= rowCount()) return; + const auto sourceIndex = rows_[static_cast(rowIndex)].sourceIndex; + if (sourceIndex >= 0) backend_->selectValue(sourceIndex); +} + +void IedSignalModel::scheduleRebuild() { + refreshTimer_.stop(); + rebuildTimer_.start(); +} + +void IedSignalModel::scheduleRefresh() { + // Latest-state presentation accumulator: any number of valuesChanged bursts + // inside one frame collapse into one scan of only the currently projected + // source rows. Protocol/runtime state remains ordered and authoritative. + ++refreshRequestCount_; + if (!rebuildTimer_.isActive() && !refreshTimer_.isActive()) { + refreshLatencyTimer_.restart(); + refreshTimer_.start(); + } +} + +void IedSignalModel::resetPerformanceCounters() { + refreshRequestCount_ = 0; + refreshFlushCount_ = 0; + lastRefreshLatencyMilliseconds_ = 0; + maxRefreshLatencyMilliseconds_ = 0; + lastRebuildMilliseconds_ = 0; + maxRebuildMilliseconds_ = 0; + refreshLatencyTimer_.invalidate(); + emit performanceCountersChanged(); +} + +bool IedSignalModel::rowMatches( + const IedPointStore::PointRecord& item, + const QString& query) const { + if (query.isEmpty()) return true; + const QStringList fields{ + item.dataObject, + item.dataAttribute, + item.value, + item.functionalConstraint, + item.type, + item.reference, + }; + for (const auto& field : fields) { + if (field.contains(query, Qt::CaseInsensitive)) return true; + } + return false; +} + +void IedSignalModel::rebuild() { + QElapsedTimer rebuildElapsed; + rebuildElapsed.start(); + const int previousCount = rowCount(); + observedIedIndex_ = backend_ != nullptr ? backend_->selectedIedIndex() : -1; + observedSelectedSourceIndex_ = backend_ != nullptr ? backend_->selectedValueIndex() : -1; + observedSourceCount_ = backend_ != nullptr ? backend_->valueCount() : 0; + observedFirstReference_ = referenceAt(backend_, 0); + observedLastReference_ = referenceAt(backend_, observedSourceCount_ - 1); + + QVector groups; + QHash groupIndex; + const auto query = filterText_.trimmed(); + + const auto consumeSource = [&](const int sourceIndex) { + if (backend_ == nullptr || sourceIndex < 0 || sourceIndex >= backend_->valueCount()) return; + const auto* item = backend_->valueRecord(sourceIndex); + if (item == nullptr || item->logicalDevice != logicalDevice_ || + item->logicalNode != logicalNode_) { + return; + } + + const auto& objectName = item->dataObject; + if (objectName.isEmpty()) return; + auto found = groupIndex.constFind(objectName); + int index{}; + if (found == groupIndex.cend()) { + index = groups.size(); + groupIndex.insert(objectName, index); + groups.push_back(ObjectGroup{objectName, {}, -1, + query.isEmpty() || objectName.contains(query, Qt::CaseInsensitive)}); + } else { + index = found.value(); + } + + auto& group = groups[index]; + group.members.push_back(sourceIndex); + const auto& attribute = item->dataAttribute; + if (group.preferred < 0 || attribute == QStringLiteral("stVal") || + attribute.endsWith(QStringLiteral(".stVal")) || + attribute == QStringLiteral("mag.f")) { + group.preferred = sourceIndex; + } + }; + + if (backend_ != nullptr && backend_->hasPreparedValueIndex()) { + // Worker-side indexing makes the hot rebuild proportional to the + // selected LN, not to the complete 20k/50k IED point catalog. + const auto& scopeIndices = backend_->valueScopeIndices(logicalDevice_, logicalNode_); + groups.reserve(scopeIndices.size() > 0 ? 16 : 0); + for (const auto sourceIndex : scopeIndices) consumeSource(sourceIndex); + } else if (backend_ != nullptr) { + // Legacy synchronous/multi-IED selection fallback still reads the typed + // canonical store directly; it never materializes a full QVariant list. + for (int sourceIndex = 0; sourceIndex < backend_->valueCount(); ++sourceIndex) { + consumeSource(sourceIndex); + } + } + + std::vector nextRows; + std::size_t scopedMemberCount{}; + for (const auto& group : groups) scopedMemberCount += static_cast(group.members.size()); + nextRows.reserve(scopedMemberCount + static_cast(groups.size())); + for (const auto& group : groups) { + QVector visibleMembers; + visibleMembers.reserve(group.members.size()); + if (query.isEmpty() || group.objectMatches) { + visibleMembers = group.members; + } else { + for (const auto sourceIndex : group.members) { + const auto* item = backend_ != nullptr ? backend_->valueRecord(sourceIndex) : nullptr; + if (item != nullptr && rowMatches(*item, query)) visibleMembers.push_back(sourceIndex); + } + } + if (visibleMembers.isEmpty()) continue; + + int preferred = group.preferred; + if (preferred < 0 || !group.members.contains(preferred)) preferred = visibleMembers.constFirst(); + const auto* preferredItem = backend_ != nullptr ? backend_->valueRecord(preferred) : nullptr; + if (preferredItem == nullptr) continue; + nextRows.push_back(makeRow(RowKind::dataObject, preferred, group.name, *preferredItem)); + for (const auto sourceIndex : visibleMembers) { + const auto* item = backend_ != nullptr ? backend_->valueRecord(sourceIndex) : nullptr; + if (item == nullptr) continue; + auto attribute = item->dataAttribute; + if (attribute.isEmpty()) attribute = group.name; + nextRows.push_back(makeRow(RowKind::dataAttribute, sourceIndex, attribute, *item)); + } + } + + beginResetModel(); + rows_ = std::move(nextRows); + sourceRows_.clear(); + for (int row = 0; row < rowCount(); ++row) { + sourceRows_[rows_[static_cast(row)].sourceIndex].push_back(row); + } + endResetModel(); + if (previousCount != rowCount()) emit visibleRowCountChanged(); + lastRebuildMilliseconds_ = rebuildElapsed.elapsed(); + maxRebuildMilliseconds_ = std::max(maxRebuildMilliseconds_, lastRebuildMilliseconds_); + emit performanceCountersChanged(); +} + +void IedSignalModel::refreshSnapshot() { + ++refreshFlushCount_; + if (refreshLatencyTimer_.isValid()) { + lastRefreshLatencyMilliseconds_ = refreshLatencyTimer_.elapsed(); + maxRefreshLatencyMilliseconds_ = + std::max(maxRefreshLatencyMilliseconds_, lastRefreshLatencyMilliseconds_); + refreshLatencyTimer_.invalidate(); + } + emit performanceCountersChanged(); + + if (backend_ == nullptr) { + scheduleRebuild(); + return; + } + + const int nextCount = backend_->valueCount(); + const bool sameStructure = backend_->selectedIedIndex() == observedIedIndex_ && + nextCount == observedSourceCount_ && + referenceAt(backend_, 0) == observedFirstReference_ && + referenceAt(backend_, nextCount - 1) == observedLastReference_; + + if (!sameStructure) { + scheduleRebuild(); + return; + } + + // A live value can be part of the active search predicate. Rebuild at the + // existing 80 ms coalescing boundary so rows enter/leave the filtered view + // correctly instead of publishing stale membership. + if (!filterText_.trimmed().isEmpty()) { + scheduleRebuild(); + return; + } + + QVector changedRows; + for (auto it = sourceRows_.cbegin(); it != sourceRows_.cend(); ++it) { + const int sourceIndex = it.key(); + const auto* after = backend_->valueRecord(sourceIndex); + if (after == nullptr) { + scheduleRebuild(); + return; + } + + for (const int rowIndex : it.value()) { + if (rowIndex < 0 || rowIndex >= rowCount()) { + scheduleRebuild(); + return; + } + auto& row = rows_[static_cast(rowIndex)]; + if (row.reference != after->reference) { + scheduleRebuild(); + return; + } + if (row.value == after->value && row.quality == after->quality && + row.writable == after->writable && row.changed == after->changed) { + continue; + } + row.value = after->value; + row.quality = after->quality; + row.writable = after->writable; + row.changed = after->changed; + changedRows.push_back(rowIndex); + } + } + + emitRowsChanged( + std::move(changedRows), + {ValueRole, QualityRole, WritableRole, ChangedRole}); +} + +void IedSignalModel::refreshSelectedRole() { + const int nextSelected = backend_ != nullptr ? backend_->selectedValueIndex() : -1; + if (nextSelected == observedSelectedSourceIndex_) return; + + QVector changedRows = sourceRows_.value(observedSelectedSourceIndex_); + changedRows += sourceRows_.value(nextSelected); + observedSelectedSourceIndex_ = nextSelected; + emitRowsChanged(std::move(changedRows), {SelectedRole}); +} + +void IedSignalModel::emitRowsChanged(QVector rows, const QList& roles) { + if (rows.isEmpty() || rowCount() <= 0) return; + std::sort(rows.begin(), rows.end()); + rows.erase(std::unique(rows.begin(), rows.end()), rows.end()); + + int rangeStart = -1; + int previous = -2; + const auto flush = [this, &roles](const int first, const int last) { + if (first < 0 || last < first || last >= rowCount()) return; + emit dataChanged(index(first, 0), index(last, 0), roles); + }; + + for (const int row : rows) { + if (row < 0 || row >= rowCount()) continue; + if (rangeStart < 0) { + rangeStart = row; + previous = row; + continue; + } + if (row == previous + 1) { + previous = row; + continue; + } + flush(rangeStart, previous); + rangeStart = row; + previous = row; + } + flush(rangeStart, previous); +} diff --git a/apps/ied_simulator/src/IedSignalModel.hpp b/apps/ied_simulator/src/IedSignalModel.hpp new file mode 100644 index 000000000..19d2238ba --- /dev/null +++ b/apps/ied_simulator/src/IedSignalModel.hpp @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IedFleetController.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +class IedSignalModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(IedFleetController* backend READ backend WRITE setBackend NOTIFY backendChanged) + Q_PROPERTY(QString logicalDevice READ logicalDevice WRITE setLogicalDevice NOTIFY scopeChanged) + Q_PROPERTY(QString logicalNode READ logicalNode WRITE setLogicalNode NOTIFY scopeChanged) + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) + Q_PROPERTY(int visibleRowCount READ visibleRowCount NOTIFY visibleRowCountChanged) + Q_PROPERTY(qulonglong refreshRequestCount READ refreshRequestCount NOTIFY performanceCountersChanged) + Q_PROPERTY(qulonglong refreshFlushCount READ refreshFlushCount NOTIFY performanceCountersChanged) + Q_PROPERTY(qint64 lastRefreshLatencyMilliseconds READ lastRefreshLatencyMilliseconds NOTIFY performanceCountersChanged) + Q_PROPERTY(qint64 maxRefreshLatencyMilliseconds READ maxRefreshLatencyMilliseconds NOTIFY performanceCountersChanged) + Q_PROPERTY(qint64 lastRebuildMilliseconds READ lastRebuildMilliseconds NOTIFY performanceCountersChanged) + Q_PROPERTY(qint64 maxRebuildMilliseconds READ maxRebuildMilliseconds NOTIFY performanceCountersChanged) + +public: + enum Roles { + KindRole = Qt::UserRole + 1, + NameRole, + DepthRole, + ValueRole, + FunctionalConstraintRole, + TypeRole, + QualityRole, + SourceIndexRole, + WritableRole, + ChangedRole, + ReferenceRole, + SelectedRole, + }; + + explicit IedSignalModel(QObject* parent = nullptr); + + [[nodiscard]] IedFleetController* backend() const noexcept; + void setBackend(IedFleetController* backend); + + [[nodiscard]] QString logicalDevice() const; + void setLogicalDevice(const QString& value); + [[nodiscard]] QString logicalNode() const; + void setLogicalNode(const QString& value); + [[nodiscard]] QString filterText() const; + void setFilterText(const QString& value); + [[nodiscard]] int visibleRowCount() const noexcept; + [[nodiscard]] quint64 refreshRequestCount() const noexcept { return refreshRequestCount_; } + [[nodiscard]] quint64 refreshFlushCount() const noexcept { return refreshFlushCount_; } + [[nodiscard]] qint64 lastRefreshLatencyMilliseconds() const noexcept { + return lastRefreshLatencyMilliseconds_; + } + [[nodiscard]] qint64 maxRefreshLatencyMilliseconds() const noexcept { + return maxRefreshLatencyMilliseconds_; + } + [[nodiscard]] qint64 lastRebuildMilliseconds() const noexcept { return lastRebuildMilliseconds_; } + [[nodiscard]] qint64 maxRebuildMilliseconds() const noexcept { return maxRebuildMilliseconds_; } + + [[nodiscard]] int rowCount(const QModelIndex& parent = QModelIndex{}) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + Q_INVOKABLE void activate(int row); + Q_INVOKABLE void resetPerformanceCounters(); + +signals: + void backendChanged(); + void scopeChanged(); + void filterTextChanged(); + void visibleRowCountChanged(); + void performanceCountersChanged(); + +private: + enum class RowKind { dataObject, dataAttribute }; + struct Row final { + RowKind kind{RowKind::dataAttribute}; + int sourceIndex{-1}; + QString name; + QString functionalConstraint; + QString type; + QString reference; + QString value; + QString quality; + bool writable{}; + bool changed{}; + }; + + void scheduleRebuild(); + void scheduleRefresh(); + void rebuild(); + void refreshSnapshot(); + void refreshSelectedRole(); + void emitRowsChanged(QVector rows, const QList& roles); + [[nodiscard]] bool rowMatches( + const IedPointStore::PointRecord& item, + const QString& query) const; + [[nodiscard]] Row makeRow( + RowKind kind, + int sourceIndex, + const QString& name, + const IedPointStore::PointRecord& item) const; + + IedFleetController* backend_{}; + QTimer rebuildTimer_; + QTimer refreshTimer_; + QElapsedTimer refreshLatencyTimer_; + std::vector rows_; + QHash> sourceRows_; + QString logicalDevice_; + QString logicalNode_; + QString filterText_; + quint64 refreshRequestCount_{}; + quint64 refreshFlushCount_{}; + qint64 lastRefreshLatencyMilliseconds_{}; + qint64 maxRefreshLatencyMilliseconds_{}; + qint64 lastRebuildMilliseconds_{}; + qint64 maxRebuildMilliseconds_{}; + int observedIedIndex_{-1}; + int observedSelectedSourceIndex_{-1}; + int observedSourceCount_{}; + QString observedFirstReference_; + QString observedLastReference_; +}; diff --git a/apps/ied_simulator/src/IedSimulatorController.cpp b/apps/ied_simulator/src/IedSimulatorController.cpp index ec2384765..e8e934869 100644 --- a/apps/ied_simulator/src/IedSimulatorController.cpp +++ b/apps/ied_simulator/src/IedSimulatorController.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -15,66 +16,41 @@ #include #include #include -#include #include #include +#include #include #include -#include namespace { QString qstring(const std::string& value) { return QString::fromStdString(value); } -QString normalizedType(const ar::iec61850::scl::SclDataSetEntry& entry) { - const auto basic = qstring(entry.basic_type).trimmed(); - const auto cdc = qstring(entry.cdc).trimmed(); - const auto da = qstring(entry.da_name).trimmed(); - if (entry.is_quality || da.compare(QStringLiteral("q"), Qt::CaseInsensitive) == 0) { - return QStringLiteral("Quality"); - } - if (entry.is_timestamp || da.compare(QStringLiteral("t"), Qt::CaseInsensitive) == 0) { - return QStringLiteral("Timestamp"); - } - if (!entry.enum_type.empty() || basic.contains(QStringLiteral("Enum"), Qt::CaseInsensitive) || - cdc.compare(QStringLiteral("DPC"), Qt::CaseInsensitive) == 0) { - return QStringLiteral("Enumeration"); - } - if (basic.contains(QStringLiteral("Bool"), Qt::CaseInsensitive)) { - return QStringLiteral("Boolean"); - } - if (basic.contains(QStringLiteral("Float"), Qt::CaseInsensitive) || - basic.contains(QStringLiteral("INT"), Qt::CaseInsensitive) || - basic.contains(QStringLiteral("Integer"), Qt::CaseInsensitive)) { - return QStringLiteral("Number"); - } - return basic.isEmpty() ? QStringLiteral("Text") : basic; -} +std::optional controlModelCode(QString value) { + value = value.trimmed(); + bool numericOk{}; + const auto numeric = value.toInt(&numericOk); + if (numericOk && numeric >= 0 && numeric <= 4) return numeric; -QString initialValue(const QString& type, const QString& dataAttribute) { - if (type == QStringLiteral("Boolean")) return QStringLiteral("false"); - if (type == QStringLiteral("Enumeration")) return QStringLiteral("intermediate-state"); - if (type == QStringLiteral("Quality")) return QStringLiteral("Good"); - if (type == QStringLiteral("Timestamp")) { - return QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")); - } - if (type == QStringLiteral("Number")) return QStringLiteral("0"); - if (dataAttribute.compare(QStringLiteral("stVal"), Qt::CaseInsensitive) == 0) { - return QStringLiteral("on"); + QString token; + token.reserve(value.size()); + for (const auto character : value.toLower()) { + if (character.isLetterOrNumber()) token.append(character); } - return QStringLiteral("—"); -} - -QString displayName(const ar::iec61850::scl::SclDataSetEntry& entry) { - const auto dataObject = qstring(entry.do_name); - const auto attribute = qstring(entry.da_name); - if (!dataObject.isEmpty() && !attribute.isEmpty()) { - return dataObject + QLatin1Char('.') + attribute; - } - if (!dataObject.isEmpty()) return dataObject; - return qstring(entry.signal_reference); + if (token == QStringLiteral("statusonly")) return 0; + if (token == QStringLiteral("directwithnormalsecurity") || + token == QStringLiteral("directnormal")) return 1; + if (token == QStringLiteral("sbowithnormalsecurity") || + token == QStringLiteral("selectbeforeoperatewithnormalsecurity") || + token == QStringLiteral("sbonormal")) return 2; + if (token == QStringLiteral("directwithenhancedsecurity") || + token == QStringLiteral("directenhanced")) return 3; + if (token == QStringLiteral("sbowithenhancedsecurity") || + token == QStringLiteral("selectbeforeoperatewithenhancedsecurity") || + token == QStringLiteral("sboenhanced")) return 4; + return std::nullopt; } QString referenceFor(const ar::iec61850::scl::SclDataSetEntry& entry) { @@ -93,12 +69,14 @@ QString mmsDomainFor(const ar::iec61850::scl::SclDataSetEntry& entry) { } QString mmsItemFor(const ar::iec61850::scl::SclDataSetEntry& entry) { + auto dataObject = qstring(entry.do_name); + dataObject.replace(QLatin1Char('.'), QLatin1Char('$')); auto attribute = qstring(entry.da_name); attribute.replace(QLatin1Char('.'), QLatin1Char('$')); QStringList parts{ qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst), qstring(entry.functional_constraint), - qstring(entry.do_name)}; + dataObject}; if (!attribute.isEmpty()) parts.push_back(attribute); parts.removeAll(QString{}); return parts.join(QLatin1Char('$')); @@ -351,6 +329,7 @@ void IedSimulatorController::clear() { void IedSimulatorController::selectIed(const int index) { const int normalized = index >= 0 && index < ieds_.size() ? index : -1; if (selectedIedIndex_ == normalized) return; + if (running_ || starting_) stopSimulation(); selectedIedIndex_ = normalized; rebuildValues(); emit selectionChanged(); @@ -364,7 +343,7 @@ void IedSimulatorController::selectValue(const int index) { } bool IedSimulatorController::startSimulation() { - if (!imported() || running_ || starting_) return false; + if (!imported() || running_ || starting_ || selectedIedIndex_ < 0) return false; const auto executable = serverExecutable(); if (executable.isEmpty()) { appendActivity( @@ -386,8 +365,8 @@ bool IedSimulatorController::startSimulation() { serverProcess_.start(); appendActivity( QStringLiteral("Server"), - QStringLiteral("Starting IEC 61850 interoperability MMS endpoint on %1:%2.") - .arg(listenAddress_) + QStringLiteral("Starting IEC 61850 interoperability MMS endpoint for %1 on %2:%3.") + .arg(selectedIed().value(QStringLiteral("name")).toString(), listenAddress_) .arg(port_)); return true; } @@ -495,7 +474,7 @@ QString IedSimulatorController::diagnosticsText() const { "Interoperability profile: Authentication=None; AP-title=1,1,1,999,1; " "AE-qualifier=12; P-selector=00 00 00 01; S-selector=00 01; T-selector=00 01\n"); text += QStringLiteral( - "Counts: IED=%1; LD=%2; referenced DO=%3; referenced DA=%4; " + "Counts: IED=%1; LD=%2; DO=%3; DA/BDA=%4; " "DataSet=%5; Report=%6; GOOSE=%7\n") .arg(ieds_.size()) .arg(logicalDeviceCount_) @@ -544,6 +523,17 @@ void IedSimulatorController::consumeServerOutput( void IedSimulatorController::processServerLine( const QString& line, const bool standardError) { + // The GUI normally turns child-server protocol events into activity rows. + // For deterministic CI/interoperability diagnostics, opt in to mirroring + // those exact child lines to the parent process log. This keeps normal UI + // output quiet while making association failures observable in headless QA. + if (qEnvironmentVariableIsSet("ARSTACK_IEDSIM_TRACE_SERVER")) { + if (standardError) { + qWarning().noquote() << line; + } else { + qInfo().noquote() << line; + } + } if (!line.startsWith(QStringLiteral("IEDSIM_EVENT "))) { appendActivity( QStringLiteral("MMS"), @@ -575,7 +565,7 @@ void IedSimulatorController::processServerLine( if (truncated > 0) { appendActivity( QStringLiteral("Model"), - QStringLiteral("%1 logical-node roots exceeded the bounded host profile and were omitted.") + QStringLiteral("%1 model objects exceeded the bounded host profile and were omitted.") .arg(truncated), QStringLiteral("Warning")); } @@ -621,6 +611,17 @@ void IedSimulatorController::processServerLine( .arg(fields.value(QStringLiteral("state")).toString())); return; } + if (kind == QStringLiteral("report_sent")) { + appendActivity( + QStringLiteral("Report"), + QStringLiteral("Association %1 sent report %2 (SqNum %3, reason %4).") + .arg(fields.value(QStringLiteral("association")).toString()) + .arg(fields.value(QStringLiteral("rcb")).toString()) + .arg(fields.value(QStringLiteral("sqnum")).toString()) + .arg(fields.value(QStringLiteral("reason")).toString()), + QStringLiteral("Success")); + return; + } if (kind != QStringLiteral("server_stopped")) { appendActivity(QStringLiteral("MMS"), line); } @@ -632,65 +633,45 @@ bool IedSimulatorController::writeModelManifest() { QStringLiteral("arstack-ied-simulator-%1.model") .arg(QCoreApplication::applicationPid())); } + const auto activeIed = selectedIed(); + const auto activeIedName = activeIed.value(QStringLiteral("name")).toString(); + const auto activeDocumentIndex = + activeIed.value(QStringLiteral("documentIndex"), -1).toInt(); + if (activeIedName.isEmpty() || activeDocumentIndex < 0 || + activeDocumentIndex >= static_cast(documents_.size())) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("Select a valid IED before starting the simulator."), + QStringLiteral("Error")); + return false; + } + seedRuntimeValues(); ++modelRevision_; QByteArray manifest = "ARSTACK_IED_MODEL\t2\t" + QByteArray::number(modelRevision_) + "\n"; QSet uniqueRoots; - for (const auto& loaded : documents_) { - QFile input{loaded.path}; - if (!input.open(QIODevice::ReadOnly)) { - appendActivity( - QStringLiteral("Model"), - QStringLiteral("Could not reopen %1 for runtime model projection.").arg(loaded.path), - QStringLiteral("Error")); - return false; - } - QXmlStreamReader xml{&input}; - QString currentIed; - QString currentDomain; - while (!xml.atEnd()) { - xml.readNext(); - if (xml.isStartElement()) { - const auto element = xml.name(); - const auto attributes = xml.attributes(); - if (element == QStringLiteral("IED")) { - currentIed = attributes.value(QStringLiteral("name")).toString(); - } else if (element == QStringLiteral("LDevice")) { - currentDomain = currentIed + attributes.value(QStringLiteral("inst")).toString(); - } else if ((element == QStringLiteral("LN") || - element == QStringLiteral("LN0")) && - !currentDomain.isEmpty()) { - const auto logicalNode = - attributes.value(QStringLiteral("prefix")).toString() + - attributes.value(QStringLiteral("lnClass")).toString() + - attributes.value(QStringLiteral("inst")).toString(); - if (!logicalNode.isEmpty()) { - const auto key = currentDomain + QLatin1Char('\n') + logicalNode; - if (!uniqueRoots.contains(key)) { - uniqueRoots.insert(key); - manifest += "LN\t" + currentDomain.toUtf8() + "\t" + - logicalNode.toUtf8() + "\n"; - } - } - } - } else if (xml.isEndElement()) { - if (xml.name() == QStringLiteral("LDevice")) currentDomain.clear(); - if (xml.name() == QStringLiteral("IED")) currentIed.clear(); - } - } - if (xml.hasError()) { - appendActivity( - QStringLiteral("Model"), - QStringLiteral("Runtime model projection failed: %1").arg(xml.errorString()), - QStringLiteral("Error")); - return false; - } + const auto& loaded = documents_[static_cast(activeDocumentIndex)]; + for (const auto& logicalNode : loaded.document.logical_nodes) { + if (qstring(logicalNode.ied_name) != activeIedName) continue; + const auto domain = qstring(logicalNode.mms_domain()); + const auto item = qstring(logicalNode.name); + if (domain.isEmpty() || item.isEmpty()) continue; + const auto key = domain + QLatin1Char('\n') + item; + if (uniqueRoots.contains(key)) continue; + uniqueRoots.insert(key); + manifest += "LN\t" + manifestField(domain) + "\t" + + manifestField(item) + "\n"; } + // `values_` is reconstructed from IedSimulatorPoint::source_order. Emit + // OBJ records in that same SCL declaration order. MMS structures are + // ordered values; using QHash iteration here made child order random and + // could turn SPS {stVal, q, t} into {q, stVal, t} on the wire. QSet emittedObjects; - for (auto it = runtimeValues_.cbegin(); it != runtimeValues_.cend(); ++it) { - const auto item = it.value(); + for (const auto& runtimeValue : values_) { + const auto item = runtimeValue.toMap(); + if (item.value(QStringLiteral("iedName")).toString() != activeIedName) continue; const auto domain = item.value(QStringLiteral("mmsDomain")).toString(); const auto mmsItem = item.value(QStringLiteral("mmsItem")).toString(); if (domain.isEmpty() || mmsItem.isEmpty()) continue; @@ -704,32 +685,103 @@ bool IedSimulatorController::writeModelManifest() { manifestField(item.value(QStringLiteral("value")).toString()) + "\n"; } + // Control service objects are virtual MMS objects. Preserve the configured + // SCL ctlModel explicitly instead of pretending CO$Oper is a structural DA. + QSet emittedControls; + for (const auto& entry : loaded.document.model_entries) { + if (qstring(entry.ied_name) != activeIedName || + qstring(entry.functional_constraint).compare(QStringLiteral("CF"), Qt::CaseInsensitive) != 0 || + qstring(entry.da_name).compare(QStringLiteral("ctlModel"), Qt::CaseInsensitive) != 0) { + continue; + } + const auto model = controlModelCode(qstring(entry.configured_value)); + if (!model.has_value()) continue; + const auto domain = mmsDomainFor(entry); + const auto logicalNode = qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst); + auto dataObject = qstring(entry.do_name); + dataObject.replace(QLatin1Char('.'), QLatin1Char('$')); + const auto cdc = qstring(entry.cdc).trimmed().toUpper(); + if (domain.isEmpty() || logicalNode.isEmpty() || dataObject.isEmpty() || cdc.isEmpty()) continue; + const auto key = domain + QLatin1Char('\n') + logicalNode + QLatin1Char('\n') + dataObject; + if (emittedControls.contains(key)) continue; + emittedControls.insert(key); + manifest += "CTL " + manifestField(domain) + " " + manifestField(logicalNode) + + " " + manifestField(dataObject) + " " + manifestField(cdc) + " " + + QByteArray::number(*model) + "\n"; + } + QSet emittedDataSetMembers; - for (const auto& loaded : documents_) { - for (const auto& dataSet : loaded.document.data_sets) { - const auto dataSetDomain = qstring(dataSet.ied_name) + qstring(dataSet.ld_inst); - auto dataSetItem = qstring(dataSet.logical_node_path) + QLatin1Char('$') + - qstring(dataSet.name); - dataSetItem.replace(QLatin1Char('.'), QLatin1Char('$')); - for (const auto& entry : dataSet.entries) { - const auto memberDomain = mmsDomainFor(entry); - const auto memberItem = mmsItemFor(entry); - const auto memberKey = dataSetDomain + QLatin1Char('\n') + dataSetItem + - QLatin1Char('\n') + memberDomain + QLatin1Char('\n') + memberItem; - if (dataSetDomain.isEmpty() || dataSetItem.isEmpty() || - memberDomain.isEmpty() || memberItem.isEmpty() || - emittedDataSetMembers.contains(memberKey)) continue; - emittedDataSetMembers.insert(memberKey); - manifest += "DS\t" + manifestField(dataSetDomain) + "\t" + - manifestField(dataSetItem) + "\t" + manifestField(memberDomain) + - "\t" + manifestField(memberItem) + "\n"; - } + for (const auto& dataSet : loaded.document.data_sets) { + if (qstring(dataSet.ied_name) != activeIedName) continue; + const auto dataSetDomain = qstring(dataSet.ied_name) + qstring(dataSet.ld_inst); + auto dataSetItem = qstring(dataSet.logical_node_path) + QLatin1Char('$') + + qstring(dataSet.name); + dataSetItem.replace(QLatin1Char('.'), QLatin1Char('$')); + for (const auto& entry : dataSet.entries) { + const auto memberDomain = mmsDomainFor(entry); + const auto memberItem = mmsItemFor(entry); + const auto memberKey = dataSetDomain + QLatin1Char('\n') + dataSetItem + + QLatin1Char('\n') + memberDomain + QLatin1Char('\n') + memberItem; + if (dataSetDomain.isEmpty() || dataSetItem.isEmpty() || + memberDomain.isEmpty() || memberItem.isEmpty() || + emittedDataSetMembers.contains(memberKey)) continue; + emittedDataSetMembers.insert(memberKey); + manifest += "DS\t" + manifestField(dataSetDomain) + "\t" + + manifestField(dataSetItem) + "\t" + manifestField(memberDomain) + + "\t" + manifestField(memberItem) + "\n"; } } + + // Preserve the simulator profile already established by ARIEC61850 C#. + // The C# profile builder enables dchg/qchg/GI for URCBs, adds integrity for + // BRCBs, and exposes SeqNum/Timestamp/Reason/DataSet/ConfRev (plus EntryID + // for BRCB). SCL-specific TrgOps/OptFields parsing can enrich this later; + // these values deliberately match the source implementation being ported. + QSet emittedReportControls; + for (const auto& report : loaded.document.report_controls) { + if (qstring(report.ied_name) != activeIedName) continue; + const auto domain = qstring(report.ied_name) + qstring(report.ld_inst); + auto logicalNode = qstring(report.logical_node_path); + logicalNode.replace(QLatin1Char('.'), QLatin1Char('$')); + const auto name = qstring(report.name); + auto dataSetItem = logicalNode + QLatin1Char('$') + qstring(report.data_set_name); + dataSetItem.replace(QLatin1Char('.'), QLatin1Char('$')); + const auto item = logicalNode + + (report.buffered ? QStringLiteral("$BR$") : QStringLiteral("$RP$")) + name; + if (domain.isEmpty() || logicalNode.isEmpty() || name.isEmpty() || + dataSetItem.isEmpty()) continue; + const auto key = domain + QLatin1Char('\n') + item; + if (emittedReportControls.contains(key)) continue; + emittedReportControls.insert(key); + + auto reportId = qstring(report.report_id); + if (reportId.isEmpty()) reportId = domain + QLatin1Char('/') + item; + const auto triggerOptions = report.buffered ? 0x6CU : 0x64U; + const auto optionalFields0 = report.buffered ? 0x79U : 0x78U; + constexpr auto optionalFields1 = 0x80U; + manifest += "RCB\t" + manifestField(domain) + "\t" + manifestField(item) + "\t" + + QByteArray::number(report.buffered ? 1 : 0) + "\t" + + manifestField(reportId) + "\t" + manifestField(domain) + "\t" + + manifestField(dataSetItem) + "\t" + + QByteArray::number(report.configuration_revision) + "\t" + + QByteArray::number(report.buffer_time_milliseconds) + "\t" + + QByteArray::number(report.integrity_period_milliseconds) + "\t" + + QByteArray::number(triggerOptions) + "\t" + + QByteArray::number(optionalFields0) + "\t" + + QByteArray::number(optionalFields1) + "\n"; + } + if (uniqueRoots.isEmpty()) { appendActivity( QStringLiteral("Model"), - QStringLiteral("The imported SCL contains no discoverable logical-node roots."), + QStringLiteral("The selected IED contains no discoverable logical-node roots."), + QStringLiteral("Error")); + return false; + } + if (emittedObjects.isEmpty()) { + appendActivity( + QStringLiteral("Model"), + QStringLiteral("The selected IED contains no typed structural data-attribute leaves."), QStringLiteral("Error")); return false; } @@ -792,19 +844,24 @@ void IedSimulatorController::rebuildPresentation() { const auto collectEntry = [&](const ar::iec61850::scl::SclDataSetEntry& entry) { const auto ld = qstring(entry.ied_name) + QLatin1Char('/') + qstring(entry.ld_inst); logicalDevices.insert(ld); - const auto object = ld + QLatin1Char('/') + qstring(entry.ln_class) + qstring(entry.ln_inst) + + const auto logicalNode = qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst); + const auto object = ld + QLatin1Char('/') + logicalNode + QLatin1Char('/') + qstring(entry.do_name); dataObjects.insert(object); dataAttributes.insert(object + QLatin1Char('/') + qstring(entry.da_name)); }; - for (const auto& dataSet : document.data_sets) { - for (const auto& entry : dataSet.entries) collectEntry(entry); - } - for (const auto& stream : document.goose_streams) { - for (const auto& entry : stream.entries) collectEntry(entry); - } - for (const auto& report : document.report_controls) { - for (const auto& entry : report.entries) collectEntry(entry); + if (!document.model_entries.empty()) { + for (const auto& entry : document.model_entries) collectEntry(entry); + } else { + for (const auto& dataSet : document.data_sets) { + for (const auto& entry : dataSet.entries) collectEntry(entry); + } + for (const auto& stream : document.goose_streams) { + for (const auto& entry : stream.entries) collectEntry(entry); + } + for (const auto& report : document.report_controls) { + for (const auto& entry : report.entries) collectEntry(entry); + } } } if (ieds_.isEmpty()) { @@ -845,76 +902,87 @@ void IedSimulatorController::rebuildValues() { emit valuesChanged(); return; } - const auto selectedName = ied.value(QStringLiteral("name")).toString(); - const auto& document = documents_[static_cast(documentIndex)].document; - std::unordered_set seen; - const auto appendEntry = [&](const ar::iec61850::scl::SclDataSetEntry& entry) { - if (!entry.ied_name.empty() && qstring(entry.ied_name) != selectedName) return; - const auto reference = referenceFor(entry).toStdString(); - if (!seen.insert(reference).second) return; - const auto key = referenceFor(entry); - if (!runtimeValues_.contains(key)) runtimeValues_.insert(key, valueMap(entry)); - values_.push_back(runtimeValues_.value(key)); - }; - for (const auto& dataSet : document.data_sets) { - for (const auto& entry : dataSet.entries) appendEntry(entry); - } - for (const auto& stream : document.goose_streams) { - for (const auto& entry : stream.entries) appendEntry(entry); + + ar::iec61850::simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = ied.value(QStringLiteral("name")).toString().toStdString(); + options.runtime_ied_name = options.ied_name; + const auto built = ar::iec61850::simulation::IedSimulatorProfileBuilder::build( + documents_[static_cast(documentIndex)].document, + options); + + std::vector points; + points.reserve(built.profile.point_count()); + for (const auto& device : built.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + for (const auto& point : node.points) points.push_back(&point); + } } - for (const auto& report : document.report_controls) { - for (const auto& entry : report.entries) appendEntry(entry); + std::stable_sort( + points.begin(), points.end(), + [](const auto* left, const auto* right) { + return left->source_order < right->source_order; + }); + + for (const auto* point : points) { + const auto key = qstring(point->reference); + if (!runtimeValues_.contains(key)) runtimeValues_.insert(key, valueMap(*point)); + values_.push_back(runtimeValues_.value(key)); } + selectedValueIndex_ = values_.isEmpty() ? -1 : 0; emit valuesChanged(); } void IedSimulatorController::seedRuntimeValues() { - const auto seedEntry = [this](const ar::iec61850::scl::SclDataSetEntry& entry) { - const auto key = referenceFor(entry); - if (!key.isEmpty() && !runtimeValues_.contains(key)) { - runtimeValues_.insert(key, valueMap(entry)); - } - }; - for (const auto& loaded : documents_) { - for (const auto& dataSet : loaded.document.data_sets) { - for (const auto& entry : dataSet.entries) seedEntry(entry); - } - for (const auto& stream : loaded.document.goose_streams) { - for (const auto& entry : stream.entries) seedEntry(entry); - } - for (const auto& report : loaded.document.report_controls) { - for (const auto& entry : report.entries) seedEntry(entry); + if (selectedIedIndex_ < 0 || selectedIedIndex_ >= ieds_.size()) return; + const auto ied = ieds_.at(selectedIedIndex_).toMap(); + const int documentIndex = ied.value(QStringLiteral("documentIndex")).toInt(); + if (documentIndex < 0 || documentIndex >= static_cast(documents_.size())) return; + + ar::iec61850::simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = ied.value(QStringLiteral("name")).toString().toStdString(); + options.runtime_ied_name = options.ied_name; + const auto built = ar::iec61850::simulation::IedSimulatorProfileBuilder::build( + documents_[static_cast(documentIndex)].document, + options); + for (const auto& device : built.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + for (const auto& point : node.points) { + const auto key = qstring(point.reference); + if (!runtimeValues_.contains(key)) runtimeValues_.insert(key, valueMap(point)); + } } } } QVariantMap IedSimulatorController::valueMap( - const ar::iec61850::scl::SclDataSetEntry& entry) { - const auto type = normalizedType(entry); + const ar::iec61850::simulation::IedSimulatorPoint& point) { QVariantMap item; - item.insert(QStringLiteral("name"), displayName(entry)); - item.insert(QStringLiteral("reference"), referenceFor(entry)); - item.insert(QStringLiteral("logicalDevice"), qstring(entry.ld_inst)); + const auto dataObject = qstring(point.data_object); + const auto dataAttribute = qstring(point.data_attribute); item.insert( - QStringLiteral("logicalNode"), - qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst)); - item.insert(QStringLiteral("dataObject"), qstring(entry.do_name)); - item.insert(QStringLiteral("dataAttribute"), qstring(entry.da_name)); - item.insert(QStringLiteral("fc"), qstring(entry.functional_constraint)); - item.insert(QStringLiteral("cdc"), qstring(entry.cdc)); - item.insert(QStringLiteral("type"), type); - item.insert(QStringLiteral("rawType"), qstring(entry.basic_type)); - item.insert(QStringLiteral("iedName"), qstring(entry.ied_name)); - item.insert(QStringLiteral("mmsDomain"), mmsDomainFor(entry)); - item.insert(QStringLiteral("mmsItem"), mmsItemFor(entry)); - item.insert(QStringLiteral("value"), initialValue(type, qstring(entry.da_name))); + QStringLiteral("name"), + dataAttribute.isEmpty() ? dataObject : dataObject + QLatin1Char('.') + dataAttribute); + item.insert(QStringLiteral("reference"), qstring(point.reference)); + item.insert(QStringLiteral("logicalDevice"), qstring(point.logical_device)); + item.insert(QStringLiteral("logicalNode"), qstring(point.logical_node)); + item.insert(QStringLiteral("dataObject"), dataObject); + item.insert(QStringLiteral("dataAttribute"), dataAttribute); + item.insert(QStringLiteral("fc"), qstring(point.functional_constraint)); + item.insert(QStringLiteral("cdc"), qstring(point.cdc)); + item.insert(QStringLiteral("type"), qstring(point.display_type)); + item.insert(QStringLiteral("rawType"), qstring(point.basic_type)); + item.insert(QStringLiteral("iedName"), qstring(point.ied_name)); + item.insert(QStringLiteral("mmsDomain"), qstring(point.mms_domain)); + item.insert(QStringLiteral("mmsItem"), qstring(point.mms_item)); + item.insert(QStringLiteral("value"), qstring(point.initial_value)); item.insert(QStringLiteral("quality"), QStringLiteral("Good")); item.insert(QStringLiteral("origin"), QStringLiteral("Simulator")); - item.insert(QStringLiteral("writable"), !entry.is_quality && !entry.is_timestamp); + item.insert(QStringLiteral("writable"), true); item.insert(QStringLiteral("changed"), false); item.insert(QStringLiteral("updated"), QStringLiteral("—")); - if (type == QStringLiteral("Enumeration")) { + if (point.display_type == "Enumeration" && + (point.cdc == "DPC" || point.cdc == "DPS")) { item.insert( QStringLiteral("options"), QStringList{ @@ -922,7 +990,7 @@ QVariantMap IedSimulatorController::valueMap( QStringLiteral("off"), QStringLiteral("on"), QStringLiteral("bad-state")}); - } else if (type == QStringLiteral("Boolean")) { + } else if (point.display_type == "Boolean") { item.insert( QStringLiteral("options"), QStringList{QStringLiteral("false"), QStringLiteral("true")}); diff --git a/apps/ied_simulator/src/IedSimulatorController.hpp b/apps/ied_simulator/src/IedSimulatorController.hpp index 726454e7a..3d082977d 100644 --- a/apps/ied_simulator/src/IedSimulatorController.hpp +++ b/apps/ied_simulator/src/IedSimulatorController.hpp @@ -2,6 +2,7 @@ #pragma once #include "ariec61850/scl/model.hpp" +#include "ariec61850/simulation/ied_simulator_profile.hpp" #include #include @@ -131,7 +132,7 @@ class IedSimulatorController : public QObject { void removeModelManifest(); [[nodiscard]] QString serverExecutable() const; [[nodiscard]] static QVariantMap valueMap( - const ar::iec61850::scl::SclDataSetEntry& entry); + const ar::iec61850::simulation::IedSimulatorPoint& point); std::vector documents_; QVariantList ieds_; diff --git a/apps/ied_simulator/src/MmsClientController.cpp b/apps/ied_simulator/src/MmsClientController.cpp new file mode 100644 index 000000000..1984b0b5e --- /dev/null +++ b/apps/ied_simulator/src/MmsClientController.cpp @@ -0,0 +1,611 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsClientController.hpp" +#include "MmsSclClientProjection.hpp" + +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/live_model.hpp" +#include "ariec61850/mms/scl_association.hpp" +#include "ariec61850/mms/scl_sync_health.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace mms = ar::iec61850::mms; +namespace scl = ar::iec61850::scl; + +namespace { +std::span responsePayload(const mms::MmsConfirmedExchangeResult& exchange) { + return exchange.presentation_payload.empty() + ? exchange.envelope.mms_payload + : std::span{exchange.presentation_payload}; +} + +struct ReadValue final { + QString key; + QString display; +}; + +QVector readTargets( + mms::MmsAssociationRuntime& association, + const QVector& targets, + const std::stop_token stopToken) { + if (targets.isEmpty()) return {}; + const auto invokeId = association.next_invoke_id(); + mms::MmsReadRequest request; + request.invoke_id = invokeId; + request.variables.reserve(static_cast(targets.size())); + for (const auto& target : targets) { + request.variables.push_back(mms::MmsObjectName::domain_specific( + target.domain.toStdString(), target.item.toStdString())); + } + const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, invokeId, stopToken); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("MMS Read did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_read_response( + responsePayload(exchange), invokeId); + if (response.results.size() != static_cast(targets.size())) { + throw std::runtime_error("MMS Read result count does not match requested variables."); + } + QVector values; + values.reserve(targets.size()); + for (int index = 0; index < targets.size(); ++index) { + const auto& result = response.results[static_cast(index)]; + if (!result.success()) continue; + values.push_back({ + targets.at(index).key, + QString::fromStdString(mms::MmsDataCodec::to_display_string(*result.value))}); + } + if (values.isEmpty()) throw std::runtime_error("MMS Read returned no successful AccessResult."); + return values; +} + +mms::MmsDataValue parseWriteValue( + const MmsLiveTreeModel::Selection& selection, + const QString& text) { + const auto type = selection.mmsType.trimmed().toLower(); + const auto value = text.trimmed(); + if (type == QStringLiteral("boolean")) { + if (value.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0 || + value == QStringLiteral("1") || value.compare(QStringLiteral("on"), Qt::CaseInsensitive) == 0) { + return mms::MmsDataValue::boolean(true); + } + if (value.compare(QStringLiteral("false"), Qt::CaseInsensitive) == 0 || + value == QStringLiteral("0") || value.compare(QStringLiteral("off"), Qt::CaseInsensitive) == 0) { + return mms::MmsDataValue::boolean(false); + } + throw std::invalid_argument("Boolean Write requires true/false, 1/0 or on/off."); + } + if (type == QStringLiteral("integer")) { + bool ok{}; + const auto parsed = value.toLongLong(&ok, 0); + if (!ok) throw std::invalid_argument("Integer Write value is invalid."); + return mms::MmsDataValue::integer(parsed); + } + if (type == QStringLiteral("unsigned")) { + bool ok{}; + const auto parsed = value.toULongLong(&ok, 0); + if (!ok) throw std::invalid_argument("Unsigned Write value is invalid."); + return mms::MmsDataValue::unsigned_integer(parsed); + } + if (type == QStringLiteral("floating-point")) { + bool ok{}; + const auto parsed = value.toDouble(&ok); + if (!ok) throw std::invalid_argument("Floating-point Write value is invalid."); + if (selection.sclType.compare(QStringLiteral("FLOAT64"), Qt::CaseInsensitive) == 0) { + return mms::MmsDataValue::floating_point(parsed); + } + return mms::MmsDataValue::floating_point(static_cast(parsed)); + } + if (type == QStringLiteral("visible-string")) { + return mms::MmsDataValue::visible_string(text.toStdString()); + } + if (type == QStringLiteral("mms-string")) { + return mms::MmsDataValue::mms_string(text.toStdString()); + } + throw std::invalid_argument("Selected exact MMS type is not in the guarded scalar Write set."); +} + +QString snapshotKey(const arstack::iedsim::SclSnapshotValue& value) { + return QString::fromStdString(value.domain) + QLatin1Char('\x1f') + + QString::fromStdString(value.item); +} +} // namespace + +struct MmsClientController::WorkerState final { + std::unique_ptr session; + std::shared_ptr discovery; + std::shared_ptr trustedScl; + std::shared_ptr sclSnapshot; +}; + +MmsClientController::MmsClientController(QObject* parent) + : QObject(parent), treeModel_(this), workerState_(std::make_shared()), + stopSource_(std::make_shared()) { + ioPool_.setMaxThreadCount(1); + ioPool_.setExpiryTimeout(-1); +} + +MmsClientController::~MmsClientController() { + if (stopSource_) stopSource_->request_stop(); + ioPool_.clear(); + ioPool_.waitForDone(); +} + +void MmsClientController::setHost(const QString& value) { + const auto normalized = value.trimmed(); + if (host_ == normalized) return; + host_ = normalized; + emit configurationChanged(); +} + +void MmsClientController::setPort(const int value) { + if (value < 1 || value > 65'535 || port_ == value) return; + port_ = value; + emit configurationChanged(); +} + +void MmsClientController::setTrustedSclPath(const QString& value) { + const auto normalized = value.trimmed(); + if (trustedSclPath_ == normalized) return; + trustedSclPath_ = normalized; + if (trustedSclPath_.isEmpty()) { + appendDiagnostic(QStringLiteral( + "Trusted SCL source cleared; next Connect will use full live discovery.")); + } else { + appendDiagnostic(QStringLiteral("Trusted SCL source armed · %1").arg(trustedSclPath_)); + } + emit configurationChanged(); +} + +bool MmsClientController::connected() const noexcept { return state_ == State::connected; } + +bool MmsClientController::busy() const noexcept { + return state_ == State::connecting || state_ == State::discovering || operationBusy_; +} + +QString MmsClientController::stateText() const { + switch (state_) { + case State::disconnected: return QStringLiteral("Disconnected"); + case State::connecting: return QStringLiteral("Connecting"); + case State::discovering: return trustedSclAvailable() + ? QStringLiteral("Synchronizing SCL") + : QStringLiteral("Discovering"); + case State::connected: return operationBusy_ ? QStringLiteral("Working") : QStringLiteral("Connected"); + case State::faulted: return QStringLiteral("Faulted"); + } + return {}; +} + +QString MmsClientController::endpoint() const { + return host_ + QLatin1Char(':') + QString::number(port_); +} + +QString MmsClientController::lastDiagnostic() const { + return diagnostics_.isEmpty() ? QString{} : diagnostics_.constLast(); +} + +void MmsClientController::appendDiagnostic(const QString& text) { + if (text.isEmpty()) return; + constexpr auto maximumDiagnostics = 128; + while (diagnostics_.size() >= maximumDiagnostics) diagnostics_.removeFirst(); + diagnostics_.push_back(text); + emit diagnosticsChanged(); +} + +QString MmsClientController::diagnosticsText() const { return diagnostics_.join(QLatin1Char('\n')); } + +void MmsClientController::clearModelState() { + treeModel_.clear(); + iedName_.clear(); + modelSummary_.clear(); + modelSource_.clear(); + associationProfile_.clear(); + logicalDeviceCount_ = 0; + logicalNodeCount_ = 0; + dataObjectCount_ = 0; + dataAttributeCount_ = 0; + emit modelChanged(); +} + +void MmsClientController::setOperationBusy(const bool value) { + if (operationBusy_ == value) return; + operationBusy_ = value; + emit stateChanged(); +} + +std::shared_ptr MmsClientController::replaceStopSource() { + if (stopSource_) stopSource_->request_stop(); + stopSource_ = std::make_shared(); + return stopSource_; +} + +bool MmsClientController::connectToIed() { + if (busy() || connected()) return false; + if (host_.trimmed().isEmpty() || port_ < 1 || port_ > 65'535) { + lastError_ = QStringLiteral("A non-empty host and TCP port 1..65535 are required."); + state_ = State::faulted; + emit stateChanged(); + return false; + } + + const auto stop = replaceStopSource(); + const auto generation = ++generation_; + const auto requestedHost = host_.trimmed(); + const auto requestedPort = port_; + const auto requestedTrustedSclPath = trustedSclPath_.trimmed(); + state_ = State::connecting; + operationBusy_ = false; + lastError_.clear(); + clearModelState(); + appendDiagnostic(QStringLiteral("Connect %1:%2 · generation %3 · model source %4") + .arg(requestedHost) + .arg(requestedPort) + .arg(generation) + .arg(requestedTrustedSclPath.isEmpty() + ? QStringLiteral("live discovery") + : QStringLiteral("trusted SCL"))); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([ + self, worker, stop, generation, requestedHost, requestedPort, requestedTrustedSclPath] { + try { + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->discovery.reset(); + worker->trustedScl.reset(); + worker->sclSnapshot.reset(); + + std::shared_ptr trustedScl; + mms::MmsSclAssociationContext sclAssociation; + if (!requestedTrustedSclPath.isEmpty()) { + scl::SclParser parser; + trustedScl = std::make_shared( + parser.load(std::filesystem::path{requestedTrustedSclPath.toStdString()})); + sclAssociation = mms::resolve_scl_association_context( + *trustedScl, requestedHost.toStdString()); + } + + mms::MmsAssociationOptions associationOptions; + associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; + associationOptions.request_timeout = std::chrono::milliseconds{5'000}; + associationOptions.addressing = sclAssociation.addressing; + + if (self && trustedScl && sclAssociation.selected()) { + const auto associationText = sclAssociation.uses_engineering_addressing() + ? QStringLiteral("SCL engineering OSI addressing") + : QStringLiteral("compatibility OSI defaults"); + const auto endpointText = sclAssociation.scl_host.empty() + ? QStringLiteral("not specified") + : QStringLiteral("%1:%2") + .arg(QString::fromStdString(sclAssociation.scl_host)) + .arg(sclAssociation.scl_port); + const auto ied = QString::fromStdString(sclAssociation.ied_name); + const auto ap = QString::fromStdString(sclAssociation.access_point_name); + QMetaObject::invokeMethod( + self, + [self, generation, associationText, endpointText, ied, ap] { + if (!self || self->generation_ != generation) return; + self->appendDiagnostic( + QStringLiteral("Trusted SCL association · IED %1 · AP %2 · SCL endpoint %3 · %4") + .arg(ied, ap, endpointText, associationText)); + }, + Qt::QueuedConnection); + } + + auto session = std::make_unique( + mms::TcpMmsTransportOptions{}, associationOptions); + session->connect( + {requestedHost.toStdString(), static_cast(requestedPort)}, + stop->get_token()); + + if (self) { + const bool usingTrustedScl = trustedScl != nullptr; + QMetaObject::invokeMethod(self, [self, generation, usingTrustedScl] { + if (!self || self->generation_ != generation) return; + self->state_ = State::discovering; + self->appendDiagnostic(usingTrustedScl + ? QStringLiteral( + "Association accepted; validating Domains and reading trusted SCL FC roots.") + : QStringLiteral("Association accepted; discovering live model.")); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + + const auto associationProfile = + QString::fromStdString(session->association().active_association_profile()); + + if (trustedScl) { + auto snapshot = std::make_shared( + session->synchronize_scl( + *trustedScl, + sclAssociation.ied_name, + {}, + stop->get_token())); + const auto health = + mms::MmsSclSynchronizationHealthClassifier::evaluate(*snapshot); + const auto healthSummary = QString::fromStdString(health.summary()); + mms::MmsSclSynchronizationHealthClassifier::require_compatible(health); + + auto model = std::make_shared( + arstack::iedsim::build_scl_live_model(*trustedScl, *snapshot)); + auto initialValues = std::make_shared>( + arstack::iedsim::collect_scl_snapshot_values(*trustedScl, *snapshot)); + + worker->discovery.reset(); + worker->trustedScl = trustedScl; + worker->sclSnapshot = snapshot; + worker->session = std::move(session); + + if (self) { + QMetaObject::invokeMethod( + self, + [self, generation, model, snapshot, initialValues, associationProfile, healthSummary] { + if (!self || self->generation_ != generation) return; + self->treeModel_.applyDocument(*model); + for (const auto& value : *initialValues) { + self->treeModel_.applyReadValue( + snapshotKey(value), QString::fromStdString(value.display)); + } + self->iedName_ = QString::fromStdString(model->identity.ied_name); + self->modelSummary_ = QString::fromStdString(model->summary); + self->modelSource_ = QStringLiteral("Trusted SCL + initial snapshot"); + self->associationProfile_ = associationProfile; + self->logicalDeviceCount_ = + static_cast(model->coverage.logical_device_count); + self->logicalNodeCount_ = + static_cast(model->coverage.logical_node_count); + self->dataObjectCount_ = + static_cast(model->coverage.data_object_count); + self->dataAttributeCount_ = + static_cast(model->coverage.data_attribute_count); + self->state_ = State::connected; + self->lastError_.clear(); + self->appendDiagnostic(healthSummary); + self->appendDiagnostic( + QStringLiteral( + "SCL synchronization complete · %1 Domain(s) online · %2 Read(s) · " + "%3 mapped leaf value(s) · missing %4 · extra %5") + .arg(snapshot->domains.online.size()) + .arg(snapshot->read_request_count) + .arg(snapshot->mapped_leaf_count) + .arg(snapshot->domains.missing.size()) + .arg(snapshot->domains.extra.size())); + emit self->modelChanged(); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + return; + } + + mms::MmsLiveDiscoveryOptions options; + options.maximum_variable_type_probes = 8'192U; + options.maximum_data_set_directories = 4'096U; + options.maximum_report_control_probes = 1'024U; + auto discovery = std::make_shared( + session->discover(options, stop->get_token())); + auto model = std::make_shared( + mms::MmsLiveModelBuilder::build(*discovery)); + worker->discovery = discovery; + worker->trustedScl.reset(); + worker->sclSnapshot.reset(); + worker->session = std::move(session); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation, model, associationProfile] { + if (!self || self->generation_ != generation) return; + self->treeModel_.applyDocument(*model); + self->iedName_ = QString::fromStdString(model->identity.ied_name); + self->modelSummary_ = QString::fromStdString(model->summary); + self->modelSource_ = QStringLiteral("Live MMS discovery"); + self->associationProfile_ = associationProfile; + self->logicalDeviceCount_ = static_cast(model->coverage.logical_device_count); + self->logicalNodeCount_ = static_cast(model->coverage.logical_node_count); + self->dataObjectCount_ = static_cast(model->coverage.data_object_count); + self->dataAttributeCount_ = static_cast(model->coverage.data_attribute_count); + self->state_ = State::connected; + self->lastError_.clear(); + self->appendDiagnostic( + QStringLiteral("Discovery complete · %1 LD · %2 LN · %3 DA") + .arg(self->logicalDeviceCount_) + .arg(self->logicalNodeCount_) + .arg(self->dataAttributeCount_)); + emit self->modelChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->discovery.reset(); + worker->trustedScl.reset(); + worker->sclSnapshot.reset(); + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->state_ = State::faulted; + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("Connection failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +void MmsClientController::disconnectFromIed() { + const auto previous = stopSource_; + if (previous) previous->request_stop(); + stopSource_ = std::make_shared(); + ++generation_; + state_ = State::disconnected; + operationBusy_ = false; + lastError_.clear(); + clearModelState(); + appendDiagnostic(QStringLiteral("Disconnected · session generation invalidated.")); + emit stateChanged(); + + const auto worker = workerState_; + ioPool_.start([worker] { + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->discovery.reset(); + worker->trustedScl.reset(); + worker->sclSnapshot.reset(); + }); +} + +bool MmsClientController::reconnect() { + disconnectFromIed(); + return connectToIed(); +} + +void MmsClientController::startRead( + QVector targets, + QString operationName) { + if (targets.isEmpty() || !connected() || operationBusy_) return; + setOperationBusy(true); + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const QPointer self{this}; + ioPool_.start([self, worker, stop, generation, targets = std::move(targets), operationName = std::move(operationName)] { + try { + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error("MMS association is not active."); + } + const auto values = readTargets(worker->session->association(), targets, stop->get_token()); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, values, operationName] { + if (!self || self->generation_ != generation) return; + for (const auto& value : values) self->treeModel_.applyReadValue(value.key, value.display); + self->setOperationBusy(false); + self->lastError_.clear(); + self->appendDiagnostic( + QStringLiteral("%1 complete · %2 value(s)").arg(operationName).arg(values.size())); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message, operationName] { + if (!self || self->generation_ != generation) return; + self->setOperationBusy(false); + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("%1 failed · %2").arg(operationName).arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); +} + +bool MmsClientController::readSelected() { + if (!connected() || operationBusy_) return false; + const auto targets = treeModel_.selectedReadTargets(); + if (targets.isEmpty()) return false; + startRead(targets, QStringLiteral("Read selected")); + return true; +} + +bool MmsClientController::refreshVisible(const int firstRow, const int lastRow) { + if (!connected() || operationBusy_) return false; + const auto targets = treeModel_.readTargetsForVisibleRange(firstRow, lastRow, 64); + if (targets.isEmpty()) return false; + startRead(targets, QStringLiteral("Refresh visible")); + return true; +} + +bool MmsClientController::writeSelected(const QString& textValue) { + if (!connected() || operationBusy_) return false; + const auto selection = treeModel_.selectionSnapshot(); + if (!selection || !selection->writable) { + lastError_ = QStringLiteral("Selected attribute is not in the guarded writable FC/type set."); + emit stateChanged(); + return false; + } + + mms::MmsDataValue value = mms::MmsDataValue::boolean(false); + try { + value = parseWriteValue(*selection, textValue); + } catch (const std::exception& exception) { + lastError_ = QString::fromUtf8(exception.what()); + emit stateChanged(); + return false; + } + + setOperationBusy(true); + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + const auto target = MmsLiveTreeModel::ReadTarget{ + selection->key, selection->domain, selection->item}; + const auto reference = selection->reference; + const QPointer self{this}; + ioPool_.start([self, worker, stop, generation, target, reference, value = std::move(value)]() mutable { + try { + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error("MMS association is not active."); + } + auto& association = worker->session->association(); + const auto invokeId = association.next_invoke_id(); + mms::MmsWriteRequest request; + request.invoke_id = invokeId; + request.variables.push_back(mms::MmsObjectName::domain_specific( + target.domain.toStdString(), target.item.toStdString())); + 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, invokeId, stop->get_token()); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("MMS Write did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_write_response( + responsePayload(exchange), invokeId); + if (response.results.size() != 1U || !response.results.front().success) { + throw std::runtime_error("MMS Write returned a failed AccessResult."); + } + const auto values = readTargets( + association, QVector{target}, stop->get_token()); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, reference, values] { + if (!self || self->generation_ != generation) return; + for (const auto& read : values) self->treeModel_.applyReadValue(read.key, read.display); + self->setOperationBusy(false); + self->lastError_.clear(); + self->appendDiagnostic(QStringLiteral("Guarded Write verified · %1").arg(reference)); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message, reference] { + if (!self || self->generation_ != generation) return; + self->setOperationBusy(false); + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("Write failed · %1 · %2").arg(reference).arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} \ No newline at end of file diff --git a/apps/ied_simulator/src/MmsClientController.hpp b/apps/ied_simulator/src/MmsClientController.hpp new file mode 100644 index 000000000..31a55ef86 --- /dev/null +++ b/apps/ied_simulator/src/MmsClientController.hpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "MmsLiveTreeModel.hpp" + +#include +#include +#include +#include + +#include +#include + +class MmsClientController : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString host READ host WRITE setHost NOTIFY configurationChanged) + Q_PROPERTY(int port READ port WRITE setPort NOTIFY configurationChanged) + Q_PROPERTY(QString trustedSclPath READ trustedSclPath WRITE setTrustedSclPath NOTIFY configurationChanged) + Q_PROPERTY(bool trustedSclAvailable READ trustedSclAvailable NOTIFY configurationChanged) + Q_PROPERTY(QString trustedSclHealth READ trustedSclHealth NOTIFY stateChanged) + Q_PROPERTY(bool trustedSclDegraded READ trustedSclDegraded NOTIFY stateChanged) + Q_PROPERTY(bool trustedSclIncompatible READ trustedSclIncompatible NOTIFY stateChanged) + Q_PROPERTY(bool connected READ connected NOTIFY stateChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(bool operationBusy READ operationBusy NOTIFY stateChanged) + Q_PROPERTY(QString stateText READ stateText NOTIFY stateChanged) + Q_PROPERTY(QString endpoint READ endpoint NOTIFY stateChanged) + Q_PROPERTY(QString iedName READ iedName NOTIFY modelChanged) + Q_PROPERTY(QString modelSummary READ modelSummary NOTIFY modelChanged) + Q_PROPERTY(QString modelSource READ modelSource NOTIFY modelChanged) + Q_PROPERTY(QString associationProfile READ associationProfile NOTIFY modelChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY stateChanged) + Q_PROPERTY(QString lastDiagnostic READ lastDiagnostic NOTIFY diagnosticsChanged) + Q_PROPERTY(qulonglong generation READ generation NOTIFY stateChanged) + Q_PROPERTY(int logicalDeviceCount READ logicalDeviceCount NOTIFY modelChanged) + Q_PROPERTY(int logicalNodeCount READ logicalNodeCount NOTIFY modelChanged) + Q_PROPERTY(int dataObjectCount READ dataObjectCount NOTIFY modelChanged) + Q_PROPERTY(int dataAttributeCount READ dataAttributeCount NOTIFY modelChanged) + Q_PROPERTY(MmsLiveTreeModel* treeModel READ treeModel CONSTANT) + +public: + explicit MmsClientController(QObject* parent = nullptr); + ~MmsClientController() override; + + [[nodiscard]] QString host() const { return host_; } + [[nodiscard]] int port() const noexcept { return port_; } + [[nodiscard]] QString trustedSclPath() const { return trustedSclPath_; } + [[nodiscard]] bool trustedSclAvailable() const noexcept { return !trustedSclPath_.isEmpty(); } + [[nodiscard]] QString trustedSclHealth() const { + if (lastError_.contains(QStringLiteral("SCL online identity incompatible"), Qt::CaseInsensitive)) { + return QStringLiteral("incompatible"); + } + if (modelSource_ != QStringLiteral("Trusted SCL + initial snapshot")) return {}; + if (modelSummary_.contains(QStringLiteral("Trusted SCL [degraded]"), Qt::CaseInsensitive)) { + return QStringLiteral("degraded"); + } + if (modelSummary_.contains(QStringLiteral("Trusted SCL [matched]"), Qt::CaseInsensitive)) { + return QStringLiteral("matched"); + } + return {}; + } + [[nodiscard]] bool trustedSclDegraded() const { + return trustedSclHealth() == QStringLiteral("degraded"); + } + [[nodiscard]] bool trustedSclIncompatible() const { + return trustedSclHealth() == QStringLiteral("incompatible"); + } + void setHost(const QString& value); + void setPort(int value); + void setTrustedSclPath(const QString& value); + + [[nodiscard]] bool connected() const noexcept; + [[nodiscard]] bool busy() const noexcept; + [[nodiscard]] bool operationBusy() const noexcept { return operationBusy_; } + [[nodiscard]] QString stateText() const; + [[nodiscard]] QString endpoint() const; + [[nodiscard]] QString iedName() const { return iedName_; } + [[nodiscard]] QString modelSummary() const { return modelSummary_; } + [[nodiscard]] QString modelSource() const { return modelSource_; } + [[nodiscard]] QString associationProfile() const { return associationProfile_; } + [[nodiscard]] QString lastError() const { return lastError_; } + [[nodiscard]] QString lastDiagnostic() const; + [[nodiscard]] qulonglong generation() const noexcept { return generation_; } + [[nodiscard]] int logicalDeviceCount() const noexcept { return logicalDeviceCount_; } + [[nodiscard]] int logicalNodeCount() const noexcept { return logicalNodeCount_; } + [[nodiscard]] int dataObjectCount() const noexcept { return dataObjectCount_; } + [[nodiscard]] int dataAttributeCount() const noexcept { return dataAttributeCount_; } + [[nodiscard]] MmsLiveTreeModel* treeModel() noexcept { return &treeModel_; } + + Q_INVOKABLE bool connectToIed(); + Q_INVOKABLE void disconnectFromIed(); + Q_INVOKABLE bool reconnect(); + Q_INVOKABLE bool readSelected(); + Q_INVOKABLE bool refreshVisible(int firstRow, int lastRow); + Q_INVOKABLE bool writeSelected(const QString& textValue); + Q_INVOKABLE QString diagnosticsText() const; + +signals: + void configurationChanged(); + void stateChanged(); + void modelChanged(); + void diagnosticsChanged(); + +private: + enum class State : quint8 { disconnected, connecting, discovering, connected, faulted }; + struct WorkerState; + + void startRead(QVector targets, QString operationName); + void appendDiagnostic(const QString& text); + void clearModelState(); + void setOperationBusy(bool value); + [[nodiscard]] std::shared_ptr replaceStopSource(); + + QString host_{QStringLiteral("127.0.0.1")}; + int port_{102}; + QString trustedSclPath_; + State state_{State::disconnected}; + bool operationBusy_{}; + QString iedName_; + QString modelSummary_; + QString modelSource_; + QString associationProfile_; + QString lastError_; + QStringList diagnostics_; + qulonglong generation_{}; + int logicalDeviceCount_{}; + int logicalNodeCount_{}; + int dataObjectCount_{}; + int dataAttributeCount_{}; + MmsLiveTreeModel treeModel_; + QThreadPool ioPool_; + std::shared_ptr workerState_; + std::shared_ptr stopSource_; +}; diff --git a/apps/ied_simulator/src/MmsFileSettingsController.cpp b/apps/ied_simulator/src/MmsFileSettingsController.cpp new file mode 100644 index 000000000..3ac505263 --- /dev/null +++ b/apps/ied_simulator/src/MmsFileSettingsController.cpp @@ -0,0 +1,898 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsFileSettingsController.hpp" + +#include "MmsSettingGroupPolicy.hpp" +#include "ariec61850/mms/control_block_read.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/file_service.hpp" +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace mms = ar::iec61850::mms; + +namespace { +std::span responsePayload( + const mms::MmsConfirmedExchangeResult& exchange) { + return exchange.presentation_payload.empty() + ? std::span{exchange.envelope.mms_payload} + : std::span{exchange.presentation_payload}; +} + +std::string lowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char character) { + return static_cast(std::tolower(static_cast(character))); + }); + return value; +} + +bool containsFunctionalConstraint( + const std::string& combined, + const std::string& candidate) { + std::size_t begin{}; + while (begin <= combined.size()) { + const auto end = combined.find('/', begin); + const auto token = combined.substr( + begin, end == std::string::npos ? std::string::npos : end - begin); + if (lowerAscii(token) == lowerAscii(candidate)) return true; + if (end == std::string::npos) break; + begin = end + 1U; + } + return false; +} + +std::vector mergedSettingGroups( + const mms::MmsDiscoverySnapshot& snapshot) { + const auto inventory = mms::MmsControlBlockInventoryBuilder::build(snapshot); + std::vector result; + std::map indexByKey; + + for (const auto& candidate : inventory) { + if (candidate.kind != mms::MmsControlBlockKind::setting_group) continue; + const auto key = lowerAscii( + candidate.domain + "\x1f" + candidate.logical_node + "\x1f" + candidate.name); + auto found = indexByKey.find(key); + if (found == indexByKey.end()) { + mms::MmsControlBlockCandidate merged; + merged.kind = mms::MmsControlBlockKind::setting_group; + merged.domain = candidate.domain; + merged.logical_node = candidate.logical_node; + merged.functional_constraint = candidate.functional_constraint; + merged.name = candidate.name; + merged.reference = candidate.domain + "/" + candidate.logical_node + "." + candidate.name; + result.push_back(std::move(merged)); + found = indexByKey.emplace(key, result.size() - 1U).first; + } else if (!containsFunctionalConstraint( + result[found->second].functional_constraint, + candidate.functional_constraint)) { + result[found->second].functional_constraint += "/" + candidate.functional_constraint; + } + + auto& merged = result[found->second]; + for (const auto& attribute : candidate.attributes) { + const auto duplicate = std::any_of( + merged.attributes.begin(), merged.attributes.end(), [&](const auto& existing) { + return lowerAscii(existing.variable.reference()) == + lowerAscii(attribute.variable.reference()); + }); + if (!duplicate) merged.attributes.push_back(attribute); + } + } + + for (auto& candidate : result) { + std::sort(candidate.attributes.begin(), candidate.attributes.end(), [](const auto& left, const auto& right) { + return lowerAscii(left.attribute_path) < lowerAscii(right.attribute_path); + }); + } + std::sort(result.begin(), result.end(), [](const auto& left, const auto& right) { + return lowerAscii(left.reference) < lowerAscii(right.reference); + }); + return result; +} + +QString displayValue(const mms::MmsControlBlockAttributeReadEvidence* attribute) { + if (attribute == nullptr || !attribute->value) return {}; + return QString::fromStdString(mms::MmsDataCodec::to_display_string(*attribute->value)); +} + +QVariantMap settingGroupMap(const mms::MmsControlBlockReadResult& result) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(result.candidate.reference)); + map.insert(QStringLiteral("domain"), QString::fromStdString(result.candidate.domain)); + map.insert(QStringLiteral("logicalNode"), QString::fromStdString(result.candidate.logical_node)); + map.insert(QStringLiteral("name"), QString::fromStdString(result.candidate.name)); + map.insert(QStringLiteral("functionalConstraints"), + QString::fromStdString(result.candidate.functional_constraint)); + map.insert(QStringLiteral("complete"), result.complete()); + map.insert(QStringLiteral("error"), QString::fromStdString(result.error)); + + QVariantList attributes; + for (const auto& attribute : result.attributes) { + QVariantMap item; + item.insert(QStringLiteral("path"), QString::fromStdString(attribute.attribute_path)); + item.insert(QStringLiteral("reference"), QString::fromStdString(attribute.variable.reference())); + item.insert(QStringLiteral("success"), attribute.success()); + item.insert(QStringLiteral("value"), displayValue(&attribute)); + item.insert(QStringLiteral("failureCode"), + attribute.failure_code ? QVariant::fromValue(*attribute.failure_code) + : QVariant{}); + attributes.push_back(item); + } + map.insert(QStringLiteral("attributes"), attributes); + map.insert(QStringLiteral("attributeCount"), static_cast(attributes.size())); + + const auto state = MmsSettingGroupPolicy::inspect(result); + map.insert(QStringLiteral("numOfSG"), + state.numOfSG ? QVariant::fromValue(*state.numOfSG) : QVariant{}); + map.insert(QStringLiteral("actSG"), + state.activeSG ? QVariant::fromValue(*state.activeSG) : QVariant{}); + map.insert(QStringLiteral("editSG"), + state.editSG ? QVariant::fromValue(*state.editSG) : QVariant{}); + map.insert(QStringLiteral("cnfEdit"), + state.cnfEdit ? QVariant{*state.cnfEdit} : QVariant{}); + map.insert(QStringLiteral("lActTm"), + displayValue(MmsSettingGroupPolicy::findAttribute(result, "LActTm"))); + + const auto blocked = MmsSettingGroupPolicy::activationBlockReason(state); + map.insert(QStringLiteral("canActivate"), blocked.empty()); + map.insert(QStringLiteral("activationBlockedReason"), QString::fromStdString(blocked)); + map.insert(QStringLiteral("fullEditSupported"), false); + return map; +} + +QVariantMap failedSettingGroupMap( + const mms::MmsControlBlockCandidate& candidate, + const QString& error) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); + map.insert(QStringLiteral("domain"), QString::fromStdString(candidate.domain)); + map.insert(QStringLiteral("logicalNode"), QString::fromStdString(candidate.logical_node)); + map.insert(QStringLiteral("name"), QString::fromStdString(candidate.name)); + map.insert(QStringLiteral("functionalConstraints"), + QString::fromStdString(candidate.functional_constraint)); + map.insert(QStringLiteral("complete"), false); + map.insert(QStringLiteral("error"), error); + map.insert(QStringLiteral("attributes"), QVariantList{}); + map.insert(QStringLiteral("attributeCount"), 0); + map.insert(QStringLiteral("canActivate"), false); + map.insert(QStringLiteral("activationBlockedReason"), error); + map.insert(QStringLiteral("fullEditSupported"), false); + return map; +} + +QVariantList readSettingGroups( + mms::MmsAssociationRuntime& association, + const std::vector& candidates, + const std::stop_token stopToken) { + QVariantList maps; + maps.reserve(static_cast(candidates.size())); + mms::MmsControlBlockReadClient reader{association}; + for (const auto& candidate : candidates) { + if (stopToken.stop_requested()) { + throw std::runtime_error("Setting Group refresh cancelled."); + } + try { + maps.push_back(settingGroupMap(reader.read(candidate, 16U, stopToken))); + } catch (const std::exception& exception) { + maps.push_back(failedSettingGroupMap( + candidate, QString::fromUtf8(exception.what()))); + } + } + return maps; +} + +QString remoteTimestamp(const std::vector& bytes) { + if (bytes.empty()) return {}; + const auto printable = std::all_of(bytes.begin(), bytes.end(), [](const std::uint8_t value) { + return value >= 0x20U && value <= 0x7EU; + }); + if (printable) { + return QString::fromLatin1( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + } + return QString::fromLatin1( + QByteArray(reinterpret_cast(bytes.data()), + static_cast(bytes.size())).toHex(' ')); +} + +QVariantList fileEntryMaps(const mms::MmsFileDirectoryResult& result) { + QVariantList entries; + entries.reserve(static_cast(result.entries.size())); + for (const auto& entry : result.entries) { + QVariantMap map; + map.insert(QStringLiteral("name"), QString::fromStdString(entry.name)); + map.insert(QStringLiteral("path"), QString::fromStdString(entry.path)); + map.insert(QStringLiteral("directory"), entry.likely_directory()); + map.insert(QStringLiteral("sizeBytes"), + entry.size_bytes ? QVariant::fromValue(*entry.size_bytes) + : QVariant{}); + map.insert(QStringLiteral("modified"), remoteTimestamp(entry.last_modified)); + entries.push_back(map); + } + return entries; +} + +QString localPathFromQml(const QString& value) { + const QUrl url{value}; + if (url.isLocalFile()) return url.toLocalFile(); + return value; +} + +class QtFileSink final : public mms::MmsFileSink { +public: + explicit QtFileSink(QString path) : file_{std::move(path)} {} + + [[nodiscard]] bool open(std::string& error) noexcept { + if (file_.open(QIODevice::WriteOnly | QIODevice::Truncate)) return true; + error = file_.errorString().toStdString(); + return false; + } + + [[nodiscard]] bool write( + const std::span data, + std::string& error) noexcept override { + const auto written = file_.write( + reinterpret_cast(data.data()), + static_cast(data.size())); + if (written == static_cast(data.size())) return true; + error = file_.errorString().toStdString(); + return false; + } + + [[nodiscard]] bool flush(std::string& error) noexcept override { + if (file_.flush()) return true; + error = file_.errorString().toStdString(); + return false; + } + + [[nodiscard]] bool reset(std::string& error) noexcept override { + if (!file_.resize(0) || !file_.seek(0)) { + error = file_.errorString().toStdString(); + return false; + } + return true; + } + +private: + QFile file_; +}; + +class CallbackProgressSink final : public mms::MmsFileProgressSink { +public: + using Callback = std::function; + explicit CallbackProgressSink(Callback callback) : callback_{std::move(callback)} {} + void report(const mms::MmsFileTransferProgress& progress) noexcept override { + try { + callback_(progress); + } catch (...) { + } + } +private: + Callback callback_; +}; +} // namespace + +struct MmsFileSettingsController::WorkerState final { + std::unique_ptr session; + std::vector settingGroups; +}; + +MmsFileSettingsController::MmsFileSettingsController(QObject* parent) + : QObject(parent), + workerState_(std::make_shared()), + sessionStopSource_(std::make_shared()), + operationStopSource_(std::make_shared()) { + ioPool_.setMaxThreadCount(1); + ioPool_.setExpiryTimeout(-1); +} + +MmsFileSettingsController::~MmsFileSettingsController() { + if (operationStopSource_) operationStopSource_->request_stop(); + if (sessionStopSource_) sessionStopSource_->request_stop(); + ioPool_.clear(); + ioPool_.waitForDone(); +} + +void MmsFileSettingsController::setHost(const QString& value) { + const auto normalized = value.trimmed(); + if (host_ == normalized) return; + host_ = normalized; + emit configurationChanged(); +} + +void MmsFileSettingsController::setPort(const int value) { + if (value < 1 || value > 65'535 || port_ == value) return; + port_ = value; + emit configurationChanged(); +} + +bool MmsFileSettingsController::connected() const noexcept { + return state_ == State::connected; +} + +bool MmsFileSettingsController::busy() const noexcept { + return state_ == State::connecting || state_ == State::discovering || operationBusy_; +} + +QString MmsFileSettingsController::stateText() const { + switch (state_) { + case State::disconnected: return QStringLiteral("Disconnected"); + case State::connecting: return QStringLiteral("Connecting"); + case State::discovering: return QStringLiteral("Discovering SGCB"); + case State::connected: return operationBusy_ ? QStringLiteral("Working") : QStringLiteral("Connected"); + case State::faulted: return QStringLiteral("Faulted"); + } + return {}; +} + +QString MmsFileSettingsController::endpoint() const { + return host_ + QLatin1Char(':') + QString::number(port_); +} + +double MmsFileSettingsController::downloadPercent() const noexcept { + if (!downloadExpectedKnown_ || downloadExpectedBytes_ == 0U) return -1.0; + return std::min(100.0, + (static_cast(downloadBytes_) * 100.0) / + static_cast(downloadExpectedBytes_)); +} + +void MmsFileSettingsController::appendDiagnostic(const QString& text) { + if (text.isEmpty()) return; + constexpr auto maximumDiagnostics = 128; + while (diagnostics_.size() >= maximumDiagnostics) diagnostics_.removeFirst(); + diagnostics_.push_back(text); + emit diagnosticsChanged(); +} + +QString MmsFileSettingsController::diagnosticsText() const { + return diagnostics_.join(QLatin1Char('\n')); +} + +void MmsFileSettingsController::setOperationBusy(const bool value) { + if (operationBusy_ == value) return; + operationBusy_ = value; + emit stateChanged(); +} + +std::shared_ptr MmsFileSettingsController::replaceSessionStopSource() { + if (sessionStopSource_) sessionStopSource_->request_stop(); + sessionStopSource_ = std::make_shared(); + return sessionStopSource_; +} + +std::shared_ptr MmsFileSettingsController::replaceOperationStopSource() { + if (operationStopSource_) operationStopSource_->request_stop(); + operationStopSource_ = std::make_shared(); + return operationStopSource_; +} + +void MmsFileSettingsController::clearRemoteState() { + associationProfile_.clear(); + currentDirectory_.clear(); + fileEntries_.clear(); + filePageCount_ = 0; + settingGroups_.clear(); + selectedSettingGroupIndex_ = -1; + selectedSettingGroup_.clear(); + emit filesChanged(); + emit settingsChanged(); + emit selectionChanged(); +} + +void MmsFileSettingsController::refreshSelectedSettingGroup() { + if (selectedSettingGroupIndex_ < 0 || + selectedSettingGroupIndex_ >= settingGroups_.size()) { + selectedSettingGroup_.clear(); + } else { + selectedSettingGroup_ = settingGroups_.at(selectedSettingGroupIndex_).toMap(); + } + emit selectionChanged(); +} + +bool MmsFileSettingsController::connectToIed() { + if (busy() || connected()) return false; + if (host_.trimmed().isEmpty() || port_ < 1 || port_ > 65'535) { + lastError_ = QStringLiteral("A non-empty host and TCP port 1..65535 are required."); + state_ = State::faulted; + emit stateChanged(); + return false; + } + + const auto sessionStop = replaceSessionStopSource(); + replaceOperationStopSource(); + const auto generation = ++generation_; + const auto requestedHost = host_.trimmed(); + const auto requestedPort = port_; + state_ = State::connecting; + operationBusy_ = false; + lastError_.clear(); + clearRemoteState(); + appendDiagnostic(QStringLiteral("Connect utility session %1:%2 · generation %3") + .arg(requestedHost).arg(requestedPort).arg(generation)); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, sessionStop, generation, requestedHost, requestedPort] { + try { + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->settingGroups.clear(); + + mms::MmsAssociationOptions associationOptions; + associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; + associationOptions.request_timeout = std::chrono::milliseconds{5'000}; + auto session = std::make_unique( + mms::TcpMmsTransportOptions{}, associationOptions); + session->connect( + {requestedHost.toStdString(), static_cast(requestedPort)}, + sessionStop->get_token()); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->state_ = State::discovering; + self->appendDiagnostic(QStringLiteral("Association accepted; discovering Setting Groups.")); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + + mms::MmsLiveDiscoveryOptions options; + options.probe_variable_types = false; + options.read_data_set_directories = false; + options.probe_report_controls = false; + options.maximum_pages_per_query = 256U; + options.maximum_domains = 4'096U; + options.maximum_names_per_domain = 65'536U; + const auto discovery = session->discover(options, sessionStop->get_token()); + auto candidates = mergedSettingGroups(discovery.names); + if (candidates.size() > 64U) { + throw std::runtime_error("Setting Group inventory exceeds the desktop bound of 64 SGCBs."); + } + const auto settings = readSettingGroups( + session->association(), candidates, sessionStop->get_token()); + const auto associationProfile = + QString::fromStdString(session->association().active_association_profile()); + + worker->settingGroups = candidates; + worker->session = std::move(session); + + if (self) { + QMetaObject::invokeMethod(self, + [self, generation, settings, associationProfile] { + if (!self || self->generation_ != generation) return; + self->associationProfile_ = associationProfile; + self->settingGroups_ = settings; + self->selectedSettingGroupIndex_ = settings.isEmpty() ? -1 : 0; + self->refreshSelectedSettingGroup(); + self->state_ = State::connected; + self->lastError_.clear(); + self->appendDiagnostic( + QStringLiteral("Utility discovery complete · %1 SGCB(s)") + .arg(settings.size())); + emit self->settingsChanged(); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->settingGroups.clear(); + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->state_ = State::faulted; + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("Utility connection failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +void MmsFileSettingsController::disconnectFromIed() { + if (operationStopSource_) operationStopSource_->request_stop(); + if (sessionStopSource_) sessionStopSource_->request_stop(); + sessionStopSource_ = std::make_shared(); + operationStopSource_ = std::make_shared(); + ++generation_; + state_ = State::disconnected; + operationBusy_ = false; + downloadActive_ = false; + lastError_.clear(); + clearRemoteState(); + emit transferChanged(); + appendDiagnostic(QStringLiteral("Utility session disconnected · generation invalidated.")); + emit stateChanged(); + + const auto worker = workerState_; + ioPool_.start([worker] { + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->settingGroups.clear(); + }); +} + +bool MmsFileSettingsController::reconnect() { + disconnectFromIed(); + return connectToIed(); +} + +bool MmsFileSettingsController::browseDirectory(const QString& remoteDirectory) { + if (!connected() || operationBusy_) return false; + const auto operationStop = replaceOperationStopSource(); + const auto generation = generation_; + const auto requested = remoteDirectory.trimmed(); + setOperationBusy(true); + lastError_.clear(); + appendDiagnostic(QStringLiteral("FileDirectory · %1") + .arg(requested.isEmpty() ? QStringLiteral("/") : requested)); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, operationStop, generation, requested] { + try { + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error("MMS association is not active."); + } + mms::MmsAssociationFileServiceChannel channel{worker->session->association()}; + mms::MmsFileTransferRuntime runtime{channel}; + mms::MmsFileDirectoryOptions options; + options.maximum_pages = 64U; + options.maximum_entries = 4'096U; + options.maximum_diagnostics = 64U; + options.maximum_diagnostic_bytes = 512U; + const auto result = runtime.list_directory( + requested.toStdString(), options, operationStop->get_token()); + if (!result.success) { + throw std::runtime_error(result.message.empty() + ? "FileDirectory failed." + : result.message); + } + const auto entries = fileEntryMaps(result); + const auto directory = QString::fromStdString(result.directory_name); + const auto pages = static_cast(result.pages.size()); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, entries, directory, pages] { + if (!self || self->generation_ != generation) return; + self->fileEntries_ = entries; + self->currentDirectory_ = directory; + self->filePageCount_ = pages; + self->lastError_.clear(); + self->setOperationBusy(false); + self->appendDiagnostic( + QStringLiteral("FileDirectory complete · %1 entries · %2 page(s)") + .arg(entries.size()).arg(pages)); + emit self->filesChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->lastError_ = message; + self->setOperationBusy(false); + self->appendDiagnostic(QStringLiteral("FileDirectory failed · %1").arg(message)); + emit self->filesChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsFileSettingsController::downloadFile( + const QString& remotePath, + const QString& localFileUrl) { + if (!connected() || operationBusy_) return false; + const auto normalizedRemote = remotePath.trimmed(); + const auto localPath = localPathFromQml(localFileUrl).trimmed(); + if (normalizedRemote.isEmpty() || localPath.isEmpty()) { + lastError_ = QStringLiteral("Remote path and local Save As path are required."); + emit stateChanged(); + return false; + } + + const auto operationStop = replaceOperationStopSource(); + const auto generation = generation_; + setOperationBusy(true); + downloadActive_ = true; + downloadBytes_ = 0U; + downloadExpectedBytes_ = 0U; + downloadExpectedKnown_ = false; + lastDownloadRemotePath_ = normalizedRemote; + lastDownloadLocalPath_ = localPath; + lastError_.clear(); + appendDiagnostic(QStringLiteral("Download · %1").arg(normalizedRemote)); + emit transferChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, operationStop, generation, normalizedRemote, localPath] { + try { + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error("MMS association is not active."); + } + QtFileSink sink{localPath}; + std::string sinkError; + if (!sink.open(sinkError)) { + throw std::runtime_error("Could not open local output file: " + sinkError); + } + + std::uint64_t lastPublished{}; + CallbackProgressSink progress{[self, generation, &lastPublished]( + const mms::MmsFileTransferProgress& update) { + const bool publish = update.complete || update.bytes_transferred == 0U || + update.bytes_transferred >= lastPublished + 64U * 1024U; + if (!publish) return; + lastPublished = update.bytes_transferred; + if (!self) return; + const auto bytes = static_cast(update.bytes_transferred); + const auto expectedKnown = update.expected_bytes.has_value(); + const auto expected = expectedKnown + ? static_cast(*update.expected_bytes) + : 0U; + QMetaObject::invokeMethod(self, + [self, generation, bytes, expectedKnown, expected] { + if (!self || self->generation_ != generation) return; + self->downloadBytes_ = bytes; + self->downloadExpectedKnown_ = expectedKnown; + self->downloadExpectedBytes_ = expected; + emit self->transferChanged(); + }, + Qt::QueuedConnection); + }}; + + mms::MmsAssociationFileServiceChannel channel{worker->session->association()}; + mms::MmsFileTransferRuntime runtime{channel}; + mms::MmsFileTransferOptions options; + options.maximum_bytes = 512ULL * 1024ULL * 1024ULL; + options.maximum_read_operations = 100'000U; + options.maximum_block_bytes = 1U * 1024U * 1024U; + options.maximum_diagnostics = 64U; + options.maximum_diagnostic_bytes = 512U; + const auto result = runtime.download_adaptive( + normalizedRemote.toStdString(), sink, options, &progress, + operationStop->get_token()); + if (!result.success || !result.remote_file_closed) { + QFile::remove(localPath); + throw std::runtime_error(result.message.empty() + ? "File download failed or remote FileClose was not confirmed." + : result.message); + } + + if (self) { + const auto bytes = static_cast(result.bytes_transferred); + const auto expectedKnown = result.expected_bytes.has_value(); + const auto expected = expectedKnown + ? static_cast(*result.expected_bytes) + : 0U; + QMetaObject::invokeMethod(self, + [self, generation, bytes, expectedKnown, expected] { + if (!self || self->generation_ != generation) return; + self->downloadBytes_ = bytes; + self->downloadExpectedKnown_ = expectedKnown; + self->downloadExpectedBytes_ = expected; + self->downloadActive_ = false; + self->lastError_.clear(); + self->setOperationBusy(false); + self->appendDiagnostic( + QStringLiteral("Download complete · %1 bytes · FileClose confirmed") + .arg(bytes)); + emit self->transferChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + QFile::remove(localPath); + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->downloadActive_ = false; + self->lastError_ = message; + self->setOperationBusy(false); + self->appendDiagnostic(QStringLiteral("Download failed · %1").arg(message)); + emit self->transferChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsFileSettingsController::cancelOperation() { + if (!operationBusy_ || !operationStopSource_) return false; + operationStopSource_->request_stop(); + appendDiagnostic(QStringLiteral("Cancel requested for active utility operation.")); + return true; +} + +bool MmsFileSettingsController::refreshSettingGroups() { + if (!connected() || operationBusy_) return false; + const auto operationStop = replaceOperationStopSource(); + const auto generation = generation_; + setOperationBusy(true); + lastError_.clear(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, operationStop, generation] { + try { + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error("MMS association is not active."); + } + const auto settings = readSettingGroups( + worker->session->association(), worker->settingGroups, + operationStop->get_token()); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, settings] { + if (!self || self->generation_ != generation) return; + self->settingGroups_ = settings; + if (self->settingGroups_.isEmpty()) { + self->selectedSettingGroupIndex_ = -1; + } else if (self->selectedSettingGroupIndex_ < 0 || + self->selectedSettingGroupIndex_ >= self->settingGroups_.size()) { + self->selectedSettingGroupIndex_ = 0; + } + self->refreshSelectedSettingGroup(); + self->lastError_.clear(); + self->setOperationBusy(false); + self->appendDiagnostic( + QStringLiteral("Setting Groups refreshed · %1 SGCB(s)") + .arg(settings.size())); + emit self->settingsChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->lastError_ = message; + self->setOperationBusy(false); + self->appendDiagnostic(QStringLiteral("Setting Group refresh failed · %1").arg(message)); + emit self->settingsChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsFileSettingsController::selectSettingGroup(const int row) { + if (row < 0 || row >= settingGroups_.size()) return false; + if (selectedSettingGroupIndex_ == row) return true; + selectedSettingGroupIndex_ = row; + refreshSelectedSettingGroup(); + return true; +} + +bool MmsFileSettingsController::activateSelectedSettingGroup(const int group) { + if (!connected() || operationBusy_ || selectedSettingGroupIndex_ < 0 || group < 1) { + return false; + } + const auto maximum = selectedSettingGroup_.value(QStringLiteral("numOfSG")).toULongLong(); + if (maximum == 0U || static_cast(group) > maximum) { + lastError_ = QStringLiteral("Requested setting group is outside 1..NumOfSG."); + appendDiagnostic(QStringLiteral("ActSG rejected · %1").arg(lastError_)); + emit stateChanged(); + return false; + } + if (!selectedSettingGroup_.value(QStringLiteral("canActivate")).toBool()) { + lastError_ = selectedSettingGroup_.value( + QStringLiteral("activationBlockedReason")).toString(); + if (lastError_.isEmpty()) { + lastError_ = QStringLiteral("Selected SGCB is not safe for guarded activation."); + } + appendDiagnostic(QStringLiteral("ActSG rejected · %1").arg(lastError_)); + emit stateChanged(); + return false; + } + + const auto row = selectedSettingGroupIndex_; + const auto operationStop = replaceOperationStopSource(); + const auto generation = generation_; + setOperationBusy(true); + lastError_.clear(); + appendDiagnostic(QStringLiteral("Guarded ActSG request · group %1 · no retry").arg(group)); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, operationStop, generation, row, group] { + try { + if (!worker->session || !worker->session->associated()) { + throw std::runtime_error("MMS association is not active."); + } + if (row < 0 || static_cast(row) >= worker->settingGroups.size()) { + throw std::runtime_error("Selected SGCB is stale."); + } + + auto& association = worker->session->association(); + const auto& candidate = worker->settingGroups[static_cast(row)]; + mms::MmsControlBlockReadClient reader{association}; + const auto before = reader.read(candidate, 16U, operationStop->get_token()); + const auto state = MmsSettingGroupPolicy::inspect(before); + MmsSettingGroupPolicy::validateActivation( + state, static_cast(group)); + const auto* activeAttribute = + MmsSettingGroupPolicy::findAttribute(before, "ActSG"); + if (activeAttribute == nullptr || !activeAttribute->value) { + throw std::runtime_error("ActSG exact MMS variable is unavailable."); + } + + mms::MmsWriteRequest request; + request.invoke_id = association.next_invoke_id(); + request.variables.push_back(activeAttribute->variable); + request.values.push_back(MmsSettingGroupPolicy::activationValue( + state, static_cast(group))); + const auto encoded = mms::MmsServiceCodec::encode_write_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed( + encoded, request.invoke_id, operationStop->get_token()); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("ActSG Write did not return MMS Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_write_response( + responsePayload(exchange), request.invoke_id); + if (response.results.size() != 1U || !response.all_success()) { + throw std::runtime_error("ActSG Write was rejected by the IED."); + } + + const auto after = reader.read(candidate, 16U, operationStop->get_token()); + const auto verified = MmsSettingGroupPolicy::inspect(after); + if (!verified.activeSG || *verified.activeSG != static_cast(group)) { + throw std::runtime_error("ActSG verification Read did not observe the requested group."); + } + const auto updated = settingGroupMap(after); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation, row, group, updated] { + if (!self || self->generation_ != generation) return; + if (row >= 0 && row < self->settingGroups_.size()) { + self->settingGroups_[row] = updated; + } + if (self->selectedSettingGroupIndex_ == row) { + self->selectedSettingGroup_ = updated; + emit self->selectionChanged(); + } + self->lastError_.clear(); + self->setOperationBusy(false); + self->appendDiagnostic( + QStringLiteral("ActSG verified · group %1 · one Write + verification Read") + .arg(group)); + emit self->settingsChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->lastError_ = message; + self->setOperationBusy(false); + self->appendDiagnostic(QStringLiteral("ActSG failed · no retry · %1").arg(message)); + emit self->settingsChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} diff --git a/apps/ied_simulator/src/MmsFileSettingsController.hpp b/apps/ied_simulator/src/MmsFileSettingsController.hpp new file mode 100644 index 000000000..77311db38 --- /dev/null +++ b/apps/ied_simulator/src/MmsFileSettingsController.hpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +class MmsFileSettingsController : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString host READ host WRITE setHost NOTIFY configurationChanged) + Q_PROPERTY(int port READ port WRITE setPort NOTIFY configurationChanged) + Q_PROPERTY(bool connected READ connected NOTIFY stateChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(bool operationBusy READ operationBusy NOTIFY stateChanged) + Q_PROPERTY(QString stateText READ stateText NOTIFY stateChanged) + Q_PROPERTY(QString endpoint READ endpoint NOTIFY stateChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY stateChanged) + Q_PROPERTY(QString associationProfile READ associationProfile NOTIFY settingsChanged) + Q_PROPERTY(qulonglong generation READ generation NOTIFY stateChanged) + + Q_PROPERTY(QString currentDirectory READ currentDirectory NOTIFY filesChanged) + Q_PROPERTY(QVariantList fileEntries READ fileEntries NOTIFY filesChanged) + Q_PROPERTY(int fileEntryCount READ fileEntryCount NOTIFY filesChanged) + Q_PROPERTY(int filePageCount READ filePageCount NOTIFY filesChanged) + + Q_PROPERTY(bool downloadActive READ downloadActive NOTIFY transferChanged) + Q_PROPERTY(qulonglong downloadBytes READ downloadBytes NOTIFY transferChanged) + Q_PROPERTY(qulonglong downloadExpectedBytes READ downloadExpectedBytes NOTIFY transferChanged) + Q_PROPERTY(bool downloadExpectedKnown READ downloadExpectedKnown NOTIFY transferChanged) + Q_PROPERTY(double downloadPercent READ downloadPercent NOTIFY transferChanged) + Q_PROPERTY(QString lastDownloadRemotePath READ lastDownloadRemotePath NOTIFY transferChanged) + Q_PROPERTY(QString lastDownloadLocalPath READ lastDownloadLocalPath NOTIFY transferChanged) + + Q_PROPERTY(QVariantList settingGroups READ settingGroups NOTIFY settingsChanged) + Q_PROPERTY(int settingGroupCount READ settingGroupCount NOTIFY settingsChanged) + Q_PROPERTY(int selectedSettingGroupIndex READ selectedSettingGroupIndex NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedSettingGroup READ selectedSettingGroup NOTIFY selectionChanged) + +public: + explicit MmsFileSettingsController(QObject* parent = nullptr); + ~MmsFileSettingsController() override; + + [[nodiscard]] QString host() const { return host_; } + [[nodiscard]] int port() const noexcept { return port_; } + void setHost(const QString& value); + void setPort(int value); + + [[nodiscard]] bool connected() const noexcept; + [[nodiscard]] bool busy() const noexcept; + [[nodiscard]] bool operationBusy() const noexcept { return operationBusy_; } + [[nodiscard]] QString stateText() const; + [[nodiscard]] QString endpoint() const; + [[nodiscard]] QString lastError() const { return lastError_; } + [[nodiscard]] QString associationProfile() const { return associationProfile_; } + [[nodiscard]] qulonglong generation() const noexcept { return generation_; } + + [[nodiscard]] QString currentDirectory() const { return currentDirectory_; } + [[nodiscard]] QVariantList fileEntries() const { return fileEntries_; } + [[nodiscard]] int fileEntryCount() const noexcept { return fileEntries_.size(); } + [[nodiscard]] int filePageCount() const noexcept { return filePageCount_; } + + [[nodiscard]] bool downloadActive() const noexcept { return downloadActive_; } + [[nodiscard]] qulonglong downloadBytes() const noexcept { return downloadBytes_; } + [[nodiscard]] qulonglong downloadExpectedBytes() const noexcept { return downloadExpectedBytes_; } + [[nodiscard]] bool downloadExpectedKnown() const noexcept { return downloadExpectedKnown_; } + [[nodiscard]] double downloadPercent() const noexcept; + [[nodiscard]] QString lastDownloadRemotePath() const { return lastDownloadRemotePath_; } + [[nodiscard]] QString lastDownloadLocalPath() const { return lastDownloadLocalPath_; } + + [[nodiscard]] QVariantList settingGroups() const { return settingGroups_; } + [[nodiscard]] int settingGroupCount() const noexcept { return settingGroups_.size(); } + [[nodiscard]] int selectedSettingGroupIndex() const noexcept { return selectedSettingGroupIndex_; } + [[nodiscard]] QVariantMap selectedSettingGroup() const { return selectedSettingGroup_; } + + Q_INVOKABLE bool connectToIed(); + Q_INVOKABLE void disconnectFromIed(); + Q_INVOKABLE bool reconnect(); + Q_INVOKABLE bool browseDirectory(const QString& remoteDirectory = QString{}); + Q_INVOKABLE bool downloadFile(const QString& remotePath, const QString& localFileUrl); + Q_INVOKABLE bool cancelOperation(); + Q_INVOKABLE bool refreshSettingGroups(); + Q_INVOKABLE bool selectSettingGroup(int row); + Q_INVOKABLE bool activateSelectedSettingGroup(int group); + Q_INVOKABLE QString diagnosticsText() const; + +signals: + void configurationChanged(); + void stateChanged(); + void filesChanged(); + void transferChanged(); + void settingsChanged(); + void selectionChanged(); + void diagnosticsChanged(); + +private: + enum class State : quint8 { disconnected, connecting, discovering, connected, faulted }; + struct WorkerState; + + void appendDiagnostic(const QString& text); + void clearRemoteState(); + void refreshSelectedSettingGroup(); + void setOperationBusy(bool value); + [[nodiscard]] std::shared_ptr replaceSessionStopSource(); + std::shared_ptr replaceOperationStopSource(); + + QString host_{QStringLiteral("127.0.0.1")}; + int port_{102}; + State state_{State::disconnected}; + bool operationBusy_{}; + QString lastError_; + QString associationProfile_; + QStringList diagnostics_; + qulonglong generation_{}; + + QString currentDirectory_; + QVariantList fileEntries_; + int filePageCount_{}; + + bool downloadActive_{}; + qulonglong downloadBytes_{}; + qulonglong downloadExpectedBytes_{}; + bool downloadExpectedKnown_{}; + QString lastDownloadRemotePath_; + QString lastDownloadLocalPath_; + + QVariantList settingGroups_; + int selectedSettingGroupIndex_{-1}; + QVariantMap selectedSettingGroup_; + + QThreadPool ioPool_; + std::shared_ptr workerState_; + std::shared_ptr sessionStopSource_; + std::shared_ptr operationStopSource_; +}; diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp new file mode 100644 index 000000000..f1c1bbaf2 --- /dev/null +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsLiveTreeModel.hpp" + +#include + +#include + +namespace { +QString q(const std::string& value) { return QString::fromStdString(value); } + +QString leafName(const QString& path) { + const auto dot = path.lastIndexOf(QLatin1Char('.')); + const auto dollar = path.lastIndexOf(QLatin1Char('$')); + const auto split = std::max(dot, dollar); + return split >= 0 ? path.mid(split + 1) : path; +} +} // namespace + +MmsLiveTreeModel::MmsLiveTreeModel(QObject* parent) : QAbstractListModel(parent) {} + +int MmsLiveTreeModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : visible_.size(); +} + +QVariant MmsLiveTreeModel::data(const QModelIndex& index, const int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= visible_.size()) return {}; + const auto nodeIndex = visible_.at(index.row()); + const auto& node = nodes_.at(nodeIndex); + switch (role) { + case LabelRole: return node.label; + case KindRole: return kindName(node.kind); + case ReferenceRole: return node.reference; + case DepthRole: return node.depth; + case HasChildrenRole: return !node.children.isEmpty(); + case ExpandedRole: return node.expanded; + case SelectedRole: return nodeIndex == selectedNode_; + case FunctionalConstraintRole: return node.functionalConstraint; + case MmsTypeRole: return node.mmsType; + case SclTypeRole: return node.sclType; + case TypeStatusRole: return node.typeStatus; + case ValueRole: return node.value; + case WritableRole: return node.writable; + case MmsDomainRole: return node.domain; + case MmsItemRole: return node.item; + default: return {}; + } +} + +QHash MmsLiveTreeModel::roleNames() const { + return { + {LabelRole, "label"}, {KindRole, "kind"}, {ReferenceRole, "reference"}, + {DepthRole, "depth"}, {HasChildrenRole, "hasChildren"}, {ExpandedRole, "expanded"}, + {SelectedRole, "selected"}, {FunctionalConstraintRole, "functionalConstraint"}, + {MmsTypeRole, "mmsType"}, {SclTypeRole, "sclType"}, {TypeStatusRole, "typeStatus"}, + {ValueRole, "value"}, {WritableRole, "writable"}, {MmsDomainRole, "mmsDomain"}, + {MmsItemRole, "mmsItem"}, + }; +} + +QString MmsLiveTreeModel::kindName(const NodeKind kind) { + switch (kind) { + case NodeKind::ied: return QStringLiteral("IED"); + case NodeKind::logicalDevice: return QStringLiteral("LD"); + case NodeKind::logicalNode: return QStringLiteral("LN"); + case NodeKind::dataObject: return QStringLiteral("DO"); + case NodeKind::dataAttribute: return QStringLiteral("DA"); + } + return {}; +} + +QString MmsLiveTreeModel::nodeKey(const QString& domain, const QString& item) { + return domain + QLatin1Char('\x1f') + item; +} + +bool MmsLiveTreeModel::isWritableAttribute( + const QString& functionalConstraint, + const QString& typeStatus, + const QString& mmsType) { + static const QSet writableFc{ + QStringLiteral("SP"), QStringLiteral("CF"), QStringLiteral("DC"), QStringLiteral("SE")}; + static const QSet scalarTypes{ + QStringLiteral("boolean"), QStringLiteral("integer"), QStringLiteral("unsigned"), + QStringLiteral("floating-point"), QStringLiteral("visible-string"), QStringLiteral("mms-string")}; + return typeStatus == QStringLiteral("Exact") && + writableFc.contains(functionalConstraint.toUpper()) && + scalarTypes.contains(mmsType.toLower()); +} + +int MmsLiveTreeModel::appendNode(Node node) { + const auto index = nodes_.size(); + if (node.parent >= 0 && node.parent < nodes_.size()) nodes_[node.parent].children.push_back(index); + if (!node.domain.isEmpty() && !node.item.isEmpty()) mmsIndex_.insert(nodeKey(node.domain, node.item), index); + nodes_.push_back(std::move(node)); + return index; +} + +void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocument& document) { + beginResetModel(); + nodes_.clear(); + visible_.clear(); + mmsIndex_.clear(); + selectedNode_ = -1; + + Node root; + root.kind = NodeKind::ied; + root.depth = 0; + root.expanded = true; + root.label = q(document.identity.ied_name.empty() ? document.endpoint.host : document.identity.ied_name); + root.reference = root.label; + const auto rootIndex = appendNode(std::move(root)); + + for (const auto& logicalDevice : document.logical_devices) { + Node ld; + ld.kind = NodeKind::logicalDevice; + ld.parent = rootIndex; + ld.depth = 1; + ld.expanded = true; + ld.label = q(logicalDevice.instance.empty() ? logicalDevice.mms_domain : logicalDevice.instance); + ld.reference = q(logicalDevice.mms_domain); + ld.domain = q(logicalDevice.mms_domain); + const auto ldIndex = appendNode(std::move(ld)); + + for (const auto& logicalNode : logicalDevice.logical_nodes) { + Node ln; + ln.kind = NodeKind::logicalNode; + ln.parent = ldIndex; + ln.depth = 2; + ln.expanded = false; + ln.label = q(logicalNode.name); + ln.reference = q(logicalDevice.mms_domain + "/" + logicalNode.name); + ln.domain = q(logicalDevice.mms_domain); + const auto lnIndex = appendNode(std::move(ln)); + + for (const auto& dataObject : logicalNode.data_objects) { + Node object; + object.kind = NodeKind::dataObject; + object.parent = lnIndex; + object.depth = 3; + object.expanded = false; + object.label = q(dataObject.name); + object.reference = q(dataObject.reference); + object.domain = q(logicalDevice.mms_domain); + const auto objectIndex = appendNode(std::move(object)); + + for (const auto& attribute : dataObject.attributes) { + Node da; + da.kind = NodeKind::dataAttribute; + da.parent = objectIndex; + da.depth = 4; + da.label = q(attribute.attribute_path.empty() + ? attribute.mms_item_name + : attribute.attribute_path); + da.label = leafName(da.label); + da.reference = q(attribute.mms_reference.empty() + ? attribute.object_reference + : attribute.mms_reference); + da.domain = q(logicalDevice.mms_domain); + da.item = q(attribute.mms_item_name); + da.attributePath = q(attribute.attribute_path); + da.functionalConstraint = q(attribute.functional_constraint); + da.mmsType = q(attribute.mms_type); + da.sclType = q(attribute.scl_basic_type); + da.typeStatus = q(attribute.type_discovery_status); + da.writable = isWritableAttribute( + da.functionalConstraint, da.typeStatus, da.mmsType); + (void)appendNode(std::move(da)); + } + } + } + } + + endResetModel(); + rebuildVisible(); + emit countsChanged(); + emit selectionChanged(); +} + +void MmsLiveTreeModel::clear() { + beginResetModel(); + nodes_.clear(); + visible_.clear(); + mmsIndex_.clear(); + selectedNode_ = -1; + endResetModel(); + emit countsChanged(); + emit selectionChanged(); +} + +bool MmsLiveTreeModel::matchesFilter(const Node& node, const QString& needle) const { + if (needle.isEmpty()) return true; + const auto contains = [&needle](const QString& value) { + return value.contains(needle, Qt::CaseInsensitive); + }; + return contains(node.label) || contains(node.reference) || contains(node.domain) || + contains(node.item) || contains(node.functionalConstraint) || contains(node.mmsType) || + contains(node.sclType) || contains(node.value); +} + +void MmsLiveTreeModel::appendVisibleSubtree(const int nodeIndex, const QVector* included) { + if (nodeIndex < 0 || nodeIndex >= nodes_.size()) return; + if (included != nullptr && !included->at(nodeIndex)) return; + visible_.push_back(nodeIndex); + const auto& node = nodes_.at(nodeIndex); + const bool traverseChildren = included != nullptr || node.expanded; + if (!traverseChildren) return; + for (const auto child : node.children) appendVisibleSubtree(child, included); +} + +void MmsLiveTreeModel::rebuildVisible() { + beginResetModel(); + visible_.clear(); + if (!nodes_.isEmpty()) { + if (filterText_.trimmed().isEmpty()) { + appendVisibleSubtree(0, nullptr); + } else { + QVector included(nodes_.size(), false); + const auto needle = filterText_.trimmed(); + for (int index = 0; index < nodes_.size(); ++index) { + if (!matchesFilter(nodes_.at(index), needle)) continue; + for (int cursor = index; cursor >= 0 && !included.at(cursor); cursor = nodes_.at(cursor).parent) { + included[cursor] = true; + } + } + appendVisibleSubtree(0, &included); + } + } + endResetModel(); + emit countsChanged(); + emit selectionChanged(); +} + +void MmsLiveTreeModel::setFilterText(const QString& value) { + if (filterText_ == value) return; + filterText_ = value; + emit filterTextChanged(); + rebuildVisible(); +} + +int MmsLiveTreeModel::visibleRowForNode(const int nodeIndex) const noexcept { + for (int row = 0; row < visible_.size(); ++row) { + if (visible_.at(row) == nodeIndex) return row; + } + return -1; +} + +int MmsLiveTreeModel::selectedRow() const noexcept { return visibleRowForNode(selectedNode_); } + +QString MmsLiveTreeModel::siblingValue(const int nodeIndex, const QString& wantedLeaf) const { + if (nodeIndex < 0 || nodeIndex >= nodes_.size()) return {}; + const auto& selected = nodes_.at(nodeIndex); + if (selected.kind != NodeKind::dataAttribute) return {}; + if (leafName(selected.attributePath).compare(wantedLeaf, Qt::CaseInsensitive) == 0 || + selected.label.compare(wantedLeaf, Qt::CaseInsensitive) == 0) { + return selected.value; + } + if (selected.parent < 0) return {}; + for (const auto siblingIndex : nodes_.at(selected.parent).children) { + const auto& sibling = nodes_.at(siblingIndex); + if (leafName(sibling.attributePath).compare(wantedLeaf, Qt::CaseInsensitive) == 0 || + sibling.label.compare(wantedLeaf, Qt::CaseInsensitive) == 0) { + return sibling.value; + } + } + return {}; +} + +QVariantMap MmsLiveTreeModel::selectedNode() const { + QVariantMap result; + if (selectedNode_ < 0 || selectedNode_ >= nodes_.size()) return result; + const auto& node = nodes_.at(selectedNode_); + result.insert(QStringLiteral("kind"), kindName(node.kind)); + result.insert(QStringLiteral("label"), node.label); + result.insert(QStringLiteral("reference"), node.reference); + result.insert(QStringLiteral("mmsDomain"), node.domain); + result.insert(QStringLiteral("mmsItem"), node.item); + result.insert(QStringLiteral("functionalConstraint"), node.functionalConstraint); + result.insert(QStringLiteral("mmsType"), node.mmsType); + result.insert(QStringLiteral("sclType"), node.sclType); + result.insert(QStringLiteral("typeStatus"), node.typeStatus); + result.insert(QStringLiteral("value"), node.value); + result.insert(QStringLiteral("quality"), siblingValue(selectedNode_, QStringLiteral("q"))); + result.insert(QStringLiteral("timestamp"), siblingValue(selectedNode_, QStringLiteral("t"))); + result.insert(QStringLiteral("writable"), node.writable); + result.insert(QStringLiteral("readable"), node.kind == NodeKind::dataAttribute && !node.item.isEmpty()); + return result; +} + +void MmsLiveTreeModel::selectRow(const int row) { + if (row < 0 || row >= visible_.size()) return; + const auto next = visible_.at(row); + if (next == selectedNode_) return; + const auto previousRow = selectedRow(); + selectedNode_ = next; + const auto currentRow = selectedRow(); + if (previousRow >= 0) emit dataChanged(index(previousRow), index(previousRow), {SelectedRole}); + if (currentRow >= 0) emit dataChanged(index(currentRow), index(currentRow), {SelectedRole}); + emit selectionChanged(); +} + +void MmsLiveTreeModel::toggle(const int row) { + if (row < 0 || row >= visible_.size()) return; + const auto nodeIndex = visible_.at(row); + auto& node = nodes_[nodeIndex]; + if (node.children.isEmpty() || !filterText_.trimmed().isEmpty()) return; + node.expanded = !node.expanded; + rebuildVisible(); +} + +bool MmsLiveTreeModel::selectMmsItem(const QString& domain, const QString& item) { + const auto found = mmsIndex_.constFind(nodeKey(domain, item)); + if (found == mmsIndex_.cend()) return false; + selectedNode_ = found.value(); + for (int cursor = nodes_.at(selectedNode_).parent; cursor >= 0; cursor = nodes_.at(cursor).parent) { + nodes_[cursor].expanded = true; + } + rebuildVisible(); + emit selectionChanged(); + return true; +} + +std::optional MmsLiveTreeModel::selectionSnapshot() const { + if (selectedNode_ < 0 || selectedNode_ >= nodes_.size()) return std::nullopt; + const auto& node = nodes_.at(selectedNode_); + if (node.kind != NodeKind::dataAttribute || node.domain.isEmpty() || node.item.isEmpty()) { + return std::nullopt; + } + Selection result; + result.key = nodeKey(node.domain, node.item); + result.domain = node.domain; + result.item = node.item; + result.reference = node.reference; + result.functionalConstraint = node.functionalConstraint; + result.mmsType = node.mmsType; + result.sclType = node.sclType; + result.writable = node.writable; + return result; +} + +QVector MmsLiveTreeModel::readTargetsForNode(const int nodeIndex) const { + QVector result; + if (nodeIndex < 0 || nodeIndex >= nodes_.size()) return result; + const auto& selected = nodes_.at(nodeIndex); + if (selected.kind != NodeKind::dataAttribute || selected.domain.isEmpty() || selected.item.isEmpty()) return result; + + const auto append = [&result](const Node& node) { + const auto key = nodeKey(node.domain, node.item); + if (std::none_of(result.cbegin(), result.cend(), [&key](const ReadTarget& target) { return target.key == key; })) { + result.push_back({key, node.domain, node.item}); + } + }; + append(selected); + if (selected.parent >= 0) { + for (const auto siblingIndex : nodes_.at(selected.parent).children) { + const auto& sibling = nodes_.at(siblingIndex); + const auto name = leafName(sibling.attributePath).isEmpty() ? sibling.label : leafName(sibling.attributePath); + if (name.compare(QStringLiteral("q"), Qt::CaseInsensitive) == 0 || + name.compare(QStringLiteral("t"), Qt::CaseInsensitive) == 0) { + append(sibling); + } + } + } + return result; +} + +QVector MmsLiveTreeModel::selectedReadTargets() const { + return readTargetsForNode(selectedNode_); +} + +QVector MmsLiveTreeModel::readTargetsForVisibleRange( + int firstRow, int lastRow, const int maximumTargets) const { + QVector result; + if (visible_.isEmpty() || maximumTargets <= 0) return result; + const int lastVisibleRow = static_cast(visible_.size() - 1); + firstRow = std::clamp(firstRow, 0, lastVisibleRow); + lastRow = std::clamp(lastRow, firstRow, lastVisibleRow); + QSet seen; + for (int row = firstRow; row <= lastRow && result.size() < maximumTargets; ++row) { + const auto& node = nodes_.at(visible_.at(row)); + if (node.kind != NodeKind::dataAttribute || node.domain.isEmpty() || node.item.isEmpty()) continue; + const auto key = nodeKey(node.domain, node.item); + if (seen.contains(key)) continue; + seen.insert(key); + result.push_back({key, node.domain, node.item}); + } + return result; +} + +void MmsLiveTreeModel::applyReadValue(const QString& key, const QString& displayValue) { + const auto found = mmsIndex_.constFind(key); + if (found == mmsIndex_.cend()) return; + const auto nodeIndex = found.value(); + auto& node = nodes_[nodeIndex]; + if (node.value == displayValue) return; + node.value = displayValue; + const auto row = visibleRowForNode(nodeIndex); + if (row >= 0) emit dataChanged(index(row), index(row), {ValueRole}); + if (!filterText_.trimmed().isEmpty()) rebuildVisible(); + if (nodeIndex == selectedNode_ || (selectedNode_ >= 0 && node.parent == nodes_.at(selectedNode_).parent)) { + emit selectionChanged(); + } +} diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.hpp b/apps/ied_simulator/src/MmsLiveTreeModel.hpp new file mode 100644 index 000000000..f6e0dc436 --- /dev/null +++ b/apps/ied_simulator/src/MmsLiveTreeModel.hpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_model.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +class MmsLiveTreeModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) + Q_PROPERTY(int selectedRow READ selectedRow WRITE selectRow NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedNode READ selectedNode NOTIFY selectionChanged) + Q_PROPERTY(int totalNodeCount READ totalNodeCount NOTIFY countsChanged) + Q_PROPERTY(int visibleNodeCount READ visibleNodeCount NOTIFY countsChanged) + +public: + enum Role { + LabelRole = Qt::UserRole + 1, + KindRole, + ReferenceRole, + DepthRole, + HasChildrenRole, + ExpandedRole, + SelectedRole, + FunctionalConstraintRole, + MmsTypeRole, + SclTypeRole, + TypeStatusRole, + ValueRole, + WritableRole, + MmsDomainRole, + MmsItemRole, + }; + Q_ENUM(Role) + + struct ReadTarget final { + QString key; + QString domain; + QString item; + }; + + struct Selection final { + QString key; + QString domain; + QString item; + QString reference; + QString functionalConstraint; + QString mmsType; + QString sclType; + bool writable{}; + }; + + explicit MmsLiveTreeModel(QObject* parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex& parent = {}) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] QString filterText() const { return filterText_; } + void setFilterText(const QString& value); + + [[nodiscard]] int selectedRow() const noexcept; + [[nodiscard]] QVariantMap selectedNode() const; + [[nodiscard]] int totalNodeCount() const noexcept { return nodes_.size(); } + [[nodiscard]] int visibleNodeCount() const noexcept { return visible_.size(); } + + Q_INVOKABLE void selectRow(int row); + Q_INVOKABLE void toggle(int row); + Q_INVOKABLE bool selectMmsItem(const QString& domain, const QString& item); + Q_INVOKABLE void clear(); + + void applyDocument(const ar::iec61850::mms::MmsLiveModelDocument& document); + [[nodiscard]] std::optional selectionSnapshot() const; + [[nodiscard]] QVector selectedReadTargets() const; + [[nodiscard]] QVector readTargetsForVisibleRange( + int firstRow, + int lastRow, + int maximumTargets = 64) const; + void applyReadValue(const QString& key, const QString& displayValue); + +signals: + void filterTextChanged(); + void selectionChanged(); + void countsChanged(); + +private: + enum class NodeKind : quint8 { ied, logicalDevice, logicalNode, dataObject, dataAttribute }; + + struct Node final { + NodeKind kind{NodeKind::ied}; + int parent{-1}; + QVector children; + int depth{}; + bool expanded{}; + QString label; + QString reference; + QString domain; + QString item; + QString attributePath; + QString functionalConstraint; + QString mmsType; + QString sclType; + QString typeStatus; + QString value; + bool writable{}; + }; + + [[nodiscard]] static QString kindName(NodeKind kind); + [[nodiscard]] static QString nodeKey(const QString& domain, const QString& item); + [[nodiscard]] static bool isWritableAttribute( + const QString& functionalConstraint, + const QString& typeStatus, + const QString& mmsType); + [[nodiscard]] bool matchesFilter(const Node& node, const QString& needle) const; + void rebuildVisible(); + void appendVisibleSubtree(int nodeIndex, const QVector* included); + [[nodiscard]] int visibleRowForNode(int nodeIndex) const noexcept; + [[nodiscard]] QString siblingValue(int nodeIndex, const QString& leafName) const; + [[nodiscard]] QVector readTargetsForNode(int nodeIndex) const; + [[nodiscard]] int appendNode(Node node); + + QVector nodes_; + QVector visible_; + QHash mmsIndex_; + QString filterText_; + int selectedNode_{-1}; +}; diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp new file mode 100644 index 000000000..59118849b --- /dev/null +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -0,0 +1,799 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsReportController.hpp" + +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/rcb_selection.hpp" +#include "ariec61850/mms/static_report_session.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace mms = ar::iec61850::mms; + +namespace { +constexpr int kMaximumVisibleReports = 128; + +QString boolText(const std::optional value) { + if (!value.has_value()) return QStringLiteral("—"); + return *value ? QStringLiteral("true") : QStringLiteral("false"); +} + +QString numberText(const std::optional value) { + return value.has_value() ? QString::number(*value) : QStringLiteral("—"); +} + +QString hexBytes(const std::span bytes) { + if (bytes.empty()) return {}; + const QByteArray raw{ + reinterpret_cast(bytes.data()), + static_cast(bytes.size())}; + return QString::fromLatin1(raw.toHex(' ')).toUpper(); +} + +QString availabilityText(const mms::MmsRcbAvailability value) { + switch (value) { + case mms::MmsRcbAvailability::unknown: return QStringLiteral("unknown"); + case mms::MmsRcbAvailability::available: return QStringLiteral("available"); + case mms::MmsRcbAvailability::in_use: return QStringLiteral("in-use"); + case mms::MmsRcbAvailability::used_by_caller: return QStringLiteral("caller-owned"); + case mms::MmsRcbAvailability::no_data_set: return QStringLiteral("no-dataset"); + case mms::MmsRcbAvailability::data_set_unreadable: return QStringLiteral("dataset-unreadable"); + case mms::MmsRcbAvailability::data_set_empty: return QStringLiteral("dataset-empty"); + } + return QStringLiteral("unknown"); +} + +bool sameDataSetReference(const QString& left, const QString& right) { + if (left == right) return true; + try { + return mms::MmsDataSetDirectoryCodec::parse_data_set_reference(left.toStdString()) == + mms::MmsDataSetDirectoryCodec::parse_data_set_reference(right.toStdString()); + } catch (...) { + return false; + } +} + +QVariantMap candidateMap(const mms::MmsRcbCandidateEvaluation& candidate) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); + map.insert(QStringLiteral("mode"), QString::fromStdString(candidate.mode)); + map.insert(QStringLiteral("dataSet"), QString::fromStdString(candidate.data_set_reference)); + map.insert(QStringLiteral("reportId"), QString::fromStdString(candidate.report_id)); + map.insert(QStringLiteral("score"), candidate.score); + map.insert(QStringLiteral("availability"), QString::fromUtf8(mms::mms_rcb_availability_kind_name(candidate.availability).data())); + map.insert(QStringLiteral("decision"), QString::fromUtf8(mms::mms_rcb_selection_decision_name(candidate.decision).data())); + map.insert(QStringLiteral("reason"), QString::fromStdString(candidate.reason)); + map.insert(QStringLiteral("recommendedAction"), QString::fromStdString(candidate.recommended_action)); + map.insert(QStringLiteral("preferred"), candidate.preferred); + map.insert(QStringLiteral("sameDataSet"), candidate.same_data_set); + map.insert(QStringLiteral("sameLogicalDevice"), candidate.same_logical_device); + map.insert(QStringLiteral("selectable"), candidate.selectable()); + return map; +} + +struct DiscoveryUi final { + QVariantList dataSets; + QVariantList reportControls; + QVariantList staticCandidates; + QVariantList dynamicCandidates; + QString associationProfile; +}; + +DiscoveryUi buildDiscoveryUi( + const mms::MmsLiveDiscoveryResult& discovery, + const QString& associationProfile) { + DiscoveryUi ui; + ui.associationProfile = associationProfile; + + for (const auto& candidate : discovery.report_inventory.data_sets) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); + map.insert(QStringLiteral("domain"), QString::fromStdString(candidate.domain)); + map.insert(QStringLiteral("logicalNode"), QString::fromStdString(candidate.logical_node)); + map.insert(QStringLiteral("name"), QString::fromStdString(candidate.name)); + QStringList members; + bool directoryAvailable = false; + bool deletable = false; + for (const auto& evidence : discovery.data_set_directories) { + if (evidence.candidate.reference != candidate.reference || !evidence.directory) continue; + directoryAvailable = true; + deletable = evidence.directory->deletable; + for (const auto& member : evidence.directory->members) { + QString text = QString::fromStdString(member.user_reference); + if (!member.functional_constraint.empty()) { + text += QStringLiteral(" [") + QString::fromStdString(member.functional_constraint) + QLatin1Char(']'); + } + members.push_back(text); + } + break; + } + map.insert(QStringLiteral("directoryAvailable"), directoryAvailable); + map.insert(QStringLiteral("deletable"), deletable); + map.insert(QStringLiteral("memberCount"), members.size()); + map.insert(QStringLiteral("members"), members); + ui.dataSets.push_back(map); + } + + for (const auto& candidate : discovery.report_inventory.report_controls) { + QVariantMap map; + map.insert(QStringLiteral("reference"), QString::fromStdString(candidate.reference)); + map.insert(QStringLiteral("mode"), QString::fromStdString(candidate.mode())); + map.insert(QStringLiteral("buffered"), candidate.buffered); + map.insert(QStringLiteral("domain"), QString::fromStdString(candidate.domain)); + map.insert(QStringLiteral("logicalNode"), QString::fromStdString(candidate.logical_node)); + map.insert(QStringLiteral("name"), QString::fromStdString(candidate.name)); + QStringList attributes; + for (const auto& item : candidate.attributes) attributes.push_back(QString::fromStdString(item)); + map.insert(QStringLiteral("attributes"), attributes); + map.insert(QStringLiteral("supportsEntryId"), attributes.contains(QStringLiteral("EntryID"))); + map.insert(QStringLiteral("supportsPurgeBuf"), attributes.contains(QStringLiteral("PurgeBuf"))); + + for (const auto& evidence : discovery.report_controls) { + if (evidence.candidate.reference != candidate.reference) continue; + map.insert(QStringLiteral("probeOk"), evidence.success()); + map.insert(QStringLiteral("probeError"), QString::fromStdString(evidence.error)); + if (evidence.state) { + const auto& state = *evidence.state; + map.insert(QStringLiteral("dataSet"), QString::fromStdString(state.data_set_reference)); + map.insert(QStringLiteral("reportId"), QString::fromStdString(state.report_id)); + map.insert(QStringLiteral("confRev"), numberText(state.configuration_revision)); + map.insert(QStringLiteral("bufTm"), numberText(state.buffer_time_ms)); + map.insert(QStringLiteral("intgPd"), numberText(state.integrity_period_ms)); + map.insert(QStringLiteral("sqNum"), numberText(state.sequence_number)); + map.insert(QStringLiteral("enabled"), boolText(state.report_enabled)); + map.insert(QStringLiteral("reserved"), boolText(state.reserved)); + map.insert(QStringLiteral("resvTms"), numberText(state.reservation_time_seconds)); + map.insert(QStringLiteral("owner"), hexBytes(state.owner)); + map.insert(QStringLiteral("entryId"), hexBytes(state.entry_id)); + map.insert(QStringLiteral("triggerOptions"), QString::fromStdString( + [&state] { + std::string result; + for (const auto& name : state.trigger_options.names) { + if (!result.empty()) result += ", "; + result += name; + } + return result; + }())); + map.insert(QStringLiteral("optionalFields"), QString::fromStdString( + [&state] { + std::string result; + for (const auto& name : state.optional_fields.names) { + if (!result.empty()) result += ", "; + result += name; + } + return result; + }())); + map.insert(QStringLiteral("availability"), availabilityText(state.availability)); + map.insert(QStringLiteral("availabilityReason"), QString::fromStdString(state.availability_reason)); + } + break; + } + if (!map.contains(QStringLiteral("probeOk"))) map.insert(QStringLiteral("probeOk"), false); + ui.reportControls.push_back(map); + } + + try { + const auto staticSelection = mms::MmsRcbPoolSelector::build_static_selection(discovery); + for (const auto& candidate : staticSelection.candidates) ui.staticCandidates.push_back(candidateMap(candidate)); + } catch (...) { + } + try { + const auto dynamicSelection = mms::MmsRcbPoolSelector::build_dynamic_selection(discovery); + for (const auto& candidate : dynamicSelection.candidates) ui.dynamicCandidates.push_back(candidateMap(candidate)); + } catch (...) { + } + return ui; +} + +struct ReportUi final { + QVariantList reports; + QStringList events; + qulonglong receivedCount{}; + bool active{}; + bool cleanupRequired{}; +}; + +QString reportValueText(const mms::MmsReportValue& item) { + QString value = QStringLiteral(""); + if (item.value) value = QString::fromStdString(mms::MmsDataCodec::to_display_string(*item.value)); + QString reference = QString::fromStdString(item.data_reference); + if (reference.isEmpty() && item.member) reference = QString::fromStdString(item.member->user_reference); + if (reference.isEmpty()) reference = QStringLiteral("member[%1]").arg(static_cast(item.data_set_index)); + QString reasons; + for (const auto& reason : item.reason_for_inclusion.names) { + if (!reasons.isEmpty()) reasons += QStringLiteral(", "); + reasons += QString::fromStdString(reason); + } + return QStringLiteral("%1 = %2%3") + .arg(reference, value, reasons.isEmpty() ? QString{} : QStringLiteral(" · ") + reasons); +} + +ReportUi buildReportUi(const mms::MmsStaticReportSessionSnapshot& snapshot) { + ReportUi ui; + ui.active = snapshot.active; + if (!snapshot.subscription) return ui; + const auto& subscription = *snapshot.subscription; + ui.receivedCount = subscription.received_reports; + ui.cleanupRequired = subscription.cleanup_required; + for (const auto& event : subscription.events) { + ui.events.push_back(QString::fromStdString(event.message)); + } + while (ui.events.size() > 256) ui.events.removeFirst(); + + for (auto streamIt = subscription.streams.rbegin(); streamIt != subscription.streams.rend(); ++streamIt) { + for (auto frameIt = streamIt->recent_frames.rbegin(); frameIt != streamIt->recent_frames.rend(); ++frameIt) { + if (ui.reports.size() >= kMaximumVisibleReports) break; + const auto& frame = *frameIt; + QVariantMap map; + map.insert(QStringLiteral("reportId"), QString::fromStdString(frame.header.report_id)); + map.insert(QStringLiteral("dataSet"), QString::fromStdString(frame.header.data_set_reference)); + map.insert(QStringLiteral("sequence"), numberText(frame.header.sequence_number)); + map.insert(QStringLiteral("subSequence"), numberText(frame.header.sub_sequence_number)); + map.insert(QStringLiteral("confRev"), numberText(frame.header.configuration_revision)); + map.insert(QStringLiteral("entryId"), hexBytes(frame.header.entry_id)); + map.insert(QStringLiteral("overflow"), frame.header.buffer_overflow.value_or(false)); + map.insert(QStringLiteral("decoderMode"), QString::fromStdString(frame.decoder_mode)); + map.insert(QStringLiteral("valueCount"), static_cast(frame.values.size())); + QStringList values; + for (const auto& value : frame.values) values.push_back(reportValueText(value)); + map.insert(QStringLiteral("values"), values); + map.insert(QStringLiteral("valuesSummary"), values.mid(0, 3).join(QStringLiteral(" | "))); + map.insert(QStringLiteral("duplicateCount"), static_cast(streamIt->state.duplicate_count)); + map.insert(QStringLiteral("gapCount"), static_cast(streamIt->state.gap_count)); + map.insert(QStringLiteral("resetCount"), static_cast(streamIt->state.reset_count)); + map.insert(QStringLiteral("overflowCount"), static_cast(streamIt->state.buffer_overflow_count)); + ui.reports.push_back(map); + } + if (ui.reports.size() >= kMaximumVisibleReports) break; + } + return ui; +} +} // namespace + +struct MmsReportController::WorkerState final { + std::unique_ptr session; + std::shared_ptr discovery; + std::unique_ptr report; +}; + +MmsReportController::MmsReportController(QObject* parent) + : QObject(parent), workerState_(std::make_shared()), + stopSource_(std::make_shared()) { + ioPool_.setMaxThreadCount(1); + ioPool_.setExpiryTimeout(-1); + pollTimer_.setInterval(100); + pollTimer_.setTimerType(Qt::PreciseTimer); + connect(&pollTimer_, &QTimer::timeout, this, &MmsReportController::schedulePoll); +} + +MmsReportController::~MmsReportController() { + if (stopSource_) stopSource_->request_stop(); + pollTimer_.stop(); + ioPool_.clear(); + ioPool_.waitForDone(); +} + +void MmsReportController::setHost(const QString& value) { + const auto normalized = value.trimmed(); + if (host_ == normalized) return; + host_ = normalized; + emit configurationChanged(); +} + +void MmsReportController::setPort(const int value) { + if (value < 1 || value > 65'535 || port_ == value) return; + port_ = value; + emit configurationChanged(); +} + +bool MmsReportController::connected() const noexcept { + return state_ == State::ready || state_ == State::enabling || state_ == State::active || + state_ == State::disabling || state_ == State::cleanup_required; +} + +bool MmsReportController::busy() const noexcept { + return operationBusy_ || state_ == State::connecting || state_ == State::discovering || + state_ == State::enabling || state_ == State::disabling; +} + +QString MmsReportController::stateText() const { + switch (state_) { + case State::disconnected: return QStringLiteral("Disconnected"); + case State::connecting: return QStringLiteral("Connecting"); + case State::discovering: return QStringLiteral("Discovering reports"); + case State::ready: return QStringLiteral("Ready"); + case State::enabling: return QStringLiteral("Enabling RCB"); + case State::active: return QStringLiteral("Report subscription active"); + case State::disabling: return QStringLiteral("Releasing RCB"); + case State::cleanup_required: return QStringLiteral("Cleanup required"); + case State::faulted: return QStringLiteral("Faulted"); + } + return {}; +} + +void MmsReportController::appendDiagnostic(const QString& text) { + if (text.isEmpty()) return; + while (diagnostics_.size() >= 128) diagnostics_.removeFirst(); + diagnostics_.push_back(text); + emit diagnosticsChanged(); +} + +QString MmsReportController::diagnosticsText() const { + return diagnostics_.join(QLatin1Char('\n')); +} + +std::shared_ptr MmsReportController::replaceStopSource() { + if (stopSource_) stopSource_->request_stop(); + stopSource_ = std::make_shared(); + return stopSource_; +} + +void MmsReportController::clearInventory() { + dataSets_.clear(); + reportControls_.clear(); + staticCandidates_.clear(); + dynamicCandidates_.clear(); + associationProfile_.clear(); + selectedRcbIndex_ = -1; + selectedDataSetIndex_ = -1; + selectedRcb_.clear(); + selectedDataSetMembers_.clear(); + receivedReports_.clear(); + events_.clear(); + receivedReportCount_ = 0; + emit inventoryChanged(); + emit selectionChanged(); + emit reportsChanged(); +} + +bool MmsReportController::connectToIed() { + if (busy() || connected()) return false; + if (host_.trimmed().isEmpty() || port_ < 1 || port_ > 65'535) { + lastError_ = QStringLiteral("A non-empty host and TCP port 1..65535 are required."); + state_ = State::faulted; + emit stateChanged(); + return false; + } + + const auto stop = replaceStopSource(); + const auto generation = ++generation_; + const auto requestedHost = host_.trimmed(); + const auto requestedPort = port_; + state_ = State::connecting; + operationBusy_ = true; + active_ = false; + cleanupRequired_ = false; + pollPending_ = false; + lastError_.clear(); + clearInventory(); + appendDiagnostic(QStringLiteral("Reports connect %1:%2 · generation %3") + .arg(requestedHost).arg(requestedPort).arg(generation)); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation, requestedHost, requestedPort] { + try { + if (worker->report) worker->report->stop(); + worker->report.reset(); + if (worker->session) worker->session->disconnect(); + worker->session.reset(); + worker->discovery.reset(); + + mms::MmsAssociationOptions associationOptions; + associationOptions.connect_timeout = std::chrono::milliseconds{5'000}; + associationOptions.request_timeout = std::chrono::milliseconds{5'000}; + auto session = std::make_unique( + mms::TcpMmsTransportOptions{}, associationOptions); + session->connect( + {requestedHost.toStdString(), static_cast(requestedPort)}, + stop->get_token()); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->state_ = State::discovering; + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + + mms::MmsLiveDiscoveryOptions options; + options.maximum_variable_type_probes = 8'192U; + options.maximum_data_set_directories = 4'096U; + options.maximum_report_control_probes = 1'024U; + auto discovery = std::make_shared( + session->discover(options, stop->get_token())); + const auto profile = QString::fromStdString(session->association().active_association_profile()); + const auto ui = buildDiscoveryUi(*discovery, profile); + worker->discovery = discovery; + worker->session = std::move(session); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->dataSets_ = ui.dataSets; + self->reportControls_ = ui.reportControls; + self->staticCandidates_ = ui.staticCandidates; + self->dynamicCandidates_ = ui.dynamicCandidates; + self->associationProfile_ = ui.associationProfile; + self->state_ = State::ready; + self->operationBusy_ = false; + self->lastError_.clear(); + if (!self->reportControls_.isEmpty()) self->selectedRcbIndex_ = 0; + else if (!self->dataSets_.isEmpty()) self->selectedDataSetIndex_ = 0; + self->refreshSelection(); + self->appendDiagnostic(QStringLiteral("Report discovery complete · %1 DataSet · %2 RCB") + .arg(self->dataSets_.size()).arg(self->reportControls_.size())); + emit self->inventoryChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (worker->session) worker->session->disconnect(); + worker->report.reset(); + worker->session.reset(); + worker->discovery.reset(); + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->state_ = State::faulted; + self->operationBusy_ = false; + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("Report discovery failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +void MmsReportController::disconnectFromIed() { + if (stopSource_) stopSource_->request_stop(); + stopSource_ = std::make_shared(); + const auto cleanupStop = stopSource_; + const auto generation = ++generation_; + pollTimer_.stop(); + pollPending_ = false; + operationBusy_ = false; + active_ = false; + cleanupRequired_ = false; + state_ = State::disconnected; + lastError_.clear(); + clearInventory(); + appendDiagnostic(QStringLiteral("Reports disconnected · session generation invalidated.")); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, cleanupStop, generation] { + bool cleanupRequired = false; + if (worker->report) { + worker->report->stop(cleanupStop->get_token()); + const auto snapshot = worker->report->snapshot(); + cleanupRequired = snapshot.subscription && snapshot.subscription->cleanup_required; + } + if (worker->session) worker->session->disconnect(cleanupStop->get_token()); + worker->report.reset(); + worker->discovery.reset(); + worker->session.reset(); + if (cleanupRequired && self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->lastError_ = QStringLiteral("RCB cleanup could not be confirmed before disconnect; verify the remote RCB before reuse."); + self->appendDiagnostic(self->lastError_); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + }); +} + +bool MmsReportController::reconnect() { + disconnectFromIed(); + return connectToIed(); +} + +void MmsReportController::refreshSelection() { + selectedRcb_.clear(); + selectedDataSetMembers_.clear(); + if (selectedRcbIndex_ >= 0 && selectedRcbIndex_ < reportControls_.size()) { + selectedRcb_ = reportControls_.at(selectedRcbIndex_).toMap(); + const auto bound = selectedRcb_.value(QStringLiteral("dataSet")).toString(); + if (!bound.isEmpty()) { + for (int row = 0; row < dataSets_.size(); ++row) { + const auto map = dataSets_.at(row).toMap(); + if (!sameDataSetReference(bound, map.value(QStringLiteral("reference")).toString())) continue; + selectedDataSetIndex_ = row; + selectedDataSetMembers_ = map.value(QStringLiteral("members")).toStringList(); + break; + } + } + } + if (selectedDataSetMembers_.isEmpty() && selectedDataSetIndex_ >= 0 && selectedDataSetIndex_ < dataSets_.size()) { + selectedDataSetMembers_ = dataSets_.at(selectedDataSetIndex_).toMap().value(QStringLiteral("members")).toStringList(); + } + emit selectionChanged(); +} + +bool MmsReportController::selectRcb(const int row) { + if (row < 0 || row >= reportControls_.size()) return false; + selectedRcbIndex_ = row; + refreshSelection(); + return true; +} + +bool MmsReportController::selectDataSet(const int row) { + if (row < 0 || row >= dataSets_.size()) return false; + selectedDataSetIndex_ = row; + selectedDataSetMembers_ = dataSets_.at(row).toMap().value(QStringLiteral("members")).toStringList(); + emit selectionChanged(); + return true; +} + +bool MmsReportController::enableSelected(const bool requestGeneralInterrogation) { + if (!connected() || busy() || active_ || cleanupRequired_ || selectedRcb_.isEmpty()) return false; + const auto reference = selectedRcb_.value(QStringLiteral("reference")).toString(); + if (reference.isEmpty()) return false; + + const auto generation = generation_; + const auto stop = stopSource_; + operationBusy_ = true; + state_ = State::enabling; + lastError_.clear(); + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation, reference, requestGeneralInterrogation] { + try { + if (!worker->session || !worker->session->associated() || !worker->discovery) { + throw std::runtime_error("MMS report association is not active."); + } + if (worker->report) { + const auto previous = worker->report->snapshot(); + if (previous.active || (previous.subscription && previous.subscription->cleanup_required)) { + throw std::runtime_error("Previous report subscription still owns state or requires cleanup."); + } + worker->report.reset(); + } + + mms::MmsStaticReportSessionOptions options; + options.selection.preferred_rcb_reference = reference.toStdString(); + options.selection.strict_rcb = true; + // Strict selection still pins the exact operator-selected RCB. This + // flag permits an explicitly selected URCB; it does not authorize + // fallback to another RCB because strict_rcb remains true. + options.selection.allow_urcb_fallback = true; + options.selection.allow_polling_fallback = false; + options.maximum_candidate_attempts = 1U; + options.subscription.trigger_general_interrogation = requestGeneralInterrogation; + options.subscription.reserve_unbuffered_rcb = true; + options.subscription.maximum_events = 256U; + options.subscription.monitor_options.maximum_streams = 64U; + options.subscription.monitor_options.maximum_frames_per_stream = 64U; + + auto report = std::make_unique( + worker->session->association(), *worker->discovery, options); + report->prepare(stop->get_token()); + report->start(stop->get_token()); + const auto ui = buildReportUi(report->snapshot()); + worker->report = std::move(report); + + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui, requestGeneralInterrogation] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = ui.active; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::active; + self->appendDiagnostic(requestGeneralInterrogation + ? QStringLiteral("RCB enabled; GI requested explicitly.") + : QStringLiteral("RCB enabled without GI.")); + emit self->reportsChanged(); + emit self->stateChanged(); + if (self->active_) self->pollTimer_.start(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = false; + self->lastError_ = message; + self->state_ = State::faulted; + self->appendDiagnostic(QStringLiteral("RCB enable failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsReportController::disableSelected() { + if (!active_ || operationBusy_) return false; + pollTimer_.stop(); + const auto stop = replaceStopSource(); + const auto generation = generation_; + operationBusy_ = true; + state_ = State::disabling; + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation] { + try { + if (!worker->report) throw std::runtime_error("No report subscription is active."); + worker->report->stop(stop->get_token()); + const auto snapshot = worker->report->snapshot(); + const auto ui = buildReportUi(snapshot); + if (!ui.cleanupRequired) worker->report.reset(); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + self->operationBusy_ = false; + self->active_ = false; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; + self->appendDiagnostic(ui.cleanupRequired + ? QStringLiteral("RCB release incomplete; explicit cleanup retry required.") + : QStringLiteral("RCB disabled and reservation/ownership cleanup completed.")); + emit self->reportsChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + self->operationBusy_ = false; + self->active_ = false; + self->cleanupRequired_ = true; + self->state_ = State::cleanup_required; + self->lastError_ = message; + self->appendDiagnostic(QStringLiteral("RCB release failed · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +bool MmsReportController::retryCleanup() { + if (!cleanupRequired_ || operationBusy_) return false; + const auto stop = replaceStopSource(); + const auto generation = generation_; + operationBusy_ = true; + state_ = State::disabling; + emit stateChanged(); + + const QPointer self{this}; + const auto worker = workerState_; + ioPool_.start([self, worker, stop, generation] { + try { + if (!worker->session || !worker->session->associated() || !worker->report) { + throw std::runtime_error("Cleanup retry requires the original active MMS association."); + } + worker->report->stop(stop->get_token()); + const auto ui = buildReportUi(worker->report->snapshot()); + if (!ui.cleanupRequired) worker->report.reset(); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->active_ = false; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::ready; + self->lastError_.clear(); + self->appendDiagnostic(ui.cleanupRequired + ? QStringLiteral("RCB cleanup retry remains incomplete.") + : QStringLiteral("RCB cleanup retry completed.")); + emit self->reportsChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->operationBusy_ = false; + self->lastError_ = message; + self->state_ = State::cleanup_required; + self->appendDiagnostic(QStringLiteral("Cleanup retry rejected · %1").arg(message)); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} + +void MmsReportController::schedulePoll() { + if (!active_ || operationBusy_ || pollPending_) return; + const auto generation = generation_; + const auto stop = stopSource_; + const auto worker = workerState_; + pollPending_ = true; + const QPointer self{this}; + ioPool_.start([self, worker, stop, generation] { + try { + if (!worker->session || !worker->session->associated() || !worker->report || !worker->report->active()) { + throw std::runtime_error("Report poll requires an active MMS association and RCB subscription."); + } + mms::MmsPduEnvelope envelope; + static_cast(worker->session->association().try_poll_once_for( + std::chrono::milliseconds{40}, envelope, stop->get_token())); + static_cast(worker->report->drain_queued_reports()); + const auto ui = buildReportUi(worker->report->snapshot()); + if (self) { + QMetaObject::invokeMethod(self, [self, generation, ui] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + self->active_ = ui.active; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + emit self->reportsChanged(); + }, Qt::QueuedConnection); + } + } catch (const mms::MmsTransportCancelledError&) { + if (self) { + QMetaObject::invokeMethod(self, [self, generation] { + if (!self || self->generation_ != generation) return; + self->pollPending_ = false; + }, Qt::QueuedConnection); + } + } catch (const std::exception& exception) { + ReportUi ui; + if (worker->report) { + worker->report->stop(); + ui = buildReportUi(worker->report->snapshot()); + } + if (self) { + const auto message = QString::fromUtf8(exception.what()); + QMetaObject::invokeMethod(self, [self, generation, message, ui] { + if (!self || self->generation_ != generation) return; + self->pollTimer_.stop(); + self->pollPending_ = false; + self->active_ = false; + self->cleanupRequired_ = ui.cleanupRequired; + self->receivedReports_ = ui.reports; + self->events_ = ui.events; + self->receivedReportCount_ = ui.receivedCount; + self->lastError_ = message; + self->state_ = ui.cleanupRequired ? State::cleanup_required : State::faulted; + self->appendDiagnostic(QStringLiteral("Report association/poll failed · %1").arg(message)); + emit self->reportsChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); +} diff --git a/apps/ied_simulator/src/MmsReportController.hpp b/apps/ied_simulator/src/MmsReportController.hpp new file mode 100644 index 000000000..5d4017844 --- /dev/null +++ b/apps/ied_simulator/src/MmsReportController.hpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +class MmsReportController : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString host READ host WRITE setHost NOTIFY configurationChanged) + Q_PROPERTY(int port READ port WRITE setPort NOTIFY configurationChanged) + Q_PROPERTY(bool connected READ connected NOTIFY stateChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(bool active READ active NOTIFY stateChanged) + Q_PROPERTY(bool cleanupRequired READ cleanupRequired NOTIFY stateChanged) + Q_PROPERTY(QString stateText READ stateText NOTIFY stateChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY stateChanged) + Q_PROPERTY(QString associationProfile READ associationProfile NOTIFY inventoryChanged) + Q_PROPERTY(QVariantList dataSets READ dataSets NOTIFY inventoryChanged) + Q_PROPERTY(QVariantList reportControls READ reportControls NOTIFY inventoryChanged) + Q_PROPERTY(QVariantList staticCandidates READ staticCandidates NOTIFY inventoryChanged) + Q_PROPERTY(QVariantList dynamicCandidates READ dynamicCandidates NOTIFY inventoryChanged) + Q_PROPERTY(int selectedRcbIndex READ selectedRcbIndex NOTIFY selectionChanged) + Q_PROPERTY(int selectedDataSetIndex READ selectedDataSetIndex NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedRcb READ selectedRcb NOTIFY selectionChanged) + Q_PROPERTY(QStringList selectedDataSetMembers READ selectedDataSetMembers NOTIFY selectionChanged) + Q_PROPERTY(QVariantList receivedReports READ receivedReports NOTIFY reportsChanged) + Q_PROPERTY(QStringList events READ events NOTIFY reportsChanged) + Q_PROPERTY(qulonglong receivedReportCount READ receivedReportCount NOTIFY reportsChanged) + Q_PROPERTY(qulonglong generation READ generation NOTIFY stateChanged) + +public: + explicit MmsReportController(QObject* parent = nullptr); + ~MmsReportController() override; + + [[nodiscard]] QString host() const { return host_; } + [[nodiscard]] int port() const noexcept { return port_; } + void setHost(const QString& value); + void setPort(int value); + + [[nodiscard]] bool connected() const noexcept; + [[nodiscard]] bool busy() const noexcept; + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] bool cleanupRequired() const noexcept { return cleanupRequired_; } + [[nodiscard]] QString stateText() const; + [[nodiscard]] QString lastError() const { return lastError_; } + [[nodiscard]] QString associationProfile() const { return associationProfile_; } + [[nodiscard]] QVariantList dataSets() const { return dataSets_; } + [[nodiscard]] QVariantList reportControls() const { return reportControls_; } + [[nodiscard]] QVariantList staticCandidates() const { return staticCandidates_; } + [[nodiscard]] QVariantList dynamicCandidates() const { return dynamicCandidates_; } + [[nodiscard]] int selectedRcbIndex() const noexcept { return selectedRcbIndex_; } + [[nodiscard]] int selectedDataSetIndex() const noexcept { return selectedDataSetIndex_; } + [[nodiscard]] QVariantMap selectedRcb() const { return selectedRcb_; } + [[nodiscard]] QStringList selectedDataSetMembers() const { return selectedDataSetMembers_; } + [[nodiscard]] QVariantList receivedReports() const { return receivedReports_; } + [[nodiscard]] QStringList events() const { return events_; } + [[nodiscard]] qulonglong receivedReportCount() const noexcept { return receivedReportCount_; } + [[nodiscard]] qulonglong generation() const noexcept { return generation_; } + + Q_INVOKABLE bool connectToIed(); + Q_INVOKABLE void disconnectFromIed(); + Q_INVOKABLE bool reconnect(); + Q_INVOKABLE bool selectRcb(int row); + Q_INVOKABLE bool selectDataSet(int row); + Q_INVOKABLE bool enableSelected(bool requestGeneralInterrogation = true); + Q_INVOKABLE bool disableSelected(); + Q_INVOKABLE bool retryCleanup(); + Q_INVOKABLE QString diagnosticsText() const; + +signals: + void configurationChanged(); + void stateChanged(); + void inventoryChanged(); + void selectionChanged(); + void reportsChanged(); + void diagnosticsChanged(); + +private: + enum class State : quint8 { + disconnected, + connecting, + discovering, + ready, + enabling, + active, + disabling, + cleanup_required, + faulted + }; + struct WorkerState; + + void appendDiagnostic(const QString& text); + void clearInventory(); + void refreshSelection(); + void schedulePoll(); + [[nodiscard]] std::shared_ptr replaceStopSource(); + + QString host_{QStringLiteral("127.0.0.1")}; + int port_{102}; + State state_{State::disconnected}; + bool operationBusy_{}; + bool pollPending_{}; + bool active_{}; + bool cleanupRequired_{}; + QString lastError_; + QString associationProfile_; + QVariantList dataSets_; + QVariantList reportControls_; + QVariantList staticCandidates_; + QVariantList dynamicCandidates_; + int selectedRcbIndex_{-1}; + int selectedDataSetIndex_{-1}; + QVariantMap selectedRcb_; + QStringList selectedDataSetMembers_; + QVariantList receivedReports_; + QStringList events_; + QStringList diagnostics_; + qulonglong receivedReportCount_{}; + qulonglong generation_{}; + QThreadPool ioPool_; + QTimer pollTimer_; + std::shared_ptr workerState_; + std::shared_ptr stopSource_; +}; diff --git a/apps/ied_simulator/src/MmsSclClientProjection.hpp b/apps/ied_simulator/src/MmsSclClientProjection.hpp new file mode 100644 index 000000000..b65087c47 --- /dev/null +++ b/apps/ied_simulator/src/MmsSclClientProjection.hpp @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_model.hpp" +#include "ariec61850/mms/scl_assisted_connect.hpp" +#include "ariec61850/mms/scl_sync_health.hpp" +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace arstack::iedsim { + +struct SclSnapshotValue final { + std::string domain; + std::string item; + std::string display; +}; + +namespace detail { + +[[nodiscard]] inline std::string upper(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { + return static_cast(std::toupper(ch)); + }); + return value; +} + +[[nodiscard]] inline std::string mms_path(std::string value) { + std::replace(value.begin(), value.end(), '.', '$'); + return value; +} + +[[nodiscard]] inline std::string logical_node_name( + const ar::iec61850::scl::SclLogicalNode& logical_node) { + if (!logical_node.name.empty()) return logical_node.name; + return logical_node.prefix + logical_node.ln_class + logical_node.ln_inst; +} + +[[nodiscard]] inline std::string logical_node_name( + const ar::iec61850::scl::SclDataSetEntry& entry) { + return entry.prefix + entry.ln_class + entry.ln_inst; +} + +[[nodiscard]] inline std::string mms_item( + const ar::iec61850::scl::SclDataSetEntry& entry) { + auto item = logical_node_name(entry); + if (!entry.functional_constraint.empty()) item += "$" + entry.functional_constraint; + if (!entry.do_name.empty()) item += "$" + mms_path(entry.do_name); + if (!entry.da_name.empty()) item += "$" + mms_path(entry.da_name); + return item; +} + +[[nodiscard]] inline std::string mms_type_for_scl_basic_type( + const std::string_view basic_type) { + const auto type = upper(std::string{basic_type}); + if (type == "BOOLEAN") return "boolean"; + if (type.starts_with("INT") && type.ends_with("U")) return "unsigned"; + if (type.starts_with("INT") || type == "ENUM" || type == "ENUMERATED") return "integer"; + if (type.starts_with("FLOAT")) return "floating-point"; + if (type.starts_with("VISSTRING") || type == "CURRENCY") return "visible-string"; + if (type.starts_with("UNICODE") || type.starts_with("MMSSTRING")) return "mms-string"; + if (type == "QUALITY" || type == "CHECK" || type == "OPTFLDS" || type == "TRGOPS") { + return "bit-string"; + } + if (type == "TIMESTAMP" || type == "ENTRYTIME") return "utc-time"; + if (type.starts_with("OCTET") || type == "PHYCOMADDR") return "octet-string"; + return "unknown"; +} + +[[nodiscard]] inline std::string data_set_ln_name( + const ar::iec61850::scl::SclDataSet& data_set) { + if (!data_set.logical_node_path.empty()) return data_set.logical_node_path; + if (!data_set.entries.empty()) return logical_node_name(data_set.entries.front()); + return "LLN0"; +} + +} // namespace detail + +[[nodiscard]] inline ar::iec61850::mms::MmsLiveModelDocument build_scl_live_model( + const ar::iec61850::scl::SclDocument& document, + const ar::iec61850::mms::MmsSclAssistedConnectResult& snapshot) { + namespace mms = ar::iec61850::mms; + + const auto health = mms::MmsSclSynchronizationHealthClassifier::evaluate(snapshot); + mms::MmsSclSynchronizationHealthClassifier::require_compatible(health); + + mms::MmsLiveModelDocument model; + model.source = "TrustedSclInitialSnapshot"; + model.endpoint = snapshot.endpoint; + model.identity.ied_name = snapshot.ied_name; + model.identity.source = "TrustedSCL"; + model.identity.confidence = health.matched() + ? mms::MmsLiveModelConfidence::exact + : mms::MmsLiveModelConfidence::high; + model.identity.candidate_names = {snapshot.ied_name}; + model.identity.evidence.push_back(health.summary()); + model.identity.evidence.push_back( + health.matched() + ? "Trusted SCL identity matched the online MMS Domain and FC-root evidence." + : "Trusted SCL identity retained with explicit degraded online evidence; unknown structure was not merged."); + + std::map> device_index; + std::map, std::less<>> node_index; + + const auto ensure_device = [&](const std::string& ld_inst) -> std::size_t { + const auto domain = snapshot.ied_name + ld_inst; + if (const auto found = device_index.find(domain); found != device_index.end()) { + return found->second; + } + mms::MmsLiveLogicalDevice device; + device.mms_domain = domain; + device.instance = ld_inst; + const auto index = model.logical_devices.size(); + model.logical_devices.push_back(std::move(device)); + device_index.emplace(domain, index); + model.identity.logical_device_aliases.emplace(domain, ld_inst); + return index; + }; + + const auto ensure_node = [&](const std::string& ld_inst, + const std::string& name, + const std::string& prefix, + const std::string& ln_class, + const std::string& instance) + -> std::pair { + const auto device = ensure_device(ld_inst); + const auto domain = snapshot.ied_name + ld_inst; + const auto key = domain + "\x1f" + name; + if (const auto found = node_index.find(key); found != node_index.end()) { + return found->second; + } + mms::MmsLiveLogicalNode node; + node.name = name; + node.prefix = prefix; + node.logical_node_class = ln_class; + node.instance = instance; + const auto node_pos = model.logical_devices[device].logical_nodes.size(); + model.logical_devices[device].logical_nodes.push_back(std::move(node)); + const auto result = std::pair{device, node_pos}; + node_index.emplace(key, result); + return result; + }; + + for (const auto& logical_node : document.logical_nodes) { + if (logical_node.ied_name != snapshot.ied_name) continue; + static_cast(ensure_node( + logical_node.ld_inst, + detail::logical_node_name(logical_node), + logical_node.prefix, + logical_node.ln_class, + logical_node.ln_inst)); + } + + std::map> object_index; + for (const auto& entry : document.model_entries) { + if (entry.ied_name != snapshot.ied_name || entry.functional_constraint.empty()) continue; + const auto ln_name = detail::logical_node_name(entry); + const auto [device_pos, node_pos] = ensure_node( + entry.ld_inst, ln_name, entry.prefix, entry.ln_class, entry.ln_inst); + auto& logical_node = model.logical_devices[device_pos].logical_nodes[node_pos]; + ++logical_node.functional_constraint_counts[entry.functional_constraint]; + + const auto domain = snapshot.ied_name + entry.ld_inst; + const auto object_key = domain + "\x1f" + ln_name + "\x1f" + entry.do_name; + std::size_t data_object_pos{}; + if (const auto found = object_index.find(object_key); found != object_index.end()) { + data_object_pos = found->second; + } else { + mms::MmsLiveDataObject object; + object.name = entry.do_name; + object.reference = domain + "/" + ln_name + "." + entry.do_name; + object.inferred_cdc = entry.cdc; + object.cdc_confidence = entry.cdc.empty() ? 0.0 : 1.0; + object.confidence = entry.cdc.empty() + ? mms::MmsLiveModelConfidence::high + : mms::MmsLiveModelConfidence::exact; + object.evidence.push_back("Trusted SCL DataTypeTemplates projection."); + data_object_pos = logical_node.data_objects.size(); + logical_node.data_objects.push_back(std::move(object)); + object_index.emplace(object_key, data_object_pos); + } + + mms::MmsLiveDataAttribute attribute; + attribute.object_reference = entry.signal_reference; + attribute.attribute_path = entry.da_name; + attribute.functional_constraint = entry.functional_constraint; + attribute.mms_item_name = detail::mms_item(entry); + attribute.mms_reference = domain + "/" + attribute.mms_item_name; + attribute.source = "TrustedSCL"; + attribute.scl_basic_type = entry.basic_type; + attribute.mms_type = detail::mms_type_for_scl_basic_type(entry.basic_type); + attribute.type_discovery_status = "Exact"; + attribute.type_discovery_message = "Type supplied by trusted SCL; no GVAA probe required."; + attribute.type_source = "TrustedSCL"; + attribute.type_confidence = mms::MmsLiveModelConfidence::exact; + attribute.functional_constraint_confidence = mms::MmsLiveModelConfidence::exact; + logical_node.data_objects[data_object_pos].attributes.push_back(std::move(attribute)); + } + + for (const auto& data_set : document.data_sets) { + if (data_set.ied_name != snapshot.ied_name) continue; + mms::MmsLiveDataSet projected; + projected.reference = data_set.reference; + projected.domain = snapshot.ied_name + data_set.ld_inst; + projected.logical_node = detail::data_set_ln_name(data_set); + projected.name = data_set.name; + for (std::size_t index = 0U; index < data_set.entries.size(); ++index) { + const auto& entry = data_set.entries[index]; + mms::MmsLiveDataSetMember member; + member.index = index; + member.reference = entry.signal_reference; + member.functional_constraint = entry.functional_constraint; + member.mms_reference = (entry.ied_name + entry.ld_inst) + "/" + detail::mms_item(entry); + projected.members.push_back(std::move(member)); + } + model.data_sets.push_back(std::move(projected)); + } + + for (const auto& control : document.report_controls) { + if (control.ied_name != snapshot.ied_name) continue; + mms::MmsLiveReportControl projected; + projected.reference = control.control_block_reference; + projected.domain = snapshot.ied_name + control.ld_inst; + projected.logical_node = control.logical_node_path; + projected.name = control.name; + projected.buffered = control.buffered; + projected.data_set_reference = control.data_set_reference; + projected.data_set_binding_status = control.data_set_reference.empty() ? "Unbound" : "Bound"; + projected.report_id = control.report_id; + projected.configuration_revision = std::to_string(control.configuration_revision); + projected.buffer_time_ms = std::to_string(control.buffer_time_milliseconds); + projected.integrity_period_ms = std::to_string(control.integrity_period_milliseconds); + model.report_controls.push_back(std::move(projected)); + } + + model.coverage.logical_device_count = model.logical_devices.size(); + for (const auto& device : model.logical_devices) { + model.coverage.logical_node_count += device.logical_nodes.size(); + for (const auto& node : device.logical_nodes) { + model.coverage.data_object_count += node.data_objects.size(); + for (const auto& object : node.data_objects) { + model.coverage.data_attribute_count += object.attributes.size(); + model.coverage.exact_functional_constraint_count += object.attributes.size(); + model.coverage.exact_mms_type_count += static_cast(std::count_if( + object.attributes.begin(), object.attributes.end(), [](const auto& attribute) { + return attribute.mms_type != "unknown"; + })); + } + } + } + model.coverage.data_set_count = model.data_sets.size(); + model.coverage.report_control_count = model.report_controls.size(); + for (const auto& report : model.report_controls) { + if (report.buffered) ++model.coverage.buffered_report_control_count; + else ++model.coverage.unbuffered_report_control_count; + if (report.data_set_binding_status == "Bound") ++model.coverage.report_control_bound_count; + else if (report.data_set_binding_status == "Unbound") ++model.coverage.report_control_unbound_count; + } + + model.summary = "Trusted SCL [" + std::string{health.name()} + + "] + online FC-root snapshot: " + + std::to_string(model.coverage.logical_device_count) + " LD, " + + std::to_string(model.coverage.logical_node_count) + " LN, " + + std::to_string(model.coverage.data_attribute_count) + " DA; " + + std::to_string(snapshot.read_request_count) + " initial Read request(s); " + + "missingDomains=" + std::to_string(health.missing_domain_count) + + ", extraDomains=" + std::to_string(health.extra_domain_count) + + ", accessFailures=" + std::to_string(health.access_failure_count) + + ", mappingFailures=" + std::to_string(health.structural_mapping_failure_count) + "."; + + if (health.degraded()) { + model.warnings.push_back({ + "SclOnlineIdentityDegraded", snapshot.ied_name, health.summary()}); + } + for (const auto& missing : snapshot.domains.missing) { + model.warnings.push_back({ + "SclDomainUnavailable", missing, + "Trusted SCL domain was not present in the online Domain inventory."}); + } + for (const auto& extra : snapshot.domains.extra) { + model.warnings.push_back({ + "UnexpectedOnlineDomain", extra, + "Online domain is not part of the trusted SCL model and was not merged."}); + } + return model; +} + +[[nodiscard]] inline std::vector collect_scl_snapshot_values( + const ar::iec61850::scl::SclDocument& document, + const ar::iec61850::mms::MmsSclAssistedConnectResult& snapshot) { + std::vector values; + values.reserve(snapshot.mapped_leaf_count); + for (const auto& batch : snapshot.batches) { + for (const auto& root : batch.roots) { + if (!root.mapping_success()) continue; + for (const auto& leaf : root.mapped_leaves) { + if (!leaf.value || leaf.model_entry_index >= document.model_entries.size()) continue; + const auto& entry = document.model_entries[leaf.model_entry_index]; + if (entry.ied_name != snapshot.ied_name) continue; + values.push_back({ + entry.ied_name + entry.ld_inst, + detail::mms_item(entry), + ar::iec61850::mms::MmsDataCodec::to_display_string(*leaf.value), + }); + } + } + } + return values; +} + +} // namespace arstack::iedsim diff --git a/apps/ied_simulator/src/MmsSettingGroupPolicy.hpp b/apps/ied_simulator/src/MmsSettingGroupPolicy.hpp new file mode 100644 index 000000000..e321a91a0 --- /dev/null +++ b/apps/ied_simulator/src/MmsSettingGroupPolicy.hpp @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/control_block_read.hpp" +#include "ariec61850/mms/data_value.hpp" + +#include +#include +#include +#include +#include +#include +#include + +struct MmsSettingGroupState final { + std::optional numOfSG; + std::optional activeSG; + std::optional editSG; + std::optional cnfEdit; + ar::iec61850::mms::MmsDataKind activeKind{ + ar::iec61850::mms::MmsDataKind::unknown}; + bool activeAttributePresent{}; +}; + +class MmsSettingGroupPolicy final { +public: + using ReadResult = ar::iec61850::mms::MmsControlBlockReadResult; + using Evidence = ar::iec61850::mms::MmsControlBlockAttributeReadEvidence; + using DataValue = ar::iec61850::mms::MmsDataValue; + using DataKind = ar::iec61850::mms::MmsDataKind; + + [[nodiscard]] static const Evidence* findAttribute( + const ReadResult& result, + const std::string_view name) noexcept { + for (const auto& attribute : result.attributes) { + if (matchesTail(attribute.attribute_path, name)) { + return &attribute; + } + } + return nullptr; + } + + [[nodiscard]] static MmsSettingGroupState inspect( + const ReadResult& result) noexcept { + MmsSettingGroupState state; + if (const auto* attribute = findAttribute(result, "NumOfSG"); + attribute != nullptr && attribute->value) { + state.numOfSG = unsignedValue(*attribute->value); + } + if (const auto* attribute = findAttribute(result, "ActSG"); + attribute != nullptr && attribute->value) { + state.activeAttributePresent = true; + state.activeKind = attribute->value->kind(); + state.activeSG = unsignedValue(*attribute->value); + } + if (const auto* attribute = findAttribute(result, "EditSG"); + attribute != nullptr && attribute->value) { + state.editSG = unsignedValue(*attribute->value); + } + if (const auto* attribute = findAttribute(result, "CnfEdit"); + attribute != nullptr && attribute->value) { + state.cnfEdit = boolValue(*attribute->value); + } + return state; + } + + [[nodiscard]] static std::string activationBlockReason( + const MmsSettingGroupState& state) { + if (!state.numOfSG || *state.numOfSG == 0U) { + return "NumOfSG is unavailable or zero."; + } + if (!state.activeAttributePresent || !state.activeSG) { + return "ActSG is unavailable or unreadable."; + } + if (state.activeKind != DataKind::unsigned_integer && + state.activeKind != DataKind::integer) { + return "ActSG is not an Integer/Unsigned MMS scalar."; + } + if (state.cnfEdit.value_or(false)) { + return "CnfEdit is true; activation is blocked while an edit transaction is pending."; + } + if (state.editSG && *state.editSG != 0U) { + return "EditSG is non-zero; activation is blocked while a setting group is being edited."; + } + return {}; + } + + static void validateActivation( + const MmsSettingGroupState& state, + const std::uint64_t requestedGroup) { + const auto blocked = activationBlockReason(state); + if (!blocked.empty()) { + throw std::invalid_argument(blocked); + } + if (requestedGroup == 0U || requestedGroup > *state.numOfSG) { + throw std::out_of_range( + "Requested setting group is outside 1..NumOfSG."); + } + } + + [[nodiscard]] static DataValue activationValue( + const MmsSettingGroupState& state, + const std::uint64_t requestedGroup) { + validateActivation(state, requestedGroup); + if (state.activeKind == DataKind::integer) { + return DataValue::integer(static_cast(requestedGroup)); + } + return DataValue::unsigned_integer(requestedGroup); + } + +private: + [[nodiscard]] static std::string lower(std::string value) noexcept { + for (auto& character : value) { + character = static_cast( + std::tolower(static_cast(character))); + } + return value; + } + + [[nodiscard]] static bool matchesTail( + const std::string_view path, + const std::string_view name) noexcept { + const auto normalizedPath = lower(std::string{path}); + const auto normalizedName = lower(std::string{name}); + if (normalizedPath == normalizedName) return true; + if (normalizedPath.size() <= normalizedName.size()) return false; + const auto offset = normalizedPath.size() - normalizedName.size(); + return normalizedPath.compare(offset, normalizedName.size(), normalizedName) == 0 && + (normalizedPath[offset - 1U] == '.' || normalizedPath[offset - 1U] == '$'); + } + + [[nodiscard]] static std::optional unsignedValue( + const DataValue& value) noexcept { + if (value.kind() == DataKind::unsigned_integer) { + if (const auto* parsed = std::get_if(&value.value())) { + return *parsed; + } + } + if (value.kind() == DataKind::integer) { + if (const auto* parsed = std::get_if(&value.value()); + parsed != nullptr && *parsed >= 0) { + return static_cast(*parsed); + } + } + return std::nullopt; + } + + [[nodiscard]] static std::optional boolValue( + const DataValue& value) noexcept { + if (value.kind() != DataKind::boolean) return std::nullopt; + if (const auto* parsed = std::get_if(&value.value())) { + return *parsed; + } + return std::nullopt; + } +}; diff --git a/apps/ied_simulator/src/ProductHardeningController.cpp b/apps/ied_simulator/src/ProductHardeningController.cpp new file mode 100644 index 000000000..8a36d064c --- /dev/null +++ b/apps/ied_simulator/src/ProductHardeningController.cpp @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ProductHardeningController.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +constexpr qint64 maximumStateBytes = 64 * 1024; +} + +ProductHardeningController::ProductHardeningController(QObject* parent) + : ProductHardeningController(defaultStatePath(), parent) {} + +ProductHardeningController::ProductHardeningController(QString statePath, QObject* parent) + : QObject(parent), statePath_(std::move(statePath)) { + (void)loadState(); + refreshRuntimeReadiness(); +} + +QString ProductHardeningController::defaultStatePath() { + auto directory = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + if (directory.isEmpty()) { + directory = QDir::home().filePath(QStringLiteral(".arstack61850")); + } + return QDir(directory).filePath(QStringLiteral("product-state-v1.json")); +} + +QString ProductHardeningController::normalizedHost(const QString& value) { + auto host = value.trimmed(); + if (host.startsWith(QLatin1Char('[')) && host.endsWith(QLatin1Char(']')) && host.size() > 2) { + host = host.mid(1, host.size() - 2); + } + if (host.isEmpty() || host.size() > 255) return {}; + for (const auto character : host) { + if (character.isSpace() || character.category() == QChar::Other_Control) return {}; + } + return host; +} + +QString ProductHardeningController::endpointLabel(const Endpoint& endpoint) { + const auto host = endpoint.host.contains(QLatin1Char(':')) + ? QStringLiteral("[%1]").arg(endpoint.host) + : endpoint.host; + return QStringLiteral("%1:%2").arg(host).arg(endpoint.port); +} + +QStringList ProductHardeningController::recentEndpoints() const { + QStringList result; + result.reserve(static_cast(recent_.size())); + for (const auto& endpoint : recent_) result.push_back(endpointLabel(endpoint)); + return result; +} + +QString ProductHardeningController::lastHost() const { + return recent_.empty() ? QString{} : recent_.front().host; +} + +int ProductHardeningController::lastPort() const noexcept { + return recent_.empty() ? 102 : recent_.front().port; +} + +bool ProductHardeningController::npcapRequired() const noexcept { +#ifdef Q_OS_WIN + return true; +#else + return false; +#endif +} + +void ProductHardeningController::resetDefaults() { + workspaceIndex_ = 0; + recent_.clear(); +} + +void ProductHardeningController::setStateFault(const QString& message) { + settingsHealthy_ = false; + settingsStatus_ = message; +} + +bool ProductHardeningController::loadState() { + resetDefaults(); + QFile file(statePath_); + if (!file.exists()) { + settingsHealthy_ = true; + settingsStatus_ = QStringLiteral("No persisted state yet; safe defaults active."); + emit stateChanged(); + return true; + } + if (!file.open(QIODevice::ReadOnly)) { + setStateFault(QStringLiteral("Persisted state ignored: %1").arg(file.errorString())); + emit stateChanged(); + return false; + } + if (file.size() < 0 || file.size() > maximumStateBytes) { + setStateFault(QStringLiteral("Persisted state ignored: file exceeds 64 KiB bound.")); + emit stateChanged(); + return false; + } + + QJsonParseError parseError; + const auto document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + setStateFault(QStringLiteral("Persisted state ignored: malformed JSON.")); + emit stateChanged(); + return false; + } + + const auto object = document.object(); + if (object.value(QStringLiteral("schema")).toInt(-1) != stateSchemaVersion) { + setStateFault(QStringLiteral("Persisted state ignored: unsupported schema.")); + emit stateChanged(); + return false; + } + const auto workspace = object.value(QStringLiteral("workspaceIndex")).toInt(-1); + if (workspace < minimumWorkspaceIndex || workspace > maximumWorkspaceIndex) { + setStateFault(QStringLiteral("Persisted state ignored: workspace index is outside bounds.")); + emit stateChanged(); + return false; + } + + const auto recent = object.value(QStringLiteral("recentEndpoints")); + if (!recent.isArray() || recent.toArray().size() > maximumRecentEndpoints) { + setStateFault(QStringLiteral("Persisted state ignored: recent endpoint list is invalid or unbounded.")); + emit stateChanged(); + return false; + } + + std::vector loaded; + loaded.reserve(static_cast(recent.toArray().size())); + for (const auto& value : recent.toArray()) { + if (!value.isObject()) { + setStateFault(QStringLiteral("Persisted state ignored: malformed recent endpoint.")); + emit stateChanged(); + return false; + } + const auto endpointObject = value.toObject(); + const auto host = normalizedHost(endpointObject.value(QStringLiteral("host")).toString()); + const auto port = endpointObject.value(QStringLiteral("port")).toInt(0); + if (host.isEmpty() || port < 1 || port > 65'535) { + setStateFault(QStringLiteral("Persisted state ignored: invalid recent endpoint.")); + emit stateChanged(); + return false; + } + loaded.push_back({host, port}); + } + + workspaceIndex_ = workspace; + recent_ = std::move(loaded); + settingsHealthy_ = true; + settingsStatus_ = QStringLiteral("Persisted product state restored safely."); + emit stateChanged(); + return true; +} + +bool ProductHardeningController::persistState() { + const QFileInfo info(statePath_); + if (!QDir().mkpath(info.absolutePath())) { + setStateFault(QStringLiteral("Unable to create product-state directory.")); + return false; + } + + QJsonArray recent; + for (const auto& endpoint : recent_) { + QJsonObject item; + item.insert(QStringLiteral("host"), endpoint.host); + item.insert(QStringLiteral("port"), endpoint.port); + recent.append(item); + } + + QJsonObject object; + object.insert(QStringLiteral("schema"), stateSchemaVersion); + object.insert(QStringLiteral("workspaceIndex"), workspaceIndex_); + object.insert(QStringLiteral("recentEndpoints"), recent); + const auto payload = QJsonDocument(object).toJson(QJsonDocument::Compact); + + QSaveFile output(statePath_); + if (!output.open(QIODevice::WriteOnly)) { + setStateFault(QStringLiteral("Atomic state write failed to open: %1").arg(output.errorString())); + return false; + } + if (output.write(payload) != payload.size()) { + output.cancelWriting(); + setStateFault(QStringLiteral("Atomic state write was incomplete: %1").arg(output.errorString())); + return false; + } + if (!output.commit()) { + setStateFault(QStringLiteral("Atomic state commit failed: %1").arg(output.errorString())); + return false; + } + + settingsHealthy_ = true; + settingsStatus_ = QStringLiteral("Product state saved atomically."); + return true; +} + +void ProductHardeningController::setWorkspaceIndex(const int value) { + if (value < minimumWorkspaceIndex || value > maximumWorkspaceIndex) { + settingsStatus_ = QStringLiteral("Rejected invalid workspace index."); + emit stateChanged(); + return; + } + if (workspaceIndex_ == value) return; + workspaceIndex_ = value; + (void)persistState(); + emit stateChanged(); +} + +bool ProductHardeningController::rememberEndpoint(const QString& hostValue, const int port) { + const auto host = normalizedHost(hostValue); + if (host.isEmpty() || port < 1 || port > 65'535) { + settingsStatus_ = QStringLiteral("Rejected invalid MMS endpoint."); + emit stateChanged(); + return false; + } + + const auto duplicate = std::find_if(recent_.begin(), recent_.end(), [&](const Endpoint& endpoint) { + return endpoint.port == port && endpoint.host.compare(host, Qt::CaseInsensitive) == 0; + }); + if (duplicate != recent_.end()) recent_.erase(duplicate); + recent_.insert(recent_.begin(), {host, port}); + if (recent_.size() > static_cast(maximumRecentEndpoints)) { + recent_.resize(static_cast(maximumRecentEndpoints)); + } + const auto persisted = persistState(); + emit stateChanged(); + return persisted; +} + +void ProductHardeningController::clearRecentEndpoints() { + if (recent_.empty()) return; + recent_.clear(); + (void)persistState(); + emit stateChanged(); +} + +QString ProductHardeningController::recentHost(const int index) const { + if (index < 0 || index >= static_cast(recent_.size())) return {}; + return recent_[static_cast(index)].host; +} + +int ProductHardeningController::recentPort(const int index) const { + if (index < 0 || index >= static_cast(recent_.size())) return 0; + return recent_[static_cast(index)].port; +} + +bool ProductHardeningController::reload() { + return loadState(); +} + +void ProductHardeningController::refreshRuntimeReadiness() { +#ifdef Q_OS_WIN + QLibrary wpcap(QStringLiteral("wpcap")); + QLibrary packet(QStringLiteral("Packet")); + const bool wpcapReady = wpcap.load(); + const bool packetReady = packet.load(); + if (wpcapReady) wpcap.unload(); + if (packetReady) packet.unload(); + npcapAvailable_ = wpcapReady && packetReady; + npcapStatus_ = npcapAvailable_ + ? QStringLiteral("Npcap runtime detected; raw Ethernet workspaces are available.") + : QStringLiteral("Npcap runtime not detected. Install Npcap before using Windows GOOSE monitor/publisher raw Ethernet features."); +#else + npcapAvailable_ = true; + npcapStatus_ = QStringLiteral("Npcap is not required on this platform."); +#endif + emit runtimeReadinessChanged(); +} diff --git a/apps/ied_simulator/src/ProductHardeningController.hpp b/apps/ied_simulator/src/ProductHardeningController.hpp new file mode 100644 index 000000000..c11dc2d73 --- /dev/null +++ b/apps/ied_simulator/src/ProductHardeningController.hpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +#include + +class ProductHardeningController : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(int workspaceIndex READ workspaceIndex WRITE setWorkspaceIndex NOTIFY stateChanged) + Q_PROPERTY(QStringList recentEndpoints READ recentEndpoints NOTIFY stateChanged) + Q_PROPERTY(QString lastHost READ lastHost NOTIFY stateChanged) + Q_PROPERTY(int lastPort READ lastPort NOTIFY stateChanged) + Q_PROPERTY(QString statePath READ statePath CONSTANT) + Q_PROPERTY(bool settingsHealthy READ settingsHealthy NOTIFY stateChanged) + Q_PROPERTY(QString settingsStatus READ settingsStatus NOTIFY stateChanged) + Q_PROPERTY(bool automaticReconnectOnStartup READ automaticReconnectOnStartup CONSTANT) + Q_PROPERTY(bool npcapRequired READ npcapRequired CONSTANT) + Q_PROPERTY(bool npcapAvailable READ npcapAvailable NOTIFY runtimeReadinessChanged) + Q_PROPERTY(bool rawEthernetReady READ rawEthernetReady NOTIFY runtimeReadinessChanged) + Q_PROPERTY(QString npcapStatus READ npcapStatus NOTIFY runtimeReadinessChanged) + +public: + explicit ProductHardeningController(QObject* parent = nullptr); + explicit ProductHardeningController(QString statePath, QObject* parent = nullptr); + + [[nodiscard]] int workspaceIndex() const noexcept { return workspaceIndex_; } + void setWorkspaceIndex(int value); + + [[nodiscard]] QStringList recentEndpoints() const; + [[nodiscard]] QString lastHost() const; + [[nodiscard]] int lastPort() const noexcept; + [[nodiscard]] QString statePath() const { return statePath_; } + [[nodiscard]] bool settingsHealthy() const noexcept { return settingsHealthy_; } + [[nodiscard]] QString settingsStatus() const { return settingsStatus_; } + [[nodiscard]] bool automaticReconnectOnStartup() const noexcept { return false; } + [[nodiscard]] bool npcapRequired() const noexcept; + [[nodiscard]] bool npcapAvailable() const noexcept { return npcapAvailable_; } + [[nodiscard]] bool rawEthernetReady() const noexcept { return !npcapRequired() || npcapAvailable_; } + [[nodiscard]] QString npcapStatus() const { return npcapStatus_; } + + Q_INVOKABLE bool rememberEndpoint(const QString& host, int port); + Q_INVOKABLE void clearRecentEndpoints(); + Q_INVOKABLE QString recentHost(int index) const; + Q_INVOKABLE int recentPort(int index) const; + Q_INVOKABLE bool reload(); + Q_INVOKABLE void refreshRuntimeReadiness(); + +signals: + void stateChanged(); + void runtimeReadinessChanged(); + +private: + struct Endpoint final { + QString host; + int port{102}; + }; + + static constexpr int stateSchemaVersion = 1; + static constexpr int maximumRecentEndpoints = 8; + static constexpr int minimumWorkspaceIndex = 0; + static constexpr int maximumWorkspaceIndex = 6; + + [[nodiscard]] static QString defaultStatePath(); + [[nodiscard]] static QString normalizedHost(const QString& value); + [[nodiscard]] static QString endpointLabel(const Endpoint& endpoint); + [[nodiscard]] bool loadState(); + [[nodiscard]] bool persistState(); + void resetDefaults(); + void setStateFault(const QString& message); + + QString statePath_; + int workspaceIndex_{}; + std::vector recent_; + bool settingsHealthy_{true}; + QString settingsStatus_{QStringLiteral("Product state ready.")}; + bool npcapAvailable_{}; + QString npcapStatus_; +}; diff --git a/apps/ied_simulator/src/SclWorkspaceController.cpp b/apps/ied_simulator/src/SclWorkspaceController.cpp new file mode 100644 index 000000000..7d4532470 --- /dev/null +++ b/apps/ied_simulator/src/SclWorkspaceController.cpp @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SclWorkspaceController_part_00.inc" +#include "SclWorkspaceController_part_01.inc" +#include "SclWorkspaceController_part_02.inc" diff --git a/apps/ied_simulator/src/SclWorkspaceController.hpp b/apps/ied_simulator/src/SclWorkspaceController.hpp new file mode 100644 index 000000000..149f35a41 --- /dev/null +++ b/apps/ied_simulator/src/SclWorkspaceController.hpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class SclWorkspaceController : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(bool loaded READ loaded NOTIFY workspaceChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(QString stateText READ stateText NOTIFY stateChanged) + Q_PROPERTY(QString sourcePath READ sourcePath NOTIFY workspaceChanged) + Q_PROPERTY(QString sourceName READ sourceName NOTIFY workspaceChanged) + Q_PROPERTY(QString sourceFormat READ sourceFormat NOTIFY workspaceChanged) + Q_PROPERTY(QString editionText READ editionText NOTIFY workspaceChanged) + Q_PROPERTY(QString namespaceUri READ namespaceUri NOTIFY workspaceChanged) + Q_PROPERTY(QString headerId READ headerId NOTIFY workspaceChanged) + Q_PROPERTY(QString headerVersion READ headerVersion NOTIFY workspaceChanged) + Q_PROPERTY(QString headerRevision READ headerRevision NOTIFY workspaceChanged) + Q_PROPERTY(int iedCount READ iedCount NOTIFY workspaceChanged) + Q_PROPERTY(int logicalNodeCount READ logicalNodeCount NOTIFY workspaceChanged) + Q_PROPERTY(int modelLeafCount READ modelLeafCount NOTIFY workspaceChanged) + Q_PROPERTY(int dataSetCount READ dataSetCount NOTIFY workspaceChanged) + Q_PROPERTY(int reportCount READ reportCount NOTIFY workspaceChanged) + Q_PROPERTY(int gooseCount READ gooseCount NOTIFY workspaceChanged) + Q_PROPERTY(int smvCount READ smvCount NOTIFY workspaceChanged) + Q_PROPERTY(bool exactSourceSaveSupported READ exactSourceSaveSupported NOTIFY workspaceChanged) + Q_PROPERTY(bool reconstructionSupported READ reconstructionSupported NOTIFY workspaceChanged) + Q_PROPERTY(bool editionConversionSupported READ editionConversionSupported NOTIFY workspaceChanged) + Q_PROPERTY(bool profileConversionSupported READ profileConversionSupported NOTIFY workspaceChanged) + Q_PROPERTY(QStringList preservationReport READ preservationReport NOTIFY workspaceChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY stateChanged) + Q_PROPERTY(bool lastExportVerified READ lastExportVerified NOTIFY exportChanged) + Q_PROPERTY(QString lastExportPath READ lastExportPath NOTIFY exportChanged) + Q_PROPERTY(QString lastExportMode READ lastExportMode NOTIFY exportChanged) + Q_PROPERTY(qulonglong generation READ generation NOTIFY stateChanged) + +public: + explicit SclWorkspaceController(QObject* parent = nullptr); + ~SclWorkspaceController() override; + + [[nodiscard]] bool loaded() const noexcept { return document_.has_value(); } + [[nodiscard]] bool busy() const noexcept { return busy_; } + [[nodiscard]] QString stateText() const; + [[nodiscard]] QString sourcePath() const { return sourcePath_; } + [[nodiscard]] QString sourceName() const { return sourceName_; } + [[nodiscard]] QString sourceFormat() const { return sourceFormat_; } + [[nodiscard]] QString editionText() const; + [[nodiscard]] QString namespaceUri() const; + [[nodiscard]] QString headerId() const; + [[nodiscard]] QString headerVersion() const; + [[nodiscard]] QString headerRevision() const; + [[nodiscard]] int iedCount() const noexcept; + [[nodiscard]] int logicalNodeCount() const noexcept; + [[nodiscard]] int modelLeafCount() const noexcept; + [[nodiscard]] int dataSetCount() const noexcept; + [[nodiscard]] int reportCount() const noexcept; + [[nodiscard]] int gooseCount() const noexcept; + [[nodiscard]] int smvCount() const noexcept; + [[nodiscard]] bool exactSourceSaveSupported() const noexcept { return loaded() && !sourceBytes_.isEmpty(); } + [[nodiscard]] bool reconstructionSupported() const noexcept; + [[nodiscard]] bool editionConversionSupported() const noexcept; + [[nodiscard]] bool profileConversionSupported() const noexcept; + [[nodiscard]] QStringList preservationReport() const; + [[nodiscard]] QString lastError() const { return lastError_; } + [[nodiscard]] bool lastExportVerified() const noexcept { return lastExportVerified_; } + [[nodiscard]] QString lastExportPath() const { return lastExportPath_; } + [[nodiscard]] QString lastExportMode() const { return lastExportMode_; } + [[nodiscard]] qulonglong generation() const noexcept { return generation_; } + + Q_INVOKABLE bool openFile(const QUrl& fileUrl); + Q_INVOKABLE bool saveAs(const QUrl& fileUrl, const QString& targetEdition = QStringLiteral("preserve")); + Q_INVOKABLE bool exportCanonical(const QUrl& fileUrl, const QString& targetEdition = QStringLiteral("preserve")); + Q_INVOKABLE void cancelOperation(); + Q_INVOKABLE QString diagnosticsText() const; + +signals: + void stateChanged(); + void workspaceChanged(); + void exportChanged(); + void diagnosticsChanged(); + +private: + [[nodiscard]] std::shared_ptr replaceStopSource(); + void appendDiagnostic(const QString& text); + void setFailure(const QString& text); + + std::optional document_; + QByteArray sourceBytes_; + QString sourcePath_; + QString sourceName_; + QString sourceFormat_; + bool busy_{}; + QString operationText_; + QString lastError_; + bool lastExportVerified_{}; + QString lastExportPath_; + QString lastExportMode_; + QStringList diagnostics_; + qulonglong generation_{}; + QThreadPool ioPool_; + std::shared_ptr stopSource_; +}; diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc new file mode 100644 index 000000000..467119ee6 --- /dev/null +++ b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SclWorkspaceController.hpp" + +#include "ariec61850/scl/exporter.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace scl = ar::iec61850::scl; + +namespace { +constexpr qint64 maximumSclBytes = 64LL * 1024LL * 1024LL; + +QString localPath(const QUrl& value) { + if (value.isLocalFile()) return value.toLocalFile(); + return value.toString(); +} + +QString editionKey(const scl::SclEdition edition) { + switch (edition) { + case scl::SclEdition::edition1: return QStringLiteral("ed1"); + case scl::SclEdition::edition2: return QStringLiteral("ed2"); + case scl::SclEdition::edition21: return QStringLiteral("ed2.1"); + case scl::SclEdition::unknown: return QStringLiteral("unknown"); + } + return QStringLiteral("unknown"); +} + +QString editionLabel(const scl::SclEdition edition) { + switch (edition) { + case scl::SclEdition::edition1: return QStringLiteral("Edition 1"); + case scl::SclEdition::edition2: return QStringLiteral("Edition 2"); + case scl::SclEdition::edition21: return QStringLiteral("Edition 2.1"); + case scl::SclEdition::unknown: return QStringLiteral("Unknown edition"); + } + return QStringLiteral("Unknown edition"); +} + +std::optional requestedEdition( + const QString& text, + const scl::SclEdition sourceEdition) { + const auto key = text.trimmed().toLower(); + if (key.isEmpty() || key == QStringLiteral("preserve")) return sourceEdition; + if (key == QStringLiteral("ed1")) return scl::SclEdition::edition1; + if (key == QStringLiteral("ed2")) return scl::SclEdition::edition2; + if (key == QStringLiteral("ed2.1") || key == QStringLiteral("ed21")) { + return scl::SclEdition::edition21; + } + return std::nullopt; +} + +std::optional profileFromPath(const QString& path) { + const auto suffix = QFileInfo(path).suffix().toLower(); + if (suffix == QStringLiteral("scd")) return scl::SclExportProfile::scd; + if (suffix == QStringLiteral("icd")) return scl::SclExportProfile::icd; + if (suffix == QStringLiteral("cid")) return scl::SclExportProfile::cid; + return std::nullopt; +} + +QString profileLabel(const scl::SclExportProfile profile) { + switch (profile) { + case scl::SclExportProfile::scd: return QStringLiteral("SCD"); + case scl::SclExportProfile::icd: return QStringLiteral("ICD"); + case scl::SclExportProfile::cid: return QStringLiteral("CID"); + } + return QStringLiteral("SCL"); +} + +bool entriesEquivalent( + const std::vector& left, + const std::vector& right) { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + const auto& lhs = left[index]; + const auto& rhs = right[index]; + if (lhs.index != rhs.index || lhs.signal_reference != rhs.signal_reference || + lhs.ied_name != rhs.ied_name || lhs.ld_inst != rhs.ld_inst || + lhs.prefix != rhs.prefix || lhs.ln_class != rhs.ln_class || + lhs.ln_inst != rhs.ln_inst || lhs.do_name != rhs.do_name || + lhs.da_name != rhs.da_name || lhs.functional_constraint != rhs.functional_constraint || + lhs.cdc != rhs.cdc || lhs.basic_type != rhs.basic_type || + lhs.enum_type != rhs.enum_type || lhs.is_quality != rhs.is_quality || + lhs.is_timestamp != rhs.is_timestamp || lhs.configured_value != rhs.configured_value) { + return false; + } + } + return true; +} + +bool dataSetsEquivalent( + const std::vector& left, + const std::vector& right) { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + const auto& lhs = left[index]; + const auto& rhs = right[index]; + if (lhs.key != rhs.key || lhs.ied_name != rhs.ied_name || lhs.ld_inst != rhs.ld_inst || + lhs.logical_node_path != rhs.logical_node_path || lhs.name != rhs.name || + lhs.reference != rhs.reference || !entriesEquivalent(lhs.entries, rhs.entries) || + !entriesEquivalent(lhs.expanded_entries, rhs.expanded_entries)) { + return false; + } + } + return true; +} + +bool reportsEquivalent( + const std::vector& left, + const std::vector& right) { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + const auto& lhs = left[index]; + const auto& rhs = right[index]; + if (lhs.ied_name != rhs.ied_name || lhs.ld_inst != rhs.ld_inst || + lhs.logical_node_path != rhs.logical_node_path || lhs.name != rhs.name || + lhs.report_id != rhs.report_id || lhs.data_set_name != rhs.data_set_name || + lhs.data_set_reference != rhs.data_set_reference || + lhs.data_set_binding_status != rhs.data_set_binding_status || + lhs.control_block_reference != rhs.control_block_reference || lhs.buffered != rhs.buffered || + lhs.indexed != rhs.indexed || lhs.configuration_revision != rhs.configuration_revision || + lhs.buffer_time_milliseconds != rhs.buffer_time_milliseconds || + lhs.integrity_period_milliseconds != rhs.integrity_period_milliseconds || + !entriesEquivalent(lhs.entries, rhs.entries)) { + return false; + } + } + return true; +} + +bool gooseEquivalent( + const std::vector& left, + const std::vector& right) { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + const auto& lhs = left[index]; + const auto& rhs = right[index]; + if (lhs.kind != rhs.kind || lhs.ied_name != rhs.ied_name || lhs.ld_inst != rhs.ld_inst || + lhs.control_name != rhs.control_name || + lhs.control_block_reference != rhs.control_block_reference || + lhs.data_set_name != rhs.data_set_name || lhs.data_set_reference != rhs.data_set_reference || + lhs.configuration_revision != rhs.configuration_revision || !(lhs.address == rhs.address) || + !entriesEquivalent(lhs.entries, rhs.entries) || lhs.go_id != rhs.go_id || + lhs.min_time_milliseconds != rhs.min_time_milliseconds || + lhs.max_time_milliseconds != rhs.max_time_milliseconds) { + return false; + } + } + return true; +} + +bool sampledValuesEquivalent( + const std::vector& left, + const std::vector& right) { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + const auto& lhs = left[index]; + const auto& rhs = right[index]; + if (lhs.kind != rhs.kind || lhs.ied_name != rhs.ied_name || lhs.ld_inst != rhs.ld_inst || + lhs.control_name != rhs.control_name || + lhs.control_block_reference != rhs.control_block_reference || + lhs.data_set_name != rhs.data_set_name || lhs.data_set_reference != rhs.data_set_reference || + lhs.configuration_revision != rhs.configuration_revision || !(lhs.address == rhs.address) || + !entriesEquivalent(lhs.entries, rhs.entries) || lhs.sv_id != rhs.sv_id || + lhs.smv_id != rhs.smv_id || lhs.sample_rate != rhs.sample_rate || + lhs.sample_mode != rhs.sample_mode || lhs.no_asdu != rhs.no_asdu || + !(lhs.smv_options == rhs.smv_options)) { + return false; + } + } + return true; +} + +bool modeledSemanticsEquivalent(const scl::SclDocument& left, const scl::SclDocument& right) { + // Namespace/edition/source name intentionally change during a normalized export. + // Type-template identifier strings are also implementation IDs; the resolved + // reference/FC/CDC/basic-type projection is the semantic contract. + return left.header_id == right.header_id && left.header_version == right.header_version && + left.header_revision == right.header_revision && left.ieds == right.ieds && + left.logical_nodes == right.logical_nodes && + entriesEquivalent(left.model_entries, right.model_entries) && + dataSetsEquivalent(left.data_sets, right.data_sets) && + reportsEquivalent(left.report_controls, right.report_controls) && + gooseEquivalent(left.goose_streams, right.goose_streams) && + sampledValuesEquivalent(left.sampled_values_streams, right.sampled_values_streams); +} + +struct OpenResult final { + QByteArray bytes; + std::optional document; + QString error; +}; + +OpenResult readAndParse(const QString& path, const std::stop_token stopToken) { + OpenResult result; + if (stopToken.stop_requested()) { + result.error = QStringLiteral("SCL operation cancelled."); + return result; + } + + QFile file(path); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) { + result.error = QStringLiteral("Unable to open SCL file: %1").arg(file.errorString()); + return result; + } + if (file.size() < 0 || file.size() > maximumSclBytes) { + result.error = QStringLiteral("SCL file exceeds the bounded 64 MiB workspace limit."); + return result; + } + result.bytes = file.readAll(); + if (file.error() != QFileDevice::NoError) { + result.error = QStringLiteral("Unable to read complete SCL file: %1").arg(file.errorString()); diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_01.inc b/apps/ied_simulator/src/SclWorkspaceController_part_01.inc new file mode 100644 index 000000000..0675568dd --- /dev/null +++ b/apps/ied_simulator/src/SclWorkspaceController_part_01.inc @@ -0,0 +1,220 @@ + result.bytes.clear(); + return result; + } + if (stopToken.stop_requested()) { + result.error = QStringLiteral("SCL operation cancelled."); + result.bytes.clear(); + return result; + } + + try { + scl::SclParser parser; + const std::string_view xml{result.bytes.constData(), static_cast(result.bytes.size())}; + result.document = parser.parse(xml, QFileInfo(path).fileName().toStdString()); + } catch (const std::exception& exception) { + result.error = QString::fromUtf8(exception.what()); + result.bytes.clear(); + } + return result; +} +} // namespace + +SclWorkspaceController::SclWorkspaceController(QObject* parent) + : QObject(parent), stopSource_(std::make_shared()) { + ioPool_.setMaxThreadCount(1); + ioPool_.setExpiryTimeout(-1); +} + +SclWorkspaceController::~SclWorkspaceController() { + if (stopSource_) stopSource_->request_stop(); + ioPool_.clear(); + ioPool_.waitForDone(); +} + +QString SclWorkspaceController::stateText() const { + if (busy_) return operationText_.isEmpty() ? QStringLiteral("Working") : operationText_; + if (!lastError_.isEmpty() && !loaded()) return QStringLiteral("SCL faulted"); + return loaded() ? QStringLiteral("SCL ready") : QStringLiteral("No SCL loaded"); +} + +QString SclWorkspaceController::editionText() const { + return document_ ? editionLabel(document_->edition) : QStringLiteral("—"); +} + +QString SclWorkspaceController::namespaceUri() const { + return document_ ? QString::fromStdString(document_->namespace_uri) : QString{}; +} + +QString SclWorkspaceController::headerId() const { + return document_ ? QString::fromStdString(document_->header_id) : QString{}; +} + +QString SclWorkspaceController::headerVersion() const { + return document_ ? QString::fromStdString(document_->header_version) : QString{}; +} + +QString SclWorkspaceController::headerRevision() const { + return document_ ? QString::fromStdString(document_->header_revision) : QString{}; +} + +int SclWorkspaceController::iedCount() const noexcept { + return document_ ? static_cast(document_->ieds.size()) : 0; +} + +int SclWorkspaceController::logicalNodeCount() const noexcept { + return document_ ? static_cast(document_->logical_nodes.size()) : 0; +} + +int SclWorkspaceController::modelLeafCount() const noexcept { + return document_ ? static_cast(document_->model_entries.size()) : 0; +} + +int SclWorkspaceController::dataSetCount() const noexcept { + return document_ ? static_cast(document_->data_sets.size()) : 0; +} + +int SclWorkspaceController::reportCount() const noexcept { + return document_ ? static_cast(document_->report_controls.size()) : 0; +} + +int SclWorkspaceController::gooseCount() const noexcept { + return document_ ? static_cast(document_->goose_streams.size()) : 0; +} + +int SclWorkspaceController::smvCount() const noexcept { + return document_ ? static_cast(document_->sampled_values_streams.size()) : 0; +} + +bool SclWorkspaceController::reconstructionSupported() const noexcept { + if (!document_ || document_->edition == scl::SclEdition::unknown) return false; + scl::SclCanonicalExportOptions options; + options.edition = document_->edition; + options.profile = scl::SclExportProfile::scd; + return scl::SclExporter::validate(*document_, options).success; +} + +bool SclWorkspaceController::editionConversionSupported() const noexcept { + return reconstructionSupported(); +} + +bool SclWorkspaceController::profileConversionSupported() const noexcept { + if (!document_ || document_->ieds.size() != 1U || !reconstructionSupported()) return false; + scl::SclCanonicalExportOptions options; + options.edition = document_->edition; + options.profile = scl::SclExportProfile::cid; + return scl::SclExporter::validate(*document_, options).success; +} + +QStringList SclWorkspaceController::preservationReport() const { + QStringList report; + if (!document_) { + report << QStringLiteral("Open an SCL source to inspect export safety."); + return report; + } + report << QStringLiteral("Exact Save As preserves the original bytes and is allowed only with the same source extension/profile and edition."); + if (reconstructionSupported()) { + report << QStringLiteral("Canonical reconstruction is enabled for the resolved ARStack model: LD/LN/DO/DA(BDA), DataTypeTemplates, configured DAI values, DataSets, Reports, GOOSE/SMV controls and modeled Communication addresses."); + report << QStringLiteral("Canonical output is reparsed and semantic-round-trip verified before success is reported; repeated exports are deterministic for the same model/options."); + report << QStringLiteral("Ed1, Ed2 and Ed2.1 conversion is available only through canonical reconstruction, never by namespace/version string relabel."); + report << QStringLiteral("SCD export supports the modeled IED set; ICD/CID conversion is guarded to exactly one IED."); + } else { + scl::SclCanonicalExportOptions options; + options.edition = document_->edition; + options.profile = scl::SclExportProfile::scd; + const auto validation = scl::SclExporter::validate(*document_, options); + report << QStringLiteral("Canonical reconstruction is fail-closed for this source: %1") + .arg(QString::fromStdString(validation.error)); + } + report << QStringLiteral("Canonical export is normalized, not vendor-lossless: unmodeled Services/Substation content and unknown vendor extension XML remain available only through exact source preservation."); + report << QStringLiteral("Ambiguous nested SDO parent typing and non-ctlModel EnumType ordinal domains fail closed rather than invent engineering values."); + for (const auto& warning : document_->warnings) { + report << QStringLiteral("Parser: %1").arg(QString::fromStdString(warning)); + } + for (const auto& conflict : document_->conflicts) { + report << QStringLiteral("Conflict %1 [%2]: %3") + .arg(QString::fromStdString(conflict.kind), + QString::fromStdString(conflict.key), + QString::fromStdString(conflict.description)); + } + return report; +} + +std::shared_ptr SclWorkspaceController::replaceStopSource() { + if (stopSource_) stopSource_->request_stop(); + stopSource_ = std::make_shared(); + return stopSource_; +} + +void SclWorkspaceController::appendDiagnostic(const QString& text) { + if (text.isEmpty()) return; + constexpr qsizetype maximumDiagnostics = 96; + while (diagnostics_.size() >= maximumDiagnostics) diagnostics_.removeFirst(); + diagnostics_.push_back(text); + emit diagnosticsChanged(); +} + +void SclWorkspaceController::setFailure(const QString& text) { + lastError_ = text; + appendDiagnostic(QStringLiteral("ERROR · %1").arg(text)); + emit stateChanged(); +} + +QString SclWorkspaceController::diagnosticsText() const { + return diagnostics_.join(QLatin1Char('\n')); +} + +bool SclWorkspaceController::openFile(const QUrl& fileUrl) { + if (busy_) return false; + const auto path = localPath(fileUrl).trimmed(); + if (path.isEmpty()) { + setFailure(QStringLiteral("A local SCL file path is required.")); + return false; + } + + const auto generation = ++generation_; + const auto stop = replaceStopSource(); + busy_ = true; + operationText_ = QStringLiteral("Opening SCL"); + lastError_.clear(); + lastExportVerified_ = false; + lastExportPath_.clear(); + lastExportMode_.clear(); + emit stateChanged(); + emit exportChanged(); + + QPointer self{this}; + ioPool_.start([self, path, generation, stop] { + auto result = readAndParse(path, stop->get_token()); + QMetaObject::invokeMethod(self, [self, path, generation, result = std::move(result)]() mutable { + if (!self || generation != self->generation_) return; + self->busy_ = false; + self->operationText_.clear(); + if (!result.error.isEmpty() || !result.document) { + self->lastError_ = result.error.isEmpty() + ? QStringLiteral("SCL parse did not produce a document.") + : result.error; + self->appendDiagnostic(QStringLiteral("Open rejected · %1").arg(self->lastError_)); + emit self->stateChanged(); + return; + } + + self->document_ = std::move(result.document); + self->sourceBytes_ = std::move(result.bytes); + self->sourcePath_ = path; + const QFileInfo info(path); + self->sourceName_ = info.fileName(); + self->sourceFormat_ = info.suffix().toLower(); + self->lastError_.clear(); + self->appendDiagnostic(QStringLiteral("Opened %1 · %2 · %3 bytes") + .arg(self->sourceName_, self->editionText()) + .arg(self->sourceBytes_.size())); + emit self->workspaceChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + }); + return true; +} + +bool SclWorkspaceController::saveAs(const QUrl& fileUrl, const QString& targetEdition) { + if (busy_ || !document_) return false; + const auto path = localPath(fileUrl).trimmed(); diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_02.inc b/apps/ied_simulator/src/SclWorkspaceController_part_02.inc new file mode 100644 index 000000000..44c8de055 --- /dev/null +++ b/apps/ied_simulator/src/SclWorkspaceController_part_02.inc @@ -0,0 +1,209 @@ + if (path.isEmpty()) { + setFailure(QStringLiteral("A local Save As path is required.")); + return false; + } + + const auto requested = requestedEdition(targetEdition, document_->edition); + if (!requested) { + setFailure(QStringLiteral("Unsupported target edition selector.")); + return false; + } + if (*requested != document_->edition) { + setFailure(QStringLiteral("Exact Save As cannot change edition. Use Canonical Export for %1 -> %2 conversion.") + .arg(editionKey(document_->edition), editionKey(*requested))); + return false; + } + + const auto targetFormat = QFileInfo(path).suffix().toLower(); + if (targetFormat.isEmpty() || targetFormat != sourceFormat_) { + setFailure(QStringLiteral("Exact Save As cannot relabel profile %1 -> %2. Use Canonical Export for SCD/ICD/CID conversion.") + .arg(sourceFormat_.isEmpty() ? QStringLiteral("unknown") : sourceFormat_, + targetFormat.isEmpty() ? QStringLiteral("none") : targetFormat)); + return false; + } + + const auto generation = ++generation_; + const auto stop = replaceStopSource(); + const auto bytes = sourceBytes_; + const auto expectedDocument = *document_; + busy_ = true; + operationText_ = QStringLiteral("Verifying exact Save As"); + lastError_.clear(); + lastExportVerified_ = false; + lastExportPath_.clear(); + lastExportMode_.clear(); + emit stateChanged(); + emit exportChanged(); + + QPointer self{this}; + ioPool_.start([self, path, bytes, expectedDocument, generation, stop] { + QString error; + bool verified = false; + if (stop->stop_requested()) { + error = QStringLiteral("SCL Save As cancelled before write."); + } else { + QSaveFile output(path); + if (!output.open(QIODevice::WriteOnly)) { + error = QStringLiteral("Unable to open Save As target: %1").arg(output.errorString()); + } else if (output.write(bytes) != bytes.size()) { + error = QStringLiteral("Unable to write complete SCL source: %1").arg(output.errorString()); + output.cancelWriting(); + } else if (stop->stop_requested()) { + error = QStringLiteral("SCL Save As cancelled before commit."); + output.cancelWriting(); + } else if (!output.commit()) { + error = QStringLiteral("Unable to commit SCL Save As: %1").arg(output.errorString()); + } else { + auto reread = readAndParse(path, stop->get_token()); + if (!reread.error.isEmpty() || !reread.document) { + error = reread.error.isEmpty() + ? QStringLiteral("Saved SCL could not be reparsed.") + : reread.error; + } else if (reread.bytes != bytes) { + error = QStringLiteral("Saved SCL bytes differ from the source-preserving payload."); + } else if (!modeledSemanticsEquivalent(expectedDocument, *reread.document)) { + error = QStringLiteral("Saved SCL failed modeled semantic round-trip validation."); + } else { + verified = true; + } + } + } + + QMetaObject::invokeMethod(self, [self, path, generation, error = std::move(error), verified] { + if (!self || generation != self->generation_) return; + self->busy_ = false; + self->operationText_.clear(); + self->lastExportVerified_ = verified; + self->lastExportPath_ = verified ? path : QString{}; + self->lastExportMode_ = verified ? QStringLiteral("Exact source") : QString{}; + self->lastError_ = error; + if (verified) { + self->appendDiagnostic(QStringLiteral("Verified exact source Save As · %1").arg(path)); + } else { + self->appendDiagnostic(QStringLiteral("Save As rejected · %1").arg(error)); + } + emit self->exportChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + }); + return true; +} + +bool SclWorkspaceController::exportCanonical(const QUrl& fileUrl, const QString& targetEdition) { + if (busy_ || !document_) return false; + const auto path = localPath(fileUrl).trimmed(); + if (path.isEmpty()) { + setFailure(QStringLiteral("A local canonical export path is required.")); + return false; + } + + const auto edition = requestedEdition(targetEdition, document_->edition); + if (!edition || *edition == scl::SclEdition::unknown) { + setFailure(QStringLiteral("Canonical export requires Ed1, Ed2 or Ed2.1.")); + return false; + } + const auto profile = profileFromPath(path); + if (!profile) { + setFailure(QStringLiteral("Canonical export target must use .scd, .icd or .cid. Other SCL profiles remain fail-closed.")); + return false; + } + + scl::SclCanonicalExportOptions options; + options.edition = *edition; + options.profile = *profile; + const auto validation = scl::SclExporter::validate(*document_, options); + if (!validation.success) { + setFailure(QString::fromStdString(validation.error)); + return false; + } + + const auto generation = ++generation_; + const auto stop = replaceStopSource(); + const auto expectedDocument = *document_; + busy_ = true; + operationText_ = QStringLiteral("Reconstructing canonical SCL"); + lastError_.clear(); + lastExportVerified_ = false; + lastExportPath_.clear(); + lastExportMode_.clear(); + emit stateChanged(); + emit exportChanged(); + + QPointer self{this}; + ioPool_.start([self, path, expectedDocument, options, generation, stop] { + QString error; + bool verified = false; + QString warning; + if (stop->stop_requested()) { + error = QStringLiteral("Canonical SCL export cancelled before reconstruction."); + } else { + const auto generated = scl::SclExporter::canonical(expectedDocument, options); + if (!generated.success) { + error = QString::fromStdString(generated.error); + } else if (generated.xml.size() > static_cast(maximumSclBytes)) { + error = QStringLiteral("Canonical SCL export exceeds the bounded 64 MiB workspace limit."); + } else if (stop->stop_requested()) { + error = QStringLiteral("Canonical SCL export cancelled before write."); + } else { + const QByteArray bytes{generated.xml.data(), static_cast(generated.xml.size())}; + QSaveFile output(path); + if (!output.open(QIODevice::WriteOnly)) { + error = QStringLiteral("Unable to open canonical export target: %1").arg(output.errorString()); + } else if (output.write(bytes) != bytes.size()) { + error = QStringLiteral("Unable to write complete canonical SCL: %1").arg(output.errorString()); + output.cancelWriting(); + } else if (stop->stop_requested()) { + error = QStringLiteral("Canonical SCL export cancelled before commit."); + output.cancelWriting(); + } else if (!output.commit()) { + error = QStringLiteral("Unable to commit canonical SCL export: %1").arg(output.errorString()); + } else { + auto reread = readAndParse(path, stop->get_token()); + if (!reread.error.isEmpty() || !reread.document) { + error = reread.error.isEmpty() + ? QStringLiteral("Canonical export could not be reparsed.") + : reread.error; + } else if (reread.bytes != bytes) { + error = QStringLiteral("Canonical export bytes changed after atomic commit."); + } else if (reread.document->edition != options.edition) { + error = QStringLiteral("Canonical export reparsed with the wrong IEC 61850 edition."); + } else if (!modeledSemanticsEquivalent(expectedDocument, *reread.document)) { + error = QStringLiteral("Canonical export failed modeled semantic round-trip validation."); + } else { + verified = true; + if (!generated.warnings.empty()) { + warning = QString::fromStdString(generated.warnings.front()); + } + } + } + } + } + + const auto mode = QStringLiteral("Canonical %1 %2") + .arg(profileLabel(options.profile), editionKey(options.edition)); + QMetaObject::invokeMethod(self, [self, path, generation, error = std::move(error), + warning = std::move(warning), mode, verified] { + if (!self || generation != self->generation_) return; + self->busy_ = false; + self->operationText_.clear(); + self->lastExportVerified_ = verified; + self->lastExportPath_ = verified ? path : QString{}; + self->lastExportMode_ = verified ? mode : QString{}; + self->lastError_ = error; + if (verified) { + self->appendDiagnostic(QStringLiteral("Verified %1 export · %2").arg(mode, path)); + if (!warning.isEmpty()) self->appendDiagnostic(QStringLiteral("NOTE · %1").arg(warning)); + } else { + self->appendDiagnostic(QStringLiteral("Canonical export rejected · %1").arg(error)); + } + emit self->exportChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + }); + return true; +} + +void SclWorkspaceController::cancelOperation() { + if (stopSource_) stopSource_->request_stop(); + appendDiagnostic(QStringLiteral("Cancellation requested for active SCL operation.")); +} diff --git a/apps/ied_simulator/src/ied_file_settings_workspace_qa.cpp b/apps/ied_simulator/src/ied_file_settings_workspace_qa.cpp new file mode 100644 index 000000000..ea3166dc9 --- /dev/null +++ b/apps/ied_simulator/src/ied_file_settings_workspace_qa.cpp @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsFileSettingsController.hpp" +#include "MmsSettingGroupPolicy.hpp" + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/file_service.hpp" +#include "ariec61850/mms/pdu.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/static_server_session.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mms = ar::iec61850::mms; +namespace asn1 = ar::iec61850::asn1; +namespace embedded = ar::iec61850::embedded; +namespace wire = ar::iec61850::wire; + +namespace { +using ByteVector = std::vector; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +bool waitFor(const std::function& predicate, const int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + if (predicate()) return true; + QThread::msleep(10); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +mms::MmsTypeSpecification scalarType( + const mms::MmsTypeKind kind, + std::string name) { + mms::MmsTypeSpecification type; + type.kind = kind; + type.name = std::move(name); + return type; +} + +[[nodiscard]] wire::EncodeResult copyEncoded( + const std::vector& encoded, + const std::span destination) noexcept { + if (destination.size() < encoded.size()) { + return {wire::EncodeStatus::buffer_too_small, 0U, encoded.size()}; + } + std::copy(encoded.begin(), encoded.end(), destination.begin()); + return {wire::EncodeStatus::ok, encoded.size(), encoded.size()}; +} + +[[nodiscard]] wire::EncodeResult readUnsigned( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + try { + const auto value = *static_cast(context); + return copyEncoded( + mms::MmsDataCodec::encode(mms::MmsDataValue::unsigned_integer(value)), + destination); + } catch (...) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } +} + +[[nodiscard]] wire::EncodeResult readBool( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + try { + const auto value = *static_cast(context); + return copyEncoded( + mms::MmsDataCodec::encode(mms::MmsDataValue::boolean(value)), + destination); + } catch (...) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } +} + +struct SettingFixtureState final { + std::uint64_t active{1U}; + std::uint64_t count{4U}; + std::uint64_t edit{}; + bool confirm{}; + std::uint64_t lastActivationTime{1'700'000'000U}; + std::uint64_t writeCount{}; +}; + +[[nodiscard]] mms::MmsStaticWriteResult writeActiveGroup( + void* context, + const std::span encodedData) noexcept { + auto* state = static_cast(context); + if (state == nullptr || encodedData.empty()) return {false, 11U}; + try { + const auto values = mms::MmsDataCodec::decode_all(encodedData); + if (values.size() != 1U) return {false, 11U}; + std::uint64_t requested{}; + if (values.front().kind() == mms::MmsDataKind::unsigned_integer) { + requested = std::get(values.front().value()); + } else if (values.front().kind() == mms::MmsDataKind::integer) { + const auto signedValue = std::get(values.front().value()); + if (signedValue < 0) return {false, 11U}; + requested = static_cast(signedValue); + } else { + return {false, 11U}; + } + if (requested == 0U || requested > state->count || state->edit != 0U || state->confirm) { + return {false, 11U}; + } + state->active = requested; + ++state->lastActivationTime; + ++state->writeCount; + return {true, 0U}; + } catch (...) { + return {false, 11U}; + } +} + +[[nodiscard]] embedded::IoResult qtReceive( + void* context, + const std::span destination) noexcept { + auto* socket = static_cast(context); + if (socket == nullptr || destination.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (socket->bytesAvailable() <= 0) { + return socket->state() == QAbstractSocket::UnconnectedState + ? embedded::IoResult{embedded::IoStatus::closed, 0U} + : embedded::IoResult{embedded::IoStatus::would_block, 0U}; + } + const auto count = socket->read( + reinterpret_cast(destination.data()), + static_cast(destination.size())); + if (count > 0) return {embedded::IoStatus::ok, static_cast(count)}; + if (count == 0) return {embedded::IoStatus::would_block, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult qtSend( + void* context, + const std::span bytes) noexcept { + auto* socket = static_cast(context); + if (socket == nullptr || bytes.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (socket->state() == QAbstractSocket::UnconnectedState) { + return {embedded::IoStatus::closed, 0U}; + } + const auto count = socket->write( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + if (count > 0) { + socket->flush(); + return {embedded::IoStatus::ok, static_cast(count)}; + } + if (count == 0) return {embedded::IoStatus::would_block, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +class DeterministicSettingGroupFixture final { +public: + DeterministicSettingGroupFixture() { + unsignedType_ = mms::MmsServiceCodec::encode_type_specification( + scalarType(mms::MmsTypeKind::unsigned_integer, "INT32U")); + boolType_ = mms::MmsServiceCodec::encode_type_specification( + scalarType(mms::MmsTypeKind::boolean, "BOOLEAN")); + + objects_[0] = mms::MmsStaticObjectEntry{ + "SETLD0", "LLN0$SP$SGCB$ActSG", unsignedType_, readUnsigned, &state_.active, + false, writeActiveGroup, &state_}; + objects_[1] = mms::MmsStaticObjectEntry{ + "SETLD0", "LLN0$SG$SGCB$NumOfSG", unsignedType_, readUnsigned, &state_.count}; + objects_[2] = mms::MmsStaticObjectEntry{ + "SETLD0", "LLN0$SE$SGCB$EditSG", unsignedType_, readUnsigned, &state_.edit}; + objects_[3] = mms::MmsStaticObjectEntry{ + "SETLD0", "LLN0$SP$SGCB$CnfEdit", boolType_, readBool, &state_.confirm}; + objects_[4] = mms::MmsStaticObjectEntry{ + "SETLD0", "LLN0$SP$SGCB$LActTm", unsignedType_, readUnsigned, + &state_.lastActivationTime}; + + table_ = std::make_unique( + std::span{objects_}); + dispatcher_ = std::make_unique(*table_); + QObject::connect(&server_, &QTcpServer::newConnection, [&] { acceptConnection(); }); + QObject::connect(&pollTimer_, &QTimer::timeout, [&] { poll(); }); + pollTimer_.setInterval(1); + } + + bool start() { + if (!table_->valid()) return false; + if (!server_.listen(QHostAddress::LocalHost, 0)) return false; + pollTimer_.start(); + return true; + } + + [[nodiscard]] quint16 port() const noexcept { return server_.serverPort(); } + [[nodiscard]] std::uint64_t active() const noexcept { return state_.active; } + [[nodiscard]] std::uint64_t writeCount() const noexcept { return state_.writeCount; } + [[nodiscard]] int acceptedConnections() const noexcept { return acceptedConnections_; } + void setEdit(const std::uint64_t group) noexcept { state_.edit = group; } + +private: + void acceptConnection() { + while (server_.hasPendingConnections()) { + auto* incoming = server_.nextPendingConnection(); + if (socket_ != nullptr) { + incoming->disconnectFromHost(); + incoming->deleteLater(); + continue; + } + ++acceptedConnections_; + socket_ = incoming; + mms::MmsStaticConnectionPolicy policy; + policy.association_id = 1U; + policy.owner[0] = 0x51U; + policy.owner_size = 1U; + runtime_ = std::make_unique(*dispatcher_, policy); + const embedded::TcpByteStream stream{socket_, qtSend, qtReceive}; + session_ = std::make_unique( + *runtime_, stream, + mms::MmsStaticServerSessionBuffers{receive_, response_, workspace_}); + QObject::connect(socket_, &QTcpSocket::disconnected, [&] { + if (runtime_) runtime_->close_transport(); + }); + } + } + + void poll() { + if (!session_) return; + for (int iteration = 0; iteration < 32; ++iteration) { + const auto result = session_->poll_once(); + if (result.terminal()) { + if (runtime_) runtime_->close_transport(); + if (socket_) socket_->deleteLater(); + socket_ = nullptr; + session_.reset(); + runtime_.reset(); + return; + } + if ((result.status == mms::MmsStaticServerSessionStatus::would_block || + result.status == mms::MmsStaticServerSessionStatus::timed_out) && + (socket_ == nullptr || socket_->bytesAvailable() == 0)) { + break; + } + } + } + + SettingFixtureState state_; + std::vector unsignedType_; + std::vector boolType_; + std::array objects_{}; + std::unique_ptr table_; + std::unique_ptr dispatcher_; + QTcpServer server_; + QTimer pollTimer_; + QTcpSocket* socket_{}; + int acceptedConnections_{}; + std::unique_ptr runtime_; + std::unique_ptr session_; + std::array receive_{}; + std::array response_{}; + std::array workspace_{}; +}; + +ByteVector concat(const std::initializer_list> parts) { + ByteVector result; + for (const auto part : parts) result.insert(result.end(), part.begin(), part.end()); + return result; +} + +ByteVector confirmedResponse( + const std::uint32_t invokeId, + const std::int32_t serviceTag, + const bool constructed, + const std::span value) { + return mms::MmsPduCodec::wrap_p_data( + mms::MmsPduCodec::encode_confirmed_response( + {invokeId, serviceTag, constructed, ByteVector{value.begin(), value.end()}})); +} + +ByteVector directoryEntry(const std::string& name, const std::uint32_t size) { + const auto graphic = asn1::BerWriter::encode_tlv( + 0x19U, asn1::BerWriter::encode_ascii(name)); + const auto fileName = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, true, 0, graphic); + const auto encodedSize = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 0, + asn1::BerWriter::encode_unsigned_integer(size)); + const auto modified = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 1, + asn1::BerWriter::encode_ascii("20260914070000Z")); + const auto attributes = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, true, 1, + concat({encodedSize, modified})); + return asn1::BerWriter::encode_tlv(0x30U, concat({fileName, attributes})); +} + +ByteVector directoryResponse( + const std::uint32_t invokeId, + const std::vector& entries, + const bool moreFollows) { + ByteVector encodedEntries; + for (const auto& entry : entries) { + encodedEntries.insert(encodedEntries.end(), entry.begin(), entry.end()); + } + const auto sequence = asn1::BerWriter::encode_tlv(0x30U, encodedEntries); + const auto list = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, true, 0, sequence); + const auto more = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 1, + asn1::BerWriter::encode_boolean(moreFollows)); + return confirmedResponse( + invokeId, mms::MmsFileServiceCodec::file_directory_service_tag, + true, concat({list, more})); +} + +ByteVector openResponse( + const std::uint32_t invokeId, + const std::int32_t frsm, + const std::uint32_t size) { + const auto id = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 0, + asn1::BerWriter::encode_signed_integer(frsm)); + const auto encodedSize = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 0, + asn1::BerWriter::encode_unsigned_integer(size)); + const auto modified = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 1, + asn1::BerWriter::encode_ascii("20260914070000Z")); + const auto attributes = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, true, 1, + concat({encodedSize, modified})); + return confirmedResponse( + invokeId, mms::MmsFileServiceCodec::file_open_service_tag, + true, concat({id, attributes})); +} + +ByteVector readResponse( + const std::uint32_t invokeId, + const std::span data, + const bool moreFollows) { + const auto block = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 0, data); + const auto more = asn1::BerWriter::encode_tlv( + asn1::BerClass::context_specific, false, 1, + asn1::BerWriter::encode_boolean(moreFollows)); + return confirmedResponse( + invokeId, mms::MmsFileServiceCodec::file_read_service_tag, + true, concat({block, more})); +} + +ByteVector closeResponse(const std::uint32_t invokeId) { + return confirmedResponse( + invokeId, mms::MmsFileServiceCodec::file_close_service_tag, false, {}); +} + +class FakeFileChannel final : public mms::MmsFileServiceChannel { +public: + using Step = std::function, std::uint32_t, std::stop_token)>; + + [[nodiscard]] std::uint32_t next_invoke_id() override { return next_++; } + [[nodiscard]] ByteVector exchange_confirmed( + const std::span request, + const std::uint32_t invokeId, + const std::stop_token stopToken) override { + if (steps.empty()) throw std::runtime_error("Fake file channel script exhausted."); + auto step = std::move(steps.front()); + steps.pop_front(); + sent.emplace_back(request.begin(), request.end()); + return step(request, invokeId, stopToken); + } + [[nodiscard]] bool associated() const noexcept override { return true; } + [[nodiscard]] std::uint32_t presentation_context_id() const noexcept override { return 3U; } + + void respond(ByteVector response) { + steps.push_back([response = std::move(response)]( + std::span, std::uint32_t, std::stop_token) { + return response; + }); + } + + std::uint32_t next_{1U}; + std::deque steps; + std::vector sent; +}; + +class MemorySink final : public mms::MmsFileSink { +public: + [[nodiscard]] bool write( + const std::span data, + std::string& error) noexcept override { + if (failWrite) { + error = "scripted sink write failure"; + return false; + } + bytes.insert(bytes.end(), data.begin(), data.end()); + return true; + } + [[nodiscard]] bool flush(std::string&) noexcept override { + flushed = true; + return true; + } + [[nodiscard]] bool reset(std::string&) noexcept override { + bytes.clear(); + flushed = false; + return true; + } + + ByteVector bytes; + bool failWrite{}; + bool flushed{}; +}; + +void proveFileRuntime() { + FakeFileChannel listing; + listing.respond(directoryResponse(1U, { + directoryEntry("TRIP_A.cfg", 3U), directoryEntry("TRIP_A.dat", 6U)}, true)); + listing.respond(directoryResponse(2U, { + directoryEntry("IED.CID", 9U)}, false)); + mms::MmsFileTransferRuntime directoryRuntime{listing}; + mms::MmsFileDirectoryOptions directoryOptions; + directoryOptions.maximum_pages = 4U; + directoryOptions.maximum_entries = 8U; + const auto directory = directoryRuntime.list_directory("COMTRADE", directoryOptions); + CHECK(directory.success); + CHECK(directory.pages.size() == 2U); + CHECK(directory.entries.size() == 3U); + + FakeFileChannel download; + download.respond(openResponse(1U, 7, 6U)); + download.respond(readResponse(2U, ByteVector{'A', 'B', 'C'}, true)); + download.respond(readResponse(3U, ByteVector{'D', 'E', 'F'}, false)); + download.respond(closeResponse(4U)); + MemorySink sink; + mms::MmsFileTransferRuntime downloadRuntime{download}; + mms::MmsFileTransferOptions transferOptions; + transferOptions.maximum_bytes = 64U; + transferOptions.maximum_read_operations = 8U; + transferOptions.maximum_block_bytes = 16U; + const auto transfer = downloadRuntime.download( + "COMTRADE/TRIP_A.cfg", sink, transferOptions); + CHECK(transfer.success); + CHECK(transfer.remote_file_closed); + CHECK(transfer.bytes_transferred == 6U); + CHECK(transfer.read_operations == 2U); + CHECK(sink.bytes == ByteVector({'A', 'B', 'C', 'D', 'E', 'F'})); + CHECK(sink.flushed); + + FakeFileChannel cleanup; + cleanup.respond(openResponse(1U, 9, 3U)); + cleanup.respond(readResponse(2U, ByteVector{'B', 'A', 'D'}, false)); + cleanup.respond(closeResponse(3U)); + MemorySink failingSink; + failingSink.failWrite = true; + mms::MmsFileTransferRuntime cleanupRuntime{cleanup}; + const auto failed = cleanupRuntime.download( + "COMTRADE/FAIL.dat", failingSink, transferOptions); + CHECK(!failed.success); + CHECK(failed.remote_file_closed); + CHECK(failed.failure_kind == mms::MmsFileFailureKind::sink); +} +} // namespace + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + qputenv("ARSTACK_IEDSIM_QA", "1"); + + try { + MmsFileSettingsController disconnected; + CHECK(!disconnected.browseDirectory(QString{})); + CHECK(!disconnected.downloadFile(QStringLiteral("A"), QStringLiteral("B"))); + CHECK(!disconnected.activateSelectedSettingGroup(1)); + + proveFileRuntime(); + + DeterministicSettingGroupFixture fixture; + CHECK(fixture.start()); + CHECK(fixture.port() != 0U); + + MmsFileSettingsController controller; + controller.setHost(QStringLiteral("127.0.0.1")); + controller.setPort(fixture.port()); + CHECK(controller.connectToIed()); + CHECK(waitFor([&] { return controller.connected(); }, 10'000)); + CHECK(controller.lastError().isEmpty()); + CHECK(controller.settingGroupCount() == 1); + CHECK(controller.selectedSettingGroupIndex() == 0); + + const auto initial = controller.selectedSettingGroup(); + CHECK(initial.value(QStringLiteral("attributeCount")).toInt() == 5); + CHECK(initial.value(QStringLiteral("numOfSG")).toULongLong() == 4U); + CHECK(initial.value(QStringLiteral("actSG")).toULongLong() == 1U); + CHECK(initial.value(QStringLiteral("editSG")).toULongLong() == 0U); + CHECK(!initial.value(QStringLiteral("cnfEdit")).toBool()); + CHECK(initial.value(QStringLiteral("canActivate")).toBool()); + CHECK(!initial.value(QStringLiteral("fullEditSupported")).toBool()); + CHECK(fixture.acceptedConnections() == 1); + + CHECK(controller.activateSelectedSettingGroup(3)); + CHECK(waitFor([&] { return !controller.operationBusy(); }, 6'000)); + CHECK(controller.lastError().isEmpty()); + CHECK(fixture.active() == 3U); + CHECK(fixture.writeCount() == 1U); + CHECK(controller.selectedSettingGroup() + .value(QStringLiteral("actSG")).toULongLong() == 3U); + + CHECK(!controller.activateSelectedSettingGroup(5)); + CHECK(fixture.writeCount() == 1U); + + fixture.setEdit(2U); + CHECK(controller.refreshSettingGroups()); + CHECK(waitFor([&] { return !controller.operationBusy(); }, 6'000)); + CHECK(controller.selectedSettingGroup() + .value(QStringLiteral("editSG")).toULongLong() == 2U); + CHECK(!controller.selectedSettingGroup() + .value(QStringLiteral("canActivate")).toBool()); + CHECK(!controller.activateSelectedSettingGroup(2)); + CHECK(fixture.writeCount() == 1U); + CHECK(fixture.acceptedConnections() == 1); + + std::cout + << "FILE_SETTINGS_WORKSPACE_PASS" + << " file_pages=2 file_entries=3 download_bytes=6 file_close=pass" + << " sgcb=1 attributes=5 actsg_verified=3 activation_writes=1" + << " persistent_association=pass full_sg_edit_claimed=false\n"; + std::cout + << "FILE_SETTINGS_WORKSPACE_NEGATIVE_PASS" + << " disconnected_ops=rejected invalid_group=rejected" + << " edit_in_progress=rejected sink_failure_cleanup=pass" + << " bounded_pages=4 bounded_entries=8 no_activation_retry=true\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << "FILE_SETTINGS_WORKSPACE_FAIL " << exception.what() << '\n'; + return 1; + } +} diff --git a/apps/ied_simulator/src/ied_goose_workspace_qa.cpp b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp new file mode 100644 index 000000000..283a5788a --- /dev/null +++ b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "GooseMonitorController.hpp" + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/mms/data_value.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850; +#define REQUIRE(condition, message) do { if (!(condition)) throw std::runtime_error(message); } while (false) + +std::vector encode_variant( + goose::GooseFrame frame, + const std::uint32_t state, + const std::uint32_t sequence, + const std::uint32_t ttl = 20U) { + frame.pdu.state_number = state; + frame.pdu.sequence_number = sequence; + frame.pdu.time_allowed_to_live_milliseconds = ttl; + return goose::GooseFrameCodec::encode(frame); +} + +QString field(const QVariantMap& map, const char* key) { + return map.value(QString::fromLatin1(key)).toString(); +} +} // namespace + +int main(int argc, char** argv) { + QCoreApplication application(argc, argv); + try { + if (argc < 2) { + std::cerr << "usage: ied_goose_workspace_qa \n"; + return 2; + } + + const auto packets = capture::PcapReader::read_all(std::filesystem::path{argv[1]}); + REQUIRE(packets.size() == 3U, "Stage M publisher evidence must contain exactly 3 packets"); + + GooseMonitorController monitor; + const auto base = GooseMonitorController::clock::time_point{} + std::chrono::seconds{10}; + const auto firstTimestamp = packets.front().timestamp; + for (const auto& packet : packets) { + const auto relative = std::chrono::duration_cast( + packet.timestamp - firstTimestamp); + REQUIRE(monitor.ingestFrameBytes(packet.frame, base + relative), + "monitor rejected Stage M GOOSE PCAP evidence"); + } + REQUIRE(monitor.rowCount() == 1, "publisher PCAP should project one monitored stream"); + REQUIRE(monitor.packetCount() == 3U, "publisher PCAP packet count mismatch"); + monitor.select(0); + const auto publisherEvidence = monitor.selectedStream(); + REQUIRE(field(publisherEvidence, "appIdText") == QStringLiteral("0x1001"), + "APPID was not projected by monitor"); + REQUIRE(publisherEvidence.value(QStringLiteral("stNum")).toULongLong() == 2U, + "state-change stNum was not observed"); + REQUIRE(publisherEvidence.value(QStringLiteral("sqNum")).toULongLong() == 0U, + "state-change sqNum reset was not observed"); + REQUIRE(!field(publisherEvidence, "goCbRef").isEmpty(), "gocbRef missing"); + REQUIRE(!field(publisherEvidence, "dataSetReference").isEmpty(), "DataSet missing"); + REQUIRE(publisherEvidence.value(QStringLiteral("valueCount")).toInt() > 0, + "decoded allData values were not exposed"); + + goose::GooseFrame baseFrame; + REQUIRE(goose::GooseFrameCodec::try_decode(packets.back().frame, baseFrame), + "could not decode Stage M evidence for sequence QA"); + const auto lastRelative = std::chrono::duration_cast( + packets.back().timestamp - firstTimestamp); + auto arrival = base + lastRelative + std::chrono::milliseconds{1}; + + const auto duplicate = encode_variant(baseFrame, 2U, 0U); + REQUIRE(monitor.ingestFrameBytes(duplicate, arrival), "duplicate frame rejected unexpectedly"); + REQUIRE(monitor.duplicateCount() == 1U, "duplicate was not classified"); + + arrival += std::chrono::milliseconds{1}; + const auto gap = encode_variant(baseFrame, 2U, 3U); + REQUIRE(monitor.ingestFrameBytes(gap, arrival), "sequence-gap frame rejected unexpectedly"); + REQUIRE(monitor.sequenceIssueCount() >= 1U, "sequence gap was not classified"); + + arrival += std::chrono::milliseconds{1}; + const auto regression = encode_variant(baseFrame, 2U, 2U); + REQUIRE(monitor.ingestFrameBytes(regression, arrival), "sequence regression rejected unexpectedly"); + + arrival += std::chrono::milliseconds{1}; + const auto stateJump = encode_variant(baseFrame, 4U, 0U); + REQUIRE(monitor.ingestFrameBytes(stateJump, arrival), "state jump rejected unexpectedly"); + + arrival += std::chrono::milliseconds{1}; + const auto stateRegression = encode_variant(baseFrame, 3U, 0U, 5U); + REQUIRE(monitor.ingestFrameBytes(stateRegression, arrival), "state regression rejected unexpectedly"); + REQUIRE(monitor.sequenceIssueCount() >= 4U, + "gap/regression/state-jump/state-regression anomaly count incomplete"); + + monitor.checkTimeoutsAt(arrival + std::chrono::milliseconds{20}); + REQUIRE(monitor.timeoutCount() >= 1U, "GOOSE TTL timeout was not surfaced"); + REQUIRE(monitor.selectedStream().value(QStringLiteral("timedOut")).toBool(), + "selected stream timeout indicator missing"); + + QTemporaryDir temporary; + REQUIRE(temporary.isValid(), "could not create temporary directory for PCAP export"); + const auto exportedPath = temporary.filePath(QStringLiteral("monitor-evidence.pcap")); + REQUIRE(monitor.exportPcap(QUrl::fromLocalFile(exportedPath)), "PCAP export failed"); + REQUIRE(QFileInfo::exists(exportedPath), "PCAP export file was not created"); + const auto exported = capture::PcapReader::read_all( + std::filesystem::path{exportedPath.toStdString()}); + REQUIRE(exported.size() == monitor.retainedPacketCount(), + "PCAP export did not preserve the retained monitor packet set"); + + const std::vector malformed{0x00U, 0x01U, 0x02U}; + REQUIRE(!monitor.ingestFrameBytes(malformed, arrival + std::chrono::milliseconds{21}), + "malformed Ethernet frame was accepted"); + REQUIRE(monitor.decodeErrorCount() == 1U, "malformed-frame counter mismatch"); + + auto oversize = baseFrame; + oversize.pdu.state_number = 5U; + oversize.pdu.sequence_number = 0U; + oversize.pdu.values.clear(); + for (int index = 0; index < monitor.memberCapacity() + 1; ++index) { + oversize.pdu.values.push_back(mms::MmsDataValue::boolean((index & 1) != 0)); + } + const auto oversizeBytes = goose::GooseFrameCodec::encode(oversize); + REQUIRE(!monitor.ingestFrameBytes(oversizeBytes, arrival + std::chrono::milliseconds{22}), + "oversize allData frame was accepted"); + REQUIRE(monitor.rejectedFrameCount() >= 1U, "oversize rejection was not counted"); + + auto additional = baseFrame; + additional.pdu.values = {mms::MmsDataValue::boolean(true)}; + additional.pdu.state_number = 1U; + additional.pdu.sequence_number = 0U; + additional.pdu.time_allowed_to_live_milliseconds = 1000U; + for (int index = 0; index < monitor.streamCapacity(); ++index) { + additional.app_id = static_cast(0x2000U + static_cast(index)); + additional.pdu.go_cb_ref = "QA/LLN0$GO$gcb" + std::to_string(index); + const auto bytes = goose::GooseFrameCodec::encode(additional); + const bool accepted = monitor.ingestFrameBytes( + bytes, arrival + std::chrono::milliseconds{30 + index}); + if (index < monitor.streamCapacity() - 1) { + REQUIRE(accepted, "bounded stream table rejected before reaching capacity"); + } else { + REQUIRE(!accepted, "bounded stream table accepted a 257th identity"); + } + } + REQUIRE(monitor.rowCount() == monitor.streamCapacity(), "stream capacity projection mismatch"); + REQUIRE(monitor.droppedStreamCount() == 1U, "stream-capacity drop counter mismatch"); + REQUIRE(monitor.recentEvents().size() <= monitor.eventCapacity(), "event retention is unbounded"); + REQUIRE(monitor.retainedPacketCount() <= monitor.retainedPacketCapacity(), + "packet retention is unbounded"); + + monitor.stopCapture(); + monitor.setInterfaceName(QStringLiteral("arstack-no-such-interface-61850")); + REQUIRE(!monitor.startCapture(), "nonexistent monitor interface did not fail closed"); + REQUIRE(!monitor.capturing(), "capture became active after invalid interface request"); + + std::cout + << "GOOSE_WORKSPACE_PASS pcap_packets=3 streams=" << monitor.rowCount() + << " appid=0x1001 values=decoded duplicate=pass gap=pass regression=pass" + " state_jump=pass state_regression=pass timeout=pass pcap_export=pass\n"; + std::cout + << "GOOSE_MONITOR_NEGATIVE_PASS malformed=rejected oversize=rejected capacity=rejected" + " missing_interface=rejected explicit_binding=required bounded_streams=" + << monitor.streamCapacity() << " bounded_members=" << monitor.memberCapacity() + << " bounded_events=" << monitor.eventCapacity() + << " retained_packets=" << monitor.retainedPacketCapacity() << "\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "GOOSE_WORKSPACE_QA_FAIL " << error.what() << '\n'; + return 1; + } +} diff --git a/apps/ied_simulator/src/ied_mms_client_workbench_qa.cpp b/apps/ied_simulator/src/ied_mms_client_workbench_qa.cpp new file mode 100644 index 000000000..c14bce43f --- /dev/null +++ b/apps/ied_simulator/src/ied_mms_client_workbench_qa.cpp @@ -0,0 +1,409 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsClientController.hpp" + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/static_server_session.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mms = ar::iec61850::mms; +namespace embedded = ar::iec61850::embedded; +namespace wire = ar::iec61850::wire; + +namespace { +bool waitFor(const std::function& predicate, const int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + if (predicate()) return true; + QThread::msleep(10); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +QString selectedValue(MmsClientController& client) { + return client.treeModel()->selectedNode().value(QStringLiteral("value")).toString(); +} + +mms::MmsTypeSpecification scalarType( + const mms::MmsTypeKind kind, + std::string name) { + mms::MmsTypeSpecification type; + type.kind = kind; + type.name = std::move(name); + return type; +} + +mms::MmsTypeSpecification structureType( + std::string name, + std::vector children) { + mms::MmsTypeSpecification type; + type.kind = mms::MmsTypeKind::structure; + type.name = std::move(name); + type.children = std::move(children); + return type; +} + +[[nodiscard]] wire::EncodeResult copyEncoded( + const std::vector& encoded, + const std::span destination) noexcept { + if (destination.size() < encoded.size()) { + return {wire::EncodeStatus::buffer_too_small, 0U, encoded.size()}; + } + std::copy(encoded.begin(), encoded.end(), destination.begin()); + return {wire::EncodeStatus::ok, encoded.size(), encoded.size()}; +} + +[[nodiscard]] wire::EncodeResult readSetPoint( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + try { + const auto value = *static_cast(context); + return copyEncoded(mms::MmsDataCodec::encode(mms::MmsDataValue::integer(value)), destination); + } catch (...) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } +} + +[[nodiscard]] wire::EncodeResult readLogicalNode( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + try { + const auto value = *static_cast(context); + const auto root = mms::MmsDataValue::structure({ + mms::MmsDataValue::structure({ + mms::MmsDataValue::structure({ + mms::MmsDataValue::integer(value), + }), + }), + }); + return copyEncoded(mms::MmsDataCodec::encode(root), destination); + } catch (...) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } +} + +[[nodiscard]] mms::MmsStaticWriteResult writeSetPoint( + void* context, + const std::span encodedData) noexcept { + if (context == nullptr || encodedData.empty()) return {false, 11U}; + try { + const auto values = mms::MmsDataCodec::decode_all(encodedData); + if (values.size() != 1U || values.front().kind() != mms::MmsDataKind::integer) { + return {false, 11U}; + } + *static_cast(context) = std::get(values.front().value()); + return {true, 0U}; + } catch (...) { + return {false, 11U}; + } +} + +[[nodiscard]] embedded::IoResult qtReceive( + void* context, + const std::span destination) noexcept { + auto* socket = static_cast(context); + if (socket == nullptr || destination.empty()) return {embedded::IoStatus::invalid_argument, 0U}; + if (socket->bytesAvailable() <= 0) { + return socket->state() == QAbstractSocket::UnconnectedState + ? embedded::IoResult{embedded::IoStatus::closed, 0U} + : embedded::IoResult{embedded::IoStatus::would_block, 0U}; + } + const auto count = socket->read( + reinterpret_cast(destination.data()), + static_cast(destination.size())); + if (count > 0) return {embedded::IoStatus::ok, static_cast(count)}; + if (count == 0) return {embedded::IoStatus::would_block, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult qtSend( + void* context, + const std::span bytes) noexcept { + auto* socket = static_cast(context); + if (socket == nullptr || bytes.empty()) return {embedded::IoStatus::invalid_argument, 0U}; + if (socket->state() == QAbstractSocket::UnconnectedState) { + return {embedded::IoStatus::closed, 0U}; + } + const auto count = socket->write( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + if (count > 0) { + socket->flush(); + return {embedded::IoStatus::ok, static_cast(count)}; + } + if (count == 0) return {embedded::IoStatus::would_block, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +class DeterministicMmsFixture final { +public: + DeterministicMmsFixture() { + const auto rootType = structureType({}, { + structureType("SP", { + structureType("SetPoint", { + scalarType(mms::MmsTypeKind::integer, "setVal"), + }), + }), + }); + rootType_ = mms::MmsServiceCodec::encode_type_specification(rootType); + leafType_ = mms::MmsServiceCodec::encode_type_specification( + scalarType(mms::MmsTypeKind::integer, "setVal")); + objects_[0] = mms::MmsStaticObjectEntry{ + "CLIENTLD0", "GGIO1", rootType_, readLogicalNode, &setPoint_}; + objects_[1] = mms::MmsStaticObjectEntry{ + "CLIENTLD0", "GGIO1$SP$SetPoint$setVal", leafType_, readSetPoint, &setPoint_, + false, writeSetPoint, &setPoint_}; + table_ = std::make_unique( + std::span{objects_}); + dispatcher_ = std::make_unique(*table_); + + QObject::connect(&server_, &QTcpServer::newConnection, [&] { acceptConnection(); }); + QObject::connect(&pollTimer_, &QTimer::timeout, [&] { poll(); }); + pollTimer_.setInterval(1); + } + + bool start() { + if (!table_->valid()) return false; + if (!server_.listen(QHostAddress::LocalHost, 0)) return false; + pollTimer_.start(); + return true; + } + + [[nodiscard]] quint16 port() const noexcept { return server_.serverPort(); } + [[nodiscard]] bool connectionIdle() const noexcept { + return socket_ == nullptr && session_ == nullptr && runtime_ == nullptr; + } + +private: + void acceptConnection() { + while (server_.hasPendingConnections()) { + auto* incoming = server_.nextPendingConnection(); + if (socket_ != nullptr) { + incoming->disconnectFromHost(); + incoming->deleteLater(); + continue; + } + socket_ = incoming; + mms::MmsStaticConnectionPolicy policy; + policy.association_id = 1U; + policy.owner[0] = 1U; + policy.owner_size = 1U; + runtime_ = std::make_unique(*dispatcher_, policy); + const embedded::TcpByteStream stream{socket_, qtSend, qtReceive}; + session_ = std::make_unique( + *runtime_, + stream, + mms::MmsStaticServerSessionBuffers{receive_, response_, workspace_}); + QObject::connect(socket_, &QTcpSocket::disconnected, [&] { + if (runtime_) runtime_->close_transport(); + }); + } + } + + void poll() { + if (!session_) return; + for (int iteration = 0; iteration < 32; ++iteration) { + const auto result = session_->poll_once(); + if (result.terminal()) { + if (runtime_) runtime_->close_transport(); + if (socket_) socket_->deleteLater(); + socket_ = nullptr; + session_.reset(); + runtime_.reset(); + return; + } + if ((result.status == mms::MmsStaticServerSessionStatus::would_block || + result.status == mms::MmsStaticServerSessionStatus::timed_out) && + (socket_ == nullptr || socket_->bytesAvailable() == 0)) { + break; + } + } + } + + std::int64_t setPoint_{7}; + std::vector rootType_; + std::vector leafType_; + std::array objects_{}; + std::unique_ptr table_; + std::unique_ptr dispatcher_; + QTcpServer server_; + QTimer pollTimer_; + QTcpSocket* socket_{}; + std::unique_ptr runtime_; + std::unique_ptr session_; + std::array receive_{}; + std::array response_{}; + std::array workspace_{}; +}; +} // namespace + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + if (argc != 2) { + std::cerr << "Usage: ied_mms_client_workbench_qa \n"; + return 2; + } + static_cast(argv[1]); + + DeterministicMmsFixture fixture; + if (!fixture.start() || fixture.port() == 0U) { + std::cerr << "Deterministic MMS fixture did not start.\n"; + return 3; + } + + MmsClientController client; + client.setHost(QStringLiteral("127.0.0.1")); + client.setPort(fixture.port()); + if (!client.connectToIed() || !waitFor([&] { return client.connected(); }, 12'000)) { + std::cerr << "Client connection/discovery failed: " << client.lastError().toStdString() << '\n'; + return 6; + } + + if (client.logicalDeviceCount() != 1 || client.logicalNodeCount() != 1 || + client.dataObjectCount() != 1 || client.dataAttributeCount() != 1) { + std::cerr << "Live model projection is incomplete: ld=" << client.logicalDeviceCount() + << " ln=" << client.logicalNodeCount() + << " do=" << client.dataObjectCount() + << " da=" << client.dataAttributeCount() << '\n'; + return 7; + } + if (!client.treeModel()->selectMmsItem( + QStringLiteral("CLIENTLD0"), QStringLiteral("GGIO1$SP$SetPoint$setVal"))) { + std::cerr << "Writable set point was not projected.\n"; + return 8; + } + + const auto selected = client.treeModel()->selectedNode(); + if (selected.value(QStringLiteral("functionalConstraint")).toString() != QStringLiteral("SP") || + selected.value(QStringLiteral("mmsType")).toString() != QStringLiteral("integer") || + !selected.value(QStringLiteral("writable")).toBool()) { + std::cerr << "Exact-type guarded Write policy did not classify the SP integer correctly.\n"; + return 9; + } + + if (!client.readSelected() || !waitFor([&] { return !client.operationBusy(); }, 6'000)) { + std::cerr << "Selected Read did not complete.\n"; + return 10; + } + const auto before = selectedValue(client); + if (before != QStringLiteral("7")) { + std::cerr << "Unexpected initial Read value: " << before.toStdString() << '\n'; + return 11; + } + + if (!client.writeSelected(QStringLiteral("9")) || + !waitFor([&] { return !client.operationBusy(); }, 6'000)) { + std::cerr << "Guarded Write did not complete: " << client.lastError().toStdString() << '\n'; + return 12; + } + if (!client.lastError().isEmpty()) { + std::cerr << "Guarded Write failed: " << client.lastError().toStdString() << '\n'; + return 12; + } + const auto after = selectedValue(client); + if (after != QStringLiteral("9")) { + std::cerr << "Write verification Read did not observe 9; got " << after.toStdString() << '\n'; + return 13; + } + + std::cout << "MMS_CLIENT_WORKBENCH_PASS" + << " ld=" << client.logicalDeviceCount() + << " ln=" << client.logicalNodeCount() + << " do=" << client.dataObjectCount() + << " da=" << client.dataAttributeCount() + << " read=" << before.toStdString() + << " write_verified=" << after.toStdString() + << " exact_type=integer fc=SP persistent_association=pass\n"; + + const auto staleGeneration = client.generation(); + if (!client.readSelected()) { + std::cerr << "Could not start stale-session negative Read.\n"; + return 14; + } + client.disconnectFromIed(); + const auto currentGeneration = client.generation(); + if (currentGeneration <= staleGeneration || !waitFor([&] { return !client.operationBusy(); }, 1'000)) { + std::cerr << "Disconnect did not invalidate the session generation.\n"; + return 15; + } + QThread::msleep(100); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (client.connected() || client.treeModel()->totalNodeCount() != 0 || !client.treeModel()->selectedNode().isEmpty()) { + std::cerr << "Stale session state was applied after disconnect.\n"; + return 16; + } + + std::cout << "MMS_CLIENT_STALE_SESSION_NEGATIVE_PASS" + << " stale_generation=" << staleGeneration + << " current_generation=" << currentGeneration + << " stale_state_not_applied=true bounded_io_worker=1\n"; + + constexpr int reconnectCycles = 24; + quint64 previousGeneration = client.generation(); + for (int cycle = 1; cycle <= reconnectCycles; ++cycle) { + if (!waitFor([&] { return fixture.connectionIdle() && !client.operationBusy(); }, 3'000)) { + std::cerr << "Fixture did not become idle before reconnect cycle " << cycle << ".\n"; + return 17; + } + if (!client.connectToIed() || !waitFor([&] { return client.connected(); }, 6'000)) { + std::cerr << "Reconnect cycle " << cycle << " failed: " + << client.lastError().toStdString() << '\n'; + return 18; + } + if (client.generation() <= previousGeneration || client.logicalDeviceCount() != 1 || + client.logicalNodeCount() != 1 || client.dataObjectCount() != 1 || + client.dataAttributeCount() != 1) { + std::cerr << "Reconnect cycle " << cycle << " restored an invalid session/model.\n"; + return 19; + } + const auto connectedGeneration = client.generation(); + client.disconnectFromIed(); + if (!waitFor( + [&] { + return !client.connected() && !client.operationBusy() && fixture.connectionIdle(); + }, + 3'000)) { + std::cerr << "Reconnect cycle " << cycle << " did not cleanly disconnect.\n"; + return 20; + } + if (client.generation() <= connectedGeneration || client.treeModel()->totalNodeCount() != 0 || + !client.treeModel()->selectedNode().isEmpty()) { + std::cerr << "Reconnect cycle " << cycle << " leaked stale session state.\n"; + return 21; + } + previousGeneration = client.generation(); + } + + std::cout << "MMS_CLIENT_RECONNECT_SOAK_PASS" + << " cycles=" << reconnectCycles + << " association_reacquire=pass" + << " clean_disconnect=pass" + << " stale_state_not_applied=true" + << " bounded_io_worker=1\n"; + return 0; +} diff --git a/apps/ied_simulator/src/ied_mms_scl_assisted_workbench_qa.cpp b/apps/ied_simulator/src/ied_mms_scl_assisted_workbench_qa.cpp new file mode 100644 index 000000000..7720e0ea4 --- /dev/null +++ b/apps/ied_simulator/src/ied_mms_scl_assisted_workbench_qa.cpp @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsClientController.hpp" + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/static_server_session.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mms = ar::iec61850::mms; +namespace embedded = ar::iec61850::embedded; +namespace wire = ar::iec61850::wire; + +namespace { + +bool waitFor(const std::function& predicate, const int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + if (predicate()) return true; + QThread::msleep(10); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +mms::MmsTypeSpecification scalarType( + const mms::MmsTypeKind kind, + std::string name) { + mms::MmsTypeSpecification type; + type.kind = kind; + type.name = std::move(name); + return type; +} + +mms::MmsTypeSpecification structureType( + std::string name, + std::vector children) { + mms::MmsTypeSpecification type; + type.kind = mms::MmsTypeKind::structure; + type.name = std::move(name); + type.children = std::move(children); + return type; +} + +[[nodiscard]] wire::EncodeResult copyEncoded( + const std::vector& encoded, + const std::span destination) noexcept { + if (destination.size() < encoded.size()) { + return {wire::EncodeStatus::buffer_too_small, 0U, encoded.size()}; + } + std::copy(encoded.begin(), encoded.end(), destination.begin()); + return {wire::EncodeStatus::ok, encoded.size(), encoded.size()}; +} + +[[nodiscard]] wire::EncodeResult readSetPoint( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + try { + const auto value = *static_cast(context); + return copyEncoded( + mms::MmsDataCodec::encode(mms::MmsDataValue::integer(value)), + destination); + } catch (...) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } +} + +[[nodiscard]] embedded::IoResult qtReceive( + void* context, + const std::span destination) noexcept { + auto* socket = static_cast(context); + if (socket == nullptr || destination.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (socket->bytesAvailable() <= 0) { + return socket->state() == QAbstractSocket::UnconnectedState + ? embedded::IoResult{embedded::IoStatus::closed, 0U} + : embedded::IoResult{embedded::IoStatus::would_block, 0U}; + } + const auto count = socket->read( + reinterpret_cast(destination.data()), + static_cast(destination.size())); + if (count > 0) return {embedded::IoStatus::ok, static_cast(count)}; + if (count == 0) return {embedded::IoStatus::would_block, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult qtSend( + void* context, + const std::span bytes) noexcept { + auto* socket = static_cast(context); + if (socket == nullptr || bytes.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (socket->state() == QAbstractSocket::UnconnectedState) { + return {embedded::IoStatus::closed, 0U}; + } + const auto count = socket->write( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + if (count > 0) { + socket->flush(); + return {embedded::IoStatus::ok, static_cast(count)}; + } + if (count == 0) return {embedded::IoStatus::would_block, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +class SclAssistedFixture final { +public: + SclAssistedFixture() { + leafType_ = mms::MmsServiceCodec::encode_type_specification( + scalarType(mms::MmsTypeKind::integer, "setVal")); + objects_[0] = mms::MmsStaticObjectEntry{ + "CLIENTLD0", + "GGIO1$SP$SetPoint$setVal", + leafType_, + readSetPoint, + &setPoint_}; + table_ = std::make_unique( + std::span{objects_}); + dispatcher_ = std::make_unique(*table_); + + QObject::connect(&server_, &QTcpServer::newConnection, [&] { acceptConnection(); }); + QObject::connect(&pollTimer_, &QTimer::timeout, [&] { poll(); }); + pollTimer_.setInterval(1); + } + + bool start() { + if (!table_->valid()) return false; + if (!server_.listen(QHostAddress::LocalHost, 0)) return false; + pollTimer_.start(); + return true; + } + + [[nodiscard]] quint16 port() const noexcept { return server_.serverPort(); } + [[nodiscard]] bool connectionIdle() const noexcept { + return socket_ == nullptr && session_ == nullptr && runtime_ == nullptr; + } + +private: + void acceptConnection() { + while (server_.hasPendingConnections()) { + auto* incoming = server_.nextPendingConnection(); + if (socket_ != nullptr) { + incoming->disconnectFromHost(); + incoming->deleteLater(); + continue; + } + socket_ = incoming; + mms::MmsStaticConnectionPolicy policy; + policy.association_id = 1U; + policy.owner[0] = 1U; + policy.owner_size = 1U; + runtime_ = std::make_unique(*dispatcher_, policy); + const embedded::TcpByteStream stream{socket_, qtSend, qtReceive}; + session_ = std::make_unique( + *runtime_, stream, + mms::MmsStaticServerSessionBuffers{receive_, response_, workspace_}); + QObject::connect(socket_, &QTcpSocket::disconnected, [&] { + if (runtime_) runtime_->close_transport(); + }); + } + } + + void poll() { + if (!session_) return; + for (int iteration = 0; iteration < 32; ++iteration) { + const auto result = session_->poll_once(); + if (result.terminal()) { + if (runtime_) runtime_->close_transport(); + if (socket_) socket_->deleteLater(); + socket_ = nullptr; + session_.reset(); + runtime_.reset(); + return; + } + if ((result.status == mms::MmsStaticServerSessionStatus::would_block || + result.status == mms::MmsStaticServerSessionStatus::timed_out) && + (socket_ == nullptr || socket_->bytesAvailable() == 0)) { + break; + } + } + } + + std::int64_t setPoint_{7}; + std::vector leafType_; + std::array objects_{}; + std::unique_ptr table_; + std::unique_ptr dispatcher_; + QTcpServer server_; + QTimer pollTimer_; + QTcpSocket* socket_{}; + std::unique_ptr runtime_; + std::unique_ptr session_; + std::array receive_{}; + std::array response_{}; + std::array workspace_{}; +}; + +[[nodiscard]] bool select( + MmsClientController& client, + const QString& item) { + return client.treeModel()->selectMmsItem(QStringLiteral("CLIENTLD0"), item); +} + +} // namespace + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + if (argc != 2) { + std::cerr << "Usage: ied_mms_scl_assisted_workbench_qa \n"; + return 2; + } + + SclAssistedFixture fixture; + if (!fixture.start() || fixture.port() == 0U) { + std::cerr << "SCL-assisted MMS fixture did not start.\n"; + return 3; + } + + MmsClientController client; + client.setHost(QStringLiteral("127.0.0.1")); + client.setPort(fixture.port()); + client.setTrustedSclPath(QString::fromLocal8Bit(argv[1])); + if (!client.trustedSclAvailable()) { + std::cerr << "Trusted SCL source was not armed.\n"; + return 4; + } + if (!client.connectToIed() || !waitFor([&] { return client.connected(); }, 12'000)) { + std::cerr << "Trusted-SCL connection failed: " << client.lastError().toStdString() << '\n'; + return 5; + } + + if (client.modelSource() != QStringLiteral("Trusted SCL + initial snapshot") || + client.associationProfile() != QStringLiteral("SclEngineering")) { + std::cerr << "Workbench selected the wrong trusted-SCL product path: model=" + << client.modelSource().toStdString() + << " association=" << client.associationProfile().toStdString() << '\n'; + return 6; + } + if (client.trustedSclHealth() != QStringLiteral("degraded") || + !client.trustedSclDegraded() || client.trustedSclIncompatible() || + !client.modelSummary().contains(QStringLiteral("Trusted SCL [degraded]")) || + !client.modelSummary().contains(QStringLiteral("accessFailures=1"))) { + std::cerr << "Workbench did not surface the expected degraded trusted-SCL health: " + << client.trustedSclHealth().toStdString() << " · " + << client.modelSummary().toStdString() << '\n'; + return 21; + } + if (client.logicalDeviceCount() != 1 || client.logicalNodeCount() != 2 || + client.dataObjectCount() != 2 || client.dataAttributeCount() != 4) { + std::cerr << "Trusted SCL topology was not preserved: ld=" << client.logicalDeviceCount() + << " ln=" << client.logicalNodeCount() + << " do=" << client.dataObjectCount() + << " da=" << client.dataAttributeCount() << '\n'; + return 7; + } + + if (!select(client, QStringLiteral("GGIO1$SP$SetPoint$setVal"))) { + std::cerr << "Trusted SCL set-point leaf was not projected.\n"; + return 8; + } + const auto sp = client.treeModel()->selectedNode(); + if (sp.value(QStringLiteral("value")).toString() != QStringLiteral("7") || + sp.value(QStringLiteral("functionalConstraint")).toString() != QStringLiteral("SP") || + sp.value(QStringLiteral("mmsType")).toString() != QStringLiteral("integer")) { + std::cerr << "Initial FC-root snapshot did not populate the trusted SP leaf.\n"; + return 9; + } + + // The fixture deliberately omits the SCL-declared ST root. The topology must + // stay present while its live value remains unavailable; it must not be + // deleted or shifted onto a neighbouring SCL leaf. + if (!select(client, QStringLiteral("GGIO1$ST$Status$stVal"))) { + std::cerr << "Missing online FC root corrupted the trusted SCL topology.\n"; + return 10; + } + const auto st = client.treeModel()->selectedNode(); + if (!st.value(QStringLiteral("value")).toString().isEmpty() || + st.value(QStringLiteral("functionalConstraint")).toString() != QStringLiteral("ST")) { + std::cerr << "Missing online FC root was not kept unavailable/fail-closed.\n"; + return 11; + } + + const auto diagnostics = client.diagnosticsText(); + if (!diagnostics.contains(QStringLiteral("Trusted SCL association")) || + !diagnostics.contains(QStringLiteral("SCL engineering OSI addressing")) || + !diagnostics.contains(QStringLiteral("SCL synchronization complete")) || + !diagnostics.contains(QStringLiteral("1 Read(s)")) || + !diagnostics.contains(QStringLiteral("1 mapped leaf value(s)"))) { + std::cerr << "SCL-assisted path did not expose deterministic association/snapshot diagnostics.\n"; + return 12; + } + + // Prove that synchronize_scl() leaves the same association usable by normal + // workbench operations after the initial snapshot. + if (!select(client, QStringLiteral("GGIO1$SP$SetPoint$setVal")) || + !client.readSelected() || + !waitFor([&] { return !client.operationBusy(); }, 6'000) || + !client.lastError().isEmpty() || + client.treeModel()->selectedNode().value(QStringLiteral("value")).toString() != + QStringLiteral("7")) { + std::cerr << "Association did not remain usable after the SCL snapshot: " + << client.lastError().toStdString() << '\n'; + return 13; + } + + client.disconnectFromIed(); + if (!waitFor([&] { return fixture.connectionIdle() && !client.operationBusy(); }, 3'000)) { + std::cerr << "Trusted-SCL session did not cleanly disconnect.\n"; + return 14; + } + + // Clearing the engineering source is the explicit user-controlled fallback + // to full network discovery. It must not happen silently while a trusted SCL + // is armed. + client.setTrustedSclPath({}); + if (client.trustedSclAvailable()) { + std::cerr << "Trusted SCL source did not clear.\n"; + return 15; + } + if (!client.connectToIed() || !waitFor([&] { return client.connected(); }, 12'000)) { + std::cerr << "Explicit live-discovery fallback failed: " + << client.lastError().toStdString() << '\n'; + return 16; + } + if (client.modelSource() != QStringLiteral("Live MMS discovery") || + !client.trustedSclHealth().isEmpty() || + client.logicalDeviceCount() != 1 || client.logicalNodeCount() != 1 || + client.dataObjectCount() != 1 || client.dataAttributeCount() != 1) { + std::cerr << "Cleared SCL source did not restore the full-discovery path.\n"; + return 17; + } + + client.disconnectFromIed(); + if (!waitFor([&] { return fixture.connectionIdle(); }, 3'000)) { + std::cerr << "Live-discovery fallback session did not cleanly disconnect.\n"; + return 18; + } + + // A configured but invalid engineering source is an explicit fault. The app + // must not hide that failure by silently switching to a different model. + client.setTrustedSclPath(QString::fromLocal8Bit(argv[1]) + QStringLiteral(".missing")); + if (!client.connectToIed() || + !waitFor([&] { return !client.busy(); }, 6'000) || + client.connected() || client.lastError().isEmpty()) { + std::cerr << "Invalid trusted SCL did not fail closed.\n"; + return 19; + } + if (client.modelSource() == QStringLiteral("Live MMS discovery")) { + std::cerr << "Invalid trusted SCL silently fell back to live discovery.\n"; + return 20; + } + + std::cout << "MMS_SCL_ASSISTED_WORKBENCH_PASS" + << " trusted_path=pass" + << " scl_association=pass" + << " identity_health=degraded" + << " identity_health_surface=pass" + << " domain_validation=pass" + << " initial_snapshot=pass" + << " mapped_leaf=1" + << " missing_fc_preserved=true" + << " persistent_association=pass" + << " explicit_fallback=pass" + << " invalid_scl_fail_closed=true\n"; + return 0; +} \ No newline at end of file diff --git a/apps/ied_simulator/src/ied_product_hardening_qa.cpp b/apps/ied_simulator/src/ied_product_hardening_qa.cpp new file mode 100644 index 000000000..7c9df0afe --- /dev/null +++ b/apps/ied_simulator/src/ied_product_hardening_qa.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ProductHardeningController.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { +bool writeJson(const QString& path, const QJsonObject& object) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; + return file.write(QJsonDocument(object).toJson(QJsonDocument::Compact)) >= 0; +} + +QJsonObject endpoint(const QString& host, const int port) { + return {{QStringLiteral("host"), host}, {QStringLiteral("port"), port}}; +} +} // namespace + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + QTemporaryDir temp; + if (!temp.isValid()) { + std::cerr << "Could not create temporary product-state directory.\n"; + return 2; + } + const auto path = temp.filePath(QStringLiteral("product-state-v1.json")); + + { + ProductHardeningController state(path); + if (!state.settingsHealthy() || state.workspaceIndex() != 0 || + !state.recentEndpoints().isEmpty() || state.automaticReconnectOnStartup()) { + std::cerr << "Safe default product state contract failed.\n"; + return 3; + } + state.setWorkspaceIndex(4); + for (int index = 1; index <= 10; ++index) { + if (!state.rememberEndpoint(QStringLiteral("192.0.2.%1").arg(index), 100 + index)) { + std::cerr << "Could not persist recent endpoint.\n"; + return 4; + } + } + if (state.recentEndpoints().size() != 8 || state.lastHost() != QStringLiteral("192.0.2.10") || + state.lastPort() != 110) { + std::cerr << "Recent endpoint bound/order contract failed.\n"; + return 5; + } + const auto beforeDedupe = state.recentEndpoints().size(); + if (!state.rememberEndpoint(QStringLiteral("192.0.2.9"), 109) || + state.recentEndpoints().size() != beforeDedupe || state.lastHost() != QStringLiteral("192.0.2.9")) { + std::cerr << "Recent endpoint dedupe contract failed.\n"; + return 6; + } + state.setWorkspaceIndex(99); + if (state.workspaceIndex() != 4 || state.rememberEndpoint(QString{}, 102) || + state.rememberEndpoint(QStringLiteral("relay.local"), 0)) { + std::cerr << "Invalid workspace/endpoint did not fail closed.\n"; + return 7; + } + } + + { + ProductHardeningController restored(path); + if (!restored.settingsHealthy() || restored.workspaceIndex() != 4 || + restored.recentEndpoints().size() != 8 || restored.lastHost() != QStringLiteral("192.0.2.9") || + restored.lastPort() != 109 || restored.automaticReconnectOnStartup()) { + std::cerr << "Restart persistence contract failed.\n"; + return 8; + } +#ifdef Q_OS_WIN + if (!restored.npcapRequired() || restored.rawEthernetReady() != restored.npcapAvailable() || + !restored.npcapStatus().contains(QStringLiteral("Npcap"), Qt::CaseInsensitive) || + (!restored.npcapAvailable() && + !restored.npcapStatus().contains(QStringLiteral("Install Npcap"), Qt::CaseInsensitive))) { + std::cerr << "Windows Npcap readiness/guidance contract failed.\n"; + return 9; + } + std::cout << "WINDOWS_RUNTIME_READINESS_PASS" + << " npcap_required=true" + << " npcap_available=" << (restored.npcapAvailable() ? "true" : "false") + << " guidance=pass" + << " raw_ethernet_ready=" << (restored.rawEthernetReady() ? "true" : "false") + << '\n'; +#else + if (restored.npcapRequired() || !restored.npcapAvailable() || !restored.rawEthernetReady()) { + std::cerr << "Non-Windows Npcap readiness contract failed.\n"; + return 9; + } +#endif + } + + { + QFile corrupt(path); + if (!corrupt.open(QIODevice::WriteOnly | QIODevice::Truncate) || corrupt.write("{not-json") < 0 || + !corrupt.flush()) { + std::cerr << "Could not create corrupt product state.\n"; + return 10; + } + // QSaveFile replaces the target during commit. Windows correctly refuses + // that replacement while another writer still owns an open handle to the + // target, unlike POSIX unlink/rename semantics. Close the deliberate + // corruption writer before exercising the application's crash recovery so + // the QA measures recovery from malformed persisted bytes, not an external + // file-lock held by the test itself. + corrupt.close(); + + ProductHardeningController damaged(path); + if (damaged.settingsHealthy() || damaged.workspaceIndex() != 0 || + !damaged.recentEndpoints().isEmpty()) { + std::cerr << "Corrupt state was not ignored fail-closed.\n"; + return 11; + } + damaged.setWorkspaceIndex(6); + if (!damaged.settingsHealthy() || !damaged.rememberEndpoint(QStringLiteral("recovered.local"), 102)) { + std::cerr << "Crash-safe state recovery failed.\n"; + return 12; + } + ProductHardeningController recovered(path); + if (!recovered.settingsHealthy() || recovered.workspaceIndex() != 6 || + recovered.lastHost() != QStringLiteral("recovered.local")) { + std::cerr << "Recovered product state did not survive restart.\n"; + return 13; + } + } + + { + QJsonObject unsupported; + unsupported.insert(QStringLiteral("schema"), 99); + unsupported.insert(QStringLiteral("workspaceIndex"), 1); + unsupported.insert(QStringLiteral("recentEndpoints"), QJsonArray{}); + if (!writeJson(path, unsupported)) return 14; + ProductHardeningController rejected(path); + if (rejected.settingsHealthy() || rejected.workspaceIndex() != 0 || + !rejected.recentEndpoints().isEmpty()) { + std::cerr << "Unsupported schema was not rejected.\n"; + return 15; + } + } + + { + QJsonArray tooMany; + for (int index = 0; index < 9; ++index) { + tooMany.append(endpoint(QStringLiteral("198.51.100.%1").arg(index + 1), 102)); + } + QJsonObject unbounded; + unbounded.insert(QStringLiteral("schema"), 1); + unbounded.insert(QStringLiteral("workspaceIndex"), 0); + unbounded.insert(QStringLiteral("recentEndpoints"), tooMany); + if (!writeJson(path, unbounded)) return 16; + ProductHardeningController rejected(path); + if (rejected.settingsHealthy() || !rejected.recentEndpoints().isEmpty()) { + std::cerr << "Unbounded persisted recents were not rejected.\n"; + return 17; + } + } + + std::cout << "PRODUCT_HARDENING_PASS" + << " state=atomic" + << " workspace_restore=4" + << " recent=8" + << " dedupe=pass" + << " bounded_recent=8" + << " crash_recovery=pass" + << " auto_reconnect=false" + << " npcap_policy=explicit\n"; + std::cout << "PRODUCT_HARDENING_NEGATIVE_PASS" + << " corrupt_state=ignored" + << " unsupported_schema=rejected" + << " invalid_workspace=rejected" + << " invalid_endpoint=rejected" + << " unbounded_recent=rejected\n"; + return 0; +} diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp new file mode 100644 index 000000000..7beb7d9c6 --- /dev/null +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedFleetController.hpp" +#include "IedReportControlManifest.hpp" +#include "MmsReportController.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +bool isIedScoutIndexedFixture(const QString& sclPath) { + return QFileInfo(sclPath).fileName() == QStringLiteral("iedscout-indexed-reports.scd"); +} + +bool verifyIndexedRcbManifestExpansion() { + using ar::iec61850::scl::SclReportControl; + + QSet emitted; + SclReportControl brcb; + brcb.ied_name = "AA1E1F06R4"; + brcb.ld_inst = "Application"; + brcb.logical_node_path = "LLN0"; + brcb.name = "Buffer"; + brcb.data_set_name = "dsDin"; + brcb.buffered = true; + brcb.indexed = true; + brcb.max_clients = 2U; + brcb.configuration_revision = 1U; + brcb.buffer_time_milliseconds = 100U; + brcb.integrity_period_milliseconds = 10'000U; + + const auto brcbLines = arstack::iedsim::reportControlManifestLines( + brcb, + QStringLiteral("AA1E1F06R4"), + emitted); + const bool brcbExpanded = + brcbLines.count('\n') == 2 && + brcbLines.contains("\tLLN0$BR$Buffer01\t1\t") && + brcbLines.contains("\tLLN0$BR$Buffer02\t1\t") && + !brcbLines.contains("\tLLN0$BR$Buffer\t1\t"); + + emitted.clear(); + SclReportControl urcb = brcb; + urcb.name = "Unbuffer"; + urcb.buffered = false; + const auto urcbLines = arstack::iedsim::reportControlManifestLines( + urcb, + QStringLiteral("AA1E1F06R4"), + emitted); + const bool urcbExpanded = + urcbLines.count('\n') == 2 && + urcbLines.contains("\tLLN0$RP$Unbuffer01\t0\t") && + urcbLines.contains("\tLLN0$RP$Unbuffer02\t0\t") && + !urcbLines.contains("\tLLN0$RP$Unbuffer\t0\t"); + + emitted.clear(); + SclReportControl nonIndexed = urcb; + nonIndexed.name = "Static"; + nonIndexed.indexed = false; + nonIndexed.max_clients = 4U; + const auto nonIndexedLines = arstack::iedsim::reportControlManifestLines( + nonIndexed, + QStringLiteral("AA1E1F06R4"), + emitted); + const bool nonIndexedStable = + nonIndexedLines.count('\n') == 1 && + nonIndexedLines.contains("\tLLN0$RP$Static\t0\t") && + !nonIndexedLines.contains("LLN0$RP$Static01"); + + return brcbExpanded && urcbExpanded && nonIndexedStable; +} + +bool verifyIedScoutIndexedInventory( + const QString& sclPath, + const QVariantList& reportControls) { + if (!isIedScoutIndexedFixture(sclPath)) return true; + + QSet references; + bool allConcreteInstancesReadable = true; + for (const auto& value : reportControls) { + const auto item = value.toMap(); + const auto reference = item.value(QStringLiteral("reference")).toString(); + if (reference.isEmpty()) continue; + references.insert(reference); + + const bool buffered = item.value(QStringLiteral("buffered")).toBool(); + const auto expectedReportId = buffered + ? QStringLiteral("IEDSCOUT01LD0/LLN0$BR$Buffer") + : QStringLiteral("IEDSCOUT01LD0/LLN0$RP$Unbuffer"); + allConcreteInstancesReadable = allConcreteInstancesReadable && + item.value(QStringLiteral("probeOk")).toBool() && + !item.value(QStringLiteral("dataSet")).toString().isEmpty() && + item.value(QStringLiteral("reportId")).toString() == expectedReportId; + } + + static const QSet expected{ + QStringLiteral("IEDSCOUT01LD0/LLN0.Buffer01"), + QStringLiteral("IEDSCOUT01LD0/LLN0.Buffer02"), + QStringLiteral("IEDSCOUT01LD0/LLN0.Unbuffer01"), + QStringLiteral("IEDSCOUT01LD0/LLN0.Unbuffer02")}; + return references == expected && allConcreteInstancesReadable; +} + +bool waitUntil(const std::function& predicate, const int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(1); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +bool loadAsync(IedFleetController& controller, const QString& path) { + if (!controller.loadFileAsync(QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()))) return false; + if (!waitUntil([&controller] { return !controller.importing(); }, 15'000)) return false; + return controller.imported() && controller.fatalError().isEmpty(); +} + +int selectEligibleRcb( + MmsReportController& reports, + const bool buffered, + const QString& exactReference = {}) { + const auto controls = reports.reportControls(); + for (int row = 0; row < controls.size(); ++row) { + const auto item = controls.at(row).toMap(); + if (item.value(QStringLiteral("buffered")).toBool() != buffered) continue; + if (!exactReference.isEmpty() && + item.value(QStringLiteral("reference")).toString() != exactReference) { + continue; + } + if (!item.value(QStringLiteral("probeOk")).toBool()) continue; + if (item.value(QStringLiteral("dataSet")).toString().isEmpty()) continue; + if (!reports.selectRcb(row)) continue; + if (!reports.selectedDataSetMembers().isEmpty()) return row; + } + return -1; +} + +bool reportProjectionPresent(const MmsReportController& reports) { + const auto frames = reports.receivedReports(); + if (frames.isEmpty()) return false; + const auto frame = frames.first().toMap(); + return !frame.value(QStringLiteral("reportId")).toString().isEmpty() && + !frame.value(QStringLiteral("dataSet")).toString().isEmpty() && + !frame.value(QStringLiteral("values")).toStringList().isEmpty() && + frame.contains(QStringLiteral("entryId")) && + frame.contains(QStringLiteral("overflow")); +} +} // namespace + +int main(int argc, char* argv[]) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + qputenv("ARSTACK_IEDSIM_QA", "1"); + QGuiApplication app(argc, argv); + if (argc != 2) { + qCritical() << "usage: ied_report_workspace_qa "; + return 2; + } + + if (!verifyIndexedRcbManifestExpansion()) { + qCritical() << "REPORTS_WORKBENCH_FAIL indexed_rcb_manifest"; + return 20; + } + + MmsReportController negative; + const bool noConnectionEnableRejected = !negative.enableSelected(true); + const bool inactiveDisableRejected = !negative.disableSelected(); + const bool invalidSelectionRejected = !negative.selectRcb(0) && !negative.selectDataSet(0); + if (!noConnectionEnableRejected || !inactiveDisableRejected || !invalidSelectionRejected) { + qCritical() << "REPORTS_WORKBENCH_FAIL negative_preconditions"; + return 3; + } + + IedFleetController simulator; + const QString sclPath = QString::fromLocal8Bit(argv[1]); + const bool iedScoutFixture = isIedScoutIndexedFixture(sclPath); + const QString exactUrcb = iedScoutFixture + ? QStringLiteral("IEDSCOUT01LD0/LLN0.Unbuffer01") + : QString{}; + const QString exactBrcb = iedScoutFixture + ? QStringLiteral("IEDSCOUT01LD0/LLN0.Buffer01") + : QString{}; + if (!loadAsync(simulator, sclPath)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL import" << simulator.fatalError(); + return 4; + } + + QTcpServer reservation; + if (!reservation.listen(QHostAddress::LocalHost, 0)) { + qCritical() << "REPORTS_WORKBENCH_FAIL reserve_port"; + return 5; + } + const quint16 port = reservation.serverPort(); + reservation.close(); + + if (!simulator.configureIedEndpoint(0, QStringLiteral("127.0.0.1"), port) || + !simulator.startSimulation() || + !waitUntil([&simulator] { return simulator.running(); }, 6'000)) { + qCritical() << "REPORTS_WORKBENCH_FAIL simulator_start"; + return 6; + } + + MmsReportController reports; + reports.setHost(QStringLiteral("127.0.0.1")); + reports.setPort(port); + if (!reports.connectToIed() || + !waitUntil([&reports] { return reports.connected() && !reports.busy(); }, 12'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL connect" << reports.lastError(); + return 7; + } + + const auto dataSets = reports.dataSets(); + const auto reportControls = reports.reportControls(); + const auto staticCandidates = reports.staticCandidates(); + const auto dynamicCandidates = reports.dynamicCandidates(); + if (dataSets.isEmpty() || reportControls.size() < 2 || staticCandidates.isEmpty()) { + qCritical() << "REPORTS_WORKBENCH_FAIL inventory" + << dataSets.size() << reportControls.size() << staticCandidates.size(); + return 8; + } + if (!verifyIedScoutIndexedInventory(sclPath, reportControls)) { + QStringList actual; + for (const auto& value : reportControls) { + const auto item = value.toMap(); + actual.push_back( + item.value(QStringLiteral("reference")).toString() + + QStringLiteral("|probe=") + + (item.value(QStringLiteral("probeOk")).toBool() ? QStringLiteral("ok") : QStringLiteral("fail")) + + QStringLiteral("|rptID=") + item.value(QStringLiteral("reportId")).toString()); + } + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL iedscout_indexed_inventory" + << actual.join(QLatin1Char(',')); + return 21; + } + + bool sawUrcb = false; + bool sawBrcb = false; + bool sawBrcbEntryId = false; + for (const auto& value : reportControls) { + const auto item = value.toMap(); + if (item.value(QStringLiteral("buffered")).toBool()) { + sawBrcb = true; + sawBrcbEntryId = sawBrcbEntryId || item.value(QStringLiteral("supportsEntryId")).toBool(); + } else { + sawUrcb = true; + } + } + if (!sawUrcb || !sawBrcb || !sawBrcbEntryId) { + qCritical() << "REPORTS_WORKBENCH_FAIL rcb_modes" + << sawUrcb << sawBrcb << sawBrcbEntryId; + return 9; + } + + const int selected = selectEligibleRcb(reports, false, exactUrcb); + if (selected < 0 || reports.selectedDataSetMembers().isEmpty()) { + QStringList controls; + for (const auto& value : reportControls) { + const auto item = value.toMap(); + controls.push_back( + item.value(QStringLiteral("reference")).toString() + + QStringLiteral("|probe=") + + (item.value(QStringLiteral("probeOk")).toBool() + ? QStringLiteral("ok") + : QStringLiteral("fail")) + + QStringLiteral("|dataset=") + + item.value(QStringLiteral("dataSet")).toString() + + QStringLiteral("|error=") + + item.value(QStringLiteral("probeError")).toString()); + } + QStringList sets; + for (const auto& value : dataSets) { + const auto item = value.toMap(); + sets.push_back( + item.value(QStringLiteral("reference")).toString() + + QStringLiteral("|members=") + + QString::number(item.value(QStringLiteral("memberCount")).toInt()) + + QStringLiteral("|directory=") + + (item.value(QStringLiteral("directoryAvailable")).toBool() + ? QStringLiteral("yes") + : QStringLiteral("no"))); + } + qCritical().noquote() + << "REPORTS_WORKBENCH_FAIL eligible_urcb" + << "controls=" + controls.join(QLatin1Char(',')) + << "datasets=" + sets.join(QLatin1Char(',')); + return 10; + } + const int memberCount = reports.selectedDataSetMembers().size(); + + if (!reports.enableSelected(true) || + !waitUntil([&reports] { return reports.active() && !reports.busy(); }, 8'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL enable_gi" << reports.lastError(); + return 11; + } + + if (!waitUntil([&reports] { return reports.receivedReportCount() > 0; }, 5'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL gi_report" << reports.diagnosticsText(); + return 12; + } + const auto reportCountAfterGi = reports.receivedReportCount(); + if (!reportProjectionPresent(reports)) { + qCritical() << "REPORTS_WORKBENCH_FAIL report_projection"; + return 13; + } + + // The controller uses the association runtime's bounded non-fatal polling + // primitive. Several quiet poll intervals must leave the association and + // subscription alive instead of converting normal idle time into a fault. + QElapsedTimer idle; + idle.start(); + while (idle.elapsed() < 450) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + if (!reports.active() || !reports.connected() || reports.cleanupRequired()) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL idle_poll" << reports.lastError(); + return 15; + } + + // Reconnect while the URCB is still active. The reconnect path must clean + // up RptEna/Resv before replacing the association. Reacquiring the same + // URCB and receiving a fresh GI is direct evidence that no zombie ownership + // was left on the server by the interrupted session. + if (!reports.reconnect() || + !waitUntil([&reports] { return reports.connected() && !reports.busy(); }, 12'000) || + reports.cleanupRequired()) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL active_reconnect" << reports.lastError(); + return 16; + } + if (selectEligibleRcb(reports, false, exactUrcb) < 0 || + !reports.enableSelected(true) || + !waitUntil([&reports] { return reports.active() && !reports.busy(); }, 8'000) || + !waitUntil([&reports] { return reports.receivedReportCount() > 0; }, 5'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL reconnect_reacquire" << reports.lastError(); + return 17; + } + + if (!reports.disableSelected() || + !waitUntil([&reports] { return !reports.active() && !reports.busy(); }, 6'000) || + reports.cleanupRequired()) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL cleanup" << reports.lastError(); + return 18; + } + + qulonglong brcbGiReports = 0; + if (iedScoutFixture) { + if (selectEligibleRcb(reports, true, exactBrcb) < 0 || + reports.selectedDataSetMembers().isEmpty()) { + qCritical() << "REPORTS_WORKBENCH_FAIL eligible_brcb"; + return 22; + } + if (!reports.enableSelected(true) || + !waitUntil([&reports] { return reports.active() && !reports.busy(); }, 8'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL brcb_enable_gi" << reports.lastError(); + return 23; + } + if (!waitUntil([&reports] { return reports.receivedReportCount() > 0; }, 5'000)) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL brcb_gi_report" + << reports.diagnosticsText(); + return 24; + } + brcbGiReports = reports.receivedReportCount(); + if (!reportProjectionPresent(reports)) { + qCritical() << "REPORTS_WORKBENCH_FAIL brcb_report_projection"; + return 25; + } + if (!reports.disableSelected() || + !waitUntil([&reports] { return !reports.active() && !reports.busy(); }, 6'000) || + reports.cleanupRequired()) { + qCritical().noquote() << "REPORTS_WORKBENCH_FAIL brcb_cleanup" << reports.lastError(); + return 26; + } + } + + reports.disconnectFromIed(); + simulator.stopSimulation(); + if (!waitUntil([&simulator] { return !simulator.anyRunning(); }, 4'000)) { + qCritical() << "REPORTS_WORKBENCH_FAIL simulator_stop"; + return 19; + } + + qInfo().noquote() + << "REPORTS_WORKBENCH_PASS" + << "datasets=" + QString::number(dataSets.size()) + << "rcbs=" + QString::number(reportControls.size()) + << "static_candidates=" + QString::number(staticCandidates.size()) + << "dynamic_candidates=" + QString::number(dynamicCandidates.size()) + << "members=" + QString::number(memberCount) + << "urcb_gi_reports=" + QString::number(reportCountAfterGi) + << "brcb_gi_reports=" + QString::number(brcbGiReports) + << "rcb_manifest_instances=pass" + << QStringLiteral("iedscout_indexed_inventory=%1").arg(iedScoutFixture ? QStringLiteral("pass") : QStringLiteral("n/a")) + << "urcb=pass" + << QStringLiteral("brcb_gi=%1").arg(iedScoutFixture ? QStringLiteral("pass") : QStringLiteral("n/a")) + << "brcb_inventory=pass" + << "entryid_indicator=pass" + << "cleanup=pass" + << "active_reconnect_cleanup=pass"; + qInfo().noquote() + << "REPORTS_WORKBENCH_NEGATIVE_PASS" + << "no_connection_enable=rejected" + << "inactive_disable=rejected" + << "invalid_selection=rejected" + << "strict_single_candidate=true" + << "idle_poll_nonfatal=true" + << "reconnect_reacquire=true"; + return 0; +} diff --git a/apps/ied_simulator/src/ied_scl_workspace_qa.cpp b/apps/ied_simulator/src/ied_scl_workspace_qa.cpp new file mode 100644 index 000000000..62069eb14 --- /dev/null +++ b/apps/ied_simulator/src/ied_scl_workspace_qa.cpp @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SclWorkspaceController.hpp" + +#include "ariec61850/scl/exporter.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace scl = ar::iec61850::scl; + +namespace { +bool waitFor(const std::function& predicate, const int timeoutMs = 8'000) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + if (predicate()) return true; + QThread::msleep(10); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +QByteArray readBytes(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) return {}; + return file.readAll(); +} + +bool containsAll(const QByteArray& bytes) { + constexpr std::string_view required[] = { + "", + "(token.size())})) return false; + } + return true; +} + +std::optional parseFile(const QString& path) { + try { + scl::SclParser parser; + return parser.load(path.toStdString()); + } catch (...) { + return std::nullopt; + } +} +} // namespace + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + if (argc != 2) { + std::cerr << "Usage: ied_scl_workspace_qa \n"; + return 2; + } + + const QString fixture = QString::fromLocal8Bit(argv[1]); + QTemporaryDir temp; + if (!temp.isValid()) { + std::cerr << "Temporary directory creation failed.\n"; + return 3; + } + + SclWorkspaceController workspace; + if (!workspace.openFile(QUrl::fromLocalFile(fixture)) || + !waitFor([&] { return !workspace.busy(); })) { + std::cerr << "SCL workspace open did not complete.\n"; + return 4; + } + if (!workspace.loaded() || !workspace.lastError().isEmpty()) { + std::cerr << "SCL workspace open failed: " << workspace.lastError().toStdString() << '\n'; + return 5; + } + if (workspace.editionText() != QStringLiteral("Edition 2") || + workspace.namespaceUri().isEmpty() || workspace.iedCount() < 1 || + workspace.logicalNodeCount() < 1 || workspace.modelLeafCount() < 1 || + !workspace.exactSourceSaveSupported() || !workspace.reconstructionSupported() || + !workspace.editionConversionSupported() || !workspace.profileConversionSupported()) { + std::cerr << "SCL workspace capability projection is incomplete.\n"; + return 6; + } + + const QString exact = temp.filePath(QStringLiteral("exact.scd")); + if (!workspace.saveAs(QUrl::fromLocalFile(exact), QStringLiteral("preserve")) || + !waitFor([&] { return !workspace.busy(); }) || !workspace.lastExportVerified() || + workspace.lastExportMode() != QStringLiteral("Exact source")) { + std::cerr << "Exact source-preserving Save As failed: " + << workspace.lastError().toStdString() << '\n'; + return 7; + } + const auto sourceBytes = readBytes(fixture); + if (sourceBytes.isEmpty() || readBytes(exact) != sourceBytes) { + std::cerr << "Exact Save As changed source bytes.\n"; + return 8; + } + + const QString canonical1 = temp.filePath(QStringLiteral("canonical-1.scd")); + if (!workspace.exportCanonical(QUrl::fromLocalFile(canonical1), QStringLiteral("preserve")) || + !waitFor([&] { return !workspace.busy(); }) || !workspace.lastExportVerified()) { + std::cerr << "Canonical SCD export failed: " << workspace.lastError().toStdString() << '\n'; + return 9; + } + const auto canonicalBytes = readBytes(canonical1); + if (canonicalBytes.isEmpty() || !containsAll(canonicalBytes)) { + std::cerr << "Canonical SCD is missing required DTT/reference/configured-value surface.\n"; + return 10; + } + const auto canonicalDoc = parseFile(canonical1); + if (!canonicalDoc || canonicalDoc->edition != scl::SclEdition::edition2 || + canonicalDoc->ieds.size() != 1U || canonicalDoc->logical_nodes.empty() || + canonicalDoc->model_entries.size() != static_cast(workspace.modelLeafCount()) || + canonicalDoc->data_sets.size() != static_cast(workspace.dataSetCount()) || + canonicalDoc->report_controls.size() != static_cast(workspace.reportCount()) || + canonicalDoc->goose_streams.size() != static_cast(workspace.gooseCount()) || + canonicalDoc->sampled_values_streams.size() != static_cast(workspace.smvCount())) { + std::cerr << "Canonical SCD reparsed with incomplete modeled semantics.\n"; + return 11; + } + + const QString canonical2 = temp.filePath(QStringLiteral("canonical-2.scd")); + if (!workspace.exportCanonical(QUrl::fromLocalFile(canonical2), QStringLiteral("ed2")) || + !waitFor([&] { return !workspace.busy(); }) || !workspace.lastExportVerified() || + readBytes(canonical2) != canonicalBytes) { + std::cerr << "Canonical output is not deterministic.\n"; + return 12; + } + + const QString ed1 = temp.filePath(QStringLiteral("converted-ed1.scd")); + if (!workspace.exportCanonical(QUrl::fromLocalFile(ed1), QStringLiteral("ed1")) || + !waitFor([&] { return !workspace.busy(); }) || !workspace.lastExportVerified()) { + std::cerr << "Ed2 -> Ed1 canonical conversion failed: " << workspace.lastError().toStdString() << '\n'; + return 13; + } + const auto ed1Doc = parseFile(ed1); + if (!ed1Doc || ed1Doc->edition != scl::SclEdition::edition1) { + std::cerr << "Ed1 canonical output did not reparse as Edition 1.\n"; + return 14; + } + + const QString ed21 = temp.filePath(QStringLiteral("converted-ed21.scd")); + if (!workspace.exportCanonical(QUrl::fromLocalFile(ed21), QStringLiteral("ed2.1")) || + !waitFor([&] { return !workspace.busy(); }) || !workspace.lastExportVerified()) { + std::cerr << "Ed2 -> Ed2.1 canonical conversion failed: " << workspace.lastError().toStdString() << '\n'; + return 15; + } + const auto ed21Doc = parseFile(ed21); + if (!ed21Doc || ed21Doc->edition != scl::SclEdition::edition21) { + std::cerr << "Ed2.1 canonical output did not reparse as Edition 2.1.\n"; + return 16; + } + + for (const auto& extension : {QStringLiteral("icd"), QStringLiteral("cid")}) { + const QString path = temp.filePath(QStringLiteral("converted.") + extension); + if (!workspace.exportCanonical(QUrl::fromLocalFile(path), QStringLiteral("preserve")) || + !waitFor([&] { return !workspace.busy(); }) || !workspace.lastExportVerified() || + !parseFile(path)) { + std::cerr << "Canonical profile export failed for ." << extension.toStdString() << '\n'; + return 17; + } + } + + const QString unsupported = temp.filePath(QStringLiteral("rejected.iid")); + if (workspace.exportCanonical(QUrl::fromLocalFile(unsupported), QStringLiteral("preserve")) || + workspace.lastError().isEmpty() || QFile::exists(unsupported)) { + std::cerr << "Unsupported canonical profile did not fail closed.\n"; + return 18; + } + + const QString exactEd1 = temp.filePath(QStringLiteral("exact-ed1.scd")); + if (workspace.saveAs(QUrl::fromLocalFile(exactEd1), QStringLiteral("ed1")) || + workspace.lastError().isEmpty() || QFile::exists(exactEd1)) { + std::cerr << "Exact cross-edition relabel did not fail closed.\n"; + return 19; + } + const QString exactCid = temp.filePath(QStringLiteral("exact-relabel.cid")); + if (workspace.saveAs(QUrl::fromLocalFile(exactCid), QStringLiteral("preserve")) || + workspace.lastError().isEmpty() || QFile::exists(exactCid)) { + std::cerr << "Exact profile relabel did not fail closed.\n"; + return 20; + } + + scl::SclParser parser; + const auto sourceDocument = parser.load(fixture.toStdString()); + scl::SclCanonicalExportOptions options{sourceDocument.edition, scl::SclExportProfile::icd}; + + auto multiIed = sourceDocument; + multiIed.ieds.push_back(sourceDocument.ieds.front()); + if (scl::SclExporter::validate(multiIed, options).success) { + std::cerr << "Multi-IED ICD export was not rejected.\n"; + return 21; + } + + auto unknownEnum = sourceDocument; + if (unknownEnum.model_entries.empty()) return 22; + unknownEnum.model_entries.front().basic_type = "Enum"; + unknownEnum.model_entries.front().da_name = "vendorMode"; + unknownEnum.model_entries.front().type_id = "VendorModeKind"; + unknownEnum.model_entries.front().enum_type = "VendorModeKind"; + options.profile = scl::SclExportProfile::scd; + if (scl::SclExporter::validate(unknownEnum, options).success) { + std::cerr << "Unknown EnumType ordinal domain was not rejected.\n"; + return 23; + } + + auto nestedSdo = sourceDocument; + nestedSdo.model_entries.front().do_name = "Parent.Child"; + if (scl::SclExporter::validate(nestedSdo, options).success) { + std::cerr << "Nested-SDO ambiguity was not rejected.\n"; + return 24; + } + + const QString malformed = temp.filePath(QStringLiteral("malformed.scd")); + { + QFile file(malformed); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write("") < 0) { + std::cerr << "Could not create malformed SCL fixture.\n"; + return 25; + } + } + SclWorkspaceController negative; + if (!negative.openFile(QUrl::fromLocalFile(malformed)) || + !waitFor([&] { return !negative.busy(); }) || negative.loaded() || + negative.lastError().isEmpty()) { + std::cerr << "Malformed SCL did not fail closed.\n"; + return 26; + } + + std::cout << "SCL_WORKSPACE_PASS" + << " edition_source=ed2" + << " exact_preserve=pass" + << " canonical=pass" + << " dtt=pass" + << " references=pass" + << " configured_values=pass" + << " deterministic=pass" + << " ed1=pass" + << " ed2=pass" + << " ed21=pass" + << " scd=pass" + << " icd=pass" + << " cid=pass" + << " semantic_roundtrip=pass" + << " ieds=" << workspace.iedCount() + << " lns=" << workspace.logicalNodeCount() + << " leaves=" << workspace.modelLeafCount() << '\n'; + std::cout << "SCL_WORKSPACE_NEGATIVE_PASS" + << " unsupported_profile=rejected" + << " exact_relabel=rejected" + << " multi_ied_icd=rejected" + << " unknown_enum=rejected" + << " nested_sdo_ambiguity=rejected" + << " malformed=rejected" + << " vendor_lossless_claimed=false\n"; + return 0; +} diff --git a/apps/ied_simulator/src/ied_setting_control_manifest_qa.cpp b/apps/ied_simulator/src/ied_setting_control_manifest_qa.cpp new file mode 100644 index 000000000..1bbb3b66f --- /dev/null +++ b/apps/ied_simulator/src/ied_setting_control_manifest_qa.cpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "IedSettingControlManifest.hpp" + +#include +#include + +#include +#include +#include + +#define CHECK(condition) do { \ + if (!(condition)) throw std::runtime_error(std::string{"CHECK failed: "} + #condition); \ +} while (false) + +int main(int argc, char** argv) { + QCoreApplication application{argc, argv}; + try { + using ar::iec61850::scl::SclSettingControl; + SclSettingControl valid; + valid.ied_name = "SGIED"; + valid.ld_inst = "LD0"; + valid.logical_node_path = "LLN0"; + valid.control_block_reference = "SGIEDLD0/LLN0$SP$SGCB"; + valid.number_of_setting_groups = 4U; + valid.active_setting_group = 1U; + + QSet emitted; + const auto line = arstack::iedsim::settingControlManifestLine( + valid, QStringLiteral("SGIED"), emitted); + CHECK(line == QByteArray{"SGCB\tSGIEDLD0\tLLN0$SP$SGCB\t4\t1\n"}); + CHECK(arstack::iedsim::settingControlManifestLine( + valid, QStringLiteral("SGIED"), emitted).isEmpty()); + + QSet wrongIed; + CHECK(arstack::iedsim::settingControlManifestLine( + valid, QStringLiteral("OTHER"), wrongIed).isEmpty()); + + auto invalid = valid; + invalid.active_setting_group = 5U; + QSet invalidSet; + CHECK(arstack::iedsim::settingControlManifestLine( + invalid, QStringLiteral("SGIED"), invalidSet).isEmpty()); + + std::cout << "SETTING_CONTROL_MANIFEST_PASS exact=pass duplicate=pass invalid=omitted\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "SETTING_CONTROL_MANIFEST_FAIL " << error.what() << '\n'; + return 1; + } +} diff --git a/apps/ied_simulator/src/ied_simulator_commissioning_qa.cpp b/apps/ied_simulator/src/ied_simulator_commissioning_qa.cpp new file mode 100644 index 000000000..cf5f6d6f0 --- /dev/null +++ b/apps/ied_simulator/src/ied_simulator_commissioning_qa.cpp @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedCommissioningModel.hpp" +#include "IedFleetController.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { +bool waitUntil(const std::function& predicate, const int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(1); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +bool loadAsync(IedFleetController& controller, const QString& path, const int timeoutMs = 15'000) { + if (!controller.loadFileAsync(QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()))) return false; + if (!waitUntil([&controller] { return !controller.importing(); }, timeoutMs)) return false; + return controller.imported() && controller.fatalError().isEmpty(); +} + +int findRow(IedCommissioningModel& model, const QString& kind, const QString& name) { + for (int row = 0; row < model.itemCount(); ++row) { + const auto item = model.item(row); + if (item.value(QStringLiteral("kind")).toString() == kind && + item.value(QStringLiteral("name")).toString() == name) { + return row; + } + } + return -1; +} + +int runPositive(const QString& path) { + IedFleetController controller; + if (!loadAsync(controller, path)) { + qCritical().noquote() << "COMMISSIONING_DEPTH_FAIL import" << controller.fatalError(); + return 10; + } + + IedCommissioningModel model; + model.setBackend(&controller); + if (model.dataSetCount() != 2 || model.reportCount() != 2 || + model.gooseCount() != 1 || model.controlCount() != 4 || model.itemCount() != 9) { + qCritical().noquote() + << "COMMISSIONING_DEPTH_FAIL counts" + << model.dataSetCount() << model.reportCount() + << model.gooseCount() << model.controlCount() << model.itemCount(); + return 11; + } + + model.setKindFilter(QStringLiteral("Report")); + if (model.itemCount() != 2) { + qCritical() << "COMMISSIONING_DEPTH_FAIL report_filter"; + return 12; + } + const int urcbRow = findRow(model, QStringLiteral("Report"), QStringLiteral("URCB01")); + const int brcbRow = findRow(model, QStringLiteral("Report"), QStringLiteral("BRCB01")); + if (urcbRow < 0 || brcbRow < 0) { + qCritical() << "COMMISSIONING_DEPTH_FAIL report_inventory"; + return 13; + } + const auto urcb = model.item(urcbRow); + const auto brcb = model.item(brcbRow); + if (urcb.value(QStringLiteral("buffered")).toBool() || + !brcb.value(QStringLiteral("buffered")).toBool() || + urcb.value(QStringLiteral("memberCount")).toInt() != 3 || + brcb.value(QStringLiteral("memberCount")).toInt() != 3 || + urcb.value(QStringLiteral("status")).toString() != QStringLiteral("Bound")) { + qCritical() << "COMMISSIONING_DEPTH_FAIL report_metadata"; + return 14; + } + + model.select(urcbRow); + if (model.memberCount() != 3 || model.member(0).value(QStringLiteral("reference")).toString().isEmpty()) { + qCritical() << "COMMISSIONING_DEPTH_FAIL report_members"; + return 15; + } + if (!model.focusBoundDataSet()) { + qCritical() << "COMMISSIONING_DEPTH_FAIL report_dataset_jump"; + return 16; + } + const auto boundDataSet = model.selectedItem(); + if (boundDataSet.value(QStringLiteral("kind")).toString() != QStringLiteral("DataSet") || + boundDataSet.value(QStringLiteral("name")).toString() != QStringLiteral("dsGO") || + model.memberCount() != 3) { + qCritical() << "COMMISSIONING_DEPTH_FAIL dataset_selection"; + return 17; + } + + model.setKindFilter(QStringLiteral("GOOSE")); + if (model.itemCount() != 1) { + qCritical() << "COMMISSIONING_DEPTH_FAIL goose_filter"; + return 18; + } + const auto goose = model.item(0); + if (goose.value(QStringLiteral("name")).toString() != QStringLiteral("GCB01") || + goose.value(QStringLiteral("goId")).toString() != QStringLiteral("trip-goose") || + goose.value(QStringLiteral("memberCount")).toInt() != 3 || + goose.value(QStringLiteral("appId")).toString() != QStringLiteral("0x1001") || + goose.value(QStringLiteral("vlanId")).toInt() != 100 || + goose.value(QStringLiteral("minTimeMs")).toULongLong() != 4ULL || + goose.value(QStringLiteral("maxTimeMs")).toULongLong() != 1000ULL) { + qCritical().noquote() << "COMMISSIONING_DEPTH_FAIL goose_metadata" << goose; + return 19; + } + + model.setKindFilter(QStringLiteral("Control")); + model.setFilterText(QStringLiteral("sbo-with-enhanced-security")); + if (model.itemCount() != 1) { + qCritical() << "COMMISSIONING_DEPTH_FAIL control_filter"; + return 20; + } + const auto control = model.item(0); + if (control.value(QStringLiteral("name")).toString() != QStringLiteral("SPCSO4") || + control.value(QStringLiteral("cdc")).toString() != QStringLiteral("SPC") || + control.value(QStringLiteral("controlModel")).toString() != + QStringLiteral("sbo-with-enhanced-security")) { + qCritical() << "COMMISSIONING_DEPTH_FAIL control_metadata"; + return 21; + } + + model.setKindFilter(QStringLiteral("All")); + model.setFilterText(QStringLiteral("trip-goose")); + if (model.itemCount() != 1 || + model.item(0).value(QStringLiteral("kind")).toString() != QStringLiteral("GOOSE")) { + qCritical() << "COMMISSIONING_DEPTH_FAIL service_search"; + return 22; + } + + qInfo().noquote() + << "COMMISSIONING_DEPTH_PASS datasets=2 reports=2 goose=1 controls=4" + " dataset_members=3 report_members=3 goose_members=3 appid=0x1001 vlan=100" + " virtualized_on_demand=1"; + return 0; +} + +int runNegative(const QString& path) { + IedFleetController controller; + if (!loadAsync(controller, path)) { + qCritical().noquote() << "COMMISSIONING_NEGATIVE_FAIL import" << controller.fatalError(); + return 30; + } + + IedCommissioningModel model; + model.setBackend(&controller); + model.setKindFilter(QStringLiteral("Report")); + if (model.itemCount() != 1) { + qCritical() << "COMMISSIONING_NEGATIVE_FAIL report_count"; + return 31; + } + const auto report = model.item(0); + model.select(0); + if (report.value(QStringLiteral("status")).toString() != QStringLiteral("Unresolved") || + model.memberCount() != 0 || model.focusBoundDataSet()) { + qCritical().noquote() << "COMMISSIONING_NEGATIVE_FAIL unresolved_report" << report; + return 32; + } + + model.setKindFilter(QStringLiteral("GOOSE")); + if (model.itemCount() != 1 || model.item(0).value(QStringLiteral("memberCount")).toInt() != 0) { + qCritical() << "COMMISSIONING_NEGATIVE_FAIL unresolved_goose"; + return 33; + } + + qInfo().noquote() + << "COMMISSIONING_NEGATIVE_PASS unresolved_report=visible member_count=0" + " dataset_jump=rejected unresolved_goose=visible"; + return 0; +} +} // namespace + +int main(int argc, char* argv[]) { + QGuiApplication app(argc, argv); + if (argc != 3) { + qCritical() << "usage: ied_simulator_commissioning_qa "; + return 2; + } + qputenv("ARSTACK_IEDSIM_QA", "1"); + const int positive = runPositive(QString::fromLocal8Bit(argv[1])); + if (positive != 0) return positive; + return runNegative(QString::fromLocal8Bit(argv[2])); +} diff --git a/apps/ied_simulator/src/ied_simulator_goose_publication_qa.cpp b/apps/ied_simulator/src/ied_simulator_goose_publication_qa.cpp new file mode 100644 index 000000000..5b4e5188e --- /dev/null +++ b/apps/ied_simulator/src/ied_simulator_goose_publication_qa.cpp @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/goose/publisher_runtime.hpp" +#include "ariec61850/goose/raw_ethernet_publisher.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850; + +#define REQUIRE(condition, message) do { if (!(condition)) throw std::runtime_error(message); } while (false) + +mms::MmsDataValue sample_value(const scl::SclDataSetEntry& entry, const std::size_t index) { + if (entry.is_quality) { + const std::uint8_t quality[]{0U, 0U}; + return mms::MmsDataValue::bit_string(3U, quality); + } + if (entry.is_timestamp) { + return mms::MmsDataValue::utc_time(mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'700'000'000}}, 0U}); + } + std::string type = entry.basic_type; + std::transform(type.begin(), type.end(), type.begin(), [](const unsigned char value) { + return static_cast(std::toupper(value)); + }); + if (type.find("BOOLEAN") != std::string::npos || type == "BOOL") { + return mms::MmsDataValue::boolean((index & 1U) != 0U); + } + if (type.find("FLOAT") != std::string::npos || type.find("DOUBLE") != std::string::npos) { + return mms::MmsDataValue::floating_point(static_cast(index) + 0.5); + } + if (type.find("UINT") != std::string::npos || type.find("UNSIGNED") != std::string::npos) { + return mms::MmsDataValue::unsigned_integer(static_cast(index)); + } + if (type.find("INT") != std::string::npos) { + return mms::MmsDataValue::integer(static_cast(index)); + } + return mms::MmsDataValue::visible_string("qa-" + std::to_string(index)); +} + +mms::MmsDataValue changed_sample_value( + const scl::SclDataSetEntry& entry, + const std::size_t index) { + if (entry.is_quality) { + const std::uint8_t quality[]{0U, 0x80U}; + return mms::MmsDataValue::bit_string(3U, quality); + } + if (entry.is_timestamp) { + return mms::MmsDataValue::utc_time(mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'700'000'001}}, 0U}); + } + std::string type = entry.basic_type; + std::transform(type.begin(), type.end(), type.begin(), [](const unsigned char value) { + return static_cast(std::toupper(value)); + }); + if (type.find("BOOLEAN") != std::string::npos || type == "BOOL") { + return mms::MmsDataValue::boolean((index & 1U) == 0U); + } + if (type.find("FLOAT") != std::string::npos || type.find("DOUBLE") != std::string::npos) { + return mms::MmsDataValue::floating_point(static_cast(index) + 1.5); + } + if (type.find("UINT") != std::string::npos || type.find("UNSIGNED") != std::string::npos) { + return mms::MmsDataValue::unsigned_integer(static_cast(index + 1U)); + } + if (type.find("INT") != std::string::npos) { + return mms::MmsDataValue::integer(static_cast(index + 1U)); + } + return mms::MmsDataValue::visible_string("qa-changed-" + std::to_string(index)); +} + +std::uint32_t doubled_ttl(const std::uint32_t delay) { + return delay > std::numeric_limits::max() / 2U + ? std::numeric_limits::max() + : std::max(1U, delay * 2U); +} +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 3) { + std::cerr << "usage: ied_simulator_goose_publication_qa \n"; + return 2; + } + const auto document = scl::SclParser{}.load(std::filesystem::path{argv[1]}); + const auto found = std::find_if( + document.goose_streams.begin(), document.goose_streams.end(), + [](const scl::SclGooseStream& stream) { + return stream.address.app_id.has_value() && + stream.address.destination_mac.has_value() && + !stream.entries.empty() && stream.min_time_milliseconds > 0U && + stream.max_time_milliseconds >= stream.min_time_milliseconds; + }); + REQUIRE(found != document.goose_streams.end(), "fixture has no addressed GOOSE stream"); + const auto& stream = *found; + + goose::GooseFrame frame; + frame.destination = ethernet::MacAddress{ + std::span{stream.address.destination_mac->data(), + stream.address.destination_mac->size()}}; + frame.source = ethernet::MacAddress::parse("02:00:00:00:00:4D"); + frame.app_id = *stream.address.app_id; + if (stream.address.vlan_id.has_value()) { + REQUIRE(stream.address.vlan_priority.has_value(), "fixture VLAN priority missing"); + frame.vlan = ethernet::VlanTag{ + *stream.address.vlan_priority, *stream.address.vlan_id}; + } + frame.pdu.go_cb_ref = stream.control_block_reference; + frame.pdu.data_set_reference = stream.data_set_reference; + frame.pdu.go_id = stream.go_id; + frame.pdu.configuration_revision = stream.configuration_revision; + const auto expectedDestination = frame.destination; + const auto expectedSource = frame.source; + + std::vector values; + values.reserve(stream.entries.size()); + for (std::size_t index = 0; index < stream.entries.size(); ++index) { + values.push_back(sample_value(stream.entries[index], index)); + } + + goose::GoosePublisherRuntime runtime{ + goose::GoosePublisherSession{std::move(frame)}, + stream.min_time_milliseconds, + stream.max_time_milliseconds}; + const auto base = goose::GoosePublisherRuntime::clock::time_point{} + std::chrono::seconds{1}; + const auto timestamp = mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'700'000'000}}, 0U}; + const auto initial = runtime.start(values, timestamp, base); + + goose::GooseFrame decodedInitial; + REQUIRE(goose::GooseFrameCodec::try_decode(initial.ethernet_bytes, decodedInitial), + "initial GOOSE frame did not decode"); + REQUIRE(decodedInitial.destination == expectedDestination, "destination MAC mismatch"); + REQUIRE(decodedInitial.source == expectedSource, "source MAC mismatch"); + REQUIRE(decodedInitial.app_id == *stream.address.app_id, "APPID mismatch"); + REQUIRE(decodedInitial.pdu.go_cb_ref == stream.control_block_reference, "gocbRef mismatch"); + REQUIRE(decodedInitial.pdu.data_set_reference == stream.data_set_reference, "DataSet mismatch"); + REQUIRE(decodedInitial.pdu.configuration_revision == stream.configuration_revision, + "ConfRev mismatch"); + REQUIRE(decodedInitial.pdu.values.size() == stream.entries.size(), "allData member count mismatch"); + REQUIRE(decodedInitial.pdu.state_number == 1U && decodedInitial.pdu.sequence_number == 0U, + "initial stNum/sqNum mismatch"); + REQUIRE(decodedInitial.pdu.time_allowed_to_live_milliseconds == + doubled_ttl(stream.min_time_milliseconds), + "initial timeAllowedToLive does not match next retransmission"); + + const auto retransmission = runtime.poll( + base + std::chrono::milliseconds{stream.min_time_milliseconds}); + REQUIRE(retransmission.has_value(), "first retransmission was not due at MinTime"); + goose::GooseFrame decodedRetransmission; + REQUIRE(goose::GooseFrameCodec::try_decode( + retransmission->ethernet_bytes, decodedRetransmission), + "retransmission did not decode"); + REQUIRE(decodedRetransmission.pdu.state_number == 1U && + decodedRetransmission.pdu.sequence_number == 1U, + "retransmission stNum/sqNum mismatch"); + const auto secondDelay = std::min( + static_cast(stream.max_time_milliseconds), + static_cast(stream.min_time_milliseconds) * 2ULL); + REQUIRE(decodedRetransmission.pdu.time_allowed_to_live_milliseconds == + doubled_ttl(static_cast(secondDelay)), + "retransmission timeAllowedToLive mismatch"); + + auto changedValues = values; + changedValues[0] = changed_sample_value(stream.entries.front(), 0U); + const auto changed = runtime.state_change( + changedValues, + timestamp, + base + std::chrono::milliseconds{stream.min_time_milliseconds + 1U}); + goose::GooseFrame decodedChanged; + REQUIRE(goose::GooseFrameCodec::try_decode(changed.ethernet_bytes, decodedChanged), + "state-change frame did not decode"); + REQUIRE(decodedChanged.pdu.state_number == 2U && decodedChanged.pdu.sequence_number == 0U, + "state change did not increment stNum/reset sqNum"); + REQUIRE(decodedChanged.pdu.time_allowed_to_live_milliseconds == + doubled_ttl(stream.min_time_milliseconds), + "state-change TTL did not restart at MinTime"); + + capture::PcapWriter::write_all( + std::filesystem::path{argv[2]}, + std::vector{ + {std::chrono::system_clock::time_point{std::chrono::seconds{1}}, initial.ethernet_bytes}, + {std::chrono::system_clock::time_point{std::chrono::seconds{1}} + + std::chrono::milliseconds{stream.min_time_milliseconds}, + retransmission->ethernet_bytes}, + {std::chrono::system_clock::time_point{std::chrono::seconds{1}} + + std::chrono::milliseconds{stream.min_time_milliseconds + 1U}, + changed.ethernet_bytes}}); + + goose::RawEthernetPublisher raw; + std::string error; + REQUIRE(!raw.open({}, error) && !error.empty(), "empty interface did not fail closed"); + error.clear(); + REQUIRE(!raw.open("arstack-no-such-interface-61850", error) && !error.empty(), + "nonexistent interface did not fail closed"); + + std::cout << "GOOSE_WIRE_INTEROP_PASS appid=" << *stream.address.app_id + << " vlan=" << (stream.address.vlan_id.has_value() + ? std::to_string(*stream.address.vlan_id) : "untagged") + << " members=" << stream.entries.size() + << " st_initial=1 sq_retx=1 st_change=2 ttl_initial=" + << decodedInitial.pdu.time_allowed_to_live_milliseconds + << " ttl_retx=" << decodedRetransmission.pdu.time_allowed_to_live_milliseconds + << " pcap_packets=3\n"; + std::cout << "GOOSE_PUBLICATION_NEGATIVE_PASS empty_interface=rejected " + "nonexistent_interface=rejected explicit_binding=required\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "GOOSE_PUBLICATION_QA_FAIL " << error.what() << '\n'; + return 1; + } +} diff --git a/apps/ied_simulator/src/ied_simulator_guardrails_qa.cpp b/apps/ied_simulator/src/ied_simulator_guardrails_qa.cpp new file mode 100644 index 000000000..5e49afab3 --- /dev/null +++ b/apps/ied_simulator/src/ied_simulator_guardrails_qa.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedActivityModel.hpp" +#include "IedRuntimeGuardrails.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { +void spin(const int milliseconds) { + QEventLoop loop; + QTimer::singleShot(milliseconds, &loop, &QEventLoop::quit); + loop.exec(); +} + +int fail(const char* message) { + std::cerr << "IEDSIM_GUARDRAILS_FAIL " << message << '\n'; + return 1; +} +} // namespace + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + + QByteArray unterminated; + const QByteArray flood(2 * 1024 * 1024, 'x'); + const auto floodResult = ar::iedsim::runtime_guardrails::appendAndDrain(unterminated, flood); + if (unterminated.size() > ar::iedsim::runtime_guardrails::kMaxBufferedProcessBytes) { + return fail("unterminated child output exceeded hard buffer cap"); + } + if (floodResult.droppedBytes <= 0) return fail("flood did not report discarded bytes"); + + QByteArray burst; + QByteArray manyLines; + for (int index = 0; index < 200; ++index) { + manyLines += "IEDSIM_EVENT kind=test index=" + QByteArray::number(index) + '\n'; + } + const auto firstDrain = ar::iedsim::runtime_guardrails::appendAndDrain(burst, manyLines); + if (firstDrain.lines.size() != ar::iedsim::runtime_guardrails::kMaxLinesPerDrain) { + return fail("per-turn line drain budget is not enforced"); + } + if (!firstDrain.moreCompleteLines) return fail("remaining complete lines were not retained"); + + int drained = firstDrain.lines.size(); + while (burst.indexOf('\n') >= 0) { + drained += ar::iedsim::runtime_guardrails::appendAndDrain(burst, {}).lines.size(); + } + if (drained != 200) return fail("line framing lost events within the bounded buffer"); + + IedActivityModel activity; + for (int index = 0; index < 5'000; ++index) { + QVariantMap event; + event.insert(QStringLiteral("time"), QString::number(index)); + event.insert(QStringLiteral("category"), QStringLiteral("Burst")); + event.insert(QStringLiteral("message"), QStringLiteral("event-%1").arg(index)); + event.insert( + QStringLiteral("severity"), + index % 10 == 0 ? QStringLiteral("Error") : QStringLiteral("Info")); + event.insert(QStringLiteral("ied"), QStringLiteral("IED-A")); + activity.push_front(event); + } + spin(30); + if (activity.retainedCount() > 300) return fail("activity retention exceeded 300 events"); + if (activity.rowCount() != activity.visibleCount()) return fail("visible activity count is inconsistent"); + + activity.setSeverityFilter(QStringLiteral("Error")); + spin(100); + for (int row = 0; row < activity.rowCount(); ++row) { + if (activity.data(activity.index(row, 0), IedActivityModel::SeverityRole).toString() != + QStringLiteral("Error")) { + return fail("severity filter leaked a non-error row"); + } + } + + activity.setSeverityFilter(QStringLiteral("All")); + activity.setFilterText(QStringLiteral("event-4999")); + spin(100); + if (activity.rowCount() != 1) return fail("text filtering did not isolate the expected newest event"); + + QTemporaryDir exportDirectory; + if (!exportDirectory.isValid()) return fail("diagnostics export temporary directory is invalid"); + const auto exportPath = exportDirectory.filePath(QStringLiteral("diagnostics.txt")); + const auto diagnostics = QStringLiteral( + "ARStack IED Simulator diagnostics\nModel: QA\nRecent activity: bounded\n"); + if (!activity.exportDiagnostics(QUrl::fromLocalFile(exportPath), diagnostics)) { + return fail("atomic diagnostics export failed"); + } + QFile exported(exportPath); + if (!exported.open(QIODevice::ReadOnly) || exported.readAll() != diagnostics.toUtf8()) { + return fail("diagnostics export did not preserve the requested evidence text"); + } + if (activity.exportDiagnostics(QUrl{}, diagnostics) || + activity.exportDiagnostics(QUrl::fromLocalFile(exportPath), QString{})) { + return fail("invalid diagnostics export request did not fail closed"); + } + + std::cout + << "IEDSIM_GUARDRAILS_PASS" + << " output_cap=" << ar::iedsim::runtime_guardrails::kMaxBufferedProcessBytes + << " drain_budget=" << ar::iedsim::runtime_guardrails::kMaxLinesPerDrain + << " retained=" << activity.retainedCount() + << " flood_dropped=" << floodResult.droppedBytes + << " diagnostics_export=atomic" + << '\n'; + return 0; +} diff --git a/apps/ied_simulator/src/ied_simulator_responsiveness_qa.cpp b/apps/ied_simulator/src/ied_simulator_responsiveness_qa.cpp new file mode 100644 index 000000000..9887cdb92 --- /dev/null +++ b/apps/ied_simulator/src/ied_simulator_responsiveness_qa.cpp @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedFleetController.hpp" +#include "IedSignalModel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +bool waitUntil(const std::function& predicate, const int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(1); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +bool loadAsync(IedFleetController& controller, const QString& path, const int timeoutMs) { + if (!controller.loadFileAsync(QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()))) return false; + if (!waitUntil([&controller] { return !controller.importing(); }, timeoutMs)) return false; + return controller.imported() && controller.fatalError().isEmpty(); +} + +int runResponsiveness(const QString& path) { + IedFleetController controller; + + qint64 maxHeartbeatGapMs{}; + QElapsedTimer heartbeatElapsed; + heartbeatElapsed.start(); + QTimer heartbeat; + heartbeat.setInterval(10); + QObject::connect(&heartbeat, &QTimer::timeout, [&] { + const auto gap = heartbeatElapsed.restart(); + maxHeartbeatGapMs = std::max(maxHeartbeatGapMs, gap); + }); + heartbeat.start(); + + if (!loadAsync(controller, path, 90'000)) { + qCritical().noquote() << "IEDSIM_RESPONSIVENESS_FAIL import" << controller.fatalError(); + return 20; + } + + const int iedCount = controller.ieds().size(); + if (iedCount <= 0 || controller.preparedIedCount() != iedCount) { + qCritical().noquote() + << "IEDSIM_RESPONSIVENESS_FAIL prepared_ieds=" << controller.preparedIedCount() + << "ieds=" << iedCount; + return 21; + } + + qint64 maxSwitchMs{}; + for (int index = 0; index < iedCount; ++index) { + QElapsedTimer switchElapsed; + switchElapsed.start(); + controller.selectIed(index); + maxSwitchMs = std::max(maxSwitchMs, switchElapsed.elapsed()); + if (!controller.hasPreparedValueIndex()) { + qCritical().noquote() << "IEDSIM_RESPONSIVENESS_FAIL unprepared_switch=" << index; + return 22; + } + } + controller.selectIed(0); + + IedSignalModel model; + model.setBackend(&controller); + const auto& navigation = controller.navigationIndexView(); + if (navigation.isEmpty()) { + qCritical() << "IEDSIM_RESPONSIVENESS_FAIL empty_navigation"; + return 23; + } + const auto scope = navigation.constFirst().toMap(); + model.setLogicalDevice(scope.value(QStringLiteral("logicalDevice")).toString()); + model.setLogicalNode(scope.value(QStringLiteral("logicalNode")).toString()); + if (!waitUntil([&model] { return model.rowCount() > 0; }, 2'000)) { + qCritical() << "IEDSIM_RESPONSIVENESS_FAIL empty_projection"; + return 24; + } + QThread::msleep(100); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + + model.resetPerformanceCounters(); + QElapsedTimer burstCall; + burstCall.start(); + const int applied = controller.qaBurstValues(5'000, std::min(64, controller.valueCount())); + const qint64 burstCallMs = burstCall.elapsed(); + if (applied != 5'000) { + qCritical().noquote() << "IEDSIM_RESPONSIVENESS_FAIL burst_applied=" << applied; + return 25; + } + if (!waitUntil([&model] { return model.refreshFlushCount() >= 1; }, 2'000)) { + qCritical() << "IEDSIM_RESPONSIVENESS_FAIL no_refresh_flush"; + return 26; + } + QThread::msleep(40); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + + const auto requests = model.refreshRequestCount(); + const auto flushes = model.refreshFlushCount(); + const double coalescing = flushes == 0 + ? 0.0 + : static_cast(requests) / static_cast(flushes); + + const auto* firstPoint = controller.valueRecord(0); + if (firstPoint == nullptr) { + qCritical() << "IEDSIM_RESPONSIVENESS_FAIL no_point"; + return 27; + } + model.resetPerformanceCounters(); + model.setFilterText(firstPoint->dataObject); + QThread::msleep(120); + QCoreApplication::processEvents(QEventLoop::AllEvents, 250); + const qint64 filterRebuildMs = model.maxRebuildMilliseconds(); + + heartbeat.stop(); + const bool passes = + requests >= 5'000 && + flushes > 0 && flushes <= 8 && + coalescing >= 500.0 && + maxSwitchMs <= 50 && + burstCallMs <= 250 && + model.maxRefreshLatencyMilliseconds() <= 150 && + filterRebuildMs <= 500 && + maxHeartbeatGapMs <= 250; + + qInfo().noquote() + << QStringLiteral( + "IEDSIM_RESPONSIVENESS_%1 ieds=%2 prepared=%3 switch_max_ms=%4 " + "burst_updates=%5 burst_call_ms=%6 refresh_requests=%7 refresh_flushes=%8 " + "coalescing=%9 refresh_max_ms=%10 filter_rebuild_max_ms=%11 heartbeat_max_gap_ms=%12") + .arg(passes ? QStringLiteral("PASS") : QStringLiteral("FAIL")) + .arg(iedCount) + .arg(controller.preparedIedCount()) + .arg(maxSwitchMs) + .arg(applied) + .arg(burstCallMs) + .arg(requests) + .arg(flushes) + .arg(coalescing, 0, 'f', 1) + .arg(model.maxRefreshLatencyMilliseconds()) + .arg(filterRebuildMs) + .arg(maxHeartbeatGapMs); + return passes ? 0 : 28; +} + +int runFleetRollback(const QString& path) { + IedFleetController controller; + if (!loadAsync(controller, path, 15'000) || controller.ieds().size() < 2) { + qCritical() << "FLEET_PARTIAL_START_RECOVERY_FAIL import_or_ied_count"; + return 30; + } + if (controller.preparedIedCount() != controller.ieds().size()) { + qCritical() << "FLEET_PARTIAL_START_RECOVERY_FAIL profiles_not_prepared"; + return 31; + } + + QTcpServer blocker; + if (!blocker.listen(QHostAddress(QStringLiteral("127.0.0.1")), 0)) { + qCritical() << "FLEET_PARTIAL_START_RECOVERY_FAIL reserve_port"; + return 32; + } + const int port = static_cast(blocker.serverPort()); + if (!controller.configureIedEndpoint(0, QStringLiteral("127.0.0.1"), port) || + !controller.configureIedEndpoint(1, QStringLiteral("127.0.0.2"), port)) { + qCritical() << "FLEET_PARTIAL_START_RECOVERY_FAIL configure"; + return 33; + } + for (int index = 2; index < controller.ieds().size(); ++index) controller.setIedEnabled(index, false); + + const int launched = controller.startAllSimulations(); + if (launched <= 0) { + qCritical() << "FLEET_PARTIAL_START_RECOVERY_FAIL no_launch"; + return 34; + } + if (!waitUntil( + [&controller] { + return controller.fleetStartRollbackCount() > 0 && !controller.anyRunning(); + }, + 10'000)) { + qCritical().noquote() + << "FLEET_PARTIAL_START_RECOVERY_FAIL timeout rollback=" + << controller.fleetStartRollbackCount() + << "running=" << controller.runningCount(); + return 35; + } + + qInfo().noquote() + << QStringLiteral("FLEET_PARTIAL_START_RECOVERY_PASS rollback_count=%1 port=%2 launched=%3") + .arg(controller.fleetStartRollbackCount()) + .arg(port) + .arg(launched); + return 0; +} + +int runNonblockingClear(const QString& path) { + IedFleetController controller; + if (!loadAsync(controller, path, 15'000)) { + qCritical() << "NONBLOCKING_CLEAR_FAIL import"; + return 40; + } + + QTcpServer reservation; + if (!reservation.listen(QHostAddress(QStringLiteral("127.0.0.1")), 0)) { + qCritical() << "NONBLOCKING_CLEAR_FAIL reserve_port"; + return 41; + } + const int port = static_cast(reservation.serverPort()); + reservation.close(); + if (!controller.configureIedEndpoint(0, QStringLiteral("127.0.0.1"), port) || + !controller.startSimulation()) { + qCritical() << "NONBLOCKING_CLEAR_FAIL start"; + return 42; + } + if (!waitUntil([&controller] { return controller.running(); }, 8'000)) { + qCritical() << "NONBLOCKING_CLEAR_FAIL readiness"; + return 43; + } + + QElapsedTimer callElapsed; + callElapsed.start(); + controller.clear(); + const qint64 callMs = callElapsed.elapsed(); + if (callMs > 50) { + qCritical().noquote() << "NONBLOCKING_CLEAR_FAIL call_ms=" << callMs; + return 44; + } + if (!controller.clearPending()) { + qCritical() << "NONBLOCKING_CLEAR_FAIL pending_not_set"; + return 45; + } + if (!waitUntil([&controller] { return !controller.imported() && !controller.clearPending(); }, 5'000)) { + qCritical() << "NONBLOCKING_CLEAR_FAIL completion"; + return 46; + } + + qInfo().noquote() + << QStringLiteral("NONBLOCKING_CLEAR_PASS call_ms=%1 port=%2").arg(callMs).arg(port); + return 0; +} + +} // namespace + +int main(int argc, char* argv[]) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + qputenv("ARSTACK_IEDSIM_QA", "1"); + QGuiApplication app(argc, argv); + + const auto args = QCoreApplication::arguments(); + if (args.size() != 3) { + qCritical() << "Usage: ied_simulator_responsiveness_qa --responsiveness|--fleet-rollback|--clear-nonblocking "; + return 2; + } + if (args.at(1) == QStringLiteral("--responsiveness")) return runResponsiveness(args.at(2)); + if (args.at(1) == QStringLiteral("--fleet-rollback")) return runFleetRollback(args.at(2)); + if (args.at(1) == QStringLiteral("--clear-nonblocking")) return runNonblockingClear(args.at(2)); + qCritical() << "Unknown QA mode:" << args.at(1); + return 2; +} diff --git a/apps/ied_simulator/src/ied_simulator_runtime_commissioning_qa.cpp b/apps/ied_simulator/src/ied_simulator_runtime_commissioning_qa.cpp new file mode 100644 index 000000000..54a70c2f1 --- /dev/null +++ b/apps/ied_simulator/src/ied_simulator_runtime_commissioning_qa.cpp @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "IedCommissioningModel.hpp" +#include "IedFleetController.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +bool waitUntil(const std::function& predicate, const int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(1); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +bool loadAsync(IedFleetController& controller, const QString& path) { + if (!controller.loadFileAsync(QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath()))) return false; + if (!waitUntil([&controller] { return !controller.importing(); }, 15'000)) return false; + return controller.imported() && controller.fatalError().isEmpty(); +} + +QString resolveBinary(const QString& root, const QString& stem) { + const QFileInfo direct{root}; + if (direct.isFile() && + (direct.fileName() == stem || direct.fileName() == stem + QStringLiteral(".exe"))) { + return direct.absoluteFilePath(); + } + QDirIterator iterator{ + root, + QStringList{stem, stem + QStringLiteral(".exe")}, + QDir::Files, + QDirIterator::Subdirectories}; + return iterator.hasNext() ? QFileInfo(iterator.next()).absoluteFilePath() : QString{}; +} + +bool runReadProbe( + const QString& executable, + const quint16 port, + const QString& domain, + const QString& item, + QString* output) { + QProcess process; + process.setProgram(executable); + process.setArguments({ + QStringLiteral("127.0.0.1"), + QString::number(port), + QStringLiteral("--domain"), domain, + QStringLiteral("--item"), item, + QStringLiteral("--timeout-ms"), QStringLiteral("3000")}); + process.start(); + if (!waitUntil([&process] { return process.state() == QProcess::NotRunning; }, 6'000)) { + process.kill(); + process.waitForFinished(1'000); + return false; + } + const auto stdoutText = QString::fromUtf8(process.readAllStandardOutput()); + const auto stderrText = QString::fromUtf8(process.readAllStandardError()); + if (output != nullptr) *output = stdoutText.trimmed(); + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0 || + !stdoutText.contains(QStringLiteral("MMS_READ"))) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL read_probe" + << stdoutText << stderrText; + return false; + } + return true; +} + +int selectBufferedReport(IedCommissioningModel& model) { + model.setKindFilter(QStringLiteral("Report")); + for (int row = 0; row < model.itemCount(); ++row) { + if (!model.item(row).value(QStringLiteral("buffered")).toBool()) continue; + model.select(row); + return row; + } + return -1; +} + +int selectFirstKind(IedCommissioningModel& model, const QString& kind) { + model.setKindFilter(kind); + if (model.itemCount() <= 0) return -1; + model.select(0); + return 0; +} +} // namespace + +int main(int argc, char* argv[]) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + qputenv("ARSTACK_IEDSIM_QA", "1"); + QGuiApplication app(argc, argv); + if (argc != 3) { + qCritical() << "usage: ied_simulator_runtime_commissioning_qa "; + return 2; + } + + const QString sclPath = QString::fromLocal8Bit(argv[1]); + const QString buildRoot = QString::fromLocal8Bit(argv[2]); + const auto brcbProbe = resolveBinary(buildRoot, QStringLiteral("ariec61850_mms_brcb_event_probe")); + const auto readProbe = resolveBinary(buildRoot, QStringLiteral("ariec61850_mms_read_probe")); + if (brcbProbe.isEmpty() || readProbe.isEmpty()) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL probes" << brcbProbe << readProbe; + return 3; + } + + IedFleetController controller; + if (!loadAsync(controller, sclPath)) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL import" << controller.fatalError(); + return 4; + } + + QTcpServer reservation; + if (!reservation.listen(QHostAddress::LocalHost, 0)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL reserve_port"; + return 5; + } + const quint16 port = reservation.serverPort(); + reservation.close(); + if (!controller.configureIedEndpoint(0, QStringLiteral("127.0.0.1"), port) || + !controller.startSimulation() || + !waitUntil([&controller] { return controller.running(); }, 6'000)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL runtime_start"; + return 6; + } + + IedCommissioningModel model; + model.setBackend(&controller); + + if (model.behaviorCapacity() != 16 || selectBufferedReport(model) < 0) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL report_inventory"; + return 7; + } + const auto report = model.selectedItem(); + const int reportMember = model.firstDrivableMember(); + if (reportMember < 0 || !model.focusMemberValue(reportMember)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL report_member"; + return 8; + } + const auto originalReportValue = controller.selectedValue().value(QStringLiteral("value")).toString(); + const auto reportReference = report.value(QStringLiteral("reference")).toString(); + const int slash = reportReference.indexOf(QLatin1Char('/')); + if (slash <= 0 || slash >= reportReference.size() - 1) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL report_reference" << reportReference; + return 9; + } + + QByteArray brcbStdout; + QByteArray brcbStderr; + QProcess brcb; + QObject::connect(&brcb, &QProcess::readyReadStandardOutput, [&] { + brcbStdout += brcb.readAllStandardOutput(); + }); + QObject::connect(&brcb, &QProcess::readyReadStandardError, [&] { + brcbStderr += brcb.readAllStandardError(); + }); + brcb.setProgram(brcbProbe); + brcb.setArguments({ + QStringLiteral("127.0.0.1"), + QString::number(port), + QStringLiteral("--domain"), reportReference.left(slash), + QStringLiteral("--rcb"), reportReference.mid(slash + 1), + QStringLiteral("--timeout-ms"), QStringLiteral("6000")}); + brcb.start(); + if (!waitUntil([&] { + return brcbStdout.contains("MMS_BRCB_EVENT_READY") || brcb.state() == QProcess::NotRunning; + }, 6'000) || !brcbStdout.contains("MMS_BRCB_EVENT_READY")) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL brcb_ready" + << brcbStdout << brcbStderr; + return 10; + } + + const auto ticksBeforeReport = model.behaviorTickCount(); + if (!model.pulseSelectedService(300) || + !waitUntil([&] { return model.behaviorTickCount() > ticksBeforeReport; }, 1'000) || + !waitUntil([&] { + return brcbStdout.contains("MMS_BRCB_EVENT_PASS") || brcb.state() == QProcess::NotRunning; + }, 7'000)) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL report_pulse" + << brcbStdout << brcbStderr; + return 11; + } + if (!waitUntil([&model] { return model.behaviorCount() == 0; }, 2'000) || + !model.focusMemberValue(reportMember) || + controller.selectedValue().value(QStringLiteral("value")).toString() != originalReportValue) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL pulse_restore"; + return 12; + } + + if (selectFirstKind(model, QStringLiteral("GOOSE")) < 0) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL goose_inventory"; + return 13; + } + const int gooseMember = model.firstDrivableMember(); + if (gooseMember < 0 || !model.focusMemberValue(gooseMember)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL goose_member"; + return 14; + } + const auto originalGooseSource = controller.selectedValue().value(QStringLiteral("value")).toString(); + if (!model.pulseSelectedService(200) || + !waitUntil([&model] { return model.behaviorCount() == 0; }, 1'500) || + !model.focusMemberValue(gooseMember) || + controller.selectedValue().value(QStringLiteral("value")).toString() != originalGooseSource) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL goose_source_stimulus"; + return 15; + } + + if (selectBufferedReport(model) < 0 || !model.focusBoundDataSet()) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL dataset_jump"; + return 16; + } + const int behaviorMember = model.firstDrivableMember(); + if (behaviorMember < 0 || !model.focusMemberValue(behaviorMember)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL behavior_member"; + return 17; + } + const auto initial = controller.selectedValue(); + const auto initialValue = initial.value(QStringLiteral("value")).toString(); + bool numeric{}; + static_cast(initialValue.toDouble(&numeric)); + const QString mode = numeric ? QStringLiteral("Ramp") : QStringLiteral("Toggle"); + const auto ticksBeforeBehavior = model.behaviorTickCount(); + if (!model.startMemberBehavior(behaviorMember, mode, 100, 1.0) || + !waitUntil([&] { return model.behaviorTickCount() >= ticksBeforeBehavior + 3U; }, 2'000)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL periodic_behavior"; + return 18; + } + if (!model.focusMemberValue(behaviorMember)) return 19; + const auto driven = controller.selectedValue(); + if (driven.value(QStringLiteral("value")).toString() == initialValue) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL periodic_no_change"; + return 20; + } + + QString readOutput; + if (!runReadProbe( + readProbe, + port, + driven.value(QStringLiteral("mmsDomain")).toString(), + driven.value(QStringLiteral("mmsItem")).toString(), + &readOutput)) { + return 21; + } + + if (model.startMemberBehavior(behaviorMember, QStringLiteral("NoSuchMode"), 100, 1.0) || + model.startMemberBehavior(behaviorMember, QStringLiteral("Toggle"), 50, 1.0)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL invalid_behavior_accepted"; + return 22; + } + + if (!model.stopMemberBehavior(behaviorMember, true) || + !waitUntil([&model] { return model.behaviorCount() == 0; }, 1'000) || + !model.focusMemberValue(behaviorMember) || + controller.selectedValue().value(QStringLiteral("value")).toString() != initialValue) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL behavior_restore"; + return 23; + } + + if (selectFirstKind(model, QStringLiteral("Control")) < 0 || !model.focusControlStatus()) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL control_status_focus"; + return 24; + } + const auto controlStatus = controller.selectedValue(); + if (controlStatus.value(QStringLiteral("fc")).toString() != QStringLiteral("ST") || + controlStatus.value(QStringLiteral("dataAttribute")).toString() != QStringLiteral("stVal")) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL control_status_semantics" << controlStatus; + return 25; + } + + // Lifecycle invariant: a stimulus must not survive a runtime stop and then + // silently become the next startup manifest baseline. The scheduler owns + // the driven value, so stopping the child restores that owned value locally + // before the slot is discarded. Newer external edits are never overwritten. + if (selectBufferedReport(model) < 0 || !model.focusBoundDataSet()) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL lifecycle_dataset"; + return 26; + } + const int lifecycleMember = model.firstDrivableMember(); + if (lifecycleMember < 0 || !model.focusMemberValue(lifecycleMember)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL lifecycle_member"; + return 27; + } + const auto lifecycleInitial = controller.selectedValue(); + const auto lifecycleOriginal = lifecycleInitial.value(QStringLiteral("value")).toString(); + const auto lifecycleDomain = lifecycleInitial.value(QStringLiteral("mmsDomain")).toString(); + const auto lifecycleItem = lifecycleInitial.value(QStringLiteral("mmsItem")).toString(); + const auto lifecycleTicksBefore = model.behaviorTickCount(); + if (!model.startMemberBehavior(lifecycleMember, QStringLiteral("Toggle"), 100, 1.0) || + !waitUntil([&] { return model.behaviorTickCount() > lifecycleTicksBefore; }, 1'000) || + !model.focusMemberValue(lifecycleMember) || + controller.selectedValue().value(QStringLiteral("value")).toString() == lifecycleOriginal) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL lifecycle_drive"; + return 28; + } + + controller.stopSimulation(); + if (!waitUntil([&controller] { return !controller.anyRunning(); }, 3'000) || + !waitUntil([&model] { return model.behaviorCount() == 0; }, 1'500) || + !model.focusMemberValue(lifecycleMember) || + controller.selectedValue().value(QStringLiteral("value")).toString() != lifecycleOriginal) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL lifecycle_stop_restore"; + return 29; + } + + if (!controller.startSimulation() || + !waitUntil([&controller] { return controller.running(); }, 6'000)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL lifecycle_restart"; + return 30; + } + QString restartRead; + if (!runReadProbe(readProbe, port, lifecycleDomain, lifecycleItem, &restartRead) || + !restartRead.contains(QStringLiteral(" value=") + lifecycleOriginal)) { + qCritical().noquote() << "RUNTIME_COMMISSIONING_FAIL restart_baseline" << restartRead; + return 31; + } + + controller.stopSimulation(); + if (!waitUntil([&controller] { return !controller.anyRunning(); }, 3'000)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL runtime_stop"; + return 32; + } + model.setKindFilter(QStringLiteral("DataSet")); + if (model.itemCount() > 0) model.select(0); + const int stoppedMember = model.firstDrivableMember(); + if (stoppedMember >= 0 && + model.startMemberBehavior(stoppedMember, QStringLiteral("Toggle"), 100, 1.0)) { + qCritical() << "RUNTIME_COMMISSIONING_FAIL stopped_runtime_accepted"; + return 33; + } + + qInfo().noquote() + << "RUNTIME_COMMISSIONING_ACTIONS_PASS" + << "report_pulse=brcb_event" + << "goose_source_stimulus=pass" + << "control_status_focus=pass" + << "behavior_mode=" + mode + << "behavior_ticks=" + QString::number(model.behaviorTickCount()) + << "behavior_rejected=" + QString::number(model.behaviorRejectedCount()) + << "bounded_slots=" + QString::number(model.behaviorCapacity()) + << "mms_visibility=pass" + << "restore=pass" + << "runtime_stop_restore=pass" + << "restart_baseline=pass"; + qInfo().noquote() + << "RUNTIME_COMMISSIONING_NEGATIVE_PASS" + << "stopped_runtime=rejected" + << "bad_mode=rejected" + << "fast_interval=rejected" + << "external_control_semantics=preserved"; + return 0; +} diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 81b59bb04..8a1123adf 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -1,34 +1,97 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#include "IedSimulatorController.hpp" +#include "IedFleetController.hpp" #include #include +#include #include +#include #include #include #include #include #include +#include +#include + +namespace { +bool configureEndpoint(QObject* backend, const QString& specification, const int defaultPort) { + const auto equals = specification.indexOf(QLatin1Char('=')); + if (equals <= 0 || equals >= specification.size() - 1) return false; + + bool indexOk{}; + const auto index = specification.left(equals).toInt(&indexOk); + if (!indexOk || index < 0) return false; + + auto endpoint = specification.mid(equals + 1).trimmed(); + if (endpoint.isEmpty()) return false; + + int port = defaultPort; + const auto colon = endpoint.lastIndexOf(QLatin1Char(':')); + if (colon > 0) { + bool portOk{}; + const auto requestedPort = endpoint.mid(colon + 1).toInt(&portOk); + if (!portOk || requestedPort < 1 || requestedPort > 65'535) return false; + port = requestedPort; + endpoint = endpoint.left(colon); + } + if (endpoint.isEmpty()) return false; + + bool configured{}; + const bool invoked = QMetaObject::invokeMethod( + backend, + "configureIedEndpoint", + Q_RETURN_ARG(bool, configured), + Q_ARG(int, index), + Q_ARG(QString, endpoint), + Q_ARG(int, port)); + return invoked && configured; +} +} // namespace + int main(int argc, char* argv[]) { QQuickStyle::setStyle(QStringLiteral("Basic")); QGuiApplication app(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); - QCoreApplication::setApplicationName(QStringLiteral("ARStack IED Simulator")); - QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0")); + QCoreApplication::setApplicationName(QStringLiteral("ARStack IED Lab")); + QCoreApplication::setApplicationVersion(QStringLiteral("0.2.0")); + app.setWindowIcon(QIcon(QStringLiteral(":/iedsim/assets/app-icon.png"))); QCommandLineParser parser; - parser.setApplicationDescription(QStringLiteral("ARStack IEC 61850 IED Simulator")); + parser.setApplicationDescription(QStringLiteral("ARStack IEC 61850 multi-IED simulation lab")); parser.addHelpOption(); parser.addVersionOption(); const QCommandLineOption sclOption{ QStringLiteral("scl"), QStringLiteral("Import an engineering file before showing the window."), QStringLiteral("path")}; + const QCommandLineOption qaAsyncImportOption{ + QStringLiteral("qa-async-import"), + QStringLiteral("QA: import one engineering file through the interactive bounded worker and exit."), + QStringLiteral("path")}; + const QCommandLineOption qaAsyncImportRepeatOption{ + QStringLiteral("qa-async-import-repeat"), + QStringLiteral("QA: repeat the bounded async import in one process for lifecycle/leak soak."), + QStringLiteral("count"), + QStringLiteral("1")}; + const QCommandLineOption qaRuntimeCyclesOption{ + QStringLiteral("qa-runtime-cycles"), + QStringLiteral("QA: repeatedly start and stop the selected MMS runtime in one GUI process."), + QStringLiteral("count")}; const QCommandLineOption runtimeOption{ QStringLiteral("runtime"), - QStringLiteral("Start the MMS runtime after importing the model.")}; + QStringLiteral("Start the selected MMS runtime after importing the model.")}; + const QCommandLineOption iedEndpointOption{ + QStringLiteral("ied-endpoint"), + QStringLiteral( + "Assign a fleet endpoint as INDEX=IPv4[:PORT]. Repeat for multiple IEDs."), + QStringLiteral("assignment")}; + const QCommandLineOption startIedOption{ + QStringLiteral("start-ied"), + QStringLiteral("Start one IED index after import. Repeat to start a fleet subset."), + QStringLiteral("index")}; const QCommandLineOption screenshotOption{ QStringLiteral("screenshot"), QStringLiteral("Capture the rendered window and exit."), @@ -38,23 +101,33 @@ int main(int argc, char* argv[]) { QStringLiteral("Load the QML scene, wait briefly, and exit.")}; const QCommandLineOption portOption{ QStringLiteral("port"), - QStringLiteral("Override the MMS listen port."), + QStringLiteral("Override the default MMS listen port."), QStringLiteral("number")}; const QCommandLineOption setFirstValueOption{ QStringLiteral("set-first-value"), QStringLiteral("QA: apply a value to the first runtime point after start."), QStringLiteral("value")}; + const QCommandLineOption qaLiveBurstOption{ + QStringLiteral("qa-live-burst"), + QStringLiteral("QA: enqueue a bounded burst through the live runtime data plane."), + QStringLiteral("count")}; const QCommandLineOption exitAfterOption{ QStringLiteral("exit-after-ms"), QStringLiteral("QA: exit after the specified runtime duration."), QStringLiteral("milliseconds")}; parser.addOptions({ sclOption, + qaAsyncImportOption, + qaAsyncImportRepeatOption, + qaRuntimeCyclesOption, runtimeOption, + iedEndpointOption, + startIedOption, screenshotOption, smokeOption, portOption, setFirstValueOption, + qaLiveBurstOption, exitAfterOption}); parser.process(app); @@ -70,10 +143,14 @@ int main(int argc, char* argv[]) { if (!engine.rootObjects().isEmpty()) { auto* const rootObject = engine.rootObjects().constFirst(); auto* const backend = rootObject->findChild(QStringLiteral("simulatorBackend")); + int defaultPort = 102; if (backend != nullptr && parser.isSet(portOption)) { bool valid{}; const auto port = parser.value(portOption).toInt(&valid); - if (valid && port >= 1 && port <= 65'535) backend->setProperty("port", port); + if (valid && port >= 1 && port <= 65'535) { + defaultPort = port; + backend->setProperty("port", port); + } } if (backend != nullptr && parser.isSet(sclOption)) { QMetaObject::invokeMethod( @@ -81,11 +158,220 @@ int main(int argc, char* argv[]) { "loadFile", Q_ARG(QUrl, QUrl::fromLocalFile(parser.value(sclOption)))); } - if (backend != nullptr && parser.isSet(runtimeOption)) { - QTimer::singleShot(150, backend, [backend] { - QMetaObject::invokeMethod(backend, "startSimulation"); - }); + if (backend != nullptr && parser.isSet(qaAsyncImportOption)) { + bool repeatOk{}; + const auto parsedRepeat = parser.value(qaAsyncImportRepeatOption).toInt(&repeatOk); + const int repeatCount = repeatOk ? std::clamp(parsedRepeat, 1, 100) : 1; + const auto importUrl = QUrl::fromLocalFile(parser.value(qaAsyncImportOption)); + auto iteration = std::make_shared(0); + + bool accepted{}; + const bool invoked = QMetaObject::invokeMethod( + backend, + "loadFileAsync", + Q_RETURN_ARG(bool, accepted), + Q_ARG(QUrl, importUrl)); + if (!invoked || !accepted) { + qWarning().noquote() << "Async import request was rejected."; + QTimer::singleShot(0, &app, [] { QCoreApplication::exit(5); }); + } else { + auto* const importTimer = new QTimer{backend}; + importTimer->setInterval(25); + QObject::connect( + importTimer, + &QTimer::timeout, + backend, + [&app, backend, importTimer, importUrl, repeatCount, iteration] { + if (backend->property("importing").toBool()) return; + if (!backend->property("imported").toBool()) { + importTimer->stop(); + importTimer->deleteLater(); + qWarning().noquote() + << "Async import failed:" + << backend->property("fatalError").toString(); + app.exit(6); + return; + } + + ++(*iteration); + qInfo().noquote() + << "ASYNC_IMPORT_ITERATION" + << *iteration + << backend->property("sourceName").toString(); + if (*iteration < repeatCount) { + bool nextAccepted{}; + const bool nextInvoked = QMetaObject::invokeMethod( + backend, + "loadFileAsync", + Q_RETURN_ARG(bool, nextAccepted), + Q_ARG(QUrl, importUrl)); + if (nextInvoked && nextAccepted) return; + + importTimer->stop(); + importTimer->deleteLater(); + qWarning().noquote() << "Repeated async import request was rejected."; + app.exit(5); + return; + } + + importTimer->stop(); + importTimer->deleteLater(); + qInfo().noquote() + << "ASYNC_IMPORT_OK" + << backend->property("sourceName").toString() + << "iterations=" << *iteration; + app.exit(0); + }); + importTimer->start(); + QTimer::singleShot(60'000, backend, [&app, importTimer] { + if (!importTimer->isActive()) return; + importTimer->stop(); + importTimer->deleteLater(); + qWarning().noquote() << "Async import timed out."; + app.exit(7); + }); + } + } + + bool fleetConfigurationValid = true; + if (backend != nullptr) { + for (const auto& specification : parser.values(iedEndpointOption)) { + if (configureEndpoint(backend, specification, defaultPort)) continue; + fleetConfigurationValid = false; + qWarning().noquote() << "Invalid or rejected --ied-endpoint:" << specification; + } } + + bool qaRuntimeCycleScheduled = false; + if (backend != nullptr && fleetConfigurationValid && parser.isSet(qaRuntimeCyclesOption)) { + bool countOk{}; + const auto requested = parser.value(qaRuntimeCyclesOption).toInt(&countOk); + const int cycleCount = countOk ? std::clamp(requested, 1, 100) : 0; + if (cycleCount <= 0 || !backend->property("imported").toBool()) { + fleetConfigurationValid = false; + qWarning().noquote() + << "--qa-runtime-cycles requires a positive count and a successfully loaded --scl model."; + QTimer::singleShot(0, &app, [] { QCoreApplication::exit(8); }); + } else { + qaRuntimeCycleScheduled = true; + auto cycle = std::make_shared(0); + auto phase = std::make_shared(0); + auto* const cycleTimer = new QTimer{backend}; + cycleTimer->setInterval(25); + QObject::connect( + cycleTimer, + &QTimer::timeout, + backend, + [&app, backend, cycleTimer, cycleCount, cycle, phase] { + if (*phase == 0) { + bool started{}; + const bool invoked = QMetaObject::invokeMethod( + backend, + "startSimulation", + Q_RETURN_ARG(bool, started)); + if (!invoked || !started) { + cycleTimer->stop(); + cycleTimer->deleteLater(); + qWarning().noquote() + << "Runtime lifecycle soak could not start cycle" + << (*cycle + 1); + app.exit(9); + return; + } + *phase = 1; + return; + } + + if (*phase == 1) { + if (!backend->property("running").toBool()) return; + qInfo().noquote() << "RUNTIME_CYCLE_STARTED" << (*cycle + 1); + + // Hold the second restarted process beyond the 900 ms + // delayed-kill grace period. A stale timer from the + // first stop must not be able to kill this generation. + if (*cycle == 1) { + *phase = 3; + QTimer::singleShot(1'100, backend, [phase] { *phase = 4; }); + return; + } + + QMetaObject::invokeMethod(backend, "stopSimulation"); + *phase = 2; + return; + } + + if (*phase == 3) { + if (backend->property("running").toBool()) return; + cycleTimer->stop(); + cycleTimer->deleteLater(); + qWarning().noquote() + << "Runtime died inside the delayed-kill restart guard window."; + app.exit(11); + return; + } + + if (*phase == 4) { + if (!backend->property("running").toBool()) { + cycleTimer->stop(); + cycleTimer->deleteLater(); + qWarning().noquote() + << "Runtime was not alive after the delayed-kill restart guard window."; + app.exit(11); + return; + } + qInfo().noquote() << "RUNTIME_RESTART_GUARD_PASS cycle=" << (*cycle + 1); + QMetaObject::invokeMethod(backend, "stopSimulation"); + *phase = 2; + return; + } + + if (backend->property("anyRunning").toBool()) return; + ++(*cycle); + qInfo().noquote() << "RUNTIME_CYCLE_FINISHED" << *cycle; + if (*cycle >= cycleCount) { + cycleTimer->stop(); + cycleTimer->deleteLater(); + qInfo().noquote() << "RUNTIME_CYCLE_SOAK_PASS cycles=" << *cycle; + app.exit(0); + return; + } + *phase = 0; + }); + cycleTimer->start(); + QTimer::singleShot(45'000, backend, [&app, cycleTimer] { + if (!cycleTimer->isActive()) return; + cycleTimer->stop(); + cycleTimer->deleteLater(); + qWarning().noquote() << "Runtime lifecycle soak timed out."; + app.exit(10); + }); + } + } + + if (!fleetConfigurationValid) { + QTimer::singleShot(0, &app, [] { QCoreApplication::exit(2); }); + } else if (backend != nullptr && !qaRuntimeCycleScheduled) { + if (parser.isSet(runtimeOption)) { + QTimer::singleShot(150, backend, [backend] { + QMetaObject::invokeMethod(backend, "startSimulation"); + }); + } + const auto startIndices = parser.values(startIedOption); + if (!startIndices.isEmpty()) { + QTimer::singleShot(150, backend, [backend, startIndices] { + for (const auto& text : startIndices) { + bool valid{}; + const auto index = text.toInt(&valid); + if (!valid || index < 0) { + qWarning().noquote() << "Invalid --start-ied index:" << text; + continue; + } + QMetaObject::invokeMethod(backend, "startIed", Q_ARG(int, index)); + } + }); + } + } + if (backend != nullptr && parser.isSet(setFirstValueOption)) { const auto value = parser.value(setFirstValueOption); auto* const applyTimer = new QTimer{backend}; @@ -104,6 +390,34 @@ int main(int argc, char* argv[]) { }); applyTimer->start(); } + if (backend != nullptr && parser.isSet(qaLiveBurstOption)) { + bool countOk{}; + const auto requested = parser.value(qaLiveBurstOption).toInt(&countOk); + const int count = countOk ? std::clamp(requested, 1, 100'000) : 0; + if (count <= 0) { + qWarning().noquote() << "--qa-live-burst requires a positive count."; + QTimer::singleShot(0, &app, [] { QCoreApplication::exit(12); }); + } else { + auto* const liveTimer = new QTimer{backend}; + liveTimer->setInterval(25); + QObject::connect(liveTimer, &QTimer::timeout, backend, [backend, liveTimer, count] { + if (!backend->property("running").toBool()) return; + int accepted{}; + const bool invoked = QMetaObject::invokeMethod( + backend, + "qaBurstLiveValues", + Q_RETURN_ARG(int, accepted), + Q_ARG(int, count)); + liveTimer->stop(); + liveTimer->deleteLater(); + if (!invoked || accepted != count) { + qWarning().noquote() + << "Live burst was not fully accepted" << accepted << "of" << count; + } + }); + liveTimer->start(); + } + } if (parser.isSet(screenshotOption)) { const auto outputPath = parser.value(screenshotOption); QTimer::singleShot(1600, &app, [&app, rootObject, outputPath] { diff --git a/apps/ied_simulator/test_application_close_soak.py b/apps/ied_simulator/test_application_close_soak.py new file mode 100644 index 000000000..68eb17eac --- /dev/null +++ b/apps/ied_simulator/test_application_close_soak.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Release-candidate application-close soak. + +Launches the real Qt workbench with one MMS simulator child, waits until the +configured TCP endpoint is accepting connections, lets the application exit +normally, then verifies that no helper process using that port survives and +that the listen port can be rebound. This covers the aboutToQuit teardown path +rather than only the explicit Stop button path. +""" + +from __future__ import annotations + +import argparse +import os +import pathlib +import socket +import subprocess +import sys +import time +from typing import Iterable + + +def reserve_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 wait_until_listening(port: int, process: subprocess.Popen[str], timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + return False + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(0.15) + if probe.connect_ex(("127.0.0.1", port)) == 0: + return True + time.sleep(0.03) + return False + + +def linux_server_processes_for_port(port: int) -> list[tuple[int, str]]: + proc = pathlib.Path("/proc") + if not proc.is_dir(): + return [] + needle = str(port) + matches: list[tuple[int, str]] = [] + for entry in proc.iterdir(): + if not entry.name.isdigit(): + continue + try: + raw = (entry / "cmdline").read_bytes() + except (FileNotFoundError, PermissionError, ProcessLookupError, OSError): + continue + if not raw: + continue + parts = [part.decode("utf-8", errors="replace") for part in raw.split(b"\0") if part] + joined = " ".join(parts) + if "ariec61850_ied_simulator_server" not in joined: + continue + for index, part in enumerate(parts[:-1]): + if part == "--port" and parts[index + 1] == needle: + matches.append((int(entry.name), joined)) + break + return matches + + +def port_rebindable(port: int) -> bool: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def wait_cleanup(port: int, timeout: float) -> tuple[bool, list[tuple[int, str]]]: + deadline = time.monotonic() + timeout + last_processes: list[tuple[int, str]] = [] + while time.monotonic() < deadline: + last_processes = linux_server_processes_for_port(port) + if not last_processes and port_rebindable(port): + return True, [] + time.sleep(0.05) + return False, last_processes + + +def tail(text: str, lines: int = 30) -> str: + return "\n".join(text.splitlines()[-lines:]) + + +def run_case(app: pathlib.Path, scl: pathlib.Path, cycle: int) -> None: + port = reserve_port() + env = os.environ.copy() + env.setdefault("QT_QPA_PLATFORM", "offscreen") + command = [ + str(app), + "--scl", + str(scl), + "--port", + str(port), + "--runtime", + "--exit-after-ms", + "1600", + ] + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + if not wait_until_listening(port, process, timeout=5.0): + output, _ = process.communicate(timeout=8.0) + raise RuntimeError( + f"cycle {cycle}: runtime never listened on 127.0.0.1:{port}; rc={process.returncode}\n{tail(output)}" + ) + + try: + output, _ = process.communicate(timeout=10.0) + except subprocess.TimeoutExpired: + process.kill() + output, _ = process.communicate(timeout=3.0) + raise RuntimeError(f"cycle {cycle}: application did not exit normally\n{tail(output)}") + + if process.returncode != 0: + raise RuntimeError( + f"cycle {cycle}: application exited with {process.returncode}\n{tail(output)}" + ) + + cleaned, survivors = wait_cleanup(port, timeout=3.0) + if not cleaned: + survivor_text = "; ".join(f"pid={pid} {cmd}" for pid, cmd in survivors) or "none-visible" + raise RuntimeError( + f"cycle {cycle}: child/socket cleanup failed for port {port}; survivors={survivor_text}" + ) + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True, type=pathlib.Path) + parser.add_argument("--scl", required=True, type=pathlib.Path) + parser.add_argument("--cycles", type=int, default=12) + args = parser.parse_args(argv) + + if args.cycles < 1 or args.cycles > 50: + parser.error("--cycles must be between 1 and 50") + if not args.app.is_file(): + parser.error(f"application not found: {args.app}") + if not args.scl.is_file(): + parser.error(f"SCL fixture not found: {args.scl}") + + try: + for cycle in range(1, args.cycles + 1): + run_case(args.app.resolve(), args.scl.resolve(), cycle) + except RuntimeError as exc: + print(f"APPLICATION_CLOSE_SOAK_FAIL {exc}", file=sys.stderr) + return 1 + + print( + "APPLICATION_CLOSE_SOAK_PASS" + f" cycles={args.cycles}" + " runtime_started=pass" + " about_to_quit_cleanup=pass" + " orphan_children=0" + " socket_release=pass" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_gui_live_value.py b/apps/ied_simulator/test_gui_live_value.py index 0d7c31b31..58596d530 100644 --- a/apps/ied_simulator/test_gui_live_value.py +++ b/apps/ied_simulator/test_gui_live_value.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Launch the Qt simulator and prove GUI-applied state is visible over MMS.""" +"""Launch the Qt simulator and prove GUI/live SCL model values are visible over MMS.""" from __future__ import annotations import argparse +from concurrent.futures import ThreadPoolExecutor import os from pathlib import Path import socket @@ -22,19 +23,584 @@ def creation_flags() -> int: return subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 -def resolve_read_probe(argument: str) -> str: +def resolve_named_probe(argument: str, stem: str, label: str) -> str: path = Path(argument) - if path.is_file(): + names = {stem, f"{stem}.exe"} + if path.is_file() and path.name in names: return str(path) if path.is_dir(): - names = {"ariec61850_mms_read_probe", "ariec61850_mms_read_probe.exe"} matches = sorted( - candidate for candidate in path.rglob("ariec61850_mms_read_probe*") + candidate for candidate in path.rglob(f"{stem}*") if candidate.is_file() and candidate.name in names ) if matches: return str(matches[0]) - raise FileNotFoundError(f"MMS read probe not found under {path}") + raise FileNotFoundError(f"{label} not found under {path}") + + +def resolve_read_probe(argument: str) -> str: + return resolve_named_probe(argument, "ariec61850_mms_read_probe", "MMS read probe") + + +def resolve_urcb_probe(argument: str) -> str: + return resolve_named_probe(argument, "ariec61850_mms_urcb_gi_probe", "MMS URCB GI probe") + + +def resolve_brcb_probe(argument: str) -> str: + return resolve_named_probe(argument, "ariec61850_mms_brcb_event_probe", "MMS BRCB event probe") + + +def probe_command(read_probe: str, port: int, item: str) -> list[str]: + return [ + read_probe, + "127.0.0.1", + str(port), + "--domain", + "MU01LD0", + "--item", + item, + ] + + +def run_probe(read_probe: str, port: int, item: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + probe_command(read_probe, port, item) + + ["--timeout-ms", "3000"], + capture_output=True, + text=True, + timeout=6, + check=False, + creationflags=creation_flags(), + ) + + + + +def measure_same_association_read_latency( + read_probe: str, + port: int, + item: str, + count: int = 100, +) -> tuple[float, float]: + """Measure a bounded burst on one established MMS association.""" + started = time.monotonic() + result = subprocess.run( + probe_command(read_probe, port, item) + + [ + "--count", + str(count), + "--delay-ms", + "1", + "--timeout-ms", + "3000", + ], + capture_output=True, + text=True, + timeout=6, + check=False, + creationflags=creation_flags(), + ) + elapsed = time.monotonic() - started + observed = result.stdout.count("MMS_READ index=") + if result.returncode != 0 or observed != count: + raise RuntimeError( + "same-association read latency burst failed: " + f"exit={result.returncode} reads={observed}/{count} " + f"stdout={result.stdout!r} stderr={result.stderr!r}" + ) + # This is deliberately a generous CI guard, not a real-time claim. It catches + # pathological multi-second request/response latency while tolerating shared + # runner scheduling and process startup variance. + if elapsed >= 2.5: + raise RuntimeError( + f"same-association 100-read burst exceeded latency budget: {elapsed:.3f}s" + ) + return elapsed, (elapsed * 1000.0 / count) + + +def run_urcb_gi_probe(urcb_probe: str, port: int) -> str: + result = subprocess.run( + [ + urcb_probe, + "127.0.0.1", + str(port), + "--domain", + "MU01LD0", + "--rcb", + "LLN0$RP$URCB01", + "--timeout-ms", + "5000", + ], + capture_output=True, + text=True, + timeout=9, + check=False, + creationflags=creation_flags(), + ) + if result.returncode != 0 or "MMS_URCB_GI_PASS" not in result.stdout: + raise RuntimeError( + "URCB enable/GI/InformationReport regression failed: " + f"exit={result.returncode} stdout={result.stdout!r} stderr={result.stderr!r}" + ) + return result.stdout.strip() + + +def update_manifest_value(manifest_path: Path, item: str, new_value: str) -> int: + text = manifest_path.read_text(encoding="utf-8") + lines = text.splitlines() + if not lines or not lines[0].startswith("ARSTACK_IED_MODEL\t2\t"): + raise RuntimeError("simulator manifest header is missing") + header = lines[0].split("\t") + revision = int(header[2]) + 1 + lines[0] = f"ARSTACK_IED_MODEL\t2\t{revision}" + + prefix = f"OBJ\tMU01LD0\t{item}\t" + changed = False + for index, line in enumerate(lines[1:], start=1): + if not line.startswith(prefix): + continue + fields = line.split("\t") + if len(fields) < 6: + raise RuntimeError(f"malformed OBJ line for {item}: {line}") + fields[5] = new_value + lines[index] = "\t".join(fields) + changed = True + break + if not changed: + raise RuntimeError(f"manifest object not found: {item}") + + temporary = manifest_path.with_suffix(manifest_path.suffix + ".test-new") + temporary.write_text("\n".join(lines) + "\n", encoding="utf-8") + os.replace(temporary, manifest_path) + return revision + + +def run_brcb_event_probe( + brcb_probe: str, + port: int, + manifest_path: Path, +) -> str: + """Enable BRCB, mutate one DataSet member, and require buffered report delivery.""" + process = subprocess.Popen( + [ + brcb_probe, + "127.0.0.1", + str(port), + "--domain", + "MU01LD0", + "--rcb", + "LLN0$BR$BRCB01", + "--timeout-ms", + "6000", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creation_flags(), + ) + try: + if process.stdout is None: + raise RuntimeError("BRCB probe stdout pipe was not created") + with ThreadPoolExecutor(max_workers=1) as executor: + ready = executor.submit(process.stdout.readline).result(timeout=6).strip() + if "MMS_BRCB_EVENT_READY" not in ready: + stderr = process.stderr.read() if process.stderr is not None else "" + raise RuntimeError( + "BRCB did not become ready after RptEna=true: " + f"stdout={ready!r} stderr={stderr!r}" + ) + + update_manifest_value(manifest_path, "XCBR1$ST$Pos$stVal", "true") + remaining_stdout, stderr = process.communicate(timeout=9) + output = ready + "\n" + remaining_stdout + if process.returncode != 0 or "MMS_BRCB_EVENT_PASS" not in output: + raise RuntimeError( + "BRCB buffered InformationReport regression failed: " + f"exit={process.returncode} stdout={output!r} stderr={stderr!r}" + ) + return output.strip() + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + +def resolve_control_probe(argument: str) -> str: + return resolve_named_probe(argument, "ariec61850_control_interop_probe", "control interoperability probe") + + +def run_direct_normal_control_regression( + control_probe: str, + read_probe: str, + port: int, +) -> str: + common = [ + control_probe, + "127.0.0.1", + str(port), + "--object", + "MU01LD0/GGIO1.SPCSO1", + "--timeout-ms", + "5000", + ] + discovery = subprocess.run( + common, + capture_output=True, + text=True, + timeout=8, + check=False, + creationflags=creation_flags(), + ) + if ( + discovery.returncode != 0 + or "ctlModel=direct-normal" not in discovery.stdout + or "cdc=SPC" not in discovery.stdout + or "STATUS_BEFORE false" not in discovery.stdout + or "status=DISCOVERY_PASS" not in discovery.stdout + ): + raise RuntimeError( + "configured Direct-Normal discovery failed: " + f"exit={discovery.returncode} stdout={discovery.stdout!r} stderr={discovery.stderr!r}" + ) + + # 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", + "operate", + "--value", + "on", + "--value-kind", + "bool", + "--interlock-check", + "on", + "--synchro-check", + "on", + "--arm", + "IEC61850-LAB-CONTROL", + ], + capture_output=True, + text=True, + timeout=8, + check=False, + creationflags=creation_flags(), + ) + if ( + 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 IEDScout Check=0xC0 compatibility regression failed: " + f"exit={checked.returncode} stdout={checked.stdout!r} stderr={checked.stderr!r}" + ) + + accepted = subprocess.run( + common + + [ + "--action", + "operate", + "--value", + "on", + "--value-kind", + "bool", + "--interlock-check", + "off", + "--synchro-check", + "off", + "--arm", + "IEC61850-LAB-CONTROL", + ], + capture_output=True, + text=True, + timeout=8, + check=False, + creationflags=creation_flags(), + ) + if ( + accepted.returncode != 0 + or "completion=accepted" not in accepted.stdout + or "accepted=true" not in accepted.stdout + or "STATUS_AFTER true" not in accepted.stdout + or "NO_RETRY_EVIDENCE controlWrites=1" not in accepted.stdout + ): + raise RuntimeError( + "Direct-Normal Oper wire regression failed: " + f"exit={accepted.returncode} stdout={accepted.stdout!r} stderr={accepted.stderr!r}" + ) + + status = run_probe(read_probe, port, "GGIO1$ST$SPCSO1$stVal") + if status.returncode != 0 or "value=true" not in status.stdout: + raise RuntimeError( + "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" + checked.stdout.strip() + "\n" + accepted.stdout.strip() + + +def run_control_action( + control_probe: str, + port: int, + object_reference: str, + action: str, + value: str, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + control_probe, + "127.0.0.1", + str(port), + "--object", + object_reference, + "--timeout-ms", + "5000", + "--termination-timeout-ms", + "5000", + "--action", + action, + "--value", + value, + "--value-kind", + "bool", + "--interlock-check", + "off", + "--synchro-check", + "off", + "--arm", + "IEC61850-LAB-CONTROL", + ], + capture_output=True, + text=True, + timeout=10, + check=False, + creationflags=creation_flags(), + ) + + +def run_sbo_enhanced_control_regressions( + control_probe: str, + read_probe: str, + port: int, +) -> str: + outputs: list[str] = [] + + # SBO normal: Select/Cancel must be non-mutating; Select/Operate must mutate. + sbo_cancel = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO2", "select-cancel", "on") + if ( + sbo_cancel.returncode != 0 + or "ctlModel=sbo-normal" not in sbo_cancel.stdout + or "completion=accepted" not in sbo_cancel.stdout + or "STATUS_AFTER false" not in sbo_cancel.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO2$Cancel" not in sbo_cancel.stdout + ): + raise RuntimeError( + "SBO-normal Select/Cancel wire regression failed: " + f"exit={sbo_cancel.returncode} stdout={sbo_cancel.stdout!r} stderr={sbo_cancel.stderr!r}" + ) + outputs.append(sbo_cancel.stdout.strip()) + + sbo_oper = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO2", "select-operate", "on") + if ( + sbo_oper.returncode != 0 + or "ctlModel=sbo-normal" not in sbo_oper.stdout + or "completion=accepted" not in sbo_oper.stdout + or "STATUS_AFTER true" not in sbo_oper.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO2$Oper" not in sbo_oper.stdout + ): + raise RuntimeError( + "SBO-normal Select/Operate wire regression failed: " + f"exit={sbo_oper.returncode} stdout={sbo_oper.stdout!r} stderr={sbo_oper.stderr!r}" + ) + outputs.append(sbo_oper.stdout.strip()) + + # Direct enhanced: confirmed Write is not completion; positive correlated + # CommandTermination is mandatory and no automatic retry is allowed. + direct_enhanced = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO3", "operate", "on") + if ( + direct_enhanced.returncode != 0 + or "ctlModel=direct-enhanced" not in direct_enhanced.stdout + or "completion=positive-termination" not in direct_enhanced.stdout + or "termination=true" not in direct_enhanced.stdout + or "STATUS_AFTER true" not in direct_enhanced.stdout + or "NO_RETRY_EVIDENCE controlWrites=1" not in direct_enhanced.stdout + ): + raise RuntimeError( + "Direct-enhanced CommandTermination regression failed: " + f"exit={direct_enhanced.returncode} stdout={direct_enhanced.stdout!r} stderr={direct_enhanced.stderr!r}" + ) + outputs.append(direct_enhanced.stdout.strip()) + + # SBO enhanced: first prove SBOw/Cancel is non-mutating, then prove exact + # SBOw -> Oper -> positive CommandTermination sequence. + enhanced_cancel = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO4", "select-cancel", "on") + if ( + enhanced_cancel.returncode != 0 + or "ctlModel=sbo-enhanced" not in enhanced_cancel.stdout + or "STATUS_AFTER false" not in enhanced_cancel.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$SBOw" not in enhanced_cancel.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$Cancel" not in enhanced_cancel.stdout + ): + raise RuntimeError( + "SBO-enhanced SBOw/Cancel regression failed: " + f"exit={enhanced_cancel.returncode} stdout={enhanced_cancel.stdout!r} stderr={enhanced_cancel.stderr!r}" + ) + outputs.append(enhanced_cancel.stdout.strip()) + + enhanced_oper = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO4", "select-operate", "on") + if ( + enhanced_oper.returncode != 0 + or "ctlModel=sbo-enhanced" not in enhanced_oper.stdout + or "completion=positive-termination" not in enhanced_oper.stdout + or "termination=true" not in enhanced_oper.stdout + or "STATUS_AFTER true" not in enhanced_oper.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$SBOw" not in enhanced_oper.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$Oper" not in enhanced_oper.stdout + or "NO_RETRY_EVIDENCE controlWrites=2" not in enhanced_oper.stdout + ): + raise RuntimeError( + "SBO-enhanced SBOw/Oper/CommandTermination regression failed: " + f"exit={enhanced_oper.returncode} stdout={enhanced_oper.stdout!r} stderr={enhanced_oper.stderr!r}" + ) + outputs.append(enhanced_oper.stdout.strip()) + + for item in ("SPCSO2", "SPCSO3", "SPCSO4"): + status = run_probe(read_probe, port, f"GGIO1$ST${item}$stVal") + if status.returncode != 0 or "value=true" not in status.stdout: + raise RuntimeError( + f"{item} status was not persistent across a second association: " + f"exit={status.returncode} stdout={status.stdout!r} stderr={status.stderr!r}" + ) + return "\n".join(outputs) + + +def prove_concurrent_associations(read_probe: str, port: int, item: str) -> float: + """Keep association A open and require association B to finish meanwhile.""" + holder = subprocess.Popen( + probe_command(read_probe, port, item) + + [ + "--count", + "2", + "--delay-ms", + "4000", + "--timeout-ms", + "3000", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creation_flags(), + ) + try: + if holder.stdout is None: + raise RuntimeError("concurrency holder stdout pipe was not created") + with ThreadPoolExecutor(max_workers=1) as executor: + first_line = executor.submit(holder.stdout.readline).result(timeout=5).strip() + if "value=42" not in first_line: + stderr = holder.stderr.read() if holder.stderr is not None else "" + raise RuntimeError( + "first concurrent association did not establish: " + f"stdout={first_line!r} stderr={stderr!r}" + ) + + started = time.monotonic() + try: + second = subprocess.run( + probe_command(read_probe, port, item) + + ["--timeout-ms", "1500"], + capture_output=True, + text=True, + timeout=2.5, + check=False, + creationflags=creation_flags(), + ) + except subprocess.TimeoutExpired as error: + raise RuntimeError( + "second MMS association was blocked by the first association" + ) from error + elapsed = time.monotonic() - started + if second.returncode != 0 or "value=42" not in second.stdout: + raise RuntimeError( + "second concurrent association failed: " + f"exit={second.returncode} stdout={second.stdout!r} stderr={second.stderr!r}" + ) + if elapsed >= 2.5: + raise RuntimeError( + f"second association completed too slowly for concurrency proof: {elapsed:.3f}s" + ) + + remaining_stdout, stderr = holder.communicate(timeout=7) + holder_output = first_line + "\n" + remaining_stdout + if holder.returncode != 0 or holder_output.count("value=42") < 2: + raise RuntimeError( + "held association did not remain healthy: " + f"exit={holder.returncode} stdout={holder_output!r} stderr={stderr!r}" + ) + return elapsed + finally: + if holder.poll() is None: + holder.kill() + holder.wait(timeout=3) + + +def prove_same_association_refresh( + read_probe: str, + port: int, + manifest_path: Path, + item: str, + initial_fragment: str, + updated_value: str, + updated_fragment: str, +) -> str: + process = subprocess.Popen( + probe_command(read_probe, port, item) + + [ + "--count", + "2", + "--delay-ms", + "1200", + "--timeout-ms", + "3000", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creation_flags(), + ) + try: + if process.stdout is None: + raise RuntimeError("read probe stdout pipe was not created") + with ThreadPoolExecutor(max_workers=1) as executor: + first_line = executor.submit(process.stdout.readline).result(timeout=5).strip() + if initial_fragment not in first_line: + stderr = process.stderr.read() if process.stderr is not None else "" + raise RuntimeError( + f"first read for {item} did not expose {initial_fragment!r}: " + f"stdout={first_line!r} stderr={stderr!r}" + ) + + update_manifest_value(manifest_path, item, updated_value) + remaining_stdout, stderr = process.communicate(timeout=7) + output = first_line + "\n" + remaining_stdout + if process.returncode != 0 or updated_fragment not in output: + raise RuntimeError( + f"same-association refresh failed for {item}: exit={process.returncode} " + f"stdout={output!r} stderr={stderr!r}" + ) + return output + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=3) def main() -> int: @@ -44,10 +610,14 @@ def main() -> int: parser.add_argument("--scl", required=True) args = parser.parse_args() read_probe = resolve_read_probe(args.read_probe) + urcb_probe = resolve_urcb_probe(args.read_probe) + brcb_probe = resolve_brcb_probe(args.read_probe) + control_probe = resolve_control_probe(args.read_probe) port = free_port() environment = dict(os.environ) environment["QT_QPA_PLATFORM"] = "offscreen" + environment["ARSTACK_IEDSIM_TRACE_SERVER"] = "1" with tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as app_log: app = subprocess.Popen( [ @@ -60,7 +630,7 @@ def main() -> int: "--set-first-value", "42", "--exit-after-ms", - "12000", + "45000", ], stdout=app_log, stderr=subprocess.STDOUT, @@ -82,13 +652,44 @@ def main() -> int: manifest_text = manifest_path.read_text(encoding="utf-8") except (FileNotFoundError, PermissionError, UnicodeDecodeError): manifest_text = "" - if manifest_text.startswith("ARSTACK_IED_MODEL\t2\t2\n") and ( - "TCTR1$MX$Amp$instMag$i\tINT32\tNumber\t42" in manifest_text + mapped_value = "TCTR1$MX$Amp$instMag$i\tINT32\tNumber\t0" + structural_only_value = ( + "TCTR1$MX$AmpUnmapped$instMag$i\tINT32\tNumber\t0" + ) + urcb_manifest = ( + "RCB\tMU01LD0\tLLN0$RP$URCB01\t0\t" + "MU01LD0/LLN0$RP$URCB01\tMU01LD0\tLLN0$dsGO\t1\t100\t1000\t100\t120\t128" + ) + brcb_manifest = ( + "RCB\tMU01LD0\tLLN0$BR$BRCB01\t1\t" + "MU01LD0/LLN0$BR$BRCB01\tMU01LD0\tLLN0$dsGO\t2\t75\t1000\t108\t121\t128" + ) + configured_controls = ( + "CTL\tMU01LD0\tGGIO1\tSPCSO1\tSPC\t1", + "CTL\tMU01LD0\tGGIO1\tSPCSO2\tSPC\t2", + "CTL\tMU01LD0\tGGIO1\tSPCSO3\tSPC\t3", + "CTL\tMU01LD0\tGGIO1\tSPCSO4\tSPC\t4", + ) + if ( + manifest_text.startswith("ARSTACK_IED_MODEL\t2\t1\n") + and mapped_value in manifest_text + and structural_only_value in manifest_text + and "XCBR1$ST$Pos$q\tQuality\tQuality\tgood" in manifest_text + and "XCBR1$ST$Pos$t\tTimestamp\tTimestamp\t" in manifest_text + and urcb_manifest in manifest_text + and brcb_manifest in manifest_text + and all(control in manifest_text for control in configured_controls) + and "GGIO1$CF$SPCSO1$ctlModel\tEnum\tEnumeration\t1" in manifest_text + and "GGIO1$CF$SPCSO2$ctlModel\tEnum\tEnumeration\t2" in manifest_text + and "GGIO1$CF$SPCSO3$ctlModel\tEnum\tEnumeration\t3" in manifest_text + and "GGIO1$CF$SPCSO4$ctlModel\tEnum\tEnumeration\t4" in manifest_text ): break time.sleep(0.1) else: - raise RuntimeError("GUI did not publish manifest revision 2 with value 42") + raise RuntimeError( + "GUI did not keep the startup manifest at revision 1 while preserving full model/report/control metadata" + ) deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: @@ -96,40 +697,117 @@ def main() -> int: last_error = f"application exited early with code {app.returncode}" break time.sleep(0.25) - probe = subprocess.run( - [ - read_probe, - "127.0.0.1", - str(port), - "--domain", - "MU01LD0", - "--item", - "TCTR1$MX$Amp$instMag$i", - "--timeout-ms", - "3000", - ], - capture_output=True, - text=True, - timeout=6, - check=False, - creationflags=creation_flags(), + mapped_probe = run_probe( + read_probe, + port, + "TCTR1$MX$Amp$instMag$i", ) - if probe.returncode == 0 and "value=42" in probe.stdout: - app.wait(timeout=14) - print( - "IEDSIM_GUI_LIVE_VALUE_PASS " - "reference=MU01LD0/TCTR1$MX$Amp$instMag$i value=42" + if mapped_probe.returncode != 0 or "value=42" not in mapped_probe.stdout: + last_error = ( + "mapped leaf: " + f"exit={mapped_probe.returncode} stdout={mapped_probe.stdout} " + f"stderr={mapped_probe.stderr}" ) - return 0 + time.sleep(0.2) + continue + + structural_probe = run_probe( + read_probe, + port, + "TCTR1$MX$AmpUnmapped$instMag$i", + ) + if structural_probe.returncode == 0 and "value=0" in structural_probe.stdout: + break last_error = ( - f"exit={probe.returncode} stdout={probe.stdout} stderr={probe.stderr}" + "structural-only leaf: " + f"exit={structural_probe.returncode} stdout={structural_probe.stdout} " + f"stderr={structural_probe.stderr}" ) time.sleep(0.2) - raise RuntimeError(last_error) + else: + raise RuntimeError(last_error) + + control_output = run_direct_normal_control_regression( + control_probe, + read_probe, + port, + ) + sbo_enhanced_output = run_sbo_enhanced_control_regressions( + control_probe, + read_probe, + port, + ) + concurrent_seconds = prove_concurrent_associations( + read_probe, + port, + "TCTR1$MX$Amp$instMag$i", + ) + read_burst_seconds, read_burst_average_ms = measure_same_association_read_latency( + read_probe, + port, + "TCTR1$MX$Amp$instMag$i", + ) + urcb_output = run_urcb_gi_probe(urcb_probe, port) + brcb_output = run_brcb_event_probe( + brcb_probe, + port, + manifest_path, + ) + quality_output = prove_same_association_refresh( + read_probe, + port, + manifest_path, + "XCBR1$ST$Pos$q", + "value=030000", + "questionable,old-data,test", + "value=03C110", + ) + timestamp_output = prove_same_association_refresh( + read_probe, + port, + manifest_path, + "XCBR1$ST$Pos$t", + "value=unix-ms=", + "1700000000123", + "value=unix-ms=1700000000123 UTC", + ) + + app.wait(timeout=47) + app_log.flush() + app_log.seek(0) + app_output = app_log.read() + if "IEDSIM_LIVE_ACK generation=" not in app_output: + raise RuntimeError("GUI edit was not acknowledged by the live runtime data plane") + if "tcp_nodelay=true" not in app_output: + raise RuntimeError("accepted MMS sockets did not prove TCP_NODELAY enabled") + print( + "IEDSIM_GUI_LIVE_VALUE_PASS " + "hot_delta=acknowledged manifest_hot_rewrites=0 " + "edited=MU01LD0/TCTR1$MX$Amp$instMag$i:42 " + "structural=MU01LD0/TCTR1$MX$AmpUnmapped$instMag$i:0 " + f"concurrent_association_seconds={concurrent_seconds:.3f} " + f"read_burst_100_seconds={read_burst_seconds:.3f} " + f"read_burst_average_ms={read_burst_average_ms:.3f} " + "tcp_nodelay=pass " + "control_direct_normal=pass " + "control_sbo_normal=pass " + "control_direct_enhanced=pass " + "control_sbo_enhanced=pass " + "urcb_gi=pass " + "brcb_event=pass " + "quality=same-association:030000->03C110 " + "timestamp=same-association:current->1700000000123" + ) + print(control_output) + print(sbo_enhanced_output) + print(urcb_output) + print(brcb_output) + print(quality_output.strip()) + print(timestamp_output.strip()) + return 0 except BaseException: if app.poll() is None: try: - # Let Qt destroy its QProcess child and release the listener. app.wait(timeout=4) except subprocess.TimeoutExpired: app.kill() diff --git a/apps/ied_simulator/test_gui_multi_ied.py b/apps/ied_simulator/test_gui_multi_ied.py new file mode 100644 index 000000000..d158aee0a --- /dev/null +++ b/apps/ied_simulator/test_gui_multi_ied.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Prove the Qt simulator can run two independent IED endpoints simultaneously.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time + + +def creation_flags() -> int: + return subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + + +def resolve_read_probe(argument: str) -> str: + path = Path(argument) + names = {"ariec61850_mms_read_probe", "ariec61850_mms_read_probe.exe"} + if path.is_file() and path.name in names: + return str(path) + if path.is_dir(): + matches = sorted( + candidate + for candidate in path.rglob("ariec61850_mms_read_probe*") + if candidate.is_file() and candidate.name in names + ) + if matches: + return str(matches[0]) + raise FileNotFoundError(f"MMS read probe not found under {path}") + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def read_point( + read_probe: str, + host: str, + port: int, + domain: str, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + read_probe, + host, + str(port), + "--domain", + domain, + "--item", + "TCTR1$MX$AmpSv$instMag$i", + "--timeout-ms", + "1600", + ], + capture_output=True, + text=True, + timeout=4, + check=False, + creationflags=creation_flags(), + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True) + parser.add_argument("--read-probe", required=True) + parser.add_argument("--scl", required=True) + args = parser.parse_args() + + read_probe = resolve_read_probe(args.read_probe) + port = free_port() + environment = dict(os.environ) + environment["QT_QPA_PLATFORM"] = "offscreen" + + with tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as app_log: + app = subprocess.Popen( + [ + args.app, + "--scl", + args.scl, + "--ied-endpoint", + f"0=127.0.0.1:{port}", + "--ied-endpoint", + f"1=127.0.0.2:{port}", + "--start-ied", + "0", + "--start-ied", + "1", + "--exit-after-ms", + "25000", + ], + stdout=app_log, + stderr=subprocess.STDOUT, + text=True, + env=environment, + creationflags=creation_flags(), + ) + try: + deadline = time.monotonic() + 12.0 + first: subprocess.CompletedProcess[str] | None = None + second: subprocess.CompletedProcess[str] | None = None + while time.monotonic() < deadline: + if app.poll() is not None: + break + first = read_point( + read_probe, + "127.0.0.1", + port, + "MU01_4I_4V_1MU01", + ) + second = read_point( + read_probe, + "127.0.0.2", + port, + "MU01_4I_4V_2MU01", + ) + first_ok = first.returncode == 0 and "value=0" in first.stdout + second_ok = second.returncode == 0 and "value=0" in second.stdout + if first_ok and second_ok: + print( + "MULTI_IED_PASS " + f"endpoint0=127.0.0.1:{port} domain0=MU01_4I_4V_1MU01 " + f"endpoint1=127.0.0.2:{port} domain1=MU01_4I_4V_2MU01" + ) + return 0 + time.sleep(0.25) + + app_log.seek(0) + output = app_log.read() + raise RuntimeError( + "two-IED same-port/different-IP regression failed: " + f"app_exit={app.poll()} first={None if first is None else (first.returncode, first.stdout, first.stderr)} " + f"second={None if second is None else (second.returncode, second.stdout, second.stderr)} " + f"app_output={output!r}" + ) + finally: + if app.poll() is None: + app.terminate() + try: + app.wait(timeout=4) + except subprocess.TimeoutExpired: + app.kill() + app.wait(timeout=3) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_interop_golden_synthetic.py b/apps/ied_simulator/test_interop_golden_synthetic.py new file mode 100644 index 000000000..06ec6fb75 --- /dev/null +++ b/apps/ied_simulator/test_interop_golden_synthetic.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +import argparse +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time +import xml.etree.ElementTree as ET + + +def creation_flags() -> int: + if os.name == "nt" and hasattr(subprocess, "CREATE_NO_WINDOW"): + return int(subprocess.CREATE_NO_WINDOW) + return 0 + + +def find_binary(build_dir: Path, name: str) -> Path: + for candidate in (build_dir / name, build_dir / f"{name}.exe"): + if candidate.is_file(): + return candidate + for suffix in (name, f"{name}.exe"): + matches = sorted(build_dir.rglob(suffix)) + if matches: + return matches[0] + raise RuntimeError(f"Could not find {name} below {build_dir}") + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def run(command: list[str], timeout: int = 15) -> str: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + creationflags=creation_flags(), + ) + output = result.stdout + result.stderr + print(output, end="") + if result.returncode != 0: + raise RuntimeError( + f"Command failed ({result.returncode}): {' '.join(command)}\n{output}" + ) + return output + + +def run_live_discovery(live_discover: Path, port: int) -> int: + """Prove that real GetNameList discovery exposes the complete RCB hierarchy.""" + command = [ + str(live_discover), + "127.0.0.1", + str(port), + "--model-json", + "--timeout-ms", + "7000", + ] + started = time.monotonic() + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=20, + check=False, + creationflags=creation_flags(), + ) + elapsed_ms = int((time.monotonic() - started) * 1000) + if result.stderr: + print(result.stderr, end="") + if result.returncode not in (0, 1): + raise RuntimeError( + "Golden production discovery failed: " + f"exit={result.returncode} stderr={result.stderr!r} stdout={result.stdout!r}" + ) + try: + model = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError( + f"Golden production discovery did not emit JSON: {error}\n{result.stdout}" + ) from error + + if model.get("schemaVersion") != "live-ied-model-v1": + raise RuntimeError( + "Golden production discovery did not emit live-ied-model-v1 JSON" + ) + + coverage = model.get("coverage") + if not isinstance(coverage, dict): + raise RuntimeError("Golden production discovery omitted coverage") + + report_count = int(coverage.get("reportControlCount", 0) or 0) + not_read = int(coverage.get("reportControlBindingNotReadCount", 0) or 0) + read_failed = int(coverage.get("reportControlBindingReadFailedCount", 0) or 0) + if (report_count, not_read, read_failed) != (34, 0, 0): + raise RuntimeError( + "Golden production discovery RCB coverage mismatch: " + f"count={report_count} not_read={not_read} read_failed={read_failed}; " + "expected 34/0/0" + ) + + # live_discover intentionally reports this non-RCB warning while GO/SV/SG/LG + # deep value reads remain optional companion evidence. It must not mask a + # proven-complete Report hierarchy, but every other warning remains fatal. + warnings = model.get("warnings", []) + allowed_warning_codes = {"CONTROL_BLOCK_VALUE_READ_PENDING"} + unexpected_warnings = [ + warning for warning in warnings + if not isinstance(warning, dict) + or warning.get("code") not in allowed_warning_codes + ] + if unexpected_warnings: + raise RuntimeError( + "Golden production discovery emitted unexpected warnings: " + f"{unexpected_warnings}" + ) + + allowed_warning_count = len(warnings) - len(unexpected_warnings) + print( + "INTEROP_GOLDEN_DISCOVERY_PASS " + f"report_controls={report_count} rcb_read_complete=true " + f"unexpected_warnings=0 allowed_non_rcb_warnings={allowed_warning_count} " + f"elapsed_ms={elapsed_ms}" + ) + return elapsed_ms + + +def terminate_process_tree(process: subprocess.Popen[str]) -> None: + if os.name == "nt" and process.poll() is None: + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + capture_output=True, + text=True, + check=False, + creationflags=creation_flags(), + ) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + return + + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def verify_fixture(scl: Path) -> None: + root = ET.parse(scl).getroot() + namespace = {"scl": "http://www.iec.ch/61850/2003/SCL"} + controls = root.findall(".//scl:LN0/scl:ReportControl", namespace) + unconfigured = [ + control for control in controls + if control.attrib.get("name", "").startswith("Uncfg") + and control.attrib.get("buffered") == "false" + and control.attrib.get("indexed") == "false" + and not control.attrib.get("datSet") + ] + indexed_urcb = [ + control for control in controls + if control.attrib.get("name") == "Unbuffer" + and control.attrib.get("buffered") == "false" + and control.attrib.get("indexed") == "true" + ] + indexed_brcb = [ + control for control in controls + if control.attrib.get("name") == "Buffer" + and control.attrib.get("buffered") == "true" + and control.attrib.get("indexed") == "true" + ] + setting = root.find(".//scl:LN0/scl:SettingControl", namespace) + if len(unconfigured) != 30: + raise RuntimeError( + f"Golden fixture must contain 30 unconfigured URCBs, got {len(unconfigured)}" + ) + if len(indexed_urcb) != 1 or len(indexed_brcb) != 1: + raise RuntimeError( + "Golden fixture must contain one indexed URCB family and one indexed BRCB family" + ) + for control in indexed_urcb + indexed_brcb: + enabled = control.find("scl:RptEnabled", namespace) + if enabled is None or enabled.attrib.get("max") != "2": + raise RuntimeError("Indexed golden RCB family must have RptEnabled max=2") + if ( + setting is None + or setting.attrib.get("numOfSGs") != "4" + or setting.attrib.get("actSG") != "1" + ): + raise RuntimeError( + "Golden fixture must contain SettingControl numOfSGs=4 actSG=1" + ) + + +def verify_manifest_inventory(manifest_text: str) -> None: + urcbs = 0 + brcbs = 0 + sgcbs = 0 + for line in manifest_text.splitlines(): + fields = line.split("\t") + if len(fields) >= 4 and fields[0] == "RCB": + if fields[3] == "0": + urcbs += 1 + elif fields[3] == "1": + brcbs += 1 + elif fields and fields[0] == "SGCB": + sgcbs += 1 + + if (urcbs, brcbs, sgcbs) != (32, 2, 1): + raise RuntimeError( + "Golden manifest inventory mismatch: " + f"urcbs={urcbs} brcbs={brcbs} sgcbs={sgcbs}; expected 32/2/1" + ) + + +def require_read(read_probe: Path, port: int, item: str, expected: str) -> str: + output = run([ + str(read_probe), "127.0.0.1", str(port), + "--domain", "IEDGOLDENLD0", "--item", item, + "--timeout-ms", "5000", + ]) + if expected not in output: + raise RuntimeError( + f"Read evidence for {item} did not contain {expected!r}:\n{output}" + ) + return output + + +def wait_for_read(read_probe: Path, port: int, item: str, expected: str) -> str: + deadline = time.monotonic() + 6.0 + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + return require_read(read_probe, port, item, expected) + except (RuntimeError, subprocess.TimeoutExpired) as error: + last_error = error + time.sleep(0.10) + raise RuntimeError(f"MMS server did not become readable for {item}: {last_error}") + + +def update_manifest_value(manifest_path: Path, item: str, new_value: str) -> int: + text = manifest_path.read_text(encoding="utf-8") + lines = text.splitlines() + if not lines or not lines[0].startswith("ARSTACK_IED_MODEL\t2\t"): + raise RuntimeError("Golden simulator manifest header is missing") + header = lines[0].split("\t") + revision = int(header[2]) + 1 + lines[0] = f"ARSTACK_IED_MODEL\t2\t{revision}" + + prefix = f"OBJ\tIEDGOLDENLD0\t{item}\t" + changed = False + for index, line in enumerate(lines[1:], start=1): + if not line.startswith(prefix): + continue + fields = line.split("\t") + if len(fields) < 6: + raise RuntimeError(f"Malformed golden OBJ line for {item}: {line}") + fields[5] = new_value + lines[index] = "\t".join(fields) + changed = True + break + if not changed: + raise RuntimeError(f"Golden manifest object not found: {item}") + + temporary = manifest_path.with_suffix(manifest_path.suffix + ".golden-new") + temporary.write_text("\n".join(lines) + "\n", encoding="utf-8") + os.replace(temporary, manifest_path) + return revision + + +def run_brcb_event_probe( + brcb_probe: Path, + port: int, + manifest_path: Path, +) -> str: + """Enable indexed BRCB, mutate one DataSet member, require buffered report.""" + process = subprocess.Popen( + [ + str(brcb_probe), "127.0.0.1", str(port), + "--domain", "IEDGOLDENLD0", + "--rcb", "LLN0$BR$Buffer01", + "--timeout-ms", "9000", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creation_flags(), + ) + try: + if process.stdout is None: + raise RuntimeError("Golden BRCB probe stdout pipe was not created") + with ThreadPoolExecutor(max_workers=1) as executor: + ready = executor.submit(process.stdout.readline).result(timeout=7).strip() + print(ready) + if "MMS_BRCB_EVENT_READY" not in ready: + stderr = process.stderr.read() if process.stderr is not None else "" + raise RuntimeError( + "Golden BRCB did not become ready after RptEna=true: " + f"stdout={ready!r} stderr={stderr!r}" + ) + + revision = update_manifest_value( + manifest_path, + "XCBR1$ST$Pos$stVal", + "true", + ) + remaining_stdout, stderr = process.communicate(timeout=13) + output = ready + "\n" + remaining_stdout + print(remaining_stdout, end="") + if stderr: + print(stderr, end="") + if process.returncode != 0 or "MMS_BRCB_EVENT_PASS" not in output: + raise RuntimeError( + "Golden BRCB buffered InformationReport regression failed: " + f"revision={revision} exit={process.returncode} " + f"stdout={output!r} stderr={stderr!r}" + ) + if "rptid=IEDGOLDENLD0/LLN0$BR$Buffer" not in output: + raise RuntimeError("Golden BRCB report changed the unsuffixed RptID identity") + return output + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True, type=Path) + parser.add_argument("--build-dir", required=True, type=Path) + parser.add_argument("--scl", required=True, type=Path) + args = parser.parse_args() + + app = args.app.resolve() + build_dir = args.build_dir.resolve() + scl = args.scl.resolve() + if not app.is_file() or not scl.is_file(): + raise RuntimeError("Golden acceptance requires an existing app and SCL fixture") + verify_fixture(scl) + + live_discover = find_binary(build_dir, "ariec61850_live_discover") + read_probe = find_binary(build_dir, "ariec61850_mms_read_probe") + urcb_probe = find_binary(build_dir, "ariec61850_mms_urcb_gi_probe") + brcb_probe = find_binary(build_dir, "ariec61850_mms_brcb_event_probe") + sgcb_probe = find_binary(build_dir, "ariec61850_mms_sgcb_probe") + + port = free_port() + discovery_ms = -1 + with tempfile.TemporaryDirectory(prefix="arstack-interop-golden-") as temp: + log_path = Path(temp) / "golden-runtime.log" + environment = os.environ.copy() + environment["QT_QPA_PLATFORM"] = "offscreen" + environment["ARSTACK_IEDSIM_TRACE_SERVER"] = "1" + with log_path.open("w+", encoding="utf-8") as log: + process = subprocess.Popen( + [ + str(app), "--scl", str(scl), "--runtime", "--port", str(port), + "--exit-after-ms", "45000", + ], + stdout=log, + stderr=subprocess.STDOUT, + text=True, + env=environment, + creationflags=creation_flags(), + ) + manifest_path = ( + Path(tempfile.gettempdir()) + / f"arstack-ied-simulator-{process.pid}.model" + ) + try: + deadline = time.monotonic() + 15.0 + ready_text = "" + manifest_text = "" + while time.monotonic() < deadline: + log.flush() + ready_text = log_path.read_text( + encoding="utf-8", errors="replace" + ) + try: + manifest_text = manifest_path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError, UnicodeDecodeError): + manifest_text = "" + if ( + "RCB\tIEDGOLDENLD0\tLLN0$BR$Buffer01\t1\t" in manifest_text + and "OBJ\tIEDGOLDENLD0\tXCBR1$ST$Pos$stVal\t" in manifest_text + and "SGCB\tIEDGOLDENLD0\tLLN0$SP$SGCB\t4\t1" in manifest_text + ): + break + if process.poll() is not None: + break + time.sleep(0.05) + else: + ready_text = log_path.read_text( + encoding="utf-8", errors="replace" + ) + + if not manifest_text: + raise RuntimeError( + "Golden runtime manifest was not available for event injection.\n" + + ready_text + ) + verify_manifest_inventory(manifest_text) + + # The desktop binary is a GUI subsystem executable on Windows, so + # stdout is not a reliable readiness channel there. A complete + # manifest plus an actual MMS Read proves the runtime is live. + wait_for_read( + read_probe, port, "LLN0$RP$Uncfg01$RptEna", "value=false" + ) + + # Critical regression: direct reads of known ObjectNames are not + # enough. external IEC 61850 client first builds its Report tree from production + # GetNameList discovery. This gate must fail if the server omits, + # truncates, misorders, or cannot read the RCB hierarchy. + discovery_ms = run_live_discovery(live_discover, port) + + # First/last dense unconfigured URCBs must survive manifest projection + # and be MMS-readable, proving they were not dropped by capacity logic. + require_read( + read_probe, port, "LLN0$RP$Uncfg30$RptEna", "value=false" + ) + + # Indexed instances are concrete MMS ObjectNames while RptID remains + # the unsuffixed SCL family identity used by external IEC 61850 client. + require_read( + read_probe, port, "LLN0$RP$Unbuffer01$RptID", + "IEDGOLDENLD0/LLN0$RP$Unbuffer", + ) + require_read( + read_probe, port, "LLN0$BR$Buffer01$RptID", + "IEDGOLDENLD0/LLN0$BR$Buffer", + ) + + urcb = run([ + str(urcb_probe), "127.0.0.1", str(port), + "--domain", "IEDGOLDENLD0", + "--rcb", "LLN0$RP$Unbuffer01", + "--timeout-ms", "7000", + ], timeout=12) + if ( + "MMS_URCB_GI_PASS" not in urcb + or "rptid=IEDGOLDENLD0/LLN0$RP$Unbuffer" not in urcb + ): + raise RuntimeError( + "Indexed URCB GI evidence is incomplete:\n" + urcb + ) + + brcb = run_brcb_event_probe(brcb_probe, port, manifest_path) + if "MMS_BRCB_EVENT_PASS" not in brcb: + raise RuntimeError( + "Indexed BRCB report evidence is incomplete:\n" + brcb + ) + + first_sg = run([ + str(sgcb_probe), "127.0.0.1", str(port), + "--domain", "IEDGOLDENLD0", "--root", "LLN0$SP$SGCB", + "--expect-num", "4", "--expect-act", "1", "--activate", "3", + ]) + if ( + "SGCB_PROBE_PASS discovery=5/5 num=4 initial=1 final=3" + not in first_sg + ): + raise RuntimeError( + "Golden SGCB activation evidence is incomplete:\n" + first_sg + ) + + second_sg = run([ + str(sgcb_probe), "127.0.0.1", str(port), + "--domain", "IEDGOLDENLD0", "--root", "LLN0$SP$SGCB", + "--expect-num", "4", "--expect-act", "3", "--reject", "5", + ]) + if ( + "SGCB_PROBE_PASS discovery=5/5 num=4 initial=3 final=3" + not in second_sg + ): + raise RuntimeError( + "Golden SGCB cross-association evidence is incomplete:\n" + + second_sg + ) + + log.flush() + final_log = log_path.read_text( + encoding="utf-8", errors="replace" + ) + if "IEDSIM_EVENT kind=client_error" in final_log: + raise RuntimeError( + "Golden runtime emitted client_error:\n" + final_log + ) + finally: + terminate_process_tree(process) + + print( + "INTEROP_GOLDEN_SYNTHETIC_PASS " + "rcb_runtime=34 unconfigured_urcb=30 indexed_urcb=2 indexed_brcb=2 " + "sgcb=1 omitted_urcbs=0 omitted_brcbs=0 " + "discovery=pass rcb_discovery=34 rcb_read_complete=true " + f"discovery_ms={discovery_ms} " + "urcb_gi=pass brcb_report=pass sgcb_cross_association=pass" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_live_data_plane_negative.py b/apps/ied_simulator/test_live_data_plane_negative.py new file mode 100644 index 000000000..ad3698b33 --- /dev/null +++ b/apps/ied_simulator/test_live_data_plane_negative.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Negative-path evidence for the generation/revision guarded live data plane.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import select +import socket +import subprocess +import tempfile +import time + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def resolve_executable(argument: str, names: set[str]) -> str: + path = Path(argument) + if path.is_file() and path.name in names: + return str(path.resolve()) + if path.is_dir(): + matches = sorted( + candidate for candidate in path.rglob("*") + if candidate.is_file() and candidate.name in names + ) + if matches: + return str(matches[0].resolve()) + raise FileNotFoundError(f"executable {sorted(names)} not found under {path}") + + +def encode_line(generation: int, revision: int, domain: str, item: str, value: str) -> str: + return "\t".join( + [ + "ARSTACK_LIVE", + "1", + str(generation), + str(revision), + domain.encode().hex(), + item.encode().hex(), + value.encode().hex(), + ] + ) + "\n" + + +def read_until(process: subprocess.Popen[str], needle: str, timeout_s: float) -> str: + if process.stdout is None: + raise RuntimeError("server stdout unavailable") + deadline = time.monotonic() + timeout_s + output = "" + while time.monotonic() < deadline: + if process.poll() is not None: + output += process.stdout.read() or "" + break + ready, _, _ = select.select([process.stdout], [], [], 0.05) + if not ready: + continue + line = process.stdout.readline() + if not line: + continue + output += line + if needle in output: + return output + raise RuntimeError(f"timed out waiting for {needle!r}; output={output[-4000:]}") + + +def run_probe(probe: str, port: int, expected: str) -> str: + completed = subprocess.run( + [ + probe, + "127.0.0.1", + str(port), + "--domain", + "TESTLD0", + "--item", + "LLN0$ST$Mod$stVal", + "--timeout-ms", + "2500", + ], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + output = (completed.stdout or "") + (completed.stderr or "") + if completed.returncode != 0 or f"value={expected}" not in completed.stdout: + raise RuntimeError(f"MMS value mismatch; expected {expected}; output={output!r}") + return completed.stdout.strip() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--read-probe", required=True) + args = parser.parse_args() + + server = resolve_executable( + args.server, + {"ariec61850_ied_simulator_server", "ariec61850_ied_simulator_server.exe"}, + ) + probe = resolve_executable( + args.read_probe, + {"ariec61850_mms_read_probe", "ariec61850_mms_read_probe.exe"}, + ) + port = free_port() + domain = "TESTLD0" + item = "LLN0$ST$Mod$stVal" + + with tempfile.TemporaryDirectory(prefix="arstack-live-negative-") as temporary: + manifest = Path(temporary) / "model.tsv" + manifest.write_text( + "ARSTACK_IED_MODEL\t2\t1\n" + "LN\tTESTLD0\tLLN0\n" + "OBJ\tTESTLD0\tLLN0$ST$Mod$stVal\tBOOLEAN\tBoolean\tfalse\n", + encoding="utf-8", + ) + process = subprocess.Popen( + [ + server, + "--host", + "127.0.0.1", + "--port", + str(port), + "--model-manifest", + str(manifest), + "--live-stdin", + "--live-generation", + "7", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + try: + output = read_until(process, "kind=server_ready", 5.0) + if process.stdin is None: + raise RuntimeError("server stdin unavailable") + + process.stdin.write(encode_line(6, 1, domain, item, "true")) + process.stdin.flush() + output += read_until(process, "accepted=false", 3.0) + if "reason=stale-generation-or-revision" not in output: + raise RuntimeError(f"stale generation was not rejected; output={output[-4000:]}") + run_probe(probe, port, "false") + + process.stdin.write(encode_line(7, 1, domain, item, "true")) + process.stdin.flush() + output += read_until(process, "revision=1 accepted=true", 3.0) + run_probe(probe, port, "true") + + process.stdin.write(encode_line(7, 1, domain, item, "false")) + process.stdin.flush() + output += read_until(process, "revision=1 accepted=false", 3.0) + if output.count("reason=stale-generation-or-revision") < 2: + raise RuntimeError(f"duplicate revision was not rejected; output={output[-4000:]}") + run_probe(probe, port, "true") + + print( + "LIVE_DATA_PLANE_NEGATIVE_PASS " + "stale_generation=rejected duplicate_revision=rejected " + "valid_revision=accepted stale_state_not_applied=true" + ) + return 0 + finally: + if process.stdin is not None: + try: + process.stdin.close() + except OSError: + pass + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_live_hot_updates.py b/apps/ied_simulator/test_live_hot_updates.py new file mode 100644 index 000000000..450860c01 --- /dev/null +++ b/apps/ied_simulator/test_live_hot_updates.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Milestone-J hot update data-plane regression and bounded burst evidence.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import socket +import subprocess +import tempfile +import time + +BURST_RE = re.compile( + r"IEDSIM_LIVE_BURST\s+requested=(?P\d+)\s+accepted=(?P\d+)\s+" + r"coalesced=(?P\d+)\s+rejected=(?P\d+)\s+" + r"pending=(?P\d+)\s+pending_max=(?P\d+)\s+final=(?P\S+)" +) +ACK_RE = re.compile( + r"IEDSIM_LIVE_ACK\s+generation=(?P\d+)\s+revision=(?P\d+)\s+" + r"latency_ms=(?P\d+)\s+pending=(?P\d+)\s+inflight=(?P\d+)" +) + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def creation_flags() -> int: + return subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + + +def resolve_probe(argument: str) -> str: + path = Path(argument) + names = {"ariec61850_mms_read_probe", "ariec61850_mms_read_probe.exe"} + if path.is_file() and path.name in names: + return str(path.resolve()) + if path.is_dir(): + matches = sorted( + candidate for candidate in path.rglob("ariec61850_mms_read_probe*") + if candidate.is_file() and candidate.name in names + ) + if matches: + return str(matches[0].resolve()) + raise FileNotFoundError(f"MMS read probe not found under {path}") + + +def read_value(probe: str, port: int) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + probe, + "127.0.0.1", + str(port), + "--domain", + "MU01LD0", + "--item", + "TCTR1$MX$Amp$instMag$i", + "--timeout-ms", + "2500", + ], + capture_output=True, + text=True, + timeout=5, + check=False, + creationflags=creation_flags(), + ) + + +def wait_for_value(probe: str, port: int, expected: str, deadline_s: float) -> str: + deadline = time.monotonic() + deadline_s + last = "" + while time.monotonic() < deadline: + result = read_value(probe, port) + last = result.stdout + result.stderr + if result.returncode == 0 and f"value={expected}" in result.stdout: + return result.stdout.strip() + time.sleep(0.04) + raise RuntimeError(f"MMS never exposed value={expected}; last probe output={last!r}") + + +def wait_for_manifest(pid: int, deadline_s: float = 8.0) -> Path: + path = Path(tempfile.gettempdir()) / f"arstack-ied-simulator-{pid}.model" + deadline = time.monotonic() + deadline_s + while time.monotonic() < deadline: + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="replace") + if text.startswith("ARSTACK_IED_MODEL\t2\t1\n"): + return path + time.sleep(0.03) + raise RuntimeError(f"startup manifest not created at {path}") + + +def run_case(app: Path, probe: str, scl: Path, updates: int) -> None: + port = free_port() + environment = os.environ.copy() + environment["QT_QPA_PLATFORM"] = "offscreen" + environment["ARSTACK_IEDSIM_QA"] = "1" + + with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as log: + process = subprocess.Popen( + [ + str(app), + "--scl", + str(scl), + "--port", + str(port), + "--runtime", + "--qa-live-burst", + str(updates), + "--exit-after-ms", + "12000", + ], + stdout=log, + stderr=subprocess.STDOUT, + text=True, + env=environment, + creationflags=creation_flags(), + ) + manifest = wait_for_manifest(process.pid) + try: + expected = str(updates - 1) + read_output = wait_for_value(probe, port, expected, 8.0) + manifest_text = manifest.read_text(encoding="utf-8", errors="replace") + if not manifest_text.startswith("ARSTACK_IED_MODEL\t2\t1\n"): + raise RuntimeError("hot update rewrote the startup manifest revision") + mapped = "TCTR1$MX$Amp$instMag$i\tINT32\tNumber\t0" + if mapped not in manifest_text: + raise RuntimeError( + "hot update unexpectedly rewrote the startup OBJ value instead of using the delta channel" + ) + + process.wait(timeout=15) + if process.returncode != 0: + raise RuntimeError(f"simulator exited {process.returncode}") + log.flush() + log.seek(0) + output = log.read() + bursts = list(BURST_RE.finditer(output)) + acks = list(ACK_RE.finditer(output)) + if not bursts: + raise RuntimeError(f"missing IEDSIM_LIVE_BURST evidence; output={output[-6000:]}") + burst = bursts[-1] + metrics = {key: int(value) for key, value in burst.groupdict().items() if key != "final"} + if metrics["requested"] != updates or metrics["accepted"] != updates: + raise RuntimeError(f"live burst was not fully accepted: {metrics}") + if metrics["rejected"] != 0: + raise RuntimeError(f"live burst rejected updates: {metrics}") + if metrics["pending_max"] > 256: + raise RuntimeError(f"live pending queue exceeded bound: {metrics}") + if burst.group("final") != expected: + raise RuntimeError( + f"live burst final value mismatch: {burst.group('final')} != {expected}" + ) + if updates >= 1000 and metrics["coalesced"] < updates - 64: + raise RuntimeError( + f"expected same-point burst to coalesce heavily, got {metrics['coalesced']} of {updates}" + ) + if not acks: + raise RuntimeError(f"missing IEDSIM_LIVE_ACK evidence; output={output[-6000:]}") + max_latency = max(int(match.group("latency")) for match in acks) + max_inflight = max(int(match.group("inflight")) for match in acks) + max_revision = max(int(match.group("revision")) for match in acks) + generations = {int(match.group("generation")) for match in acks} + if max_latency > 1500: + raise RuntimeError(f"live ACK latency exceeded 1500 ms: {max_latency}") + if max_inflight > 256: + raise RuntimeError(f"live in-flight ACK tracking exceeded bound: {max_inflight}") + if max_revision <= 0 or any(generation <= 0 for generation in generations): + raise RuntimeError( + f"invalid parsed live ACK evidence: revision={max_revision} generations={generations}" + ) + + print( + "LIVE_HOT_UPDATE_CASE " + f"updates={updates} coalesced={metrics['coalesced']} " + f"pending_max={metrics['pending_max']} max_ack_ms={max_latency} " + f"max_inflight={max_inflight} max_ack_revision={max_revision} " + f"manifest_revision=1 final={expected} " + f"mms_read={read_output.splitlines()[0] if read_output else 'ok'}" + ) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + try: + manifest.unlink() + except FileNotFoundError: + pass + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True, type=Path) + parser.add_argument("--read-probe", required=True) + parser.add_argument("--scl", required=True, type=Path) + parser.add_argument("--bursts", default="1000,10000") + args = parser.parse_args() + + app = args.app.resolve() + scl = args.scl.resolve() + probe = resolve_probe(args.read_probe) + if not app.is_file(): + raise FileNotFoundError(f"simulator not found: {app}") + if not scl.is_file(): + raise FileNotFoundError(f"SCL fixture not found: {scl}") + + bursts = [int(token) for token in args.bursts.split(",") if token.strip()] + if not bursts or any(value <= 0 or value > 100000 for value in bursts): + raise ValueError("--bursts values must be 1..100000") + for updates in bursts: + run_case(app, probe, scl, updates) + print( + "LIVE_RUNTIME_DATA_PLANE_PASS " + f"cases={','.join(str(value) for value in bursts)} " + "manifest_hot_rewrites=0 bounded_pending=256 bounded_inflight=256" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_malformed_import.py b/apps/ied_simulator/test_malformed_import.py new file mode 100644 index 000000000..6bd04e574 --- /dev/null +++ b/apps/ied_simulator/test_malformed_import.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Prove malformed SCL fails closed through the interactive async importer.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True, type=Path) + args = parser.parse_args() + + app = args.app.resolve() + if not app.is_file(): + raise SystemExit(f"simulator executable not found: {app}") + + with tempfile.TemporaryDirectory(prefix="arstack-malformed-scl-") as temporary: + path = Path(temporary) / "malformed.scd" + path.write_text( + "", + encoding="utf-8", + ) + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + completed = subprocess.run( + [str(app), "--qa-async-import", str(path)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + timeout=15, + check=False, + ) + + output = completed.stdout or "" + if completed.returncode != 6: + raise RuntimeError( + "malformed SCL did not fail through the controlled importer path: " + f"exit={completed.returncode} output={output[-4000:]}" + ) + if "Async import failed:" not in output: + raise RuntimeError( + "malformed SCL failure was not surfaced to the QA control path: " + f"output={output[-4000:]}" + ) + if "ASYNC_IMPORT_OK" in output: + raise RuntimeError("malformed SCL was incorrectly reported as a successful import") + + print("MALFORMED_IMPORT_PASS exit=6 fail_closed=true", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_runtime_lifecycle.py b/apps/ied_simulator/test_runtime_lifecycle.py new file mode 100644 index 000000000..03e0f803b --- /dev/null +++ b/apps/ied_simulator/test_runtime_lifecycle.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Same-process start/stop/restart soak for the Qt IED simulator runtime.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import socket +import subprocess + + +def reserve_ephemeral_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 main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app", required=True, type=Path) + parser.add_argument("--scl", required=True, type=Path) + parser.add_argument("--cycles", type=int, default=12) + args = parser.parse_args() + + app = args.app.resolve() + scl = args.scl.resolve() + if not app.is_file(): + raise SystemExit(f"simulator executable not found: {app}") + if not scl.is_file(): + raise SystemExit(f"SCL fixture not found: {scl}") + if args.cycles < 1: + raise SystemExit("--cycles must be positive") + + port = reserve_ephemeral_port() + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + completed = subprocess.run( + [ + str(app), + "--scl", + str(scl), + "--port", + str(port), + "--qa-runtime-cycles", + str(args.cycles), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + timeout=50, + check=False, + ) + output = completed.stdout or "" + marker = f"RUNTIME_CYCLE_SOAK_PASS cycles= {args.cycles}" + if completed.returncode != 0 or marker not in output: + raise RuntimeError( + "runtime lifecycle soak failed: " + f"exit={completed.returncode} port={port} output={output[-6000:]}" + ) + + started = output.count("RUNTIME_CYCLE_STARTED") + finished = output.count("RUNTIME_CYCLE_FINISHED") + if started != args.cycles or finished != args.cycles: + raise RuntimeError( + f"expected {args.cycles} complete cycles; started={started} finished={finished}; " + f"output={output[-6000:]}" + ) + if args.cycles >= 2 and "RUNTIME_RESTART_GUARD_PASS" not in output: + raise RuntimeError( + "restart generation guard was not exercised beyond the delayed-kill window; " + f"output={output[-6000:]}" + ) + + print( + f"RUNTIME_LIFECYCLE_PASS cycles={args.cycles} port={port} " + f"started={started} finished={finished} delayed_kill_guard=pass", + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_runtime_responsiveness.py b/apps/ied_simulator/test_runtime_responsiveness.py new file mode 100644 index 000000000..366bf2058 --- /dev/null +++ b/apps/ied_simulator/test_runtime_responsiveness.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Milestone-I runtime-scale and GUI responsiveness evidence.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import subprocess +import tempfile + +from benchmark_large_scl import build_scl + +RESPONSIVENESS_RE = re.compile( + r"IEDSIM_RESPONSIVENESS_PASS\s+ieds=(?P\d+)\s+prepared=(?P\d+)" +) + + +def run_checked(command: list[str], timeout_s: float, marker: str) -> str: + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + completed = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + timeout=timeout_s, + check=False, + ) + output = completed.stdout or "" + print(output, end="" if output.endswith("\n") else "\n") + if completed.returncode != 0: + raise RuntimeError( + f"{' '.join(command)} exited {completed.returncode}; output={output[-5000:]}" + ) + if marker not in output: + raise RuntimeError(f"missing {marker}; output={output[-5000:]}") + return output + + +def prepared_counts(output: str) -> tuple[int, int]: + matches = list(RESPONSIVENESS_RE.finditer(output)) + if not matches: + raise RuntimeError(f"missing responsiveness profile counts; output={output[-5000:]}") + match = matches[-1] + return int(match.group("ieds")), int(match.group("prepared")) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--qa", required=True, type=Path) + parser.add_argument("--multi-scl", required=True, type=Path) + parser.add_argument("--single-scl", required=True, type=Path) + args = parser.parse_args() + + qa = args.qa.resolve() + multi_scl = args.multi_scl.resolve() + single_scl = args.single_scl.resolve() + if not qa.is_file(): + raise RuntimeError(f"QA executable not found: {qa}") + + with tempfile.TemporaryDirectory(prefix="arstack-runtime-scale-") as temporary: + root = Path(temporary) + for target in (20_000, 50_000): + xml, generated = build_scl(target) + scl = root / f"runtime-{target}.scd" + scl.write_text(xml, encoding="utf-8") + output = run_checked( + [str(qa), "--responsiveness", str(scl)], + 120.0, + "IEDSIM_RESPONSIVENESS_PASS", + ) + ieds, prepared = prepared_counts(output) + if ieds != 1 or prepared != 1: + raise RuntimeError( + f"{target} point run expected one fully prebuilt IED, got ieds={ieds} prepared={prepared}" + ) + print(f"RUNTIME_SCALE_CASE target={target} generated={generated} pass=true") + + multi_output = run_checked( + [str(qa), "--responsiveness", str(multi_scl)], + 30.0, + "IEDSIM_RESPONSIVENESS_PASS", + ) + ieds, prepared = prepared_counts(multi_output) + if ieds < 2 or prepared != ieds: + raise RuntimeError( + f"multi-IED responsiveness run did not fully prebuild the fleet: ieds={ieds} prepared={prepared}" + ) + + run_checked( + [str(qa), "--fleet-rollback", str(multi_scl)], + 30.0, + "FLEET_PARTIAL_START_RECOVERY_PASS", + ) + run_checked( + [str(qa), "--clear-nonblocking", str(single_scl)], + 30.0, + "NONBLOCKING_CLEAR_PASS", + ) + + print( + "RUNTIME_SCALE_RESPONSIVENESS_PASS large=20000,50000 " + f"multi_ied={ieds} fleet_rollback=pass nonblocking_clear=pass" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/test_sgcb_production.py b/apps/ied_simulator/test_sgcb_production.py new file mode 100644 index 000000000..95bd5c8c5 --- /dev/null +++ b/apps/ied_simulator/test_sgcb_production.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import argparse +from pathlib import Path +import socket +import subprocess +import tempfile +import time + + +def find_binary(build_dir: Path, name: str) -> Path: + candidates = [build_dir / name, build_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.is_file(): + return candidate + for suffix in (name, f"{name}.exe"): + matches = list(build_dir.rglob(suffix)) + if matches: + return matches[0] + raise RuntimeError(f"Could not find {name} below {build_dir}") + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def run_probe(probe: Path, port: int, extra: list[str]) -> str: + command = [ + str(probe), "127.0.0.1", str(port), + "--domain", "SGIEDLD0", "--root", "LLN0$SP$SGCB", + "--expect-num", "4", *extra, + ] + result = subprocess.run(command, check=False, text=True, capture_output=True, timeout=15) + output = result.stdout + result.stderr + print(output, end="") + if result.returncode != 0: + raise RuntimeError(f"SGCB probe failed ({result.returncode}): {output}") + return output + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True, type=Path) + parser.add_argument("--build-dir", required=True, type=Path) + args = parser.parse_args() + server = args.server.resolve() + probe = find_binary(args.build_dir.resolve(), "ariec61850_mms_sgcb_probe") + if not server.is_file(): + raise RuntimeError(f"Production simulator server does not exist: {server}") + + with tempfile.TemporaryDirectory(prefix="arstack-sgcb-") as temp: + root = Path(temp) + manifest = root / "sgcb-production.manifest" + log_path = root / "server.log" + manifest.write_text( + "ARSTACK_IED_MODEL\t2\t1\n" + "LN\tSGIEDLD0\tLLN0\n" + "OBJ\tSGIEDLD0\tLLN0$ST$Mod$stVal\tBOOLEAN\tBOOLEAN\tfalse\n" + "SGCB\tSGIEDLD0\tLLN0$SP$SGCB\t4\t1\n", + encoding="utf-8", + ) + port = free_port() + with log_path.open("w+", encoding="utf-8") as log: + process = subprocess.Popen( + [ + str(server), "--host", "127.0.0.1", "--port", str(port), + "--model-manifest", str(manifest), "--max-connections", "2", + ], + stdout=log, + stderr=subprocess.STDOUT, + text=True, + ) + try: + deadline = time.monotonic() + 10.0 + ready = False + while time.monotonic() < deadline: + log.flush() + text = log_path.read_text(encoding="utf-8") + if "IEDSIM_EVENT kind=server_ready" in text: + ready = True + break + if process.poll() is not None: + break + time.sleep(0.025) + if not ready: + raise RuntimeError( + "Production SGCB server never reached ready state:\n" + + log_path.read_text(encoding="utf-8")) + text = log_path.read_text(encoding="utf-8") + if "sgcbs=1" not in text: + raise RuntimeError("server_ready did not advertise sgcbs=1:\n" + text) + + first = run_probe( + probe, port, ["--expect-act", "1", "--activate", "3"]) + if "SGCB_PROBE_PASS discovery=5/5 num=4 initial=1 final=3" not in first or \ + "activation=verified" not in first: + raise RuntimeError("First association evidence is incomplete.") + + second = run_probe( + probe, port, ["--expect-act", "3", "--reject", "5"]) + if "SGCB_PROBE_PASS discovery=5/5 num=4 initial=3 final=3" not in second or \ + "rejection=verified" not in second: + raise RuntimeError("Second association evidence is incomplete.") + + process.wait(timeout=10) + if process.returncode != 0: + raise RuntimeError( + f"Production SGCB server exited {process.returncode}:\n" + + log_path.read_text(encoding="utf-8")) + text = log_path.read_text(encoding="utf-8") + if "IEDSIM_EVENT kind=client_error" in text: + raise RuntimeError("Production SGCB run emitted client_error:\n" + text) + if text.count("IEDSIM_EVENT kind=client_connected") != 2: + raise RuntimeError("Expected exactly two independent MMS associations.\n" + text) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + + print( + "SGCB_PRODUCTION_SOCKET_PASS discovery=5/5 activation=1->3 " + "cross_association=3 reject_out_of_range=pass associations=2" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/ied_simulator/tests/scl_assisted_workbench/CMakeLists.txt b/apps/ied_simulator/tests/scl_assisted_workbench/CMakeLists.txt new file mode 100644 index 000000000..8d4258260 --- /dev/null +++ b/apps/ied_simulator/tests/scl_assisted_workbench/CMakeLists.txt @@ -0,0 +1,34 @@ +cmake_minimum_required(VERSION 3.24) + +project(ARStackSclAssistedWorkbenchQA LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) + +# Reuse the exact desktop build graph so the P3 QA is linked against the same +# core, compatibility server, Qt version and controller implementation as the +# shipped workbench. The parent app target is configured but this QA target can +# still be built independently. +add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. ied-simulator) + +add_executable(ied_mms_scl_assisted_workbench_qa + ${CMAKE_CURRENT_LIST_DIR}/../../src/ied_mms_scl_assisted_workbench_qa.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/MmsClientController.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/MmsClientController.hpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/MmsLiveTreeModel.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/MmsLiveTreeModel.hpp) + +target_link_libraries(ied_mms_scl_assisted_workbench_qa PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Network + Qt6::Qml + ariec61850_ied_simulator_server_core + ARIEC61850::core) + +target_include_directories(ied_mms_scl_assisted_workbench_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/../../src) +target_compile_features(ied_mms_scl_assisted_workbench_qa PRIVATE cxx_std_20) diff --git a/apps/smv_injector_gui/assets/apple-touch-icon.png b/apps/smv_injector_gui/assets/apple-touch-icon.png new file mode 100644 index 000000000..f89e472ad Binary files /dev/null and b/apps/smv_injector_gui/assets/apple-touch-icon.png differ diff --git a/apps/smv_injector_gui/assets/favicon-96x96.png b/apps/smv_injector_gui/assets/favicon-96x96.png new file mode 100644 index 000000000..b19f5859d Binary files /dev/null and b/apps/smv_injector_gui/assets/favicon-96x96.png differ diff --git a/apps/smv_injector_gui/assets/favicon.ico b/apps/smv_injector_gui/assets/favicon.ico new file mode 100644 index 000000000..e0f876200 Binary files /dev/null and b/apps/smv_injector_gui/assets/favicon.ico differ diff --git a/apps/smv_injector_gui/assets/site.webmanifest b/apps/smv_injector_gui/assets/site.webmanifest new file mode 100644 index 000000000..9eaf3e67f --- /dev/null +++ b/apps/smv_injector_gui/assets/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "ARStack61850 SMV Injector", + "short_name": "ARStack61850", + "icons": [ + { + "src": "web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "theme_color": "#071018", + "background_color": "#071018", + "display": "standalone" +} diff --git a/apps/smv_injector_gui/assets/web-app-manifest-192x192.png b/apps/smv_injector_gui/assets/web-app-manifest-192x192.png new file mode 100644 index 000000000..56fdc82b9 Binary files /dev/null and b/apps/smv_injector_gui/assets/web-app-manifest-192x192.png differ diff --git a/apps/smv_injector_gui/assets/web-app-manifest-512x512.png b/apps/smv_injector_gui/assets/web-app-manifest-512x512.png new file mode 100644 index 000000000..3e55dca30 Binary files /dev/null and b/apps/smv_injector_gui/assets/web-app-manifest-512x512.png differ diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index a0979df64..735821520 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -4,6 +4,11 @@ + + + + + ARStack61850 · SMV Injector @@ -13,7 +18,7 @@
- +
ARStack61850

SMV Injector

diff --git a/apps/smv_injector_gui/styles.css b/apps/smv_injector_gui/styles.css index 9b0cd2e37..68193f99f 100644 --- a/apps/smv_injector_gui/styles.css +++ b/apps/smv_injector_gui/styles.css @@ -50,18 +50,12 @@ button { color: inherit; } .toolbar-actions, .switch-row, .section-heading { display: flex; align-items: center; } .brand { gap: 14px; } .brand-mark { - width: 42px; height: 42px; border-radius: 12px; position: relative; - background: linear-gradient(145deg, rgba(79,199,255,.16), rgba(91,140,255,.10)); - border: 1px solid rgba(79,199,255,.28); - box-shadow: inset 0 0 24px rgba(79,199,255,.08); -} -.brand-mark span { - position: absolute; left: 10px; right: 10px; height: 2px; border-radius: 999px; - background: linear-gradient(90deg, transparent, var(--cyan), transparent); -} -.brand-mark span:nth-child(1){ top: 12px; transform: rotate(-24deg); } -.brand-mark span:nth-child(2){ top: 20px; } -.brand-mark span:nth-child(3){ top: 28px; transform: rotate(24deg); } + width: 42px; + height: 42px; + object-fit: contain; + display: block; + flex: 0 0 auto; +} .brand h1, .panel h2, .section-heading h2 { margin: 2px 0 0; font-size: 20px; line-height: 1.15; } .eyebrow { color: var(--muted); text-transform: uppercase; letter-spacing: .12em; font-size: 10px; font-weight: 750; } .connection-strip { gap: 12px; } diff --git a/assets/site.webmanifest b/assets/site.webmanifest index ccf313a3e..288a2e156 100644 --- a/assets/site.webmanifest +++ b/assets/site.webmanifest @@ -1,21 +1,21 @@ { - "name": "MyWebSite", - "short_name": "MySite", + "name": "ARStack61850", + "short_name": "ARStack61850", "icons": [ { - "src": "/web-app-manifest-192x192.png", + "src": "web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", - "purpose": "maskable" + "purpose": "any maskable" }, { - "src": "/web-app-manifest-512x512.png", + "src": "web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", - "purpose": "maskable" + "purpose": "any maskable" } ], - "theme_color": "#ffffff", - "background_color": "#ffffff", + "theme_color": "#071018", + "background_color": "#071018", "display": "standalone" -} \ No newline at end of file +} diff --git a/docs/INTEROP_SERVER_GOLDEN_PROFILE.md b/docs/INTEROP_SERVER_GOLDEN_PROFILE.md new file mode 100644 index 000000000..e2db60195 --- /dev/null +++ b/docs/INTEROP_SERVER_GOLDEN_PROFILE.md @@ -0,0 +1,245 @@ +# external IEC 61850 client Server Golden Profile + +Status: external behavioral evidence for ARStack IED Simulator parity work. +Tracking: Issue #95 and PR #82. + +## Capture identity + +Golden capture: `external IEC 61850 client_As_Server_Discovery.pcapng` +SHA-256: `6f90cb4aa87326f07d569ebf40575d15841831259cc52e21f2e847b86d11ab99` + +The capture records external vendor external IEC 61850 client operating as the IEC 61850 server for the same reference IEC 61850 model engineering model used by the ARStack simulator parity work. The values below are measured capture evidence, not generic IEC 61850 constants. + +## Association/discovery sequence + +The capture contains 418 MMS confirmed request/response exchanges with invoke IDs `1..418` in strict increasing order. + +Measured service totals: + +| Service | Count | +| --- | ---: | +| GetNameList | 140 | +| GetVariableAccessAttributes | 119 | +| Read | 157 | +| GetNamedVariableListAttributes | 2 | + +No Write/Control mutation is part of this discovery profile. + +## Directory behavior + +The initial VMD-domain GetNameList returns 32 MMS domains. Domain order follows the engineering model's LogicalDevice order. + +NamedVariable discovery then consists of 105 domain-specific pages containing 8,741 identifiers in total. The server returns at most 100 identifiers per page. Seventy-three continuation requests were observed, and every `continueAfter` value is exactly the final identifier from the preceding page. + +The directory is hierarchical, not leaf-only. Representative names include: + +```text +LLN0 +LLN0$ST +LLN0$ST$Beh +LLN0$ST$Beh$stVal +LLN0$ST$Beh$q +LLN0$ST$Beh$t +LLN0$CF +LLN0$DC +LLN0$SP +LLN0$RP +LLN0$RP$A_URCB01 +LLN0$RP$A_URCB01$RptID +... +``` + +The Application domain similarly advertises `LLN0$BR`, concrete BRCB roots such as `LLN0$BR$Buffer01`, `LLN0$RP`, concrete URCB roots such as `LLN0$RP$Unbuffer01`, and their attributes. + +### Required ARStack invariant + +A hierarchy name advertised by GetNameList must be readable and type-queryable; a readable/type-queryable hierarchy node must be represented in the same canonical directory. GetNameList, GVAA and Read must therefore project from one canonical node graph rather than three independently synthesized views. + +## GVAA behavior + +There are 119 GetVariableAccessAttributes requests. Every observed GVAA target is a Logical Node root rather than an individual leaf. The response TypeSpecification is a full nested MMS Structure with named components. + +For the Application LLN0, the top-level FC components observed in the TypeSpecification include: + +```text +ST, CO, CF, DC, SP, SG, RP, BR, SE, EX, OR +``` + +The exact FC set depends on the Logical Node. ARStack must derive it from the canonical model and preserve deterministic semantic ordering; it must not hard-code the Application example as a global FC list. + +## Read behavior + +The capture contains 157 Read requests covering 869 object references. + +617 references are FC-root reads. The remaining reads include deeper service/data structures such as concrete RCB roots and metadata objects. + +The Read response structure matches the hierarchy/type view used for GVAA. This is evidence for compiling data and type projections from the same immutable hierarchy. + +## ReportControl instance behavior + +The source SCL contains 32 ReportControl definitions, but the golden server exposes 34 concrete RCB instances. Runtime RCB instance count is therefore distinct from SCL ReportControl definition count. + +The difference comes from indexed ReportControl instance expansion using `RptEnabled max`. + +Observed examples: + +```text +Application / Buffer -> Buffer01, Buffer02 +Application / Unbuffer -> Unbuffer01, Unbuffer02 +``` + +ADD indexed URCBs similarly expose a two-digit instance suffix, including the max=1 case. + +### Golden URCB root schema + +A concrete URCB root Read returns a 12-component MMS Structure in this order: + +```text +RptID +RptEna +Resv +DatSet +ConfRev +OptFlds +BufTm +SqNum +TrgOps +IntgPd +GI +Owner +``` + +Representative initial state observed for `AA1E1F06R4ADD/LLN0$RP$A_URCB01`: + +- RptID: configured report identity +- RptEna: false +- Resv: false +- DatSet: empty for that free/unbound instance +- ConfRev: 1 +- BufTm: 0 +- SqNum: 0 +- IntgPd: 0 +- GI: false +- Owner: empty + +### Golden BRCB root schema + +A concrete BRCB root Read returns a 15-component MMS Structure in this order: + +```text +RptID +RptEna +DatSet +ConfRev +OptFlds +BufTm +SqNum +TrgOps +IntgPd +GI +PurgeBuf +EntryID +TimeofEntry +ResvTms +Owner +``` + +The ARStack compatibility facade must use this order for both synthesized TypeSpecification and Read values. + +## Normal/process semantic defaults + +The golden server does not represent an unedited simulator as an all-zero data model. + +For `LLN0$ST`, the representative normal state contains standard DOs `Beh`, `Health` and `Mod` with: + +```text +stVal = 1 +q = Good, test=false +t = valid current-ish UTC timestamp +``` + +This is particularly important because a generic Enumeration=`0` fallback can make an engineering client classify the simulated IED as off/test-related instead of normal/process. + +ARStack default-value precedence must be: + +1. explicit instance value from SCL (`DAI/Val`); +2. standard semantic default derived from LN/DO/DA/CDC/bType/FC plus IED/header/model context; +3. conservative type-safe fallback. + +Examples of semantic defaults when no explicit SCL value exists: + +- `Mod.stVal` -> normal/on semantic code (`1` for this standard enum); +- `Beh.stVal` -> normal/on semantic code; +- `Health.stVal` -> OK semantic code; +- Quality -> Good with test=false and operatorBlocked=false; +- Timestamp -> simulation start/current UTC, not Unix epoch zero; +- `ctlModel` -> configured SCL value when present; otherwise preserve safe/status-only semantics; +- identity/NamePlate strings -> derive from SCL metadata where the standard object meaning is unambiguous. + +Do not implement a global `Enumeration=1` rule. Enumeration semantics differ across standard types such as Dbpos/DPC, control models, health and mode values. + +## DataSet behavior + +The golden discovery queries DataSet inventory and then GetNamedVariableListAttributes for configured DataSets. Member ordering is the configured SCL order. Discovery does not mutate or dynamically rebuild configured DataSets. + +## Transport/segmentation behavior + +Large MMS responses are segmented at the COTP layer. The capture includes response TSDUs requiring multiple COTP data segments; the largest observed assembled response is several kilobytes while individual TPKTs remain close to the negotiated TPDU size. + +ARStack must retain one logical MMS response and segment only at transport, rather than truncating application data to one TPKT. + +## Architecture derived from the evidence + +### Canonical immutable model compiler + +Compile the selected SCL/CID once into a stable graph: + +```text +IED + -> MMS domain / LogicalDevice + -> LogicalNode + -> FC + -> DataObject / structured component + -> DataAttribute / BDA +``` + +Service objects such as DataSets, RCBs, SGCB and control objects attach to this graph. Intermediate hierarchy nodes are virtual and do not need one concrete `MmsStaticObjectEntry` each. + +A flattened immutable directory index is derived once from the graph for GetNameList pagination. GVAA and Read reference the same graph. This prevents inconsistent views and avoids rebuilding trees on every request. + +### Memory and lifetime + +- concrete leaves/service endpoints retain bounded object storage; +- virtual nodes live in one compiled hierarchy/directory, not duplicated per association; +- TypeSpecifications are compiled once and backing storage remains stable; +- per-association mutable state contains only ownership/reservation/report enable/session state and bounded scratch buffers; +- RAII owns sockets and workers; +- no detached per-RCB worker/thread; +- no per-request unbounded heap graph reconstruction. + +### Live updates and coalescing + +Keep ARStack's generation/revision checks and bounded live-update history. Coalesce repeated updates to the same point before they become a retained report event. + +For report scheduling, coalescing is permitted only while an event is still pending in its BufferTime window. Once a BRCB report entry is retained, later changes must create later retained state rather than rewrite historical evidence. + +### Scheduler + +Use one bounded scheduler per IED (or equivalent centralized timed-event queue) for BufferTime, integrity and GI work. Do not spawn one timer/thread per RCB. Preserve deterministic ordering for equal-deadline work. + +## Acceptance gates + +Internal regression must lock the measured behavior without converting model-specific counts into generic protocol constants. The reference IEC 61850 model fixture should prove the exact capture shape where appropriate: + +- 32 domains; +- 8,741 discoverable NamedVariable identifiers; +- 105 NamedVariable pages at max 100/page; +- exact continuation token behavior; +- 119 LN-root GVAAs; +- 34 runtime RCB instances from 32 ReportControl definitions; +- exact URCB/BRCB root field order; +- normal LLN0 Mod/Beh/Health defaults; +- valid non-zero timestamps; +- large-response COTP segmentation. + +Final closure still requires a real external IEC 61850 client retest against an ARStack RC. Simulator/unit/CI evidence alone is not external interoperability proof. diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index 5052569c3..43b36ea3b 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + cmake_minimum_required(VERSION 3.20) project(ARIEC61850EmbeddedCore @@ -11,9 +13,9 @@ option(ARIEC61850_EMBEDDED_WARNINGS_AS_ERRORS "Treat embedded-profile warnings a set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/..") -# Keep this source list deliberately small. It is the MCU portability boundary: -# wire codecs and protocol state only, with no TCP host transport, live discovery, -# PCAP, COMTRADE, SCL parsing, filesystem, JSON or CLI tooling. +# MCU portability boundary: protocol/wire/state sources only. Host transports, +# filesystem/PCAP/SCL/CLI layers stay out. Retain the owning codecs used by the +# embedded regression harness alongside the allocation-free span/server path. set(ARIEC61850_EMBEDDED_SOURCES ${ARIEC61850_ROOT}/src/asn1/ber.cpp ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp @@ -40,6 +42,7 @@ set(ARIEC61850_EMBEDDED_SOURCES ${ARIEC61850_ROOT}/src/osi/session_span.cpp ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_compat_span.cpp ${ARIEC61850_ROOT}/src/mms/utc_time.cpp ${ARIEC61850_ROOT}/src/mms/data_value.cpp @@ -51,6 +54,7 @@ set(ARIEC61850_EMBEDDED_SOURCES ${ARIEC61850_ROOT}/src/mms/file_service.cpp ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_compat_span.cpp ${ARIEC61850_ROOT}/src/mms/services_span.cpp ${ARIEC61850_ROOT}/src/mms/write_span.cpp ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp @@ -58,10 +62,15 @@ set(ARIEC61850_EMBEDDED_SOURCES ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_direct_control.cpp ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp ${ARIEC61850_ROOT}/src/mms/static_server_session.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_report_connection.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_objects.cpp ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp ${ARIEC61850_ROOT}/src/mms/static_brcb_control.cpp ${ARIEC61850_ROOT}/src/mms/static_brcb_objects.cpp @@ -133,6 +142,13 @@ foreach(server_smoke IN ITEMS mms_static_model mms_write mms_dispatcher + mms_interop_bulk_read + mms_interop_pagination + mms_interop_fc_root_read + mms_interop_rcb_root + mms_interop_rcb_control + mms_interop_composed_gvaa + mms_urcb_event mms_connection_runtime mms_negotiated_limits mms_data_set diff --git a/embedded/embedded_link_smoke.cpp b/embedded/embedded_link_smoke.cpp index d9071adb2..ea85a67db 100644 --- a/embedded/embedded_link_smoke.cpp +++ b/embedded/embedded_link_smoke.cpp @@ -1,114 +1,50 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/asn1/ber_span_reader.hpp" #include "ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp" -#include "ariec61850/embedded/io.hpp" #include "ariec61850/embedded/profile.hpp" -#include "ariec61850/ethernet/ethernet.hpp" -#include "ariec61850/goose/frame_codec.hpp" -#include "ariec61850/mms/data_codec.hpp" -#include "ariec61850/osi/tpkt.hpp" -#include "ariec61850/sampled_values/frame_codec.hpp" -#include "ariec61850/wire/encode_result.hpp" +#include "ariec61850/osi/tpkt_span.hpp" #include -#include -#include #include #include -#include - -namespace { - -ar::iec61850::embedded::IoResult discard_frame( - void*, const std::span frame) noexcept { - return {ar::iec61850::embedded::IoStatus::ok, frame.size()}; -} - -} // namespace int main() { using namespace ar::iec61850; - // The executable is linked from every embedded source file by CMake, so - // the linker catches host-only dependencies even when this smoke program - // does not exercise every protocol at runtime. - std::array bytes{0x05U, 0x00U}; - std::size_t offset = 0U; - asn1::BerTlv tlv; - const bool parsed = asn1::BerReader::try_read_tlv(bytes, offset, tlv); - + // CMake links every source in the bounded embedded profile into this + // executable. Keep the smoke itself on the same span-only boundary so + // missing transitive dependencies are exposed without pulling legacy + // heap-owning protocol implementations back into the embedded profile. static_assert( embedded::Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); static_assert( embedded::boards::WaveshareEsp32S3PoeEth8Di8Do::w5500_macraw_socket == 0U); - const std::array destination_bytes{ - 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; - const std::array source_bytes{ - 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; - - sampled_values::SampledValueAsdu asdu; - asdu.sv_id = "ESP32S3IO/LLN0$MSVCB01"; - asdu.data_set_reference = "ESP32S3IO/LLN0$PhsMeas1"; - asdu.sample_count = 120U; - asdu.configuration_revision = 1U; - asdu.reference_time = mms::Iec61850UtcTime{ - std::chrono::system_clock::time_point{std::chrono::seconds{1'781'260'260}}, - 0U}; - asdu.sample_synchronization = 2U; - asdu.sample_rate = std::uint16_t{4'000U}; - asdu.sample_mode = std::uint16_t{1U}; - asdu.sample_payload = { - 0x00U, 0x00U, 0x00U, 0x64U, - 0x00U, 0x00U, 0x00U, 0x01U, - 0x00U, 0x00U, 0x00U, 0xC8U, - 0x00U, 0x00U, 0x00U, 0x03U}; - - sampled_values::SampledValuesFrame sv_frame{ - ethernet::MacAddress{destination_bytes}, - ethernet::MacAddress{source_bytes}, - ethernet::VlanTag{4U, 200U}, - 0x4001U, - 0U, - 0U, - sampled_values::SampledValuesPdu{{asdu}}}; + constexpr std::array ber_null{0x05U, 0x00U}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(ber_null, tlv) || + tlv.encoded_bytes != ber_null.size()) { + return 1; + } - std::array - frame_buffer{}; - const auto first_encode = sampled_values::SampledValuesFrameCodec::encode_into( - sv_frame, frame_buffer); - if (!first_encode.success() || first_encode.bytes_written == 0U) { + constexpr std::array payload{0xAAU}; + std::array tpkt{}; + const auto encoded = osi::TpktSpanCodec::encode_into(payload, tpkt); + if (!encoded.success() || + encoded.bytes_written != osi::TpktSpanCodec::header_length + payload.size()) { return 2; } - // Reuse the same caller-owned frame buffer. The encoder itself performs no - // heap allocation on this path; configuration objects are prepared once. - sv_frame.pdu.asdus.front().sample_count = 121U; - const auto second_encode = sampled_values::SampledValuesFrameCodec::encode_into( - sv_frame, frame_buffer); - if (!second_encode.success() || - second_encode.bytes_written != first_encode.bytes_written) { + osi::TpktFrameView decoded; + if (!osi::TpktSpanCodec::try_decode_view( + std::span{tpkt}.first(encoded.bytes_written), + decoded) || + decoded.version != osi::TpktSpanCodec::supported_version || + decoded.payload.size() != payload.size() || + decoded.payload[0] != payload[0]) { return 3; } - if (second_encode.required_bytes <= 1U) { - return 4; - } - const auto too_small = sampled_values::SampledValuesFrameCodec::encode_into( - sv_frame, - std::span{frame_buffer}.first( - second_encode.required_bytes - 1U)); - if (too_small.status != wire::EncodeStatus::buffer_too_small || - too_small.required_bytes != second_encode.required_bytes) { - return 5; - } - - embedded::RawEthernetPort ethernet_port; - ethernet_port.transmit = &discard_frame; - const auto sent = ethernet_port.send( - std::span{frame_buffer}.first( - second_encode.bytes_written)); - - return parsed && sent.success() ? 0 : 1; + return 0; } diff --git a/embedded/mms_brcb_connection_hard_profile_smoke.cpp b/embedded/mms_brcb_connection_hard_profile_smoke.cpp index 606f84232..b3b534850 100644 --- a/embedded/mms_brcb_connection_hard_profile_smoke.cpp +++ b/embedded/mms_brcb_connection_hard_profile_smoke.cpp @@ -8,6 +8,7 @@ #include "ariec61850/osi/session_span.hpp" #include "ariec61850/osi/tpkt_span.hpp" +#include #include #include #include @@ -74,6 +75,7 @@ constexpr std::array kAssociationRequest{ result.owner[0] = owner[0]; result.owner[1] = owner[1]; result.owner_size = owner.size(); + result.maximum_tpdu_size_code = 0x07U; return result; } @@ -83,7 +85,7 @@ constexpr std::array kAssociationRequest{ std::span response, std::span workspace, std::span scratch) noexcept { - constexpr std::array tpdu_size{0x0AU}; + constexpr std::array tpdu_size{0x07U}; constexpr std::array source_tsap{0x00U, 0x01U}; constexpr std::array destination_tsap{0x00U, 0x01U}; const std::array parameters{ @@ -118,15 +120,37 @@ constexpr std::array kAssociationRequest{ [[nodiscard]] bool decode_report_frame( const std::span frame, - mms::MmsInformationReportView& report) noexcept { - osi::TpktFrameView tpkt; - osi::CotpTpduView cotp; + const std::span reassembled, + mms::MmsInformationReportView& report, + std::size_t* segment_count = nullptr) noexcept { + std::size_t input_offset{}; + std::size_t reassembled_size{}; + std::size_t segments{}; + while (input_offset < frame.size()) { + const auto remaining = frame.subspan(input_offset); + const auto peek = osi::TpktSpanCodec::peek_frame(remaining); + if (!peek.ready() || peek.frame_bytes == 0U) return false; + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(remaining.first(peek.frame_bytes), tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) || + cotp.kind != osi::CotpWireKind::data || + cotp.user_data.size() > reassembled.size() - reassembled_size) { + return false; + } + std::copy(cotp.user_data.begin(), cotp.user_data.end(), + reassembled.begin() + static_cast(reassembled_size)); + reassembled_size += cotp.user_data.size(); + input_offset += peek.frame_bytes; + ++segments; + if ((input_offset == frame.size()) != cotp.end_of_transmission) return false; + } + if (segment_count != nullptr) *segment_count = segments; osi::SessionDataTransferView session; osi::PresentationPdvView pdv; - return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && - osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) && - cotp.kind == osi::CotpWireKind::data && cotp.end_of_transmission && - osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + return segments != 0U && + osi::SessionSpanCodec::try_decode_data_transfer_view( + reassembled.first(reassembled_size), session) && osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( session.presentation_payload, pdv) && pdv.context_id == 3U && @@ -170,7 +194,8 @@ int main() { dispatcher, connection_policy(foreign_b.association_id, kOwnerB)}; const mms::MmsStaticBrcbDefinition definition{ - "LD0", "LLN0$BR$Events", "LD0/LLN0$BR$Events", + "LD0", "LLN0$BR$Events", + "LD0/LLN0$BR$Events_P0_3_Negotiated_TPDU_Segmentation_Proof", "LD0", "LLN0$Events", 4U, {0x7FU, 0x80U}, 0U, 0x70U}; std::array slot_storage{}; std::array slots{ @@ -273,10 +298,15 @@ int main() { return 12; } + std::array report_storage{}; mms::MmsInformationReportView decoded; + std::size_t report_segments{}; if (!decode_report_frame( - std::span{response}.first(retry.bytes_written), decoded) || - decoded.item_count != 12U) { + std::span{response}.first(retry.bytes_written), + report_storage, + decoded, + &report_segments) || + report_segments < 2U || decoded.item_count != 12U) { return 13; } mms::MmsReadAccessResultView item; diff --git a/embedded/mms_brcb_hard_profile_smoke.cpp b/embedded/mms_brcb_hard_profile_smoke.cpp index 60d4d066a..e4da4c56b 100644 --- a/embedded/mms_brcb_hard_profile_smoke.cpp +++ b/embedded/mms_brcb_hard_profile_smoke.cpp @@ -77,6 +77,25 @@ constexpr std::array kReportTime{ return true; } +[[nodiscard]] bool decode_visible_string( + const mms::MmsReadAccessResultView& item, + const std::string_view expected) noexcept { + if (!item.success) return false; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 10 || tlv.constructed || tlv.value.size() != expected.size()) { + return false; + } + for (std::size_t index = 0U; index < expected.size(); ++index) { + if (tlv.value[index] != static_cast( + static_cast(expected[index]))) { + return false; + } + } + return true; +} + [[nodiscard]] bool expected_entry( const std::array& entry, const std::uint64_t value) noexcept { @@ -89,6 +108,15 @@ constexpr std::array kReportTime{ return true; } +[[nodiscard]] bool front_report( + mms::MmsStaticBrcbRuntime& runtime, + mms::MmsStaticBrcbEntryView& entry, + mms::MmsInformationReportView& report) noexcept { + return runtime.front(entry) && + mms::MmsInformationReportSpanCodec::try_decode_information_report( + entry.mms_pdu, report); +} + } // namespace int main() { @@ -147,6 +175,7 @@ int main() { } mms::MmsStaticBrcbCapturePlan plan; if (runtime.next_due(119U, plan) || !runtime.next_due(120U, plan) || + plan.reason != mms::MmsStaticBrcbCaptureReason::event || plan.entry_number != 1U || plan.sequence_number != 1U || plan.buffer_overflow) { return 4; } @@ -179,10 +208,12 @@ int main() { if (!report.try_item(5U, item) || !decode_boolean(item, boolean_value) || boolean_value || !report.try_item(6U, item) || !decode_octet_string(item, capture.entry_id) || !report.try_item(8U, item) || !decode_bit_string(item, 5U, 0xA0U) || + !report.try_item(9U, item) || !decode_visible_string(item, "LD0/X1") || + !report.try_item(10U, item) || !decode_visible_string(item, "LD0/X3") || !report.try_item(11U, item) || !decode_boolean(item, boolean_value) || !boolean_value || !report.try_item(12U, item) || !decode_boolean(item, boolean_value) || !boolean_value || - !report.try_item(13U, item) || !decode_bit_string(item, 2U, 0x80U) || - !report.try_item(14U, item) || !decode_bit_string(item, 2U, 0x40U)) { + !report.try_item(13U, item) || !decode_bit_string(item, 2U, 0x40U) || + !report.try_item(14U, item) || !decode_bit_string(item, 2U, 0x20U)) { return 8; } @@ -232,7 +263,6 @@ int main() { return 15; } - // Entry 2 was the oldest undelivered report and was lost. Entry 3 is now front. if (!runtime.front(entry) || entry.entry_id.size() != 8U || entry.entry_id[7] != 3U || runtime.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok || !runtime.front(entry) || entry.entry_id[7] != 4U || !entry.buffer_overflow || @@ -250,7 +280,6 @@ int main() { return 17; } - // Delivered entries remain replayable until buffer eviction or PurgeBuf. if (runtime.replay_from(fourth_id) != mms::MmsStaticBrcbStatus::ok || runtime.queue_size() != 1U || !runtime.front(entry) || entry.entry_id[7U] != 4U || runtime.commit_delivery(fourth_id) != mms::MmsStaticBrcbStatus::ok || @@ -269,7 +298,6 @@ int main() { return 19; } - // Repeated capture/delivery proves bounded retained-history behavior and EntryID progression. for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { const auto now = static_cast(1'000U + (iteration * 25U)); if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_update, now) != @@ -291,5 +319,89 @@ int main() { return 22; } + // Periodic integrity and GI are independent full-DataSet producers feeding + // the same retained queue. A GI must not clear an already pending event. + const mms::MmsStaticBrcbDefinition scheduled_definition{ + "LD0", + "LLN0$BR$Scheduled", + "LD0/LLN0$BR$Scheduled", + "LD0", + "LLN0$Events", + 12U, + {0x7FU, 0x80U}, + 20U, + 0x7CU, + 100U}; + std::array, 6U> scheduled_storage{}; + std::array scheduled_slots{ + mms::MmsStaticBrcbSlot{scheduled_storage[0]}, + mms::MmsStaticBrcbSlot{scheduled_storage[1]}, + mms::MmsStaticBrcbSlot{scheduled_storage[2]}, + mms::MmsStaticBrcbSlot{scheduled_storage[3]}, + mms::MmsStaticBrcbSlot{scheduled_storage[4]}, + mms::MmsStaticBrcbSlot{scheduled_storage[5]}}; + mms::MmsStaticBrcbPendingState scheduled_pending{}; + mms::MmsStaticBrcbRuntime scheduled{ + scheduled_definition, scheduled_pending, scheduled_slots, + object_table, data_set_table}; + if (!scheduled.initialize() || + scheduled.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + scheduled.next_due(1'000U, plan) || + scheduled.next_due(1'099U, plan) || + !scheduled.next_due(1'100U, plan) || + plan.reason != mms::MmsStaticBrcbCaptureReason::integrity) { + return 23; + } + capture = scheduled.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || capture.included_member_count != 3U || + !front_report(scheduled, entry, report) || report.item_count != 18U || + !report.try_item(8U, item) || !decode_bit_string(item, 5U, 0xE0U) || + !report.try_item(15U, item) || !decode_bit_string(item, 2U, 0x08U) || + !report.try_item(16U, item) || !decode_bit_string(item, 2U, 0x08U) || + !report.try_item(17U, item) || !decode_bit_string(item, 2U, 0x08U) || + scheduled.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok) { + return 24; + } + + if (scheduled.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 1'150U) != + mms::MmsStaticBrcbStatus::ok || + scheduled.request_general_interrogation() != mms::MmsStaticBrcbStatus::ok || + !scheduled.next_due(1'170U, plan) || + plan.reason != mms::MmsStaticBrcbCaptureReason::general_interrogation) { + return 25; + } + capture = scheduled.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || capture.included_member_count != 3U || + !front_report(scheduled, entry, report) || report.item_count != 18U || + !report.try_item(15U, item) || !decode_bit_string(item, 2U, 0x04U) || + !report.try_item(16U, item) || !decode_bit_string(item, 2U, 0x04U) || + !report.try_item(17U, item) || !decode_bit_string(item, 2U, 0x04U) || + scheduled.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok) { + return 26; + } + + if (!scheduled.next_due(1'170U, plan) || + plan.reason != mms::MmsStaticBrcbCaptureReason::event) { + return 27; + } + capture = scheduled.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || capture.included_member_count != 1U || + !front_report(scheduled, entry, report) || report.item_count != 12U || + !report.try_item(11U, item) || !decode_bit_string(item, 2U, 0x40U) || + scheduled.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok) { + return 28; + } + + if (!scheduled.next_due(1'200U, plan) || + plan.reason != mms::MmsStaticBrcbCaptureReason::integrity) { + return 29; + } + capture = scheduled.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || capture.included_member_count != 3U || + !front_report(scheduled, entry, report) || + !report.try_item(15U, item) || !decode_bit_string(item, 2U, 0x08U)) { + return 30; + } + return 0; -} \ No newline at end of file +} diff --git a/embedded/mms_brcb_objects_hard_profile_smoke.cpp b/embedded/mms_brcb_objects_hard_profile_smoke.cpp index e1349f9be..a31842293 100644 --- a/embedded/mms_brcb_objects_hard_profile_smoke.cpp +++ b/embedded/mms_brcb_objects_hard_profile_smoke.cpp @@ -19,6 +19,9 @@ constexpr std::array kTrue{0x83U, 0x01U, 0xFFU}; constexpr std::array kFalse{0x83U, 0x01U, 0x00U}; constexpr std::array kFiveSeconds{0x85U, 0x01U, 0x05U}; constexpr std::array kZeroSeconds{0x85U, 0x01U, 0x00U}; +constexpr std::array kTriggerAll{0x84U, 0x02U, 0x02U, 0x7CU}; +constexpr std::array kIedScoutOptionalFields{ + 0x84U, 0x03U, 0x06U, 0x7BU, 0x80U}; [[nodiscard]] wire::EncodeResult read_boolean( const void* context, @@ -145,6 +148,32 @@ constexpr std::array kZeroSeconds{0x85U, 0x01U, 0x00U}; : !result.success && result.failure_code == expected_failure; } +[[nodiscard]] bool dispatch_trigger_enable_transaction( + const mms::MmsStaticApplicationDispatcher& dispatcher, + const mms::MmsStaticRequestAccessContext& client, + std::uint8_t& invoke) noexcept { + std::array request{ + 0xA0U,0x3CU,0x02U,0x01U,0x00U,0xA5U,0x37U,0xA0U,0x2CU, + 0x30U,0x14U,0xA0U,0x12U,0xA1U,0x10U,0x1AU,0x03U,0x4CU,0x44U,0x30U, + 0x1AU,0x09U,0x42U,0x31U,0x24U,0x52U,0x70U,0x74U,0x45U,0x6EU,0x61U, + 0x30U,0x14U,0xA0U,0x12U,0xA1U,0x10U,0x1AU,0x03U,0x4CU,0x44U,0x30U, + 0x1AU,0x09U,0x42U,0x31U,0x24U,0x54U,0x72U,0x67U,0x4FU,0x70U,0x73U, + 0xA0U,0x07U,0x83U,0x01U,0xFFU,0x84U,0x02U,0x02U,0x7CU}; + request[4] = invoke++; + std::array response{}; + std::array workspace{}; + const auto dispatched = dispatcher.dispatch(request, response, workspace, client); + if (!dispatched.success()) return false; + mms::MmsWriteResponseView decoded; + mms::MmsWriteAccessResultView first; + mms::MmsWriteAccessResultView second; + return mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{response}.first(dispatched.bytes_written), + decoded) && + decoded.result_count == 2U && decoded.try_result(0U, first) && + decoded.try_result(1U, second) && first.success && second.success; +} + [[nodiscard]] std::array octet_data( const std::span value) noexcept { std::array encoded{}; @@ -196,9 +225,9 @@ int main() { } mms::MmsStaticBrcbControl control{reports}; - std::array object_storage{}; - std::array context_storage{}; - std::array name_storage{}; + std::array object_storage{}; + std::array context_storage{}; + std::array name_storage{}; std::uint64_t now = 100U; mms::MmsStaticBrcbObjectBank bank{ definition, @@ -210,11 +239,14 @@ int main() { name_storage, read_now, &now}; - if (!bank.initialize() || bank.object_count() != 9U || !bank.table().valid()) { + if (!bank.initialize() || bank.object_count() != 16U || !bank.table().valid()) { return 2; } - const mms::MmsStaticApplicationDispatcher dispatcher{bank.table(), data_set_table}; + mms::MmsStaticDispatchPolicy dispatch_policy; + dispatch_policy.maximum_write_variables = 16U; + const mms::MmsStaticApplicationDispatcher dispatcher{ + bank.table(), data_set_table, dispatch_policy}; const std::array owner_a{0xAAU, 0x01U}; const std::array owner_b{0xBBU, 0x01U}; const auto a = access(101U, owner_a); @@ -271,6 +303,22 @@ int main() { return 11; } + if (!dispatch_write( + dispatcher, "B1$OptFlds", kIedScoutOptionalFields, a, true, 0U, invoke) || + reports.optional_fields()[0] != 0x7BU || + reports.optional_fields()[1] != 0x80U) { + return 89; + } + if (kTriggerAll[3] != 0x7CU || + !dispatch_trigger_enable_transaction(dispatcher, a, invoke) || + !reports.enabled() || reports.trigger_options() != 0x7CU) { + return 90; + } + if (!dispatch_write(dispatcher, "B1$RptEna", kFalse, a, true, 0U, invoke) || + reports.enabled()) { + return 91; + } + if (!dispatch_write(dispatcher, "B1$ResvTms", kFiveSeconds, a, true, 0U, invoke)) { return 12; } @@ -321,7 +369,7 @@ int main() { } std::array read_buffer{}; - const auto& entry_object = object_storage[1U + 5U]; + const auto& entry_object = object_storage[1U + 11U]; const auto entry_read = entry_object.read(entry_object.context, read_buffer); if (!entry_read.success() || entry_read.bytes_written != 10U || read_buffer[0] != 0x89U || read_buffer[1] != 0x08U || @@ -329,7 +377,7 @@ int main() { [](const std::uint8_t byte) { return byte == 0U; })) { return 21; } - const auto& purge_object = object_storage[1U + 4U]; + const auto& purge_object = object_storage[1U + 10U]; const auto purge_read = purge_object.read(purge_object.context, read_buffer); if (!purge_read.success() || purge_read.bytes_written != 3U || read_buffer[0] != 0x83U || read_buffer[2] != 0x00U) { diff --git a/embedded/mms_data_set_hard_profile_smoke.cpp b/embedded/mms_data_set_hard_profile_smoke.cpp index 958e67910..d84f647ad 100644 --- a/embedded/mms_data_set_hard_profile_smoke.cpp +++ b/embedded/mms_data_set_hard_profile_smoke.cpp @@ -22,6 +22,11 @@ constexpr std::array kAttributesRequest{ 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, 0x1AU, 0x0BU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; +constexpr std::array kVmdAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x01U, + 0xACU, 0x0DU, 0x80U, 0x0BU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; constexpr std::array kAttributesResponse{ 0xA1U, 0x28U, 0x02U, 0x01U, 0x01U, 0xACU, 0x23U, 0x80U, 0x01U, 0x00U, @@ -86,6 +91,15 @@ int main() { return 1; } + mms::MmsNamedVariableListAttributesRequestView vmd_request; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + kVmdAttributesRequest, vmd_request) || + vmd_request.invoke_id != 1U || + vmd_request.name.kind != mms::MmsObjectNameViewKind::vmd_specific || + !vmd_request.name.domain.empty() || !matches(vmd_request.name.item, kEvents)) { + return 14; + } + const std::array encoded_members{ mms::MmsNamedVariableListMemberInput{"LD0", "R1"}, mms::MmsNamedVariableListMemberInput{"LD0", "M1"}}; @@ -133,6 +147,9 @@ int main() { if (!data_set_table.valid() || !data_set_table.valid_against(object_table)) { return 5; } + if (data_set_table.find(vmd_request.name) != &data_sets[0]) { + return 15; + } const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; std::array workspace{}; @@ -146,6 +163,16 @@ int main() { return 6; } + dispatched = dispatcher.dispatch(kVmdAttributesRequest, buffer, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_named_variable_list_attributes || + dispatched.invoke_id != 1U || dispatched.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(dispatched.bytes_written), + kAttributesResponse)) { + return 16; + } + dispatched = dispatcher.dispatch(kDataSetNameListRequest, buffer, workspace); if (!dispatched.success() || dispatched.service != mms::MmsWireConfirmedService::get_name_list || @@ -188,6 +215,14 @@ int main() { return 11; } + const std::array ambiguous_data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}, + mms::MmsStaticDataSetEntry{"LD1", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable ambiguous_table{ambiguous_data_sets}; + if (!ambiguous_table.valid() || ambiguous_table.find(vmd_request.name) != nullptr) { + return 17; + } + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { const auto invoke = iteration & 0x7FFFU; const auto repeated = @@ -211,4 +246,4 @@ int main() { } return 0; -} +} \ No newline at end of file diff --git a/embedded/mms_dispatcher_hard_profile_smoke.cpp b/embedded/mms_dispatcher_hard_profile_smoke.cpp index 04b902339..a1ce2957a 100644 --- a/embedded/mms_dispatcher_hard_profile_smoke.cpp +++ b/embedded/mms_dispatcher_hard_profile_smoke.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/services.hpp" #include #include @@ -85,6 +86,15 @@ constexpr std::array kReadResponse{ 0x83U, 0x01U, 0xFFU, 0x85U, 0x01U, 0x2AU}; +// ARIEC61850 golden behavior that is proven with IEDScout: the request may set +// specificationWithResult, while the response remains the compact +// listOfAccessResult form and does not synthesize variableAccessSpecification. +constexpr std::array kReadWithSpecificationResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0DU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + constexpr std::array kWriteRequest{ 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, 0xA5U, 0x16U, @@ -252,12 +262,30 @@ int main() { dispatched = dispatcher.dispatch( kReadWithSpecificationRequest, response, workspace); - if (dispatched.status != mms::MmsStaticDispatchStatus::unsupported_request || + if (!dispatched.success() || dispatched.service != mms::MmsWireConfirmedService::read || - dispatched.invoke_id != 13U) { + dispatched.invoke_id != 13U || + dispatched.bytes_written != kReadWithSpecificationResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kReadWithSpecificationResponse)) { return 30; } + mms::MmsReadResponseView specification_read_response; + mms::MmsReadAccessResultView specification_read_result; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + specification_read_response) || + specification_read_response.invoke_id != 13U || + specification_read_response.result_count != 2U || + !specification_read_response.try_result(0U, specification_read_result) || + !specification_read_result.success || + !specification_read_response.try_result(1U, specification_read_result) || + !specification_read_result.success) { + return 31; + } + std::array tiny_workspace{}; dispatched = dispatcher.dispatch(kReadRequest, response, tiny_workspace); if (dispatched.status != mms::MmsStaticDispatchStatus::workspace_too_small || @@ -408,10 +436,28 @@ int main() { return 19; } + // IEDScout-compatible dual-directory mode now advertises every virtual + // hierarchy prefix, not only the concrete flattened leaf aliases. Give the + // smoke enough page capacity to validate the complete sorted namespace in + // one response; pagination is covered separately by the dedicated profile. auto dual_directory_policy = hierarchy_policy; + dual_directory_policy.maximum_names_per_response = 16U; dual_directory_policy.advertise_flattened_child_aliases = true; + constexpr std::array dual_directory_index{{ + {"LDH", "GGIO1"}, + {"LDH", "GGIO1$ST"}, + {"LDH", "GGIO1$ST$Ind1"}, + {"LDH", "GGIO1$ST$Ind1$stVal"}, + {"LDH", "LLN0"}, + {"LDH", "LLN0$ST"}, + {"LDH", "LLN0$ST$Mod"}, + {"LDH", "LLN0$ST$Mod$stVal"}, + {"LDH", "Orphan"}, + {"LDH", "Orphan$ST"}, + {"LDH", "Orphan$ST$stVal"}}}; const mms::MmsStaticApplicationDispatcher dual_directory_dispatcher{ hierarchy_table, + std::span{dual_directory_index}, dual_directory_policy}; dispatched = dual_directory_dispatcher.dispatch( kNamedVariableDirectoryRequest, @@ -422,15 +468,21 @@ int main() { !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( std::span{response}.first(dispatched.bytes_written), dual_directory) || - dual_directory.identifier_count != 5U || + dual_directory.identifier_count != 11U || dual_directory.more_follows) { return 20; } - constexpr std::array dual_names{ - "LLN0", - "LLN0$ST$Mod$stVal", + constexpr std::array dual_names{ "GGIO1", + "GGIO1$ST", + "GGIO1$ST$Ind1", "GGIO1$ST$Ind1$stVal", + "LLN0", + "LLN0$ST", + "LLN0$ST$Mod", + "LLN0$ST$Mod$stVal", + "Orphan", + "Orphan$ST", "Orphan$ST$stVal"}; for (std::size_t index = 0U; index < dual_names.size(); ++index) { if (!dual_directory.try_identifier(index, identifier) || @@ -439,6 +491,112 @@ int main() { } } + // Exact IEC 61850 objects must keep their authoritative positional + // TypeSpecification even when flattened descendants also exist. A + // synthetic rebuild sorts ordinary child names and would otherwise turn + // {stVal,q,t} into {q,stVal,t}, while Read still returns the exact value. + mms::MmsTypeSpecification exact_sps_type; + exact_sps_type.kind = mms::MmsTypeKind::structure; + exact_sps_type.children.resize(3U); + exact_sps_type.children[0].kind = mms::MmsTypeKind::boolean; + exact_sps_type.children[0].name = "stVal"; + exact_sps_type.children[1].kind = mms::MmsTypeKind::bit_string; + exact_sps_type.children[1].name = "q"; + exact_sps_type.children[2].kind = mms::MmsTypeKind::utc_time; + exact_sps_type.children[2].name = "t"; + const auto exact_sps_type_bytes = + mms::MmsServiceCodec::encode_type_specification(exact_sps_type); + constexpr std::array kBitStringType{0x84U, 0x00U}; + constexpr std::array kUtcTimeType{0x91U, 0x00U}; + const std::array exact_order_objects{ + mms::MmsStaticObjectEntry{ + "LDH", "GGIO1$ST$Ind1", exact_sps_type_bytes, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "GGIO1$ST$Ind1$q", kBitStringType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "GGIO1$ST$Ind1$stVal", kBooleanType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "GGIO1$ST$Ind1$t", kUtcTimeType, + read_boolean, &hierarchy_value, false}}; + const mms::MmsStaticObjectTable exact_order_table{exact_order_objects}; + const mms::MmsStaticApplicationDispatcher exact_order_dispatcher{ + exact_order_table, hierarchy_policy}; + if (!exact_order_table.valid()) { + return 23; + } + + mms::MmsVariableAccessAttributesRequest exact_order_request; + exact_order_request.invoke_id = 24U; + exact_order_request.name = + mms::MmsObjectName::domain_specific("LDH", "GGIO1$ST$Ind1"); + const auto exact_order_request_pdu = + mms::MmsServiceCodec::encode_variable_access_attributes_request_pdu( + exact_order_request); + dispatched = exact_order_dispatcher.dispatch( + exact_order_request_pdu, response, workspace); + mms::MmsVariableAccessAttributesResponseView exact_order_response; + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + std::span{response}.first(dispatched.bytes_written), + exact_order_response) || + exact_order_response.invoke_id != 24U || + exact_order_response.type_specification.size() != exact_sps_type_bytes.size() || + !std::equal( + exact_order_response.type_specification.begin(), + exact_order_response.type_specification.end(), + exact_sps_type_bytes.begin())) { + return 24; + } + + // Synthetic ancestors must follow SCL/source declaration order rather + // than table/alphabetic order. Deliberately store q before stVal, then + // declare stVal as the earlier source member. + std::array synthetic_order_objects{ + mms::MmsStaticObjectEntry{ + "LDH", "GGIO2$ST$Ind1$q", kBitStringType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "GGIO2$ST$Ind1$stVal", kBooleanType, + read_boolean, &hierarchy_value, false}, + mms::MmsStaticObjectEntry{ + "LDH", "GGIO2$ST$Ind1$t", kUtcTimeType, + read_boolean, &hierarchy_value, false}}; + synthetic_order_objects[0].declaration_order = 1U; + synthetic_order_objects[1].declaration_order = 0U; + synthetic_order_objects[2].declaration_order = 2U; + const mms::MmsStaticObjectTable synthetic_order_table{synthetic_order_objects}; + const mms::MmsStaticApplicationDispatcher synthetic_order_dispatcher{ + synthetic_order_table, hierarchy_policy}; + if (!synthetic_order_table.valid()) { + return 25; + } + + mms::MmsVariableAccessAttributesRequest synthetic_order_request; + synthetic_order_request.invoke_id = 26U; + synthetic_order_request.name = + mms::MmsObjectName::domain_specific("LDH", "GGIO2$ST$Ind1"); + const auto synthetic_order_request_pdu = + mms::MmsServiceCodec::encode_variable_access_attributes_request_pdu( + synthetic_order_request); + dispatched = synthetic_order_dispatcher.dispatch( + synthetic_order_request_pdu, response, workspace); + mms::MmsVariableAccessAttributesResponseView synthetic_order_response; + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + std::span{response}.first(dispatched.bytes_written), + synthetic_order_response) || + synthetic_order_response.invoke_id != 26U || + synthetic_order_response.type_specification.size() != exact_sps_type_bytes.size() || + !std::equal( + synthetic_order_response.type_specification.begin(), + synthetic_order_response.type_specification.end(), + exact_sps_type_bytes.begin())) { + return 26; + } + for (std::uint32_t iteration = 0U; iteration < 20'000U; ++iteration) { relay_state = true; const auto read = dispatcher.dispatch(kReadRequest, response, workspace); diff --git a/embedded/mms_interop_bulk_read_hard_profile_smoke.cpp b/embedded/mms_interop_bulk_read_hard_profile_smoke.cpp new file mode 100644 index 000000000..3e6dd6b79 --- /dev/null +++ b/embedded/mms_interop_bulk_read_hard_profile_smoke.cpp @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::size_t kIedScoutBulkVariables = 78U; +constexpr std::uint32_t kInvokeId = 33U; +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kBooleanData{0x83U, 0x01U, 0xFFU}; +constexpr std::array kEvents{ + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; + +// VariableSpecification for LD0/R1, matching the normal MMS domain-specific +// named-variable encoding used by the bounded server profile. +constexpr std::array kVariableDefinition{ + 0x30U, 0x0DU, + 0xA0U, 0x0BU, + 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +// Proven ARIEC-compatible Read(variableListName) shape. The first [1] is the +// explicit variableAccessSpecification wrapper, the second [1] selects +// variableListName, and the third [1] is the domain-specific ObjectName. +constexpr std::array kVariableListReadRequest{ + 0xA0U, 0x20U, 0x02U, 0x01U, 0x26U, + 0xA4U, 0x1BU, 0x80U, 0x01U, 0xFFU, + 0xA1U, 0x16U, + 0xA1U, 0x14U, + 0xA1U, 0x12U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x0BU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; + +// Compatibility form accepted by the known-good ARIEC server: listOfVariable +// [0] appears directly in the Read service instead of inside an explicit [1] +// variableAccessSpecification wrapper. +constexpr std::array kUnwrappedReadRequest{ + 0xA0U, 0x19U, 0x02U, 0x01U, 0x27U, + 0xA4U, 0x14U, 0x80U, 0x01U, 0xFFU, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, + 0xA0U, 0x0BU, + 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +// external IEC 61850 client probes NamedVariableList directories in VMD, AA and then domain +// scope. These two requests lock the first two compatibility forms. +constexpr std::array kVmdDataSetNameListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x22U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x02U, + 0xA1U, 0x02U, 0x80U, 0x00U}; +constexpr std::array kAaDataSetNameListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x23U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x02U, + 0xA1U, 0x02U, 0x82U, 0x00U}; + +// GetNamedVariableListAttributes probes for the same DataSet in VMD and AA +// ObjectName forms, matching the proven external IEC 61850 client LLN0$Digital/Analog sequence. +constexpr std::array kVmdDataSetAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x24U, + 0xACU, 0x0DU, 0x80U, 0x0BU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; +constexpr std::array kAaDataSetAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x25U, + 0xACU, 0x0DU, 0x82U, 0x0BU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] std::size_t build_bulk_read_request( + const std::span destination) noexcept { + const auto list_content = kVariableDefinition.size() * kIedScoutBulkVariables; + const auto list_tlv = asn1::BerSpanWriter::tlv_size(0, list_content); + if (!list_tlv) { + return 0U; + } + const auto access_spec_tlv = asn1::BerSpanWriter::tlv_size(1, *list_tlv); + const auto specification_field_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + if (!access_spec_tlv || !specification_field_tlv) { + return 0U; + } + const auto read_content = *specification_field_tlv + *access_spec_tlv; + const auto read_tlv = asn1::BerSpanWriter::tlv_size(4, read_content); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, 1U); + if (!read_tlv || !invoke_tlv) { + return 0U; + } + const auto confirmed_content = *invoke_tlv + *read_tlv; + const auto confirmed_tlv = asn1::BerSpanWriter::tlv_size(0, confirmed_content); + if (!confirmed_tlv || destination.size() < *confirmed_tlv) { + return 0U; + } + + asn1::BerSpanWriter writer{destination.first(*confirmed_tlv)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, confirmed_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, 1U) || + !writer.write_byte(static_cast(kInvokeId)) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 4, read_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(0xFFU) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *list_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, list_content)) { + return 0U; + } + + for (std::size_t index = 0U; index < kIedScoutBulkVariables; ++index) { + if (!writer.write_bytes(kVariableDefinition)) { + return 0U; + } + } + + return writer.good() && writer.size() == *confirmed_tlv + ? writer.size() + : 0U; +} + +[[nodiscard]] bool boolean_result_matches( + const mms::MmsReadAccessResultView& result) noexcept { + return result.success && + result.encoded_data.size() == kBooleanData.size() && + result.encoded_data[0] == kBooleanData[0] && + result.encoded_data[1] == kBooleanData[1] && + result.encoded_data[2] == kBooleanData[2]; +} + +[[nodiscard]] bool identifier_equals( + const std::span identifier, + const std::span expected) noexcept { + if (identifier.size() != expected.size()) { + return false; + } + for (std::size_t index = 0U; index < identifier.size(); ++index) { + if (identifier[index] != expected[index]) { + return false; + } + } + return true; +} + +[[nodiscard]] bool single_boolean_read_response( + const std::span response, + const std::uint32_t expected_invoke) noexcept { + mms::MmsReadResponseView read_response; + if (!mms::MmsServiceSpanCodec::try_decode_read_response(response, read_response) || + read_response.invoke_id != expected_invoke || + read_response.result_count != 1U) { + return false; + } + mms::MmsReadAccessResultView result; + return read_response.try_result(0U, result) && boolean_result_matches(result); +} + +} // namespace + +int main() { + static_assert( + mms::MmsServiceSpanCodec::maximum_variables >= kIedScoutBulkVariables, + "external IEC 61850 client bulk discovery requires at least 78 variables per Read request."); + + const bool relay_state = true; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) { + return 1; + } + + const std::array event_members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid() || !data_set_table.valid_against(table)) { + return 2; + } + + const mms::MmsStaticDispatchPolicy policy{ + 32U, + 1U, + 10U, + 3U, + 10U}; + const mms::MmsStaticApplicationDispatcher dispatcher{table, data_set_table, policy}; + + std::array request{}; + const auto request_bytes = build_bulk_read_request(request); + if (request_bytes == 0U) { + return 3; + } + + mms::MmsReadRequestView decoded_request; + if (!mms::MmsServiceSpanCodec::try_decode_read_request( + std::span{request}.first(request_bytes), + decoded_request) || + decoded_request.invoke_id != kInvokeId || + !decoded_request.specification_with_result || + decoded_request.variable_count != kIedScoutBulkVariables) { + return 4; + } + + mms::MmsObjectNameView first_name; + mms::MmsObjectNameView last_name; + if (!decoded_request.try_variable(0U, first_name) || + !decoded_request.try_variable(kIedScoutBulkVariables - 1U, last_name) || + first_name.item.size() != 2U || last_name.item.size() != 2U || + first_name.item[0] != 0x52U || first_name.item[1] != 0x31U || + last_name.item[0] != 0x52U || last_name.item[1] != 0x31U) { + return 5; + } + + std::array response{}; + std::array workspace{}; + auto dispatched = dispatcher.dispatch( + std::span{request}.first(request_bytes), + response, + workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.invoke_id != kInvokeId || + dispatched.bytes_written == 0U) { + return 6; + } + + mms::MmsReadResponseView read_response; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + read_response) || + read_response.invoke_id != kInvokeId || + read_response.result_count != kIedScoutBulkVariables) { + return 7; + } + + mms::MmsReadAccessResultView result; + for (std::size_t index = 0U; index < kIedScoutBulkVariables; ++index) { + if (!read_response.try_result(index, result) || + !boolean_result_matches(result)) { + return 8; + } + } + + // Lock the golden Read(variableListName) behavior: DataSet reads produce one + // AccessResult per DataSet member and remain a normal Read-Response. + dispatched = dispatcher.dispatch(kVariableListReadRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.invoke_id != 0x26U || + !single_boolean_read_response( + std::span{response}.first(dispatched.bytes_written), + 0x26U)) { + return 13; + } + + // Lock the proven compatibility form where listOfVariable is unwrapped. + dispatched = dispatcher.dispatch(kUnwrappedReadRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.invoke_id != 0x27U || + !single_boolean_read_response( + std::span{response}.first(dispatched.bytes_written), + 0x27U)) { + return 14; + } + + for (const auto& directory_request : {kVmdDataSetNameListRequest, kAaDataSetNameListRequest}) { + dispatched = dispatcher.dispatch(directory_request, response, workspace); + mms::MmsGetNameListResponseView names; + std::span identifier; + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{response}.first(dispatched.bytes_written), + names) || + names.identifier_count != 1U || names.more_follows || + !names.try_identifier(0U, identifier) || + !identifier_equals(identifier, kEvents)) { + return 9; + } + } + + mms::MmsNamedVariableListAttributesRequestView vmd_attributes; + mms::MmsNamedVariableListAttributesRequestView aa_attributes; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + kVmdDataSetAttributesRequest, vmd_attributes) || + vmd_attributes.name.kind != mms::MmsObjectNameViewKind::vmd_specific || + !mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + kAaDataSetAttributesRequest, aa_attributes) || + aa_attributes.name.kind != mms::MmsObjectNameViewKind::aa_specific) { + return 10; + } + + for (const auto& attributes_request : {kVmdDataSetAttributesRequest, kAaDataSetAttributesRequest}) { + dispatched = dispatcher.dispatch(attributes_request, response, workspace); + mms::MmsNamedVariableListAttributesResponseView attributes_response; + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_named_variable_list_attributes || + !mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + std::span{response}.first(dispatched.bytes_written), + attributes_response) || + attributes_response.member_count != 1U) { + return 11; + } + } + + const std::array ambiguous_data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}, + mms::MmsStaticDataSetEntry{"LD1", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable ambiguous_table{ambiguous_data_sets}; + if (!ambiguous_table.valid() || + ambiguous_table.find(vmd_attributes.name) != nullptr || + ambiguous_table.find(aa_attributes.name) != nullptr) { + return 12; + } + + return 0; +} diff --git a/embedded/mms_interop_composed_gvaa_hard_profile_smoke.cpp b/embedded/mms_interop_composed_gvaa_hard_profile_smoke.cpp new file mode 100644 index 000000000..324d51eeb --- /dev/null +++ b/embedded/mms_interop_composed_gvaa_hard_profile_smoke.cpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; + +// ConfirmedRequestPDU invoke=33, GetVariableAccessAttributes(LDX/LLN0). +// This is the shape used by external IEC 61850 client while deciding whether a Logical Node +// exposes service branches such as RP/BR. +constexpr std::array kLln0AttributesRequest{ + 0xA0U, 0x14U, 0x02U, 0x01U, 0x21U, + 0xA6U, 0x0FU, + 0xA0U, 0x0DU, + 0xA1U, 0x0BU, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x58U, + 0x1AU, 0x04U, 0x4CU, 0x4CU, 0x4EU, 0x30U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool contains_ascii( + const std::span bytes, + const std::string_view text) noexcept { + if (text.empty() || text.size() > bytes.size()) return false; + const auto first = std::search( + bytes.begin(), bytes.end(), + text.begin(), text.end(), + [](const std::uint8_t left, const char right) noexcept { + return left == static_cast(static_cast(right)); + }); + return first != bytes.end(); +} + +} // namespace + +int main() { + const bool root_value = true; + const bool report_enabled = false; + + // The exact LLN0 entry deliberately carries a stale scalar type. Before the + // interoperability fix, exact lookup short-circuited here and hid the RP + // descendants even though GetNameList exposed them. The final composed + // object graph must win for GVAA hierarchy projection. + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LDX", "LLN0", kBooleanType, + read_boolean, &root_value, false}, + mms::MmsStaticObjectEntry{ + "LDX", "LLN0$RP$Unbuffer01$RptEna", kBooleanType, + read_boolean, &report_enabled, false}}; + + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) return 1; + + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + std::array response{}; + std::array workspace{}; + + const auto dispatched = dispatcher.dispatch( + kLln0AttributesRequest, + response, + workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_variable_access_attributes || + dispatched.invoke_id != 33U || + dispatched.bytes_written <= 14U) { + return 2; + } + + const auto encoded = std::span{response}.first(dispatched.bytes_written); + if (!contains_ascii(encoded, "RP") || + !contains_ascii(encoded, "Unbuffer01") || + !contains_ascii(encoded, "RptEna")) { + return 3; + } + + return 0; +} diff --git a/embedded/mms_interop_fc_root_read_hard_profile_smoke.cpp b/embedded/mms_interop_fc_root_read_hard_profile_smoke.cpp new file mode 100644 index 000000000..fe6cf79e7 --- /dev/null +++ b/embedded/mms_interop_fc_root_read_hard_profile_smoke.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::string_view kDomain{"AA1E1F06R4ADD"}; +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kTrue{0x83U, 0x01U, 0xFFU}; + +// Exact MMS Confirmed-Request PDU captured from vendor external IEC 61850 client SCL-connect +// discovery against the known-good ARIEC61850 simulator. Invoke 2 reads six +// Functional Constraint roots in AA1E1F06R4ADD: +// LLN0$CF, LLN0$DC, LLN0$EX, LLN0$RP, LLN0$SP, LLN0$ST. +constexpr std::array kIedScoutInvoke2{ + 0xA0U, 0x81U, 0xC3U, 0x02U, 0x01U, 0x02U, 0xA4U, 0x81U, 0xBDU, 0x80U, 0x01U, 0x00U, + 0xA1U, 0x81U, 0xB7U, 0xA0U, 0x81U, 0xB4U, 0x30U, 0x1CU, 0xA0U, 0x1AU, 0xA1U, 0x18U, + 0x1AU, 0x0DU, 0x41U, 0x41U, 0x31U, 0x45U, 0x31U, 0x46U, 0x30U, 0x36U, 0x52U, 0x34U, + 0x41U, 0x44U, 0x44U, 0x1AU, 0x07U, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x43U, 0x46U, + 0x30U, 0x1CU, 0xA0U, 0x1AU, 0xA1U, 0x18U, 0x1AU, 0x0DU, 0x41U, 0x41U, 0x31U, 0x45U, + 0x31U, 0x46U, 0x30U, 0x36U, 0x52U, 0x34U, 0x41U, 0x44U, 0x44U, 0x1AU, 0x07U, 0x4CU, + 0x4CU, 0x4EU, 0x30U, 0x24U, 0x44U, 0x43U, 0x30U, 0x1CU, 0xA0U, 0x1AU, 0xA1U, 0x18U, + 0x1AU, 0x0DU, 0x41U, 0x41U, 0x31U, 0x45U, 0x31U, 0x46U, 0x30U, 0x36U, 0x52U, 0x34U, + 0x41U, 0x44U, 0x44U, 0x1AU, 0x07U, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x45U, 0x58U, + 0x30U, 0x1CU, 0xA0U, 0x1AU, 0xA1U, 0x18U, 0x1AU, 0x0DU, 0x41U, 0x41U, 0x31U, 0x45U, + 0x31U, 0x46U, 0x30U, 0x36U, 0x52U, 0x34U, 0x41U, 0x44U, 0x44U, 0x1AU, 0x07U, 0x4CU, + 0x4CU, 0x4EU, 0x30U, 0x24U, 0x52U, 0x50U, 0x30U, 0x1CU, 0xA0U, 0x1AU, 0xA1U, 0x18U, + 0x1AU, 0x0DU, 0x41U, 0x41U, 0x31U, 0x45U, 0x31U, 0x46U, 0x30U, 0x36U, 0x52U, 0x34U, + 0x41U, 0x44U, 0x44U, 0x1AU, 0x07U, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x53U, 0x50U, + 0x30U, 0x1CU, 0xA0U, 0x1AU, 0xA1U, 0x18U, 0x1AU, 0x0DU, 0x41U, 0x41U, 0x31U, 0x45U, + 0x31U, 0x46U, 0x30U, 0x36U, 0x52U, 0x34U, 0x41U, 0x44U, 0x44U, 0x1AU, 0x07U, 0x4CU, + 0x4CU, 0x4EU, 0x30U, 0x24U, 0x53U, 0x54U}; + +[[nodiscard]] wire::EncodeResult read_true( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, kTrue.size()}; + } + if (destination.size() < kTrue.size()) { + return {wire::EncodeStatus::buffer_too_small, 0U, kTrue.size()}; + } + for (std::size_t index = 0U; index < kTrue.size(); ++index) { + destination[index] = kTrue[index]; + } + return {wire::EncodeStatus::ok, kTrue.size(), kTrue.size()}; +} + +[[nodiscard]] bool is_structure( + const std::span encoded) noexcept { + asn1::BerTlvView value; + return asn1::BerSpanReader::try_read_exact(encoded, value) && + value.tag_class == asn1::BerClass::context_specific && + value.tag_number == 2 && value.constructed; +} + +} // namespace + +int main() { + const bool context = true; + // Deliberately scramble the RP leaf order to mirror production composition: + // URCB/BRCB banks append per-association objects after the base model. The + // synthesis algorithm must not require contiguous or pre-sorted descendants. + const std::array entries{ + mms::MmsStaticObjectEntry{kDomain, "LLN0$RP$A_URCB$RptID", kBooleanType, read_true, &context}, + mms::MmsStaticObjectEntry{kDomain, "LLN0$CF$Mod$ctlModel", kBooleanType, read_true, &context}, + mms::MmsStaticObjectEntry{kDomain, "LLN0$ST$Mod$stVal", kBooleanType, read_true, &context}, + mms::MmsStaticObjectEntry{kDomain, "LLN0$DC$NamPlt$vendor", kBooleanType, read_true, &context}, + mms::MmsStaticObjectEntry{kDomain, "LLN0$RP$A_URCB_1$RptID", kBooleanType, read_true, &context}, + mms::MmsStaticObjectEntry{kDomain, "LLN0$EX$NamPlt$ldNs", kBooleanType, read_true, &context}, + mms::MmsStaticObjectEntry{kDomain, "LLN0$RP$A_URCB$RptEna", kBooleanType, read_true, &context}, + mms::MmsStaticObjectEntry{kDomain, "LLN0$SP$Some$setVal", kBooleanType, read_true, &context}}; + + const mms::MmsStaticObjectTable table{entries}; + if (!table.valid()) return 1; + + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + std::array response{}; + std::array workspace{}; + const auto dispatched = dispatcher.dispatch(kIedScoutInvoke2, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.invoke_id != 2U || dispatched.bytes_written == 0U) { + return 2; + } + + mms::MmsReadResponseView decoded; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + decoded) || + decoded.invoke_id != 2U || decoded.result_count != 6U) { + return 3; + } + + for (std::size_t index = 0U; index < decoded.result_count; ++index) { + mms::MmsReadAccessResultView result; + if (!decoded.try_result(index, result) || !result.success || + !is_structure(result.encoded_data)) { + return static_cast(10U + index); + } + } + + // RP is the fourth requested FC root. It must be a nested structure rather + // than a flat leaf or object-non-existent failure. + mms::MmsReadAccessResultView rp; + if (!decoded.try_result(3U, rp) || !rp.success || !is_structure(rp.encoded_data)) { + return 30; + } + asn1::BerTlvView rp_outer; + asn1::BerTlvView first_rcb; + std::size_t offset = 0U; + if (!asn1::BerSpanReader::try_read_exact(rp.encoded_data, rp_outer) || + !asn1::BerSpanReader::try_read_tlv(rp_outer.value, offset, first_rcb) || + first_rcb.tag_class != asn1::BerClass::context_specific || + first_rcb.tag_number != 2 || !first_rcb.constructed) { + return 31; + } + + return 0; +} diff --git a/embedded/mms_interop_pagination_hard_profile_smoke.cpp b/embedded/mms_interop_pagination_hard_profile_smoke.cpp new file mode 100644 index 000000000..91281421d --- /dev/null +++ b/embedded/mms_interop_pagination_hard_profile_smoke.cpp @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::size_t kObjectCount = 160U; +constexpr std::size_t kPageSize = 32U; + +[[nodiscard]] wire::EncodeResult read_false( + const void*, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool identifier_equals( + const std::span identifier, + const std::string_view expected) noexcept { + if (identifier.size() != expected.size()) return false; + for (std::size_t index = 0U; index < expected.size(); ++index) { + if (identifier[index] != static_cast( + static_cast(expected[index]))) { + return false; + } + } + return true; +} + +} // namespace + +int main() { + static_assert(kObjectCount > mms::MmsServiceSpanCodec::maximum_identifiers); + + std::array, kObjectCount> names{}; + std::array objects{}; + for (std::size_t index = 0U; index < kObjectCount; ++index) { + names[index][0] = 'N'; + names[index][1] = static_cast('0' + ((index / 100U) % 10U)); + names[index][2] = static_cast('0' + ((index / 10U) % 10U)); + names[index][3] = static_cast('0' + (index % 10U)); + names[index][4] = '\0'; + objects[index] = mms::MmsStaticObjectEntry{ + "LD0", + std::string_view{names[index].data(), 4U}, + kBooleanType, + read_false, + nullptr}; + } + + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) return 1; + + mms::MmsStaticDispatchPolicy policy; + policy.maximum_names_per_response = kPageSize; + policy.advertise_flattened_child_aliases = true; + const mms::MmsStaticApplicationDispatcher dispatcher{table, policy}; + + std::array response_bytes{}; + std::array workspace{}; + std::string continue_after; + std::size_t expected_index = 0U; + + for (std::uint32_t invoke_id = 1U; expected_index < kObjectCount; ++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 = "LD0"; + request.continue_after = continue_after; + + const auto encoded_request = mms::MmsServiceCodec::encode_get_name_list_request_pdu(request); + const auto dispatched = dispatcher.dispatch( + std::span{encoded_request}, + response_bytes, + workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + dispatched.invoke_id != invoke_id) { + return 2; + } + + mms::MmsGetNameListResponseView response; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{response_bytes}.first(dispatched.bytes_written), + response) || + response.invoke_id != invoke_id || + response.identifier_count == 0U || + response.identifier_count > kPageSize) { + return 3; + } + + for (std::size_t page_index = 0U; + page_index < response.identifier_count; + ++page_index) { + if (expected_index >= kObjectCount) return 4; + std::span identifier; + if (!response.try_identifier(page_index, identifier) || + !identifier_equals( + identifier, + std::string_view{names[expected_index].data(), 4U})) { + return 5; + } + ++expected_index; + } + + const auto should_have_more = expected_index < kObjectCount; + if (response.more_follows != should_have_more) return 6; + if (should_have_more) { + continue_after.assign( + names[expected_index - 1U].data(), + 4U); + } + } + + if (expected_index != kObjectCount) return 7; + + // The continuation is the exact last identifier returned on the previous + // page, matching the measured external IEC 61850 client discovery profile. A missing token + // must fail deterministically instead of restarting the directory walk. + mms::MmsGetNameListRequest invalid; + invalid.invoke_id = 6U; + invalid.object_class = mms::MmsGetNameListObjectClass::named_variable; + invalid.scope = mms::MmsObjectScopeKind::domain_specific; + invalid.domain_id = "LD0"; + invalid.continue_after = "N999"; + const auto invalid_request = mms::MmsServiceCodec::encode_get_name_list_request_pdu(invalid); + const auto invalid_result = dispatcher.dispatch( + std::span{invalid_request}, + response_bytes, + workspace); + if (invalid_result.status != mms::MmsStaticDispatchStatus::object_not_found) return 8; + + // Regression for the real external IEC 61850 client Report discovery failure: the concrete + // table only contains RCB leaves, but GetNameList must advertise every + // virtual hierarchy prefix that synthetic GVAA/Read already understands. + constexpr std::array hierarchy_items{ + "LLN0$RP$U1$RptID", + "LLN0$RP$U1$RptEna", + "LLN0$BR$B1$RptID", + "LLN0$BR$B1$RptEna"}; + std::array hierarchy_objects{}; + for (std::size_t index = 0U; index < hierarchy_items.size(); ++index) { + hierarchy_objects[index] = mms::MmsStaticObjectEntry{ + "LD0", + hierarchy_items[index], + kBooleanType, + read_false, + nullptr}; + } + const mms::MmsStaticObjectTable hierarchy_table{hierarchy_objects}; + if (!hierarchy_table.valid()) return 9; + + mms::MmsStaticDispatchPolicy hierarchy_policy; + hierarchy_policy.maximum_names_per_response = 3U; + hierarchy_policy.advertise_flattened_child_aliases = true; + const mms::MmsStaticApplicationDispatcher hierarchy_dispatcher{ + hierarchy_table, hierarchy_policy}; + + constexpr std::array expected_hierarchy{ + "LLN0", + "LLN0$BR", + "LLN0$BR$B1", + "LLN0$BR$B1$RptEna", + "LLN0$BR$B1$RptID", + "LLN0$RP", + "LLN0$RP$U1", + "LLN0$RP$U1$RptEna", + "LLN0$RP$U1$RptID"}; + + continue_after.clear(); + expected_index = 0U; + for (std::uint32_t invoke_id = 20U; + expected_index < expected_hierarchy.size(); + ++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 = "LD0"; + request.continue_after = continue_after; + + const auto encoded_request = mms::MmsServiceCodec::encode_get_name_list_request_pdu(request); + const auto dispatched = hierarchy_dispatcher.dispatch( + std::span{encoded_request}, + response_bytes, + workspace); + if (!dispatched.success()) return 10; + + mms::MmsGetNameListResponseView response; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{response_bytes}.first(dispatched.bytes_written), + response) || + response.identifier_count == 0U || + response.identifier_count > hierarchy_policy.maximum_names_per_response) { + return 11; + } + + for (std::size_t page_index = 0U; + page_index < response.identifier_count; + ++page_index) { + if (expected_index >= expected_hierarchy.size()) return 12; + std::span identifier; + if (!response.try_identifier(page_index, identifier) || + !identifier_equals(identifier, expected_hierarchy[expected_index])) { + return 13; + } + ++expected_index; + } + + const auto should_have_more = expected_index < expected_hierarchy.size(); + if (response.more_follows != should_have_more) return 14; + if (should_have_more) { + continue_after.assign(expected_hierarchy[expected_index - 1U]); + } + } + + if (expected_index != expected_hierarchy.size()) return 15; + + mms::MmsGetNameListRequest invalid_virtual; + invalid_virtual.invoke_id = 30U; + invalid_virtual.object_class = mms::MmsGetNameListObjectClass::named_variable; + invalid_virtual.scope = mms::MmsObjectScopeKind::domain_specific; + invalid_virtual.domain_id = "LD0"; + invalid_virtual.continue_after = "LLN0$RP$Missing"; + const auto invalid_virtual_request = + mms::MmsServiceCodec::encode_get_name_list_request_pdu(invalid_virtual); + const auto invalid_virtual_result = hierarchy_dispatcher.dispatch( + std::span{invalid_virtual_request}, + response_bytes, + workspace); + if (invalid_virtual_result.status != mms::MmsStaticDispatchStatus::object_not_found) { + return 16; + } + + return 0; +} diff --git a/embedded/mms_interop_rcb_control_hard_profile_smoke.cpp b/embedded/mms_interop_rcb_control_hard_profile_smoke.cpp new file mode 100644 index 000000000..a6407432b --- /dev/null +++ b/embedded/mms_interop_rcb_control_hard_profile_smoke.cpp @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_objects.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kTrue{0x83U, 0x01U, 0xFFU}; +constexpr std::array kIntgPd5000{0x86U, 0x02U, 0x13U, 0x88U}; +constexpr std::array kTrgOpsAll{0x84U, 0x02U, 0x02U, 0xFCU}; +constexpr std::array kOptFldsGolden{0x84U, 0x03U, 0x06U, 0x7BU, 0x80U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] std::uint64_t read_now(const void* context) noexcept { + return context == nullptr ? 0U : *static_cast(context); +} + +[[nodiscard]] const mms::MmsStaticObjectEntry* find_item( + const mms::MmsStaticObjectTable& table, + const std::string_view item) noexcept { + for (const auto& object : table.objects()) { + if (object.domain == "LD0" && object.item == item) return &object; + } + return nullptr; +} + +[[nodiscard]] mms::MmsStaticRequestAccessContext access( + const std::uint64_t association, + const std::span owner) noexcept { + return {association, owner}; +} + +} // namespace + +int main() { + bool source = true; + const std::array base_objects{ + mms::MmsStaticObjectEntry{ + "LD0", "X1", kBooleanType, read_boolean, &source, false}}; + const mms::MmsStaticObjectTable base_table{base_objects}; + if (!base_table.valid()) return 1; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid_against(base_table)) return 2; + + const mms::MmsStaticBrcbDefinition definition{ + "LD0", + "B1", + "BRCB-B1", + "LD0", + "LLN0$Events", + 1U, + {0x5DU, 0x80U}, + 0U, + 0x7CU, + 0U}; + + std::array slot_bytes{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot_bytes}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime reports{ + definition, pending, slots, base_table, data_set_table}; + if (!reports.initialize()) return 3; + mms::MmsStaticBrcbControl control{reports}; + + std::array object_storage{}; + std::array context_storage{}; + std::array name_storage{}; + std::uint64_t now = 100U; + mms::MmsStaticBrcbObjectBank bank{ + definition, + reports, + control, + base_objects, + object_storage, + context_storage, + name_storage, + read_now, + &now}; + if (!bank.initialize() || bank.object_count() != 16U || !bank.table().valid()) return 4; + + const auto* rpt_ena = find_item(bank.table(), "B1$RptEna"); + const auto* intg_pd = find_item(bank.table(), "B1$IntgPd"); + const auto* trg_ops = find_item(bank.table(), "B1$TrgOps"); + const auto* opt_flds = find_item(bank.table(), "B1$OptFlds"); + const auto* gi = find_item(bank.table(), "B1$GI"); + const auto* time_of_entry = find_item(bank.table(), "B1$TimeofEntry"); + if (rpt_ena == nullptr || intg_pd == nullptr || trg_ops == nullptr || + opt_flds == nullptr || gi == nullptr || time_of_entry == nullptr || + rpt_ena->contextual_write == nullptr || intg_pd->contextual_write == nullptr || + trg_ops->contextual_write == nullptr || opt_flds->contextual_write == nullptr || + gi->contextual_write == nullptr) { + return 5; + } + + const std::array owner_a{0xAAU, 0x01U}; + const std::array owner_b{0xBBU, 0x01U}; + const auto a = access(101U, owner_a); + const auto b = access(202U, owner_b); + + const auto intg = intg_pd->contextual_write(intg_pd->write_context, kIntgPd5000, a); + const auto trg = trg_ops->contextual_write(trg_ops->write_context, kTrgOpsAll, a); + const auto opt = opt_flds->contextual_write(opt_flds->write_context, kOptFldsGolden, a); + if (!intg.success || !trg.success || !opt.success || + reports.integrity_period_ms() != 5'000U || + reports.trigger_options() != 0xFCU || + reports.optional_fields()[0] != 0x7BU || + reports.optional_fields()[1] != 0x80U) { + return 6; + } + + const auto enable = rpt_ena->contextual_write(rpt_ena->write_context, kTrue, a); + if (!enable.success || !reports.enabled()) return 12; + + const auto denied_gi = gi->contextual_write(gi->write_context, kTrue, b); + if (denied_gi.success || denied_gi.failure_code != 3U) return 7; + + const auto accepted_gi = gi->contextual_write(gi->write_context, kTrue, a); + if (!accepted_gi.success || !reports.general_interrogation_pending()) return 8; + + mms::MmsStaticBrcbCapturePlan plan; + if (!reports.next_due(now, plan) || + plan.reason != mms::MmsStaticBrcbCaptureReason::general_interrogation) { + return 9; + } + + constexpr std::array report_time{1U, 2U, 3U, 4U, 5U, 6U}; + std::array encode_buffer{}; + std::array workspace{}; + const auto captured = reports.capture(plan, report_time, encode_buffer, workspace); + if (!captured.success() || reports.general_interrogation_pending() || + reports.latest_time_of_entry() != report_time) { + return 10; + } + + std::array time_data{}; + const auto time_read = time_of_entry->read(time_of_entry->context, time_data); + if (!time_read.success() || time_read.bytes_written != 8U || + time_data[0] != 0x8CU || time_data[1] != 0x06U || + !std::equal(report_time.begin(), report_time.end(), time_data.begin() + 2)) { + return 11; + } + + return 0; +} diff --git a/embedded/mms_interop_rcb_root_hard_profile_smoke.cpp b/embedded/mms_interop_rcb_root_hard_profile_smoke.cpp new file mode 100644 index 000000000..0cb098335 --- /dev/null +++ b/embedded/mms_interop_rcb_root_hard_profile_smoke.cpp @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_brcb_control.hpp" +#include "ariec61850/mms/static_brcb_objects.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kUnsigned8Type{0x86U, 0x01U, 0x08U}; +constexpr std::array kUnsigned32Type{0x86U, 0x01U, 0x20U}; +constexpr std::array kVisible129Type{0x8AU, 0x02U, 0xFFU, 0x7FU}; +constexpr std::array kVariableOctet64Type{0x89U, 0x02U, 0xFFU, 0xC0U}; +constexpr std::array kBitString10Type{0x84U, 0x01U, 0x0AU}; +constexpr std::array kBitString6Type{0x84U, 0x01U, 0x06U}; +constexpr std::array kTrue{0x83U, 0x01U, 0xFFU}; +constexpr std::array kUrcbNames{ + "RptID", "RptEna", "Resv", "DatSet", "ConfRev", "OptFlds", + "BufTm", "SqNum", "TrgOps", "IntgPd", "GI", "Owner"}; +constexpr std::array kBrcbNames{ + "RptID", "RptEna", "DatSet", "ConfRev", "OptFlds", "BufTm", "SqNum", + "TrgOps", "IntgPd", "GI", "PurgeBuf", "EntryID", "TimeofEntry", + "ResvTms", "Owner"}; + +enum class MockUrcbAttribute : std::uint8_t { + report_id, + report_enabled, + reserved, + data_set, + conf_revision, + optional_fields, + buffer_time, + sequence_number, + trigger_options, + integrity_period, + general_interrogation, + owner, +}; + +struct MockUrcbState final { + std::string report_id{"URCB-LIVE"}; + bool enabled{true}; + bool reserved{true}; +}; + +struct MockUrcbContext final { + MockUrcbState* state{}; + MockUrcbAttribute attribute{MockUrcbAttribute::report_id}; +}; + +[[nodiscard]] wire::EncodeResult emit( + const std::span bytes, + const std::span destination) noexcept { + if (destination.size() < bytes.size()) { + return {wire::EncodeStatus::buffer_too_small, 0U, bytes.size()}; + } + std::copy(bytes.begin(), bytes.end(), destination.begin()); + return {wire::EncodeStatus::ok, bytes.size(), bytes.size()}; +} + +[[nodiscard]] wire::EncodeResult emit_boolean( + const bool value, + const std::span destination) noexcept { + const std::array encoded{ + 0x83U, 0x01U, value ? std::uint8_t{0xFFU} : std::uint8_t{0x00U}}; + return emit(encoded, destination); +} + +[[nodiscard]] wire::EncodeResult emit_unsigned( + const std::uint8_t value, + const std::span destination) noexcept { + const std::array encoded{0x86U, 0x01U, value}; + return emit(encoded, destination); +} + +[[nodiscard]] wire::EncodeResult emit_visible( + const std::string_view value, + const std::span destination) noexcept { + if (value.empty() || value.size() >= 0x80U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = value.size() + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x8AU; + destination[1] = static_cast(value.size()); + for (std::size_t index = 0U; index < value.size(); ++index) { + destination[index + 2U] = static_cast( + static_cast(value[index])); + } + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult emit_empty_octets( + const std::span destination) noexcept { + constexpr std::array encoded{0x89U, 0x00U}; + return emit(encoded, destination); +} + +[[nodiscard]] wire::EncodeResult read_true( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, kTrue.size()}; + } + return emit(kTrue, destination); +} + +[[nodiscard]] wire::EncodeResult read_mock_urcb( + const void* raw_context, + const std::span destination) noexcept { + const auto* context = static_cast(raw_context); + if (context == nullptr || context->state == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + switch (context->attribute) { + case MockUrcbAttribute::report_id: + return emit_visible(context->state->report_id, destination); + case MockUrcbAttribute::report_enabled: + return emit_boolean(context->state->enabled, destination); + case MockUrcbAttribute::reserved: + return emit_boolean(context->state->reserved, destination); + case MockUrcbAttribute::data_set: + return emit_visible("LD0/LLN0$Events", destination); + case MockUrcbAttribute::conf_revision: + return emit_unsigned(1U, destination); + case MockUrcbAttribute::optional_fields: { + constexpr std::array encoded{0x84U, 0x03U, 0x06U, 0x5CU, 0x80U}; + return emit(encoded, destination); + } + case MockUrcbAttribute::buffer_time: + return emit_unsigned(0U, destination); + case MockUrcbAttribute::sequence_number: + return emit_unsigned(0U, destination); + case MockUrcbAttribute::trigger_options: { + constexpr std::array encoded{0x84U, 0x02U, 0x02U, 0x70U}; + return emit(encoded, destination); + } + case MockUrcbAttribute::integrity_period: + return emit_unsigned(0U, destination); + case MockUrcbAttribute::general_interrogation: + return emit_boolean(false, destination); + case MockUrcbAttribute::owner: + return emit_empty_octets(destination); + } + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +[[nodiscard]] std::uint64_t read_now(const void* context) noexcept { + return context == nullptr ? 0U : *static_cast(context); +} + +[[nodiscard]] bool bool_value( + const mms::MmsDataValue& value, + const bool expected) noexcept { + if (value.kind() != mms::MmsDataKind::boolean) return false; + const auto* actual = std::get_if(&value.value()); + return actual != nullptr && *actual == expected; +} + +[[nodiscard]] bool string_value( + const mms::MmsDataValue& value, + const std::string_view expected) noexcept { + if (value.kind() != mms::MmsDataKind::visible_string) return false; + const auto* actual = std::get_if(&value.value()); + return actual != nullptr && *actual == expected; +} + +[[nodiscard]] bool octets_value( + const mms::MmsDataValue& value, + const std::span expected) noexcept { + return value.kind() == mms::MmsDataKind::octet_string && + value.raw_value().size() == expected.size() && + std::equal(value.raw_value().begin(), value.raw_value().end(), expected.begin()); +} + +[[nodiscard]] bool type_names_match( + const mms::MmsTypeSpecification& type, + const std::span names) noexcept { + if (type.kind != mms::MmsTypeKind::structure || type.children.size() != names.size()) { + return false; + } + for (std::size_t index = 0U; index < names.size(); ++index) { + if (type.children[index].name != names[index]) return false; + } + return true; +} + +[[nodiscard]] bool read_roots( + const mms::MmsStaticApplicationDispatcher& dispatcher, + const std::uint32_t invoke, + mms::MmsReadResponse& decoded) { + mms::MmsReadRequest request; + request.invoke_id = invoke; + request.variables.push_back( + mms::MmsObjectName::domain_specific("LD0", "LLN0$RP$U1")); + request.variables.push_back( + mms::MmsObjectName::domain_specific("LD0", "LLN0$BR$B1")); + const auto encoded = mms::MmsServiceCodec::encode_read_request_pdu(request); + + std::array response{}; + std::array workspace{}; + const auto dispatched = dispatcher.dispatch(encoded, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.invoke_id != invoke) { + return false; + } + decoded = mms::MmsServiceCodec::decode_read_response( + std::span{response}.first(dispatched.bytes_written), + invoke); + return decoded.results.size() == 2U && + decoded.results[0].success() && decoded.results[1].success(); +} + +[[nodiscard]] bool read_type( + const mms::MmsStaticApplicationDispatcher& dispatcher, + const std::uint32_t invoke, + const std::string_view item, + mms::MmsVariableAccessAttributesResponse& decoded) { + mms::MmsVariableAccessAttributesRequest request; + request.invoke_id = invoke; + request.name = mms::MmsObjectName::domain_specific("LD0", std::string{item}); + const auto encoded = + mms::MmsServiceCodec::encode_variable_access_attributes_request_pdu(request); + + std::array response{}; + std::array workspace{}; + const auto dispatched = dispatcher.dispatch(encoded, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_variable_access_attributes || + dispatched.invoke_id != invoke) { + return false; + } + decoded = mms::MmsServiceCodec::decode_variable_access_attributes_response( + std::span{response}.first(dispatched.bytes_written), + invoke); + return !decoded.mms_deletable; +} + +} // namespace + +int main() { + try { + const bool source_value = true; + MockUrcbState urcb_state; + std::array urcb_contexts{}; + for (std::size_t index = 0U; index < urcb_contexts.size(); ++index) { + urcb_contexts[index] = MockUrcbContext{ + &urcb_state, + static_cast(index)}; + } + + std::array pre_brcb_objects{}; + pre_brcb_objects[0] = mms::MmsStaticObjectEntry{ + "LD0", "X1", kBooleanType, read_true, &source_value, false}; + constexpr std::array urcb_items{ + "LLN0$RP$U1$RptID", "LLN0$RP$U1$RptEna", "LLN0$RP$U1$Resv", + "LLN0$RP$U1$DatSet", "LLN0$RP$U1$ConfRev", "LLN0$RP$U1$OptFlds", + "LLN0$RP$U1$BufTm", "LLN0$RP$U1$SqNum", "LLN0$RP$U1$TrgOps", + "LLN0$RP$U1$IntgPd", "LLN0$RP$U1$GI", "LLN0$RP$U1$Owner"}; + const std::array, 12U> urcb_types{ + kVisible129Type, kBooleanType, kBooleanType, kVisible129Type, + kUnsigned32Type, kBitString10Type, kUnsigned32Type, kUnsigned8Type, + kBitString6Type, kUnsigned32Type, kBooleanType, kVariableOctet64Type}; + for (std::size_t index = 0U; index < urcb_items.size(); ++index) { + pre_brcb_objects[index + 1U] = mms::MmsStaticObjectEntry{ + "LD0", + urcb_items[index], + urcb_types[index], + read_mock_urcb, + &urcb_contexts[index], + false}; + } + const mms::MmsStaticObjectTable pre_brcb_table{pre_brcb_objects}; + if (!pre_brcb_table.valid()) return 1; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid_against(pre_brcb_table)) return 2; + + const mms::MmsStaticBrcbDefinition brcb_definition{ + "LD0", + "LLN0$BR$B1", + "BRCB-B1", + "LD0", + "LLN0$Events", + 7U, + {0x5DU, 0x80U}, + 0U, + 0x70U}; + std::array slot0{}; + std::array slot1{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}, + mms::MmsStaticBrcbSlot{slot1}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime brcb_runtime{ + brcb_definition, + pending, + slots, + pre_brcb_table, + data_set_table}; + if (!brcb_runtime.initialize()) return 3; + mms::MmsStaticBrcbControl brcb_control{brcb_runtime}; + + std::array final_objects{}; + std::array brcb_contexts{}; + std::array brcb_names{}; + std::uint64_t now = 100U; + mms::MmsStaticBrcbObjectBank brcb_bank{ + brcb_definition, + brcb_runtime, + brcb_control, + pre_brcb_objects, + final_objects, + brcb_contexts, + brcb_names, + read_now, + &now}; + if (!brcb_bank.initialize() || !brcb_bank.table().valid()) return 4; + + mms::MmsStaticDispatchPolicy policy; + policy.maximum_write_variables = 1U; + policy.advertise_flattened_child_aliases = true; + const mms::MmsStaticApplicationDispatcher dispatcher{ + brcb_bank.table(), data_set_table, policy}; + + mms::MmsStaticBrcbClientIdentity client; + client.association_id = 77U; + client.owner[0] = 0xAAU; + client.owner[1] = 0x55U; + client.owner_size = 2U; + if (brcb_control.reserve(client, 5U, now) != mms::MmsStaticBrcbControlStatus::ok || + brcb_control.set_report_enabled(client, true, now) != + mms::MmsStaticBrcbControlStatus::ok) { + return 5; + } + + mms::MmsReadResponse first_read; + if (!read_roots(dispatcher, 10U, first_read)) return 6; + const auto& urcb_value = *first_read.results[0].value; + const auto& brcb_value = *first_read.results[1].value; + if (urcb_value.kind() != mms::MmsDataKind::structure || + brcb_value.kind() != mms::MmsDataKind::structure || + urcb_value.children().size() != kUrcbNames.size() || + brcb_value.children().size() != kBrcbNames.size()) { + return 7; + } + const std::span no_owner; + if (!string_value(urcb_value.children()[0], "URCB-LIVE") || + !bool_value(urcb_value.children()[1], true) || + !bool_value(urcb_value.children()[2], true) || + !octets_value(urcb_value.children()[11], no_owner)) { + return 8; + } + if (!string_value(brcb_value.children()[0], "BRCB-B1") || + !bool_value(brcb_value.children()[1], true) || + !octets_value(brcb_value.children()[14], client.owner_view())) { + return 9; + } + + mms::MmsVariableAccessAttributesResponse urcb_type; + mms::MmsVariableAccessAttributesResponse brcb_type; + if (!read_type(dispatcher, 11U, "LLN0$RP$U1", urcb_type) || + !read_type(dispatcher, 12U, "LLN0$BR$B1", brcb_type) || + !type_names_match(urcb_type.type, kUrcbNames) || + !type_names_match(brcb_type.type, kBrcbNames)) { + return 10; + } + + urcb_state.enabled = false; + urcb_state.reserved = false; + if (brcb_control.set_report_enabled(client, false, now) != + mms::MmsStaticBrcbControlStatus::ok || + brcb_control.release(client, now) != mms::MmsStaticBrcbControlStatus::ok) { + return 11; + } + + now = 200U; + mms::MmsReadResponse second_read; + if (!read_roots(dispatcher, 13U, second_read)) return 12; + const auto& urcb_after = *second_read.results[0].value; + const auto& brcb_after = *second_read.results[1].value; + if (!bool_value(urcb_after.children()[1], false) || + !bool_value(urcb_after.children()[2], false) || + !bool_value(brcb_after.children()[1], false) || + !octets_value(brcb_after.children()[14], no_owner)) { + return 13; + } + + return 0; + } catch (...) { + return 100; + } +} diff --git a/embedded/mms_negotiated_limits_hard_profile_smoke.cpp b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp index c3ba8b7ed..f45f83fb8 100644 --- a/embedded/mms_negotiated_limits_hard_profile_smoke.cpp +++ b/embedded/mms_negotiated_limits_hard_profile_smoke.cpp @@ -94,6 +94,36 @@ constexpr std::array kReadRequest{ return {wire::EncodeStatus::ok, 3U, 3U}; } +[[nodiscard]] bool validate_segmented_stream( + const std::span bytes, + const std::size_t negotiated_tpdu_size_bytes, + const bool require_multiple) noexcept { + std::size_t offset{}; + std::size_t segments{}; + while (offset < bytes.size()) { + const auto remaining = bytes.subspan(offset); + const auto peek = osi::TpktSpanCodec::peek_frame(remaining); + if (!peek.ready() || peek.frame_bytes == 0U || + peek.frame_bytes > negotiated_tpdu_size_bytes + osi::TpktSpanCodec::header_length) { + return false; + } + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(remaining.first(peek.frame_bytes), tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) || + cotp.kind != osi::CotpWireKind::data) { + return false; + } + offset += peek.frame_bytes; + ++segments; + if ((offset == bytes.size()) != cotp.end_of_transmission) { + return false; + } + } + return offset == bytes.size() && segments != 0U && + (!require_multiple || segments > 1U); +} + [[nodiscard]] bool set_maximum_mms_pdu( std::array& association, const std::uint32_t value) noexcept { @@ -329,21 +359,37 @@ int main() { std::array scratch{}; std::array presentation{}; - // A 128-byte TPDU cannot carry the fixed association-accept TSDU as one DT - // TPDU. Until outbound COTP segmentation is implemented, reject it rather - // than violate the negotiated limit. + // A 128-byte negotiated TPDU must segment the association accept and + // later confirmed responses into bounded TPKTs instead of rejecting them. mms::MmsStaticConnectionRuntime tiny_tpdu{dispatcher}; if (!connect_cotp(tiny_tpdu, 0x07U, request, response, workspace, scratch)) { return 2; } const auto tiny_association = associate( tiny_tpdu, 65'000U, request, response, workspace, scratch); - if (tiny_association.status != mms::MmsStaticConnectionStatus::peer_limit_exceeded || - tiny_association.bytes_written != 0U || + if (!tiny_association.response_ready() || tiny_tpdu.negotiated_tpdu_size_bytes() != 128U || - tiny_tpdu.state() != mms::MmsStaticConnectionState::awaiting_association) { + tiny_tpdu.state() != mms::MmsStaticConnectionState::established || + !validate_segmented_stream( + std::span{response}.first(tiny_association.bytes_written), + 128U, + true)) { return 3; } + const auto tiny_read_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + if (!tiny_read_tpkt.success()) return 21; + const auto tiny_read_result = tiny_tpdu.process_tcp_window( + std::span{request}.first(tiny_read_tpkt.bytes_written), + response, + workspace); + if (!tiny_read_result.response_ready() || + !validate_segmented_stream( + std::span{response}.first(tiny_read_result.bytes_written), + 128U, + true)) { + return 22; + } mms::MmsStaticConnectionPolicy policy; policy.association_id = 101U; diff --git a/embedded/mms_urcb_event_hard_profile_smoke.cpp b/embedded/mms_urcb_event_hard_profile_smoke.cpp new file mode 100644 index 000000000..af9b3ef61 --- /dev/null +++ b/embedded/mms_urcb_event_hard_profile_smoke.cpp @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + using namespace ar::iec61850; + + static_assert(mms::MmsStaticUrcbRuntime::maximum_control_blocks >= 34U); + + bool value_a = false; + bool value_b = true; + const std::array objects{{ + {"LD0", "X1", kBooleanType, read_boolean, &value_a, false}, + {"LD0", "X2", kBooleanType, read_boolean, &value_b, false}, + }}; + const mms::MmsStaticObjectTable object_table{objects}; + if (!object_table.valid()) return 1; + + const std::array members{{ + {"LD0", "X1"}, + {"LD0", "X2"}, + }}; + const std::array data_sets{{ + {"LD0", "LLN0$Events", members, false}, + }}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid_against(object_table)) return 2; + + const std::array definitions{{ + {"LD0", + "LLN0$RP$U1", + "URCB-U1", + "LD0", + "LLN0$Events", + 1U, + {0x5CU, 0x80U}, + 25U, + 0x70U, + 0U}, + }}; + std::array states{}; + mms::MmsStaticUrcbRuntime runtime{ + definitions, states, object_table, data_set_table}; + if (!runtime.initialize()) return 3; + constexpr std::array iedscout_generic_optflds{0x7BU, 0x80U}; + constexpr std::array effective_urcb_optflds{0x78U, 0x80U}; + constexpr std::array unsupported_segmentation{0x78U, 0xC0U}; + constexpr std::array original_optflds{0x5CU, 0x80U}; + if (runtime.set_optional_fields(0U, iedscout_generic_optflds) != + mms::MmsStaticUrcbStatus::ok) return 15; + const auto* compatibility_state = runtime.state(0U); + if (compatibility_state == nullptr || + compatibility_state->optional_fields != effective_urcb_optflds) return 16; + if (runtime.set_optional_fields(0U, unsupported_segmentation) != + mms::MmsStaticUrcbStatus::invalid_value) return 17; + if (runtime.set_optional_fields(0U, original_optflds) != + mms::MmsStaticUrcbStatus::ok) return 18; + if (runtime.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok) return 4; + + // First update opens one bounded BufTm window. Later updates must coalesce + // into that same window instead of pushing the deadline forward. + if (runtime.notify( + 0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 100U) != + mms::MmsStaticUrcbStatus::ok || + runtime.notify( + 0U, 0U, mms::MmsStaticUrcbEventReason::quality_change, 110U) != + mms::MmsStaticUrcbStatus::ok || + runtime.notify( + 0U, 1U, mms::MmsStaticUrcbEventReason::data_change, 110U) != + mms::MmsStaticUrcbStatus::ok) { + return 5; + } + + const auto* pending = runtime.state(0U); + if (pending == nullptr || !pending->event_pending || + pending->event_due_ms != 125U || pending->pending_member_count != 2U || + pending->member_reason_masks[0] != 0x60U || + pending->member_reason_masks[1] != 0x40U) { + return 6; + } + + mms::MmsStaticUrcbEmissionPlan plan; + if (runtime.next_due(124U, plan)) return 7; + if (!runtime.next_due(125U, plan) || + plan.index != 0U || + plan.reason != mms::MmsStaticUrcbReportReason::data_change || + plan.sequence_number != 1U) { + return 8; + } + + constexpr std::array report_time{1U, 2U, 3U, 4U, 5U, 6U}; + std::array destination{}; + std::array workspace{}; + const auto encoded = runtime.encode(plan, report_time, destination, workspace); + if (!encoded.success() || encoded.bytes_written == 0U || encoded.member_count != 2U) { + return 9; + } + if (runtime.commit(plan, 125U) != mms::MmsStaticUrcbStatus::ok) return 10; + + const auto* committed = runtime.state(0U); + if (committed == nullptr || committed->event_pending || + committed->pending_member_count != 0U || + committed->sequence_number != 1U || + committed->event_due_ms != 0U) { + return 11; + } + if (runtime.next_due(125U, plan)) return 12; + + // Disabled reports must reject event scheduling and re-enable from a clean + // pending-event state (no stale event resurrection across RptEna cycles). + if (runtime.set_enabled(0U, false, 130U) != mms::MmsStaticUrcbStatus::ok || + runtime.notify( + 0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 131U) != + mms::MmsStaticUrcbStatus::temporarily_unavailable || + runtime.set_enabled(0U, true, 140U) != mms::MmsStaticUrcbStatus::ok) { + return 13; + } + const auto* reenabled = runtime.state(0U); + if (reenabled == nullptr || reenabled->event_pending || + reenabled->pending_member_count != 0U || reenabled->sequence_number != 0U) { + return 14; + } + + return 0; +} diff --git a/include/ariec61850/goose/publisher_runtime.hpp b/include/ariec61850/goose/publisher_runtime.hpp index 234ed98b1..a6163d3d0 100644 --- a/include/ariec61850/goose/publisher_runtime.hpp +++ b/include/ariec61850/goose/publisher_runtime.hpp @@ -39,6 +39,10 @@ class GoosePublisherSession final { [[nodiscard]] GoosePublication publish_retransmission(); + void set_time_allowed_to_live(std::uint32_t milliseconds) noexcept { + frame_template_.pdu.time_allowed_to_live_milliseconds = milliseconds; + } + [[nodiscard]] bool has_state() const noexcept { return has_state_; } [[nodiscard]] std::uint32_t state_number() const noexcept { return state_number_; } [[nodiscard]] std::uint32_t next_sequence_number() const noexcept { @@ -46,7 +50,10 @@ class GoosePublisherSession final { } private: - [[nodiscard]] GoosePublication emit(); + // Do not call this member `emit`: Qt defines `emit` as a macro in QObject + // translation units, and the GOOSE runtime is intentionally reusable by Qt + // applications without requiring include-order tricks. + [[nodiscard]] GoosePublication emit_publication(); static std::uint32_t increment_state_number(std::uint32_t current) noexcept; static std::uint32_t increment_sequence_number(std::uint32_t current) noexcept; @@ -94,7 +101,8 @@ class GoosePublisherRuntime final { [[nodiscard]] const GoosePublisherSession& session() const noexcept { return session_; } private: - void schedule_next(clock::time_point now) noexcept; + [[nodiscard]] std::uint32_t prepare_next_delay() noexcept; + void schedule_next(clock::time_point now, std::uint32_t delay_milliseconds) noexcept; GoosePublisherSession session_; RetransmissionSchedule schedule_; diff --git a/include/ariec61850/goose/raw_ethernet_publisher.hpp b/include/ariec61850/goose/raw_ethernet_publisher.hpp new file mode 100644 index 000000000..c2d72c0ee --- /dev/null +++ b/include/ariec61850/goose/raw_ethernet_publisher.hpp @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#elif defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#endif + +namespace ar::iec61850::goose { + +// Minimal RAII Layer-2 sender for IEC 61850 GOOSE. The caller must bind an +// explicit interface; this class never guesses or falls back to another link. +// Linux uses AF_PACKET. Windows dynamically loads Npcap (wpcap.dll) and the +// IP Helper adapter query, avoiding hard link dependencies while still failing +// closed when Npcap, the requested adapter, or its source MAC is unavailable. +class RawEthernetPublisher final { +public: + RawEthernetPublisher() = default; + ~RawEthernetPublisher() { close(); } + + RawEthernetPublisher(const RawEthernetPublisher&) = delete; + RawEthernetPublisher& operator=(const RawEthernetPublisher&) = delete; + + RawEthernetPublisher(RawEthernetPublisher&& other) noexcept { move_from(other); } + RawEthernetPublisher& operator=(RawEthernetPublisher&& other) noexcept { + if (this != &other) { + close(); + move_from(other); + } + return *this; + } + + [[nodiscard]] bool open( + std::string_view interface_name, + std::string& error, + std::optional configured_source_mac = std::nullopt) { + close(); + error.clear(); + if (interface_name.empty()) { + error = "GOOSE publication requires an explicit Ethernet interface."; + return false; + } +#if defined(__linux__) + (void)configured_source_mac; + if (interface_name.size() >= IFNAMSIZ) { + error = "Ethernet interface name exceeds IFNAMSIZ."; + return false; + } + + const std::string name{interface_name}; + const unsigned int index = ::if_nametoindex(name.c_str()); + if (index == 0U) { + error = "Ethernet interface was not found: " + name; + return false; + } + + const int socket_fd = ::socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); + if (socket_fd < 0) { + error = "Could not open AF_PACKET raw socket for GOOSE publication: " + + std::string{std::strerror(errno)}; + return false; + } + + struct ifreq request {}; + std::strncpy(request.ifr_name, name.c_str(), IFNAMSIZ - 1); + if (::ioctl(socket_fd, SIOCGIFHWADDR, &request) != 0) { + error = "Could not read Ethernet source MAC for " + name + ": " + + std::string{std::strerror(errno)}; + ::close(socket_fd); + return false; + } + + struct sockaddr_ll bind_address {}; + bind_address.sll_family = AF_PACKET; + bind_address.sll_protocol = htons(ethernet::goose_ethertype); + bind_address.sll_ifindex = static_cast(index); + if (::bind( + socket_fd, + reinterpret_cast(&bind_address), + sizeof(bind_address)) != 0) { + error = "Could not bind GOOSE raw socket to " + name + ": " + + std::string{std::strerror(errno)}; + ::close(socket_fd); + return false; + } + + socket_fd_ = socket_fd; + interface_index_ = static_cast(index); + interface_name_ = name; + const auto* bytes = reinterpret_cast(request.ifr_hwaddr.sa_data); + for (std::size_t offset = 0; offset < source_mac_.size(); ++offset) { + source_mac_[offset] = static_cast(bytes[offset]); + } + return true; +#elif defined(_WIN32) + const std::string requested_name{interface_name}; + wpcap_module_ = ::LoadLibraryA("wpcap.dll"); + if (wpcap_module_ == nullptr) { + error = "Npcap wpcap.dll was not found. Install Npcap to publish Layer-2 GOOSE on Windows."; + return false; + } + pcap_open_live_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_open_live")); + pcap_sendpacket_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_sendpacket")); + pcap_close_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_close")); + pcap_geterr_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_geterr")); + pcap_findalldevs_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_findalldevs")); + pcap_freealldevs_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_freealldevs")); + if (pcap_open_live_ == nullptr || pcap_sendpacket_ == nullptr || pcap_close_ == nullptr || + pcap_findalldevs_ == nullptr || pcap_freealldevs_ == nullptr) { + error = "Npcap is present but required packet-transmit/adapter exports are unavailable."; + close(); + return false; + } + + std::array errbuf{}; + PcapIf* devices{}; + if (pcap_findalldevs_(&devices, errbuf.data()) != 0 || devices == nullptr) { + error = "Npcap could not enumerate Ethernet adapters"; + if (errbuf.front() != '\0') error += ": " + std::string{errbuf.data()}; + close(); + return false; + } + + std::string pcap_name; + std::string adapter_token; + for (auto* device = devices; device != nullptr; device = device->next) { + const std::string device_name = device->name == nullptr ? std::string{} : device->name; + const std::string description = + device->description == nullptr ? std::string{} : device->description; + if (!windows_interface_matches(requested_name, device_name, description)) continue; + pcap_name = device_name; + adapter_token = adapter_token_from_pcap_name(device_name); + break; + } + pcap_freealldevs_(devices); + devices = nullptr; + if (pcap_name.empty()) { + error = "Npcap could not resolve the selected Ethernet interface: " + requested_name; + close(); + return false; + } + + std::array resolved_mac{}; + if (configured_source_mac.has_value()) { + resolved_mac = configured_source_mac->bytes(); + } else if (!resolve_windows_source_mac(adapter_token, resolved_mac, error)) { + close(); + return false; + } + + errbuf.fill('\0'); + pcap_handle_ = pcap_open_live_(pcap_name.c_str(), 65'535, 0, 1, errbuf.data()); + if (pcap_handle_ == nullptr) { + error = "Npcap could not open adapter " + requested_name + ": " + std::string{errbuf.data()}; + close(); + return false; + } + interface_name_ = requested_name; + source_mac_ = resolved_mac; + return true; +#else + (void)interface_name; + (void)configured_source_mac; + error = "Raw Ethernet GOOSE publication is unavailable on this platform."; + return false; +#endif + } + + [[nodiscard]] bool transmit( + const std::span ethernet_frame, + std::string& error) noexcept { + error.clear(); + if (ethernet_frame.size() < 14U) { + error = "GOOSE Ethernet frame is shorter than the Ethernet header."; + return false; + } +#if defined(__linux__) + if (socket_fd_ < 0 || interface_index_ <= 0) { + error = "GOOSE raw Ethernet publisher is not open."; + return false; + } + struct sockaddr_ll destination {}; + destination.sll_family = AF_PACKET; + destination.sll_protocol = htons(ethernet::goose_ethertype); + destination.sll_ifindex = interface_index_; + destination.sll_halen = ETH_ALEN; + std::memcpy(destination.sll_addr, ethernet_frame.data(), ETH_ALEN); + + const auto sent = ::sendto( + socket_fd_, + ethernet_frame.data(), + ethernet_frame.size(), + 0, + reinterpret_cast(&destination), + sizeof(destination)); + if (sent < 0 || static_cast(sent) != ethernet_frame.size()) { + error = "GOOSE raw Ethernet transmit failed on " + interface_name_ + ": " + + std::string{std::strerror(errno)}; + return false; + } + return true; +#elif defined(_WIN32) + if (pcap_handle_ == nullptr || pcap_sendpacket_ == nullptr) { + error = "GOOSE Npcap publisher is not open."; + return false; + } + if (ethernet_frame.size() > static_cast(std::numeric_limits::max())) { + error = "GOOSE frame is too large for Npcap."; + return false; + } + const int result = pcap_sendpacket_( + pcap_handle_, + reinterpret_cast(ethernet_frame.data()), + static_cast(ethernet_frame.size())); + if (result != 0) { + const char* detail = pcap_geterr_ != nullptr ? pcap_geterr_(pcap_handle_) : nullptr; + error = "GOOSE Npcap transmit failed on " + interface_name_; + if (detail != nullptr && *detail != '\0') error += ": " + std::string{detail}; + return false; + } + return true; +#else + (void)ethernet_frame; + error = "GOOSE raw Ethernet publisher is unavailable on this platform."; + return false; +#endif + } + + void close() noexcept { +#if defined(__linux__) + if (socket_fd_ >= 0) ::close(socket_fd_); + socket_fd_ = -1; + interface_index_ = 0; +#elif defined(_WIN32) + if (pcap_handle_ != nullptr && pcap_close_ != nullptr) pcap_close_(pcap_handle_); + pcap_handle_ = nullptr; + pcap_open_live_ = nullptr; + pcap_sendpacket_ = nullptr; + pcap_close_ = nullptr; + pcap_geterr_ = nullptr; + pcap_findalldevs_ = nullptr; + pcap_freealldevs_ = nullptr; + if (wpcap_module_ != nullptr) ::FreeLibrary(wpcap_module_); + wpcap_module_ = nullptr; +#endif + interface_name_.clear(); + source_mac_.fill(0U); + } + + [[nodiscard]] bool active() const noexcept { +#if defined(__linux__) + return socket_fd_ >= 0 && interface_index_ > 0; +#elif defined(_WIN32) + return pcap_handle_ != nullptr; +#else + return false; +#endif + } + [[nodiscard]] const std::string& interface_name() const noexcept { return interface_name_; } + [[nodiscard]] ethernet::MacAddress source_mac() const { + return ethernet::MacAddress{std::span{source_mac_}}; + } + +private: +#if defined(_WIN32) + struct pcap; + struct pcap_addr; + struct PcapIf final { + PcapIf* next; + char* name; + char* description; + pcap_addr* addresses; + unsigned int flags; + }; + using PcapHandle = pcap; + using PcapOpenLive = PcapHandle* (__cdecl *)(const char*, int, int, int, char*); + using PcapSendPacket = int (__cdecl *)(PcapHandle*, const unsigned char*, int); + using PcapClose = void (__cdecl *)(PcapHandle*); + using PcapGetErr = char* (__cdecl *)(PcapHandle*); + using PcapFindAllDevs = int (__cdecl *)(PcapIf**, char*); + using PcapFreeAllDevs = void (__cdecl *)(PcapIf*); + using GetAdaptersAddressesFn = ULONG (WINAPI *)( + ULONG, ULONG, PVOID, PIP_ADAPTER_ADDRESSES, PULONG); + + [[nodiscard]] static std::string ascii_lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; + } + + [[nodiscard]] static std::string normalized_windows_interface(std::string value) { + value = ascii_lower(std::move(value)); + constexpr std::string_view prefix{"\\\\device\\npf_"}; + if (value.rfind(prefix.data(), 0U) == 0U) value.erase(0U, prefix.size()); + return value; + } + + [[nodiscard]] static std::string adapter_token_from_pcap_name(const std::string& value) { + constexpr std::string_view prefix{"\\\\Device\\NPF_"}; + if (value.rfind(prefix.data(), 0U) == 0U) return value.substr(prefix.size()); + const auto brace = value.find('{'); + return brace == std::string::npos ? value : value.substr(brace); + } + + [[nodiscard]] static bool windows_interface_matches( + const std::string& requested, + const std::string& pcap_name, + const std::string& description) { + const auto wanted = normalized_windows_interface(requested); + const auto device = normalized_windows_interface(pcap_name); + const auto label = ascii_lower(description); + if (wanted == device || ascii_lower(requested) == label) return true; + const auto brace = device.find('{'); + if (brace != std::string::npos && wanted.find(device.substr(brace)) != std::string::npos) { + return true; + } + return false; + } + + [[nodiscard]] static bool resolve_windows_source_mac( + const std::string& adapter_token, + std::array& mac, + std::string& error) { + HMODULE module = ::LoadLibraryA("iphlpapi.dll"); + if (module == nullptr) { + error = "Windows IP Helper API is unavailable; source MAC cannot be resolved."; + return false; + } + const auto get_adapters = reinterpret_cast( + ::GetProcAddress(module, "GetAdaptersAddresses")); + if (get_adapters == nullptr) { + error = "Windows IP Helper API does not expose GetAdaptersAddresses."; + ::FreeLibrary(module); + return false; + } + + ULONG bytes{}; + ULONG status = get_adapters(AF_UNSPEC, 0U, nullptr, nullptr, &bytes); + if (status != ERROR_BUFFER_OVERFLOW || bytes == 0U) { + error = "Windows adapter inventory size query failed."; + ::FreeLibrary(module); + return false; + } + std::vector storage(bytes); + auto* addresses = reinterpret_cast(storage.data()); + status = get_adapters(AF_UNSPEC, 0U, nullptr, addresses, &bytes); + if (status != NO_ERROR) { + error = "Windows adapter inventory query failed with code " + std::to_string(status) + "."; + ::FreeLibrary(module); + return false; + } + + const auto wanted = normalized_windows_interface(adapter_token); + bool found{}; + for (auto* adapter = addresses; adapter != nullptr; adapter = adapter->Next) { + if (adapter->AdapterName == nullptr) continue; + if (normalized_windows_interface(adapter->AdapterName) != wanted) continue; + if (adapter->PhysicalAddressLength != mac.size()) { + error = "Selected Windows adapter does not expose a 6-byte Ethernet MAC address."; + ::FreeLibrary(module); + return false; + } + std::copy_n(adapter->PhysicalAddress, mac.size(), mac.begin()); + found = true; + break; + } + ::FreeLibrary(module); + if (!found) { + error = "Could not resolve source MAC for selected Windows/Npcap adapter: " + adapter_token; + return false; + } + return true; + } +#endif + + void move_from(RawEthernetPublisher& other) noexcept { +#if defined(__linux__) + socket_fd_ = other.socket_fd_; + interface_index_ = other.interface_index_; + other.socket_fd_ = -1; + other.interface_index_ = 0; +#elif defined(_WIN32) + wpcap_module_ = other.wpcap_module_; + pcap_handle_ = other.pcap_handle_; + pcap_open_live_ = other.pcap_open_live_; + pcap_sendpacket_ = other.pcap_sendpacket_; + pcap_close_ = other.pcap_close_; + pcap_geterr_ = other.pcap_geterr_; + pcap_findalldevs_ = other.pcap_findalldevs_; + pcap_freealldevs_ = other.pcap_freealldevs_; + other.wpcap_module_ = nullptr; + other.pcap_handle_ = nullptr; + other.pcap_open_live_ = nullptr; + other.pcap_sendpacket_ = nullptr; + other.pcap_close_ = nullptr; + other.pcap_geterr_ = nullptr; + other.pcap_findalldevs_ = nullptr; + other.pcap_freealldevs_ = nullptr; +#endif + interface_name_ = std::move(other.interface_name_); + source_mac_ = other.source_mac_; + other.interface_name_.clear(); + other.source_mac_.fill(0U); + } + +#if defined(__linux__) + int socket_fd_{-1}; + int interface_index_{}; +#elif defined(_WIN32) + HMODULE wpcap_module_{}; + PcapHandle* pcap_handle_{}; + PcapOpenLive pcap_open_live_{}; + PcapSendPacket pcap_sendpacket_{}; + PcapClose pcap_close_{}; + PcapGetErr pcap_geterr_{}; + PcapFindAllDevs pcap_findalldevs_{}; + PcapFreeAllDevs pcap_freealldevs_{}; +#endif + std::string interface_name_; + std::array source_mac_{}; +}; + +} // namespace ar::iec61850::goose diff --git a/include/ariec61850/goose/raw_ethernet_subscriber.hpp b/include/ariec61850/goose/raw_ethernet_subscriber.hpp new file mode 100644 index 000000000..1e05d3c47 --- /dev/null +++ b/include/ariec61850/goose/raw_ethernet_subscriber.hpp @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#include +#elif defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#endif + +namespace ar::iec61850::goose { + +enum class RawEthernetReceiveResult : std::uint8_t { + none, + frame, + failure, +}; + +// Minimal RAII Layer-2 receiver for IEC 61850 GOOSE. The caller must bind an +// explicit interface. Linux uses AF_PACKET with a kernel BPF for untagged and +// 802.1Q-tagged GOOSE. Windows loads Npcap dynamically and installs the same +// logical capture filter, avoiding a build-time Npcap dependency. +class RawEthernetSubscriber final { +public: + RawEthernetSubscriber() = default; + ~RawEthernetSubscriber() { close(); } + + RawEthernetSubscriber(const RawEthernetSubscriber&) = delete; + RawEthernetSubscriber& operator=(const RawEthernetSubscriber&) = delete; + + RawEthernetSubscriber(RawEthernetSubscriber&& other) noexcept { move_from(other); } + RawEthernetSubscriber& operator=(RawEthernetSubscriber&& other) noexcept { + if (this != &other) { + close(); + move_from(other); + } + return *this; + } + + [[nodiscard]] bool open(std::string_view interface_name, std::string& error) { + close(); + error.clear(); + if (interface_name.empty()) { + error = "GOOSE monitoring requires an explicit Ethernet interface."; + return false; + } +#if defined(__linux__) + if (interface_name.size() >= IFNAMSIZ) { + error = "Ethernet interface name exceeds IFNAMSIZ."; + return false; + } + const std::string name{interface_name}; + const unsigned int index = ::if_nametoindex(name.c_str()); + if (index == 0U) { + error = "Ethernet interface was not found: " + name; + return false; + } + + const int socket_fd = ::socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); + if (socket_fd < 0) { + error = "Could not open AF_PACKET raw socket for GOOSE monitoring: " + + std::string{std::strerror(errno)}; + return false; + } + + struct sockaddr_ll bind_address {}; + bind_address.sll_family = AF_PACKET; + bind_address.sll_protocol = htons(ETH_P_ALL); + bind_address.sll_ifindex = static_cast(index); + if (::bind( + socket_fd, + reinterpret_cast(&bind_address), + sizeof(bind_address)) != 0) { + error = "Could not bind GOOSE monitor to " + name + ": " + + std::string{std::strerror(errno)}; + ::close(socket_fd); + return false; + } + + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_H | BPF_ABS, 12), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ethernet::goose_ethertype, 4, 0), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ethernet::vlan_tag_ethertype, 0, 2), + BPF_STMT(BPF_LD | BPF_H | BPF_ABS, 16), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ethernet::goose_ethertype, 1, 0), + BPF_STMT(BPF_RET | BPF_K, 0), + BPF_STMT(BPF_RET | BPF_K, 0xFFFF), + }; + struct sock_fprog program {}; + program.len = static_cast(std::size(filter)); + program.filter = filter; + if (::setsockopt( + socket_fd, + SOL_SOCKET, + SO_ATTACH_FILTER, + &program, + static_cast(sizeof(program))) != 0) { + error = "Could not install the GOOSE EtherType capture filter on " + name + ": " + + std::string{std::strerror(errno)}; + ::close(socket_fd); + return false; + } + + const int flags = ::fcntl(socket_fd, F_GETFL, 0); + if (flags < 0 || ::fcntl(socket_fd, F_SETFL, flags | O_NONBLOCK) != 0) { + error = "Could not make the GOOSE monitor socket non-blocking on " + name + ": " + + std::string{std::strerror(errno)}; + ::close(socket_fd); + return false; + } + + socket_fd_ = socket_fd; + interface_index_ = static_cast(index); + interface_name_ = name; + return true; +#elif defined(_WIN32) + const std::string requested_name{interface_name}; + wpcap_module_ = ::LoadLibraryA("wpcap.dll"); + if (wpcap_module_ == nullptr) { + error = "Npcap wpcap.dll was not found. Install Npcap to monitor Layer-2 GOOSE on Windows."; + return false; + } + pcap_open_live_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_open_live")); + pcap_next_ex_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_next_ex")); + pcap_close_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_close")); + pcap_geterr_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_geterr")); + pcap_findalldevs_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_findalldevs")); + pcap_freealldevs_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_freealldevs")); + pcap_compile_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_compile")); + pcap_setfilter_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_setfilter")); + pcap_freecode_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_freecode")); + if (pcap_open_live_ == nullptr || pcap_next_ex_ == nullptr || pcap_close_ == nullptr || + pcap_geterr_ == nullptr || pcap_findalldevs_ == nullptr || pcap_freealldevs_ == nullptr || + pcap_compile_ == nullptr || pcap_setfilter_ == nullptr || pcap_freecode_ == nullptr) { + error = "Npcap is present but required packet-capture/filter exports are unavailable."; + close(); + return false; + } + + std::array errbuf{}; + PcapIf* devices{}; + if (pcap_findalldevs_(&devices, errbuf.data()) != 0 || devices == nullptr) { + error = "Npcap could not enumerate Ethernet adapters"; + if (errbuf.front() != '\0') error += ": " + std::string{errbuf.data()}; + close(); + return false; + } + + std::string pcap_name; + for (auto* device = devices; device != nullptr; device = device->next) { + const std::string device_name = device->name == nullptr ? std::string{} : device->name; + const std::string description = + device->description == nullptr ? std::string{} : device->description; + if (!windows_interface_matches(requested_name, device_name, description)) continue; + pcap_name = device_name; + break; + } + pcap_freealldevs_(devices); + devices = nullptr; + if (pcap_name.empty()) { + error = "Npcap could not resolve the selected Ethernet interface: " + requested_name; + close(); + return false; + } + + errbuf.fill('\0'); + pcap_handle_ = pcap_open_live_(pcap_name.c_str(), 65'535, 1, 1, errbuf.data()); + if (pcap_handle_ == nullptr) { + error = "Npcap could not open adapter " + requested_name + ": " + std::string{errbuf.data()}; + close(); + return false; + } + + BpfProgram program{}; + constexpr const char* filter = "(ether proto 0x88b8) or (vlan and ether proto 0x88b8)"; + if (pcap_compile_(pcap_handle_, &program, filter, 1, 0xFFFFFFFFU) != 0) { + error = "Npcap could not compile the GOOSE EtherType filter"; + const char* detail = pcap_geterr_(pcap_handle_); + if (detail != nullptr && *detail != '\0') error += ": " + std::string{detail}; + close(); + return false; + } + const int filter_result = pcap_setfilter_(pcap_handle_, &program); + pcap_freecode_(&program); + if (filter_result != 0) { + error = "Npcap could not install the GOOSE EtherType filter"; + const char* detail = pcap_geterr_(pcap_handle_); + if (detail != nullptr && *detail != '\0') error += ": " + std::string{detail}; + close(); + return false; + } + + interface_name_ = requested_name; + return true; +#else + (void)interface_name; + error = "Raw Ethernet GOOSE monitoring is unavailable on this platform."; + return false; +#endif + } + + [[nodiscard]] RawEthernetReceiveResult receive( + std::vector& ethernet_frame, + std::string& error) noexcept { + ethernet_frame.clear(); + error.clear(); +#if defined(__linux__) + if (socket_fd_ < 0 || interface_index_ <= 0) { + error = "GOOSE raw Ethernet subscriber is not open."; + return RawEthernetReceiveResult::failure; + } + std::array buffer{}; + const auto received = ::recv(socket_fd_, buffer.data(), buffer.size(), 0); + if (received < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return RawEthernetReceiveResult::none; + } + error = "GOOSE raw Ethernet receive failed on " + interface_name_ + ": " + + std::string{std::strerror(errno)}; + return RawEthernetReceiveResult::failure; + } + if (received == 0) return RawEthernetReceiveResult::none; + ethernet_frame.assign(buffer.begin(), buffer.begin() + received); + return RawEthernetReceiveResult::frame; +#elif defined(_WIN32) + if (pcap_handle_ == nullptr || pcap_next_ex_ == nullptr) { + error = "GOOSE Npcap subscriber is not open."; + return RawEthernetReceiveResult::failure; + } + PcapPacketHeader* header{}; + const unsigned char* data{}; + const int result = pcap_next_ex_(pcap_handle_, &header, &data); + if (result == 0) return RawEthernetReceiveResult::none; + if (result != 1 || header == nullptr || data == nullptr) { + error = "GOOSE Npcap receive failed on " + interface_name_; + const char* detail = pcap_geterr_ != nullptr ? pcap_geterr_(pcap_handle_) : nullptr; + if (detail != nullptr && *detail != '\0') error += ": " + std::string{detail}; + return RawEthernetReceiveResult::failure; + } + if (header->caplen < 14U || header->caplen > 65'535U) { + error = "GOOSE Npcap capture returned an invalid frame length."; + return RawEthernetReceiveResult::failure; + } + ethernet_frame.assign(data, data + header->caplen); + return RawEthernetReceiveResult::frame; +#else + error = "Raw Ethernet GOOSE monitoring is unavailable on this platform."; + return RawEthernetReceiveResult::failure; +#endif + } + + void close() noexcept { +#if defined(__linux__) + if (socket_fd_ >= 0) ::close(socket_fd_); + socket_fd_ = -1; + interface_index_ = 0; +#elif defined(_WIN32) + if (pcap_handle_ != nullptr && pcap_close_ != nullptr) pcap_close_(pcap_handle_); + pcap_handle_ = nullptr; + pcap_open_live_ = nullptr; + pcap_next_ex_ = nullptr; + pcap_close_ = nullptr; + pcap_geterr_ = nullptr; + pcap_findalldevs_ = nullptr; + pcap_freealldevs_ = nullptr; + pcap_compile_ = nullptr; + pcap_setfilter_ = nullptr; + pcap_freecode_ = nullptr; + if (wpcap_module_ != nullptr) ::FreeLibrary(wpcap_module_); + wpcap_module_ = nullptr; +#endif + interface_name_.clear(); + } + + [[nodiscard]] bool active() const noexcept { +#if defined(__linux__) + return socket_fd_ >= 0 && interface_index_ > 0; +#elif defined(_WIN32) + return pcap_handle_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] const std::string& interface_name() const noexcept { return interface_name_; } + +private: +#if defined(_WIN32) + struct pcap; + struct pcap_addr; + struct PcapIf final { + PcapIf* next; + char* name; + char* description; + pcap_addr* addresses; + unsigned int flags; + }; + struct PcapPacketHeader final { + timeval ts; + unsigned int caplen; + unsigned int len; + }; + struct BpfProgram final { + unsigned int bf_len; + void* bf_insns; + }; + using PcapHandle = pcap; + using PcapOpenLive = PcapHandle* (__cdecl *)(const char*, int, int, int, char*); + using PcapNextEx = int (__cdecl *)(PcapHandle*, PcapPacketHeader**, const unsigned char**); + using PcapClose = void (__cdecl *)(PcapHandle*); + using PcapGetErr = char* (__cdecl *)(PcapHandle*); + using PcapFindAllDevs = int (__cdecl *)(PcapIf**, char*); + using PcapFreeAllDevs = void (__cdecl *)(PcapIf*); + using PcapCompile = int (__cdecl *)(PcapHandle*, BpfProgram*, const char*, int, unsigned int); + using PcapSetFilter = int (__cdecl *)(PcapHandle*, BpfProgram*); + using PcapFreeCode = void (__cdecl *)(BpfProgram*); + + [[nodiscard]] static std::string ascii_lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; + } + + [[nodiscard]] static std::string normalized_windows_interface(std::string value) { + value = ascii_lower(std::move(value)); + constexpr std::string_view prefix{"\\\\device\\npf_"}; + if (value.rfind(prefix.data(), 0U) == 0U) value.erase(0U, prefix.size()); + return value; + } + + [[nodiscard]] static bool windows_interface_matches( + const std::string& requested, + const std::string& pcap_name, + const std::string& description) { + const auto wanted = normalized_windows_interface(requested); + const auto device = normalized_windows_interface(pcap_name); + const auto label = ascii_lower(description); + if (wanted == device || ascii_lower(requested) == label) return true; + const auto brace = device.find('{'); + return brace != std::string::npos && wanted.find(device.substr(brace)) != std::string::npos; + } + + HMODULE wpcap_module_{}; + PcapHandle* pcap_handle_{}; + PcapOpenLive pcap_open_live_{}; + PcapNextEx pcap_next_ex_{}; + PcapClose pcap_close_{}; + PcapGetErr pcap_geterr_{}; + PcapFindAllDevs pcap_findalldevs_{}; + PcapFreeAllDevs pcap_freealldevs_{}; + PcapCompile pcap_compile_{}; + PcapSetFilter pcap_setfilter_{}; + PcapFreeCode pcap_freecode_{}; +#elif defined(__linux__) + int socket_fd_{-1}; + int interface_index_{}; +#endif + std::string interface_name_; + + void move_from(RawEthernetSubscriber& other) noexcept { +#if defined(__linux__) + socket_fd_ = std::exchange(other.socket_fd_, -1); + interface_index_ = std::exchange(other.interface_index_, 0); +#elif defined(_WIN32) + wpcap_module_ = std::exchange(other.wpcap_module_, nullptr); + pcap_handle_ = std::exchange(other.pcap_handle_, nullptr); + pcap_open_live_ = std::exchange(other.pcap_open_live_, nullptr); + pcap_next_ex_ = std::exchange(other.pcap_next_ex_, nullptr); + pcap_close_ = std::exchange(other.pcap_close_, nullptr); + pcap_geterr_ = std::exchange(other.pcap_geterr_, nullptr); + pcap_findalldevs_ = std::exchange(other.pcap_findalldevs_, nullptr); + pcap_freealldevs_ = std::exchange(other.pcap_freealldevs_, nullptr); + pcap_compile_ = std::exchange(other.pcap_compile_, nullptr); + pcap_setfilter_ = std::exchange(other.pcap_setfilter_, nullptr); + pcap_freecode_ = std::exchange(other.pcap_freecode_, nullptr); +#endif + interface_name_ = std::move(other.interface_name_); + } +}; + +} // namespace ar::iec61850::goose diff --git a/include/ariec61850/mms/association_runtime.hpp b/include/ariec61850/mms/association_runtime.hpp index 71af1a698..efa0e47b6 100644 --- a/include/ariec61850/mms/association_runtime.hpp +++ b/include/ariec61850/mms/association_runtime.hpp @@ -105,6 +105,26 @@ struct MmsAssociationAttemptEvidence final { const MmsAssociationAttemptEvidence&) = default; }; +// Explicit server/called-side OSI association addressing. This is intentionally +// separate from endpoint host/port and from generic compatibility defaults so a +// trusted SCL engineering context can reach COTP/Session/Presentation/ACSE +// unchanged without redefining the local engineering-station identity. +struct MmsAssociationAddressing final { + // ASN.1 OBJECT IDENTIFIER arcs, e.g. {1, 1, 1, 999, 1}. Empty means use the + // existing compatibility default called AP-title. + std::vector called_ap_title; + std::optional called_ae_qualifier; + + // Server-side selectors. Empty means preserve the existing default at that + // layer. Calling/source selectors remain compatibility defaults. + std::vector called_p_selector; + std::vector called_s_selector; + std::vector called_t_selector; + + friend bool operator==(const MmsAssociationAddressing&, + const MmsAssociationAddressing&) = default; +}; + struct MmsAssociationOptions final { std::chrono::milliseconds connect_timeout{5'000}; std::chrono::milliseconds request_timeout{5'000}; @@ -113,6 +133,11 @@ struct MmsAssociationOptions final { std::size_t maximum_receive_chunks_per_operation{4'096U}; std::size_t maximum_queued_information_reports{1'024U}; std::size_t maximum_events{1'024U}; + + // When present, association setup uses one explicit engineering profile and + // does not silently retry generic AP-title profiles. Missing individual + // fields inside the profile retain the historical per-layer defaults. + std::optional addressing; }; struct MmsNegotiatedAssociation final { diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp index 294ceea17..c9685115f 100644 --- a/include/ariec61850/mms/connection_runtime.hpp +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -40,6 +40,26 @@ using MmsStaticAssociationClosedCallback = void (*)( std::uint64_t association_id, std::uint64_t now_ms) noexcept; +// Host-side escape hatch for confirmed services that require resources the +// portable MMS core intentionally does not own (for example a filesystem). +// The callback encodes a complete MMS ConfirmedResponse/ConfirmedError PDU +// into `response`. `handled=false` delegates to the normal dispatcher/fallback. +// Keeping this at the already-decoded confirmed-service boundary preserves +// negotiated MMS sizing, Presentation wrapping and COTP segmentation in one +// connection runtime instead of duplicating transport logic in host adapters. +struct MmsStaticConfirmedServiceExtensionResult final { + bool handled{}; + wire::EncodeResult encoded{}; +}; + +using MmsStaticConfirmedServiceCallback = MmsStaticConfirmedServiceExtensionResult (*)( + void* context, + MmsWireConfirmedService service, + std::uint32_t invoke_id, + bool service_constructed, + std::span service_value, + std::span response) noexcept; + struct MmsStaticConnectionPolicy final { static constexpr std::size_t maximum_owner_bytes = 16U; @@ -63,6 +83,13 @@ struct MmsStaticConnectionPolicy final { MmsStaticAssociationClosedCallback association_closed{}; void* association_closed_context{}; + // Optional host-owned confirmed-service bridge. The callback must not + // retain request spans. Stateful/destructive backends should make retries + // with the same invoke ID idempotent because capacity failures leave the + // input frame unconsumed and may cause the callback to be entered again. + MmsStaticConfirmedServiceCallback confirmed_service{}; + void* confirmed_service_context{}; + // Appended for aggregate source compatibility. When non-empty, this is the // adapter-owned COTP Data reassembly buffer. Otherwise the runtime may // borrow half of the stable per-session workspace while a fragmented COTP diff --git a/include/ariec61850/mms/data_codec.hpp b/include/ariec61850/mms/data_codec.hpp index 16afe93e0..cd052e579 100644 --- a/include/ariec61850/mms/data_codec.hpp +++ b/include/ariec61850/mms/data_codec.hpp @@ -28,7 +28,8 @@ class MmsDataCodec final { [[nodiscard]] static std::vector encode_content(const MmsDataValue& value); [[nodiscard]] static std::int32_t tag_for(const MmsDataValue& value); [[nodiscard]] static MmsDataValue decode_bit_string(std::span bytes); - [[nodiscard]] static float decode_floating_point(std::span bytes) noexcept; + [[nodiscard]] static MmsDataValue decode_floating_point( + std::span bytes) noexcept; }; } // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/file_service.hpp b/include/ariec61850/mms/file_service.hpp index cbfe0c62f..fb37ca24b 100644 --- a/include/ariec61850/mms/file_service.hpp +++ b/include/ariec61850/mms/file_service.hpp @@ -117,11 +117,22 @@ struct MmsFileCloseResponse final { std::uint32_t invoke_id{}; }; +struct MmsFileDeleteRequest final { + std::uint32_t invoke_id{}; + std::string remote_path; + bool rooted_backslash{true}; +}; + +struct MmsFileDeleteResponse final { + std::uint32_t invoke_id{}; +}; + class MmsFileServiceCodec final { public: static constexpr std::int32_t file_open_service_tag = 72; static constexpr std::int32_t file_read_service_tag = 73; static constexpr std::int32_t file_close_service_tag = 74; + static constexpr std::int32_t file_delete_service_tag = 76; static constexpr std::int32_t file_directory_service_tag = 77; static constexpr std::size_t maximum_path_bytes = 1'024U; static constexpr std::size_t maximum_directory_entries = 65'536U; @@ -168,6 +179,15 @@ class MmsFileServiceCodec final { [[nodiscard]] static MmsFileCloseResponse decode_file_close_response( std::span presentation_or_mms_payload, std::uint32_t expected_invoke_id); + + [[nodiscard]] static std::vector encode_file_delete_request_pdu( + const MmsFileDeleteRequest& request); + [[nodiscard]] static std::vector encode_file_delete_request_p_data( + const MmsFileDeleteRequest& request, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsFileDeleteResponse decode_file_delete_response( + std::span presentation_or_mms_payload, + std::uint32_t expected_invoke_id); }; struct MmsFileDirectoryOptions final { @@ -244,6 +264,14 @@ struct MmsFileTransferResult final { std::vector fallback_attempt_diagnostics; }; +struct MmsFileDeleteResult final { + bool success{}; + std::string remote_path; + MmsFileFailureKind failure_kind{MmsFileFailureKind::none}; + std::string message; + std::vector diagnostics; +}; + class MmsFileServiceChannel { public: virtual ~MmsFileServiceChannel() = default; @@ -297,6 +325,14 @@ class MmsFileTransferRuntime final { MmsFileProgressSink* progress = nullptr, std::stop_token stop_token = {}); + // Destructive by design: one call maps to one MMS FileDelete request. + // There is deliberately no adaptive retry because a destructive request + // must never be replayed under an alternate pathname after an ambiguous result. + [[nodiscard]] MmsFileDeleteResult remove( + const std::string& remote_path, + bool rooted_backslash = true, + std::stop_token stop_token = {}); + private: [[nodiscard]] MmsFileTransferResult download_attempt( const std::string& normalized_path, diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp index 3d74b65a5..11a06396b 100644 --- a/include/ariec61850/mms/live_discovery.hpp +++ b/include/ariec61850/mms/live_discovery.hpp @@ -4,6 +4,7 @@ #include "ariec61850/mms/association_runtime.hpp" #include "ariec61850/mms/control_block_read.hpp" #include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/scl_assisted_connect.hpp" #include "ariec61850/mms/tcp_transport.hpp" #include @@ -127,6 +128,19 @@ class MmsTcpLiveDiscoverySession final { [[nodiscard]] MmsLiveDiscoveryResult discover( const MmsLiveDiscoveryOptions& options = {}, std::stop_token stop_token = {}); + + // Trusted-SCL path: Domain validation + deterministic FC-root snapshot only. + // It deliberately does not invoke full NamedVariable/GVAA/DataSet discovery. + [[nodiscard]] MmsSclAssistedConnectResult synchronize_scl( + const scl::SclDocument& document, + std::string ied_name = {}, + const MmsSclAssistedConnectOptions& options = {}, + std::stop_token stop_token = {}) { + MmsSclAssistedConnectClient client{association_}; + return client.synchronize( + document, std::move(ied_name), options, stop_token); + } + void disconnect(std::stop_token stop_token = {}) noexcept; [[nodiscard]] bool associated() const noexcept { return association_.associated(); } diff --git a/include/ariec61850/mms/live_model_chunk02.ipp b/include/ariec61850/mms/live_model_chunk02.ipp index cd9b47eb3..c6a29e668 100644 --- a/include/ariec61850/mms/live_model_chunk02.ipp +++ b/include/ariec61850/mms/live_model_chunk02.ipp @@ -299,6 +299,47 @@ inline void append_unique_ci( for (const auto& [domain, variables] : discovery.names.domain_variables) { for (const auto& variable : variables) add(MmsLiveReferenceParser::parse_variable(domain, variable)); } + + // IEC 61850 servers commonly advertise only one MMS NamedVariable per + // Logical Node and expose FC/DO/DA members through that root's hierarchical + // TypeSpecification. Project exactly FC -> DO -> DA here. Children below a + // structured DA are BDAs and must not inflate the model's DA coverage; the + // structured DA's own TypeSpecification still preserves those child types. + for (const auto& evidence : discovery.variable_types) { + if (!evidence.success() || + evidence.variable.kind != MmsObjectNameKind::domain_specific || + evidence.variable.domain.empty() || evidence.variable.item.empty()) { + continue; + } + + const auto& root = evidence.attributes->type; + std::size_t projected_components{}; + for (const auto& fc : root.children) { + if (projected_components >= MmsServiceCodec::maximum_type_components) break; + if (fc.name.empty() || !MmsLiveReferenceParser::known_functional_constraint(fc.name)) { + continue; + } + ++projected_components; + for (const auto& data_object : fc.children) { + if (projected_components >= MmsServiceCodec::maximum_type_components) break; + if (data_object.name.empty()) continue; + ++projected_components; + for (const auto& data_attribute : data_object.children) { + if (projected_components >= MmsServiceCodec::maximum_type_components) break; + if (data_attribute.name.empty()) continue; + ++projected_components; + const auto item = evidence.variable.item + "$" + fc.name + "$" + + data_object.name + "$" + data_attribute.name; + add(MmsLiveReferenceParser::parse_variable( + evidence.variable.domain, + item, + "GetVariableAccessAttributesLogicalNodeTree", + 100U)); + } + } + } + } + for (const auto& evidence : discovery.data_set_directories) { if (!evidence.success()) continue; for (const auto& member : evidence.directory->members) { diff --git a/include/ariec61850/mms/pdu.hpp b/include/ariec61850/mms/pdu.hpp index 2caf4e68b..e7e4fc802 100644 --- a/include/ariec61850/mms/pdu.hpp +++ b/include/ariec61850/mms/pdu.hpp @@ -45,6 +45,7 @@ enum class MmsConfirmedService : std::int32_t { file_open = 72, file_read = 73, file_close = 74, + file_delete = 76, file_directory = 77, }; diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp index 94bf40ddd..ebf07c1e6 100644 --- a/include/ariec61850/mms/pdu_span.hpp +++ b/include/ariec61850/mms/pdu_span.hpp @@ -25,6 +25,10 @@ enum class MmsWireConfirmedService : std::int32_t { write = 5, get_variable_access_attributes = 6, get_named_variable_list_attributes = 12, + file_open = 72, + file_read = 73, + file_close = 74, + file_delete = 76, file_directory = 77, }; diff --git a/include/ariec61850/mms/report_subscription_runtime.hpp b/include/ariec61850/mms/report_subscription_runtime.hpp index 690bb6075..15d186d64 100644 --- a/include/ariec61850/mms/report_subscription_runtime.hpp +++ b/include/ariec61850/mms/report_subscription_runtime.hpp @@ -126,6 +126,7 @@ class MmsReportSubscriptionRuntime final { std::optional last_rcb_state_; bool enabled_by_runtime_{}; bool reservation_touched_{}; + bool restore_enabled_required_{}; bool cleanup_required_{}; std::uint64_t received_reports_{}; std::uint64_t decode_failures_{}; diff --git a/include/ariec61850/mms/scl_assisted_connect.hpp b/include/ariec61850/mms/scl_assisted_connect.hpp new file mode 100644 index 000000000..b56571eb2 --- /dev/null +++ b/include/ariec61850/mms/scl_assisted_connect.hpp @@ -0,0 +1,685 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsSclAssistedConnectError final : public MmsAssociationRuntimeError { +public: + using MmsAssociationRuntimeError::MmsAssociationRuntimeError; +}; + +struct MmsInitialFcReadReference final { + MmsObjectName variable; + std::string ied_name; + std::string logical_device; + std::string logical_node; + std::string functional_constraint; + std::vector model_entry_indices; + + [[nodiscard]] std::string reference() const { + return variable.reference(); + } +}; + +struct MmsInitialFcReadBatch final { + std::string domain; + std::string logical_node; + std::vector references; +}; + +struct MmsInitialFcReadPlan final { + std::string ied_name; + std::vector expected_domains; + std::vector batches; + + [[nodiscard]] std::size_t reference_count() const noexcept { + std::size_t count = 0U; + for (const auto& batch : batches) count += batch.references.size(); + return count; + } +}; + +struct MmsInitialFcReadPlannerOptions final { + // Compatibility-first default observed in the reference SCL-assisted trace. + // This is intentionally independent from MMS negotiated outstanding calls. + std::size_t maximum_references_per_read{10U}; + std::size_t maximum_total_references{65'536U}; +}; + +class InitialFcReadPlanner final { +public: + [[nodiscard]] static MmsInitialFcReadPlan build( + const scl::SclDocument& document, + std::string ied_name = {}, + const MmsInitialFcReadPlannerOptions& options = {}); +}; + +struct MmsSclDomainValidation final { + std::vector expected; + std::vector online; + std::vector missing; + std::vector extra; + + [[nodiscard]] bool all_expected_online() const noexcept { + return missing.empty(); + } +}; + +class MmsSclDomainValidator final { +public: + [[nodiscard]] static MmsSclDomainValidation compare( + std::span expected, + std::span online); +}; + +struct MmsSclMappedLeaf final { + std::size_t model_entry_index{}; + std::string signal_reference; + std::string basic_type; + bool quality{}; + bool timestamp{}; + std::optional value; +}; + +struct MmsInitialFcRootResult final { + MmsInitialFcReadReference reference; + std::optional access_failure_code; + std::optional root_value; + std::vector mapped_leaves; + std::string mapping_error; + + [[nodiscard]] bool read_success() const noexcept { + return root_value.has_value() && !access_failure_code.has_value(); + } + + [[nodiscard]] bool mapping_success() const noexcept { + return read_success() && mapping_error.empty(); + } +}; + +struct MmsInitialFcBatchResult final { + std::uint32_t invoke_id{}; + std::string domain; + std::string logical_node; + bool skipped_missing_domain{}; + std::vector roots; +}; + +struct MmsSclAssistedConnectOptions final { + std::size_t maximum_domain_pages{256U}; + std::size_t maximum_domains{4'096U}; + MmsInitialFcReadPlannerOptions planner{}; + + // When false, missing domains are surfaced and their batches are skipped. + // The SCL model identity is preserved; network evidence never rewrites it. + bool require_all_expected_domains{}; +}; + +struct MmsSclAssistedConnectResult final { + MmsEndpoint endpoint; + std::string ied_name; + MmsSclDomainValidation domains; + MmsInitialFcReadPlan plan; + std::vector batches; + std::vector diagnostics; + std::size_t domain_request_count{}; + std::size_t read_request_count{}; + std::size_t successful_root_count{}; + std::size_t failed_root_count{}; + std::size_t mapped_leaf_count{}; + bool associated_after_snapshot{}; + + [[nodiscard]] std::size_t confirmed_request_count() const noexcept { + return domain_request_count + read_request_count; + } + + [[nodiscard]] std::string summary() const { + std::ostringstream stream; + stream << "SCL-assisted MMS connect: endpoint=" << endpoint.host << ':' + << endpoint.port << ", IED=" << ied_name + << ", domains=" << domains.online.size() << '/' + << domains.expected.size() + << ", missing=" << domains.missing.size() + << ", extra=" << domains.extra.size() + << ", FC-roots=" << plan.reference_count() + << ", ReadRequests=" << read_request_count + << ", successfulRoots=" << successful_root_count + << ", failedRoots=" << failed_root_count + << ", mappedLeaves=" << mapped_leaf_count + << ", associated=" << (associated_after_snapshot ? "true" : "false") + << '.'; + return stream.str(); + } +}; + +namespace detail { + +[[nodiscard]] inline std::string resolve_scl_ied_name( + const scl::SclDocument& document, + std::string requested) { + if (!requested.empty()) { + const bool known = std::any_of( + document.ieds.begin(), document.ieds.end(), + [&](const auto& ied) { return ied.name == requested; }) || + std::any_of( + document.logical_nodes.begin(), document.logical_nodes.end(), + [&](const auto& logical_node) { + return logical_node.ied_name == requested; + }); + if (!known) { + throw MmsSclAssistedConnectError( + "Requested IED '" + requested + "' is not present in the SCL model."); + } + return requested; + } + + std::set> names; + for (const auto& logical_node : document.logical_nodes) { + if (!logical_node.ied_name.empty()) names.insert(logical_node.ied_name); + } + if (names.empty()) { + for (const auto& ied : document.ieds) { + if (!ied.name.empty()) names.insert(ied.name); + } + } + if (names.size() != 1U) { + throw MmsSclAssistedConnectError( + "SCL-assisted connect requires an explicit IED when the SCL model does not resolve to exactly one IED."); + } + return *names.begin(); +} + +[[nodiscard]] inline std::string logical_node_name( + const scl::SclLogicalNode& logical_node) { + if (!logical_node.name.empty()) return logical_node.name; + return logical_node.prefix + logical_node.ln_class + logical_node.ln_inst; +} + +[[nodiscard]] inline bool model_entry_matches_ln( + const scl::SclDataSetEntry& entry, + const scl::SclLogicalNode& logical_node) noexcept { + return entry.ied_name == logical_node.ied_name && + entry.ld_inst == logical_node.ld_inst && + entry.prefix == logical_node.prefix && + entry.ln_class == logical_node.ln_class && + entry.ln_inst == logical_node.ln_inst; +} + +[[nodiscard]] inline bool report_control_matches_ln( + const scl::SclReportControl& control, + const scl::SclLogicalNode& logical_node) { + if (control.ied_name != logical_node.ied_name || + control.ld_inst != logical_node.ld_inst) { + return false; + } + const auto name = logical_node_name(logical_node); + const auto domain = logical_node.mms_domain(); + const auto reference_prefix = domain + "/" + name; + if (control.control_block_reference.starts_with(reference_prefix)) return true; + if (!control.logical_node_path.empty() && + control.logical_node_path.find(name) != std::string::npos) { + return true; + } + return false; +} + +[[nodiscard]] inline std::vector split_scl_path( + const std::string_view value) { + std::vector parts; + std::size_t offset = 0U; + while (offset < value.size()) { + const auto separator = value.find('.', offset); + const auto end = separator == std::string_view::npos ? value.size() : separator; + if (end > offset) parts.emplace_back(value.substr(offset, end - offset)); + if (separator == std::string_view::npos) break; + offset = separator + 1U; + } + return parts; +} + +struct SclValueNode final { + std::string name; + std::optional model_entry_index; + std::vector children; +}; + +[[nodiscard]] inline SclValueNode* find_or_append_child( + SclValueNode& parent, + const std::string& name) { + const auto found = std::find_if( + parent.children.begin(), parent.children.end(), + [&](const auto& child) { return child.name == name; }); + if (found != parent.children.end()) return &*found; + parent.children.push_back(SclValueNode{name, std::nullopt, {}}); + return &parent.children.back(); +} + +[[nodiscard]] inline bool build_scl_value_tree( + const scl::SclDocument& document, + const MmsInitialFcReadReference& reference, + SclValueNode& root, + std::string& error) { + for (const auto model_index : reference.model_entry_indices) { + if (model_index >= document.model_entries.size()) { + error = "SCL model-entry index is out of range."; + return false; + } + const auto& entry = document.model_entries[model_index]; + auto path = split_scl_path(entry.do_name); + auto da_path = split_scl_path(entry.da_name); + path.insert(path.end(), da_path.begin(), da_path.end()); + if (path.empty()) { + error = "SCL FC-root mapping contains an empty DO/DA path."; + return false; + } + + auto* node = &root; + for (const auto& component : path) { + node = find_or_append_child(*node, component); + } + if (node->model_entry_index.has_value() && + *node->model_entry_index != model_index) { + error = "SCL FC-root mapping contains duplicate semantic leaf paths."; + return false; + } + node->model_entry_index = model_index; + } + return true; +} + +[[nodiscard]] inline bool container_value(const MmsDataValue& value) noexcept { + return value.kind() == MmsDataKind::structure || value.kind() == MmsDataKind::array; +} + +[[nodiscard]] inline bool map_scl_node( + const scl::SclDocument& document, + const SclValueNode& node, + const MmsDataValue& value, + std::vector& mapped, + std::string& error) { + if (node.children.empty()) { + if (!node.model_entry_index.has_value() || + *node.model_entry_index >= document.model_entries.size()) { + error = "SCL type tree ended without a valid leaf identity."; + return false; + } + const auto& entry = document.model_entries[*node.model_entry_index]; + MmsSclMappedLeaf leaf; + leaf.model_entry_index = *node.model_entry_index; + leaf.signal_reference = entry.signal_reference; + leaf.basic_type = entry.basic_type; + leaf.quality = entry.is_quality; + leaf.timestamp = entry.is_timestamp; + leaf.value = value; + mapped.push_back(std::move(leaf)); + return true; + } + + if (node.model_entry_index.has_value()) { + error = "SCL type tree contains a node that is both a leaf and a structure."; + return false; + } + if (!container_value(value)) { + error = "MMS Data is scalar where the SCL type tree requires a structure."; + return false; + } + if (value.children().size() != node.children.size()) { + error = "MMS Data structure child count does not match the SCL type tree."; + return false; + } + for (std::size_t index = 0U; index < node.children.size(); ++index) { + if (!map_scl_node( + document, node.children[index], value.children()[index], mapped, error)) { + return false; + } + } + return true; +} + +[[nodiscard]] inline bool map_scl_root_value( + const scl::SclDocument& document, + const MmsInitialFcReadReference& reference, + const MmsDataValue& value, + std::vector& mapped, + std::string& error) { + mapped.clear(); + error.clear(); + if (reference.model_entry_indices.empty()) { + // Control-block FC roots can be structurally useful without DA leaves in + // the generic model-entry catalog. Preserve the root value as evidence. + return true; + } + + SclValueNode root; + if (!build_scl_value_tree(document, reference, root, error)) return false; + if (!container_value(value)) { + error = "FC-root MMS Data is not a structure/array."; + return false; + } + if (value.children().size() != root.children.size()) { + error = "FC-root MMS Data child count does not match the SCL type tree."; + return false; + } + + std::vector staged; + staged.reserve(reference.model_entry_indices.size()); + for (std::size_t index = 0U; index < root.children.size(); ++index) { + if (!map_scl_node( + document, root.children[index], value.children()[index], staged, error)) { + return false; + } + } + if (staged.size() != reference.model_entry_indices.size()) { + error = "FC-root mapping did not resolve every SCL leaf exactly once."; + return false; + } + mapped = std::move(staged); + return true; +} + +[[nodiscard]] inline std::span scl_response_payload( + const MmsConfirmedExchangeResult& exchange, + const std::string_view operation) { + if (exchange.envelope.kind != MmsPduKind::confirmed_response) { + throw MmsSclAssistedConnectError( + std::string{operation} + " did not return a Confirmed-ResponsePDU."); + } + if (!exchange.presentation_payload.empty()) return exchange.presentation_payload; + if (!exchange.envelope.mms_payload.empty()) return exchange.envelope.mms_payload; + throw MmsSclAssistedConnectError( + std::string{operation} + " returned no decodable MMS payload."); +} + +} // namespace detail + +inline MmsInitialFcReadPlan InitialFcReadPlanner::build( + const scl::SclDocument& document, + std::string ied_name, + const MmsInitialFcReadPlannerOptions& options) { + if (options.maximum_references_per_read == 0U || + options.maximum_references_per_read > MmsServiceCodec::maximum_variables || + options.maximum_total_references == 0U) { + throw std::invalid_argument("Initial FC-read planner bounds are invalid."); + } + + MmsInitialFcReadPlan plan; + plan.ied_name = detail::resolve_scl_ied_name(document, std::move(ied_name)); + std::set> seen_domains; + std::set> seen_logical_nodes; + + for (const auto& logical_node : document.logical_nodes) { + if (logical_node.ied_name != plan.ied_name) continue; + const auto domain = logical_node.mms_domain(); + const auto ln_name = detail::logical_node_name(logical_node); + if (domain.empty() || ln_name.empty()) continue; + + if (seen_domains.insert(domain).second) plan.expected_domains.push_back(domain); + const auto ln_identity = domain + "\n" + ln_name; + if (!seen_logical_nodes.insert(ln_identity).second) continue; + + std::map, std::less<>> by_fc; + for (std::size_t index = 0U; index < document.model_entries.size(); ++index) { + const auto& entry = document.model_entries[index]; + if (!detail::model_entry_matches_ln(entry, logical_node) || + entry.functional_constraint.empty()) { + continue; + } + by_fc[entry.functional_constraint].push_back(index); + } + + for (const auto& control : document.report_controls) { + if (!detail::report_control_matches_ln(control, logical_node)) continue; + static_cast(by_fc[control.buffered ? "BR" : "RP"]); + } + + std::vector ln_references; + ln_references.reserve(by_fc.size()); + for (auto& [functional_constraint, model_indices] : by_fc) { + if (functional_constraint.empty()) continue; + MmsInitialFcReadReference reference; + reference.variable = MmsObjectName::domain_specific( + domain, ln_name + "$" + functional_constraint); + reference.ied_name = plan.ied_name; + reference.logical_device = logical_node.ld_inst; + reference.logical_node = ln_name; + reference.functional_constraint = functional_constraint; + reference.model_entry_indices = std::move(model_indices); + ln_references.push_back(std::move(reference)); + } + + for (std::size_t offset = 0U; offset < ln_references.size();) { + const auto remaining = ln_references.size() - offset; + const auto take = std::min(options.maximum_references_per_read, remaining); + if (plan.reference_count() + take > options.maximum_total_references) { + throw MmsSclAssistedConnectError( + "Initial FC-read plan exceeded the configured total-reference bound."); + } + MmsInitialFcReadBatch batch; + batch.domain = domain; + batch.logical_node = ln_name; + batch.references.reserve(take); + for (std::size_t index = 0U; index < take; ++index) { + batch.references.push_back(std::move(ln_references[offset + index])); + } + plan.batches.push_back(std::move(batch)); + offset += take; + } + } + + if (plan.expected_domains.empty()) { + throw MmsSclAssistedConnectError( + "Selected SCL IED contains no Logical Device/MMS domain inventory."); + } + if (plan.batches.empty()) { + throw MmsSclAssistedConnectError( + "Selected SCL IED contains no readable Functional Constraint roots."); + } + return plan; +} + +inline MmsSclDomainValidation MmsSclDomainValidator::compare( + const std::span expected, + const std::span online) { + MmsSclDomainValidation result; + result.expected.assign(expected.begin(), expected.end()); + result.online.assign(online.begin(), online.end()); + const std::set> expected_set(expected.begin(), expected.end()); + const std::set> online_set(online.begin(), online.end()); + for (const auto& domain : expected) { + if (!online_set.contains(domain)) result.missing.push_back(domain); + } + for (const auto& domain : online) { + if (!expected_set.contains(domain)) result.extra.push_back(domain); + } + return result; +} + +class MmsSclAssistedConnectClient final { +public: + explicit MmsSclAssistedConnectClient(MmsAssociationRuntime& association) + : association_{association} {} + + [[nodiscard]] MmsSclAssistedConnectResult synchronize( + const scl::SclDocument& document, + std::string ied_name = {}, + const MmsSclAssistedConnectOptions& options = {}, + const std::stop_token stop_token = {}) { + if (!association_.associated()) { + throw MmsSclAssistedConnectError( + "SCL-assisted synchronization requires an active MMS association."); + } + if (options.maximum_domain_pages == 0U || options.maximum_domains == 0U) { + throw std::invalid_argument("SCL-assisted domain-query bounds are invalid."); + } + + MmsSclAssistedConnectResult result; + result.endpoint = association_.endpoint(); + result.plan = InitialFcReadPlanner::build( + document, std::move(ied_name), options.planner); + result.ied_name = result.plan.ied_name; + + std::vector online_domains; + std::set> seen_domains; + std::string continue_after; + for (std::size_t page = 0U; page < options.maximum_domain_pages; ++page) { + MmsGetNameListRequest request; + request.invoke_id = association_.next_invoke_id(); + request.object_class = MmsGetNameListObjectClass::domain; + request.scope = MmsObjectScopeKind::vmd_specific; + request.continue_after = continue_after; + const auto encoded = MmsServiceCodec::encode_get_name_list_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + ++result.domain_request_count; + const auto response = MmsServiceCodec::decode_get_name_list_response( + detail::scl_response_payload(exchange, "SCL domain validation"), + request.invoke_id); + + const auto count_before = online_domains.size(); + for (const auto& domain : response.names) { + if (!seen_domains.insert(domain).second) continue; + if (online_domains.size() >= options.maximum_domains) { + throw MmsSclAssistedConnectError( + "SCL-assisted domain validation exceeded the configured domain bound."); + } + online_domains.push_back(domain); + } + if (!response.more_follows) break; + if (response.names.empty() || online_domains.size() == count_before) { + throw MmsSclAssistedConnectError( + "SCL-assisted domain validation reported moreFollows without forward progress."); + } + continue_after = response.names.back(); + if (page + 1U == options.maximum_domain_pages) { + throw MmsSclAssistedConnectError( + "SCL-assisted domain validation exceeded the configured page bound."); + } + } + + result.domains = MmsSclDomainValidator::compare( + result.plan.expected_domains, online_domains); + for (const auto& domain : result.domains.missing) { + result.diagnostics.push_back( + "Expected SCL domain is unavailable online: " + domain + '.'); + } + for (const auto& domain : result.domains.extra) { + result.diagnostics.push_back( + "Additional online domain is not merged into the trusted SCL model: " + + domain + '.'); + } + if (options.require_all_expected_domains && !result.domains.missing.empty()) { + throw MmsSclAssistedConnectError( + "SCL-assisted synchronization stopped because one or more expected domains are unavailable online."); + } + + const std::set> online_set( + online_domains.begin(), online_domains.end()); + result.batches.reserve(result.plan.batches.size()); + for (const auto& batch : result.plan.batches) { + MmsInitialFcBatchResult batch_result; + batch_result.domain = batch.domain; + batch_result.logical_node = batch.logical_node; + batch_result.roots.reserve(batch.references.size()); + + if (!online_set.contains(batch.domain)) { + batch_result.skipped_missing_domain = true; + for (const auto& reference : batch.references) { + MmsInitialFcRootResult root; + root.reference = reference; + root.mapping_error = "MMS domain is unavailable online; Read was not issued."; + batch_result.roots.push_back(std::move(root)); + ++result.failed_root_count; + } + result.batches.push_back(std::move(batch_result)); + continue; + } + + MmsReadRequest request; + request.invoke_id = association_.next_invoke_id(); + request.variables.reserve(batch.references.size()); + for (const auto& reference : batch.references) { + request.variables.push_back(reference.variable); + } + const auto encoded = MmsServiceCodec::encode_read_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + ++result.read_request_count; + batch_result.invoke_id = request.invoke_id; + const auto response = MmsServiceCodec::decode_read_response( + detail::scl_response_payload(exchange, "SCL initial FC-root Read"), + request.invoke_id); + if (response.results.size() != batch.references.size()) { + throw MmsSclAssistedConnectError( + "Initial FC-root Read result count does not match request order; refusing to map an ambiguous response into the SCL model."); + } + + for (std::size_t index = 0U; index < batch.references.size(); ++index) { + MmsInitialFcRootResult root; + root.reference = batch.references[index]; + const auto& access = response.results[index]; + if (!access.success()) { + root.access_failure_code = access.failure_code.value_or(0U); + ++result.failed_root_count; + result.diagnostics.push_back( + "FC-root Read failed for " + root.reference.reference() + + " with access-result code " + + std::to_string(*root.access_failure_code) + '.'); + batch_result.roots.push_back(std::move(root)); + continue; + } + + root.root_value = *access.value; + std::vector mapped; + std::string mapping_error; + if (!detail::map_scl_root_value( + document, root.reference, *root.root_value, + mapped, mapping_error)) { + root.mapping_error = std::move(mapping_error); + ++result.failed_root_count; + result.diagnostics.push_back( + "FC-root SCL mapping rejected for " + root.reference.reference() + + ": " + root.mapping_error); + } else { + root.mapped_leaves = std::move(mapped); + ++result.successful_root_count; + result.mapped_leaf_count += root.mapped_leaves.size(); + } + batch_result.roots.push_back(std::move(root)); + } + result.batches.push_back(std::move(batch_result)); + } + + result.associated_after_snapshot = association_.associated(); + if (!result.associated_after_snapshot) { + throw MmsSclAssistedConnectError( + "MMS association did not remain active after the initial SCL snapshot."); + } + return result; + } + +private: + MmsAssociationRuntime& association_; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/scl_association.hpp b/include/ariec61850/mms/scl_association.hpp new file mode 100644 index 000000000..6f649aba6 --- /dev/null +++ b/include/ariec61850/mms/scl_association.hpp @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsSclAssociationContext final { + std::string ied_name; + std::string access_point_name; + std::string scl_host; + std::uint16_t scl_port{102U}; + std::optional addressing; + + [[nodiscard]] bool selected() const noexcept { return !ied_name.empty(); } + [[nodiscard]] bool uses_engineering_addressing() const noexcept { + return addressing.has_value(); + } +}; + +// Select one SCL ConnectedAP for the requested online endpoint. Exact IP/host +// match wins. If the engineering file describes one IED/one MMS AccessPoint, +// that AccessPoint is also unambiguous even when the operator used an alias or +// hostname. Multiple unmatched AccessPoints fail closed rather than borrowing +// association selectors from an unrelated IED. +[[nodiscard]] inline MmsSclAssociationContext resolve_scl_association_context( + const scl::SclDocument& document, + const std::string_view requested_host) { + MmsSclAssociationContext result; + if (document.mms_access_points.empty()) return result; + + std::vector candidates; + candidates.reserve(document.mms_access_points.size()); + for (const auto& access_point : document.mms_access_points) { + if (!access_point.ied_name.empty()) candidates.push_back(&access_point); + } + if (candidates.empty()) return result; + + std::vector host_matches; + if (!requested_host.empty()) { + for (const auto* access_point : candidates) { + if (!access_point->ip_address.empty() && + access_point->ip_address == requested_host) { + host_matches.push_back(access_point); + } + } + } + + const scl::SclMmsAccessPoint* selected{}; + if (host_matches.size() == 1U) { + selected = host_matches.front(); + } else if (host_matches.size() > 1U) { + throw std::runtime_error( + "Trusted SCL contains multiple MMS ConnectedAP entries for endpoint '" + + std::string{requested_host} + "'. Select an unambiguous engineering endpoint."); + } else if (candidates.size() == 1U) { + selected = candidates.front(); + } else if (document.ieds.size() == 1U) { + const auto& only_ied = document.ieds.front().name; + std::vector ied_candidates; + for (const auto* access_point : candidates) { + if (access_point->ied_name == only_ied) ied_candidates.push_back(access_point); + } + if (ied_candidates.size() == 1U) selected = ied_candidates.front(); + } + + if (selected == nullptr) { + throw std::runtime_error( + "Trusted SCL MMS ConnectedAP is ambiguous for endpoint '" + + std::string{requested_host} + "'. No association addressing was guessed."); + } + if (!selected->association_parameters_valid) { + throw std::runtime_error( + "Trusted SCL MMS association addressing is invalid for " + + selected->ied_name + "/" + selected->access_point_name + ": " + + selected->association_error); + } + + result.ied_name = selected->ied_name; + result.access_point_name = selected->access_point_name; + result.scl_host = selected->ip_address; + result.scl_port = selected->tcp_port; + + if (selected->association_parameters_present) { + MmsAssociationAddressing addressing; + addressing.called_ap_title = selected->ap_title; + addressing.called_ae_qualifier = selected->ae_qualifier; + addressing.called_p_selector = selected->p_selector; + addressing.called_s_selector = selected->s_selector; + addressing.called_t_selector = selected->t_selector; + result.addressing = std::move(addressing); + } + return result; +} + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/scl_sync_health.hpp b/include/ariec61850/mms/scl_sync_health.hpp new file mode 100644 index 000000000..fabe70507 --- /dev/null +++ b/include/ariec61850/mms/scl_sync_health.hpp @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/scl_assisted_connect.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsSclSynchronizationHealthKind : std::uint8_t { + matched, + degraded, + incompatible, +}; + +struct MmsSclSynchronizationHealth final { + MmsSclSynchronizationHealthKind kind{MmsSclSynchronizationHealthKind::matched}; + std::size_t expected_domain_count{}; + std::size_t online_expected_domain_count{}; + std::size_t missing_domain_count{}; + std::size_t extra_domain_count{}; + std::size_t planned_root_count{}; + std::size_t skipped_root_count{}; + std::size_t access_failure_count{}; + std::size_t structural_mapping_failure_count{}; + std::size_t mapped_root_count{}; + + [[nodiscard]] bool matched() const noexcept { + return kind == MmsSclSynchronizationHealthKind::matched; + } + + [[nodiscard]] bool degraded() const noexcept { + return kind == MmsSclSynchronizationHealthKind::degraded; + } + + [[nodiscard]] bool incompatible() const noexcept { + return kind == MmsSclSynchronizationHealthKind::incompatible; + } + + [[nodiscard]] const char* name() const noexcept { + switch (kind) { + case MmsSclSynchronizationHealthKind::matched: return "matched"; + case MmsSclSynchronizationHealthKind::degraded: return "degraded"; + case MmsSclSynchronizationHealthKind::incompatible: return "incompatible"; + } + return "incompatible"; + } + + [[nodiscard]] std::string summary() const { + std::ostringstream stream; + stream << "SCL online identity " << name() + << ": expectedDomains=" << expected_domain_count + << ", onlineExpectedDomains=" << online_expected_domain_count + << ", missingDomains=" << missing_domain_count + << ", extraDomains=" << extra_domain_count + << ", plannedRoots=" << planned_root_count + << ", skippedRoots=" << skipped_root_count + << ", accessFailures=" << access_failure_count + << ", mappingFailures=" << structural_mapping_failure_count + << ", mappedRoots=" << mapped_root_count << '.'; + return stream.str(); + } +}; + +class MmsSclSynchronizationHealthClassifier final { +public: + [[nodiscard]] static MmsSclSynchronizationHealth evaluate( + const MmsSclAssistedConnectResult& result) noexcept { + MmsSclSynchronizationHealth health; + health.expected_domain_count = result.domains.expected.size(); + health.missing_domain_count = result.domains.missing.size(); + health.extra_domain_count = result.domains.extra.size(); + health.online_expected_domain_count = + health.missing_domain_count >= health.expected_domain_count + ? 0U + : health.expected_domain_count - health.missing_domain_count; + health.planned_root_count = result.plan.reference_count(); + + std::size_t successful_read_root_count{}; + for (const auto& batch : result.batches) { + if (batch.skipped_missing_domain) { + health.skipped_root_count += batch.roots.size(); + continue; + } + for (const auto& root : batch.roots) { + if (root.access_failure_code.has_value()) { + ++health.access_failure_count; + continue; + } + if (!root.root_value.has_value()) { + // No AccessResult value and no explicit access failure is a + // degraded evidence condition, not proof of structural mismatch. + ++health.access_failure_count; + continue; + } + ++successful_read_root_count; + if (!root.mapping_error.empty()) { + ++health.structural_mapping_failure_count; + } else if (root.mapping_success()) { + ++health.mapped_root_count; + } + } + } + + const bool no_expected_domain_online = + health.expected_domain_count != 0U && + health.online_expected_domain_count == 0U; + + // Broad structural mismatch is intentionally narrow: every FC root that + // actually returned MMS Data failed the local SCL shape and there was no + // access-denied/read-failure evidence that could explain the absence of a + // mapped root. Access policy problems therefore remain degraded instead + // of being mislabeled as a different IED/model. + const bool all_readable_roots_structurally_incompatible = + successful_read_root_count != 0U && + health.mapped_root_count == 0U && + health.structural_mapping_failure_count == successful_read_root_count && + health.access_failure_count == 0U && + health.skipped_root_count == 0U; + + if (no_expected_domain_online || all_readable_roots_structurally_incompatible) { + health.kind = MmsSclSynchronizationHealthKind::incompatible; + return health; + } + + const bool has_degradation = + health.missing_domain_count != 0U || + health.extra_domain_count != 0U || + health.skipped_root_count != 0U || + health.access_failure_count != 0U || + health.structural_mapping_failure_count != 0U; + health.kind = has_degradation + ? MmsSclSynchronizationHealthKind::degraded + : MmsSclSynchronizationHealthKind::matched; + return health; + } + + static void require_compatible(const MmsSclSynchronizationHealth& health) { + if (health.incompatible()) { + throw MmsSclAssistedConnectError( + health.summary() + + " Trusted-SCL synchronization stopped; explicit live discovery is required to inspect a different online model."); + } + } +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/services.hpp b/include/ariec61850/mms/services.hpp index 074c9d516..52bd29aa2 100644 --- a/include/ariec61850/mms/services.hpp +++ b/include/ariec61850/mms/services.hpp @@ -89,6 +89,12 @@ struct MmsTypeSpecification final { std::optional exponent_width; std::vector children; + // MMS TypeDescription uses a negative size to express a maximum + // variable-length string/octet bound (for example VisibleString -255). + // Keep the public magnitude unsigned and carry the sign semantic + // explicitly so fixed-width integer/bit-string users remain unchanged. + bool variable_length{}; + [[nodiscard]] std::string mms_type_name() const; [[nodiscard]] std::string scl_basic_type() const; [[nodiscard]] std::string signature() const; diff --git a/include/ariec61850/mms/services_span.hpp b/include/ariec61850/mms/services_span.hpp index 2f6d172da..41ac7d540 100644 --- a/include/ariec61850/mms/services_span.hpp +++ b/include/ariec61850/mms/services_span.hpp @@ -139,7 +139,12 @@ class MmsServiceSpanCodec final { public: static constexpr std::size_t maximum_identifier_bytes = 1'024U; static constexpr std::size_t maximum_identifiers = 128U; - static constexpr std::size_t maximum_variables = 64U; + + // IEDScout performs bounded bulk reads while walking a full IEC 61850 model. + // The proven ARIEC61850 trace for the SIPROTEC reference model contains a + // single 78-variable Read request. Keep the portable span profile bounded, + // but leave enough headroom for that real discovery pattern. + static constexpr std::size_t maximum_variables = 128U; [[nodiscard]] static bool try_decode_object_name_view( std::span encoded_object_name, @@ -151,15 +156,17 @@ class MmsServiceSpanCodec final { [[nodiscard]] static bool try_decode_get_name_list_request( std::span mms_pdu, MmsGetNameListRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_get_name_list_response( const MmsConfirmedPduView& confirmed, MmsGetNameListResponseView& response) noexcept; [[nodiscard]] static bool try_decode_get_name_list_response( std::span mms_pdu, MmsGetNameListResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_get_name_list_response_into( std::uint32_t invoke_id, - std::span names, + std::span identifiers, bool more_follows, std::span destination) noexcept; @@ -169,13 +176,16 @@ class MmsServiceSpanCodec final { [[nodiscard]] static bool try_decode_variable_access_attributes_request( std::span mms_pdu, MmsVariableAccessAttributesRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_variable_access_attributes_response( const MmsConfirmedPduView& confirmed, MmsVariableAccessAttributesResponseView& response) noexcept; [[nodiscard]] static bool try_decode_variable_access_attributes_response( std::span mms_pdu, MmsVariableAccessAttributesResponseView& response) noexcept; - [[nodiscard]] static wire::EncodeResult encode_variable_access_attributes_response_into( + + [[nodiscard]] static wire::EncodeResult + encode_variable_access_attributes_response_into( std::uint32_t invoke_id, bool mms_deletable, std::span encoded_type_specification, @@ -187,12 +197,14 @@ class MmsServiceSpanCodec final { [[nodiscard]] static bool try_decode_read_request( std::span mms_pdu, MmsReadRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_read_response( const MmsConfirmedPduView& confirmed, MmsReadResponseView& response) noexcept; [[nodiscard]] static bool try_decode_read_response( std::span mms_pdu, MmsReadResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_read_response_into( std::uint32_t invoke_id, std::span results, @@ -204,12 +216,14 @@ class MmsServiceSpanCodec final { [[nodiscard]] static bool try_decode_write_request( std::span mms_pdu, MmsWriteRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_write_response( const MmsConfirmedPduView& confirmed, MmsWriteResponseView& response) noexcept; [[nodiscard]] static bool try_decode_write_response( std::span mms_pdu, MmsWriteResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_write_response_into( std::uint32_t invoke_id, std::span results, diff --git a/include/ariec61850/mms/simulator_manifest_codec.hpp b/include/ariec61850/mms/simulator_manifest_codec.hpp new file mode 100644 index 000000000..fbeec8b8d --- /dev/null +++ b/include/ariec61850/mms/simulator_manifest_codec.hpp @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +// Host-side bridge from the SCL bType text carried by the simulator manifest +// to exact MMS TypeSpecification/Data values. This deliberately lives beside +// the protocol types instead of in the Qt shell so every desktop simulator +// adapter uses the same wire mapping. +class MmsSimulatorManifestCodec final { +public: + [[nodiscard]] static MmsTypeSpecification type( + const std::string_view raw_type, + const std::string_view normalized_type, + std::string name = {}) { + const auto raw = normalized_token(raw_type); + const auto normalized = normalized_token(normalized_type); + + MmsTypeSpecification result; + result.name = std::move(name); + + // The SCL bType is the wire authority. The normalized/display type is + // UI metadata and may only be used as a fallback when bType is absent. + // This prevents names such as orCat/orIdent/sboTimeout/Test from being + // promoted to UTC-Time by an accidental display classifier. + if (raw == "QUALITY") { + result.kind = MmsTypeKind::bit_string; + result.size = 13U; + return result; + } + if (raw == "DBPOS" || raw == "TCMD" || raw == "CHECK") { + result.kind = MmsTypeKind::bit_string; + result.size = 2U; + return result; + } + if (raw == "TRGOPS") { + result.kind = MmsTypeKind::bit_string; + result.size = 6U; + return result; + } + if (raw == "OPTFLDS") { + result.kind = MmsTypeKind::bit_string; + result.size = 10U; + return result; + } + if (raw == "TIMESTAMP") { + result.kind = MmsTypeKind::utc_time; + return result; + } + if (raw == "ENTRYTIME") { + result.kind = MmsTypeKind::binary_time; + result.size = 6U; + return result; + } + if (raw == "ENUM") { + // OMICRON IEDScout advertises IEC 61850 Enum leaves as INTEGER(8) + // for the captured SIPROTEC model (Mod/Beh/Health/ctlModel). + result.kind = MmsTypeKind::integer; + result.size = 8U; + return result; + } + if (raw == "BOOLEAN" || raw == "BOOL") { + result.kind = MmsTypeKind::boolean; + return result; + } + if (const auto width = signed_integer_width(raw); width != 0U) { + result.kind = MmsTypeKind::integer; + result.size = width; + return result; + } + if (const auto width = unsigned_integer_width(raw); width != 0U) { + result.kind = MmsTypeKind::unsigned_integer; + result.size = width; + return result; + } + if (raw == "FLOAT64") { + result.kind = MmsTypeKind::floating_point; + result.size = 64U; + result.exponent_width = 11U; + return result; + } + if (raw == "FLOAT32" || raw == "FLOAT") { + result.kind = MmsTypeKind::floating_point; + result.size = 32U; + result.exponent_width = 8U; + return result; + } + if (starts_with(raw, "VISSTRING")) { + result.kind = MmsTypeKind::visible_string; + result.size = suffix_width(raw, "VISSTRING", 255U); + result.variable_length = true; + return result; + } + if (starts_with(raw, "UNICODE") || starts_with(raw, "MMSSTRING")) { + result.kind = MmsTypeKind::mms_string; + result.size = starts_with(raw, "UNICODE") + ? suffix_width(raw, "UNICODE", 255U) + : suffix_width(raw, "MMSSTRING", 255U); + result.variable_length = true; + return result; + } + if (starts_with(raw, "OCTET")) { + result.kind = MmsTypeKind::octet_string; + result.size = suffix_width(raw, "OCTET", 64U); + result.variable_length = true; + return result; + } + if (raw == "OBJREF") { + result.kind = MmsTypeKind::visible_string; + result.size = 129U; + result.variable_length = true; + return result; + } + if (raw == "CURRENCY") { + result.kind = MmsTypeKind::visible_string; + result.size = 3U; + result.variable_length = true; + return result; + } + + if (normalized == "QUALITY") { + result.kind = MmsTypeKind::bit_string; + result.size = 13U; + return result; + } + if (normalized == "TIMESTAMP") { + result.kind = MmsTypeKind::utc_time; + return result; + } + if (normalized == "ENUMERATION") { + result.kind = MmsTypeKind::integer; + result.size = 8U; + return result; + } + if (normalized == "BOOLEAN") { + result.kind = MmsTypeKind::boolean; + return result; + } + if (normalized == "NUMBER") { + result.kind = MmsTypeKind::integer; + result.size = 32U; + return result; + } + + result.kind = MmsTypeKind::visible_string; + result.size = 255U; + result.variable_length = true; + return result; + } + + [[nodiscard]] static MmsDataValue data( + const MmsTypeSpecification& type, + const std::string_view raw_type, + const std::string_view normalized_type, + const std::string_view text) { + const auto raw = normalized_token(raw_type); + const auto normalized = normalized_token(normalized_type); + + switch (type.kind) { + case MmsTypeKind::boolean: + return MmsDataValue::boolean(text_boolean(text)); + case MmsTypeKind::bit_string: + if (normalized == "QUALITY" || raw == "QUALITY") { + return quality(text); + } + if (raw == "DBPOS" || raw == "TCMD") { + return coded_bit_string(2U, dbpos_value(text)); + } + return coded_bit_string(type.size.value_or(0U), unsigned_text(text)); + case MmsTypeKind::integer: + return MmsDataValue::integer(signed_text(text)); + case MmsTypeKind::unsigned_integer: + return MmsDataValue::unsigned_integer(unsigned_text(text)); + case MmsTypeKind::floating_point: + try { + return type.size.value_or(32U) == 64U + ? MmsDataValue::floating_point(std::stod(std::string{text})) + : MmsDataValue::floating_point(std::stof(std::string{text})); + } catch (...) { + return type.size.value_or(32U) == 64U + ? MmsDataValue::floating_point(0.0) + : MmsDataValue::floating_point(0.0F); + } + case MmsTypeKind::utc_time: + return MmsDataValue::utc_time(Iec61850UtcTime{ + timestamp(text), 0U}); + case MmsTypeKind::binary_time: { + const auto bytes = hex_bytes(text); + if (!bytes.empty()) return MmsDataValue::binary_time(bytes); + constexpr std::array zero{}; + return MmsDataValue::binary_time(zero); + } + case MmsTypeKind::octet_string: { + const auto bytes = hex_bytes(text); + if (!bytes.empty()) return MmsDataValue::octet_string(bytes); + return MmsDataValue::octet_string(std::span{}); + } + case MmsTypeKind::mms_string: + return MmsDataValue::mms_string(display_text(text)); + default: + return MmsDataValue::visible_string(display_text(text)); + } + } + +private: + [[nodiscard]] static std::string normalized_token(const std::string_view value) { + std::string result; + result.reserve(value.size()); + for (const auto ch : value) { + const auto byte = static_cast(ch); + if (std::isalnum(byte) != 0) { + result.push_back(static_cast(std::toupper(byte))); + } + } + return result; + } + + [[nodiscard]] static bool starts_with( + const std::string_view value, + const std::string_view prefix) noexcept { + return value.size() >= prefix.size() && value.substr(0U, prefix.size()) == prefix; + } + + [[nodiscard]] static std::uint32_t suffix_width( + const std::string_view value, + const std::string_view prefix, + const std::uint32_t fallback) noexcept { + if (!starts_with(value, prefix) || value.size() == prefix.size()) return fallback; + std::uint32_t width{}; + const auto suffix = value.substr(prefix.size()); + const auto result = std::from_chars(suffix.data(), suffix.data() + suffix.size(), width, 10); + return result.ec == std::errc{} && result.ptr == suffix.data() + suffix.size() && width != 0U + ? width + : fallback; + } + + [[nodiscard]] static std::uint32_t signed_integer_width( + const std::string_view raw) noexcept { + if (raw == "INT8") return 8U; + if (raw == "INT16") return 16U; + if (raw == "INT24") return 24U; + if (raw == "INT32") return 32U; + if (raw == "INT64") return 64U; + return 0U; + } + + [[nodiscard]] static std::uint32_t unsigned_integer_width( + const std::string_view raw) noexcept { + if (raw == "INT8U" || raw == "UINT8") return 8U; + if (raw == "INT16U" || raw == "UINT16") return 16U; + if (raw == "INT24U" || raw == "UINT24") return 24U; + if (raw == "INT32U" || raw == "UINT32") return 32U; + if (raw == "INT64U" || raw == "UINT64") return 64U; + return 0U; + } + + [[nodiscard]] static std::string trim_copy(const std::string_view value) { + std::size_t first{}; + while (first < value.size() && std::isspace(static_cast(value[first])) != 0) { + ++first; + } + std::size_t last = value.size(); + while (last > first && std::isspace(static_cast(value[last - 1U])) != 0) { + --last; + } + return std::string{value.substr(first, last - first)}; + } + + [[nodiscard]] static bool text_boolean(const std::string_view value) { + const auto token = normalized_token(value); + return token == "1" || token == "TRUE" || token == "ON" || token == "CLOSED"; + } + + [[nodiscard]] static std::int64_t signed_text(const std::string_view value) noexcept { + const auto token = normalized_token(value); + if (token == "INTERMEDIATESTATE") return 0; + if (token == "OFF" || token == "OPEN") return 1; + if (token == "ON" || token == "CLOSED") return 2; + if (token == "BADSTATE") return 3; + + const auto text = trim_copy(value); + if (text.empty()) return 0; + std::int64_t result{}; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), result, 10); + return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() ? result : 0; + } + + [[nodiscard]] static std::uint64_t unsigned_text(const std::string_view value) noexcept { + const auto text = trim_copy(value); + if (!text.empty() && text.front() != '-') { + std::uint64_t result{}; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), result, 10); + if (parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size()) return result; + } + const auto signed_value = signed_text(value); + return signed_value < 0 ? 0U : static_cast(signed_value); + } + + [[nodiscard]] static std::uint64_t dbpos_value(const std::string_view value) noexcept { + const auto token = normalized_token(value); + if (token == "OFF" || token == "OPEN") return 1U; + if (token == "ON" || token == "CLOSED") return 2U; + if (token == "BADSTATE") return 3U; + if (token == "INTERMEDIATESTATE") return 0U; + return unsigned_text(value) & 0x03U; + } + + [[nodiscard]] static MmsDataValue coded_bit_string( + const std::uint32_t bit_count, + const std::uint64_t value) { + if (bit_count == 0U || bit_count > 64U) { + return MmsDataValue::bit_string(0U, {}); + } + const auto byte_count = static_cast((bit_count + 7U) / 8U); + const auto unused = static_cast((8U - (bit_count % 8U)) % 8U); + std::vector bytes(byte_count, 0U); + for (std::uint32_t bit = 0U; bit < bit_count; ++bit) { + const auto source_shift = bit_count - 1U - bit; + if ((value & (std::uint64_t{1U} << source_shift)) == 0U) continue; + bytes[bit / 8U] |= static_cast(0x80U >> (bit % 8U)); + } + return MmsDataValue::bit_string(unused, bytes); + } + + static void set_named_bit( + std::array& bytes, + const std::uint32_t index) noexcept { + if (index >= 13U) return; + bytes[index / 8U] |= static_cast(0x80U >> (index % 8U)); + } + + [[nodiscard]] static MmsDataValue quality(const std::string_view value) { + const auto lower = [&] { + std::string result{value}; + std::transform(result.begin(), result.end(), result.begin(), [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return result; + }(); + + std::array bytes{}; + std::uint8_t validity{}; + if (lower.find("questionable") != std::string::npos) validity = 3U; + else if (lower.find("reserved") != std::string::npos) validity = 2U; + else if (lower.find("invalid") != std::string::npos) validity = 1U; + + if ((validity & 0x02U) != 0U) set_named_bit(bytes, 0U); + if ((validity & 0x01U) != 0U) set_named_bit(bytes, 1U); + const auto flag = [&](const std::string_view hyphenated, + const std::string_view compact, + const std::uint32_t bit) { + if (lower.find(hyphenated) != std::string::npos || + lower.find(compact) != std::string::npos) { + set_named_bit(bytes, bit); + } + }; + flag("overflow", "overflow", 2U); + flag("out-of-range", "outofrange", 3U); + flag("bad-reference", "badreference", 4U); + flag("oscillatory", "oscillatory", 5U); + flag("failure", "failure", 6U); + flag("old-data", "olddata", 7U); + flag("inconsistent", "inconsistent", 8U); + flag("inaccurate", "inaccurate", 9U); + flag("substituted", "substituted", 10U); + flag("test", "test", 11U); + flag("operator-blocked", "operatorblocked", 12U); + return MmsDataValue::bit_string(3U, bytes); + } + + [[nodiscard]] static std::chrono::system_clock::time_point timestamp( + const std::string_view value) noexcept { + auto text = trim_copy(value); + if (starts_with(text, "unix-ms:") || starts_with(text, "unix-ms=")) { + text.erase(0U, 8U); + } + std::int64_t milliseconds{}; + const auto parsed = std::from_chars( + text.data(), text.data() + text.size(), milliseconds, 10); + if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || milliseconds < 0) { + milliseconds = 0; + } + return std::chrono::system_clock::time_point{std::chrono::milliseconds{milliseconds}}; + } + + [[nodiscard]] static std::vector hex_bytes(const std::string_view value) { + auto text = trim_copy(value); + if (text.size() >= 4U && + (text.substr(0U, 4U) == "hex:" || text.substr(0U, 4U) == "HEX:")) { + text.erase(0U, 4U); + } + text.erase(std::remove_if(text.begin(), text.end(), [](const char ch) { + return ch == ':' || ch == '-' || std::isspace(static_cast(ch)) != 0; + }), text.end()); + if (text.empty() || (text.size() % 2U) != 0U) return {}; + + std::vector bytes; + bytes.reserve(text.size() / 2U); + for (std::size_t offset = 0U; offset < text.size(); offset += 2U) { + unsigned value_byte{}; + const auto pair = std::string_view{text}.substr(offset, 2U); + const auto parsed = std::from_chars( + pair.data(), pair.data() + pair.size(), value_byte, 16); + if (parsed.ec != std::errc{} || parsed.ptr != pair.data() + pair.size() || value_byte > 0xFFU) { + return {}; + } + bytes.push_back(static_cast(value_byte)); + } + return bytes; + } + + [[nodiscard]] static std::string display_text(const std::string_view value) { + const auto text = trim_copy(value); + return text == "---" ? std::string{} : text; + } +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_brcb_control.hpp b/include/ariec61850/mms/static_brcb_control.hpp index 946e8b884..19d2e98ef 100644 --- a/include/ariec61850/mms/static_brcb_control.hpp +++ b/include/ariec61850/mms/static_brcb_control.hpp @@ -76,6 +76,25 @@ class MmsStaticBrcbControl final { bool enabled, std::uint64_t now_ms) noexcept; + [[nodiscard]] MmsStaticBrcbControlStatus set_optional_fields( + const MmsStaticBrcbClientIdentity& client, + std::span optional_fields, + std::uint64_t now_ms) noexcept; + + [[nodiscard]] MmsStaticBrcbControlStatus set_trigger_options( + const MmsStaticBrcbClientIdentity& client, + std::uint8_t trigger_options, + std::uint64_t now_ms) noexcept; + + [[nodiscard]] MmsStaticBrcbControlStatus set_integrity_period( + const MmsStaticBrcbClientIdentity& client, + std::uint32_t integrity_period_ms, + std::uint64_t now_ms) noexcept; + + [[nodiscard]] MmsStaticBrcbControlStatus request_general_interrogation( + const MmsStaticBrcbClientIdentity& client, + std::uint64_t now_ms) noexcept; + [[nodiscard]] MmsStaticBrcbControlStatus replay_from( const MmsStaticBrcbClientIdentity& client, std::span entry_id, diff --git a/include/ariec61850/mms/static_brcb_objects.hpp b/include/ariec61850/mms/static_brcb_objects.hpp index 97c3594b1..1610c59e2 100644 --- a/include/ariec61850/mms/static_brcb_objects.hpp +++ b/include/ariec61850/mms/static_brcb_objects.hpp @@ -17,8 +17,15 @@ enum class MmsStaticBrcbAttribute : std::uint8_t { report_enabled, data_set, conf_revision, + optional_fields, + buffer_time, + sequence_number, + trigger_options, + integrity_period, + general_interrogation, purge_buffer, entry_id, + time_of_entry, reservation_time, owner, }; @@ -38,7 +45,10 @@ struct MmsStaticBrcbObjectContext final { // opaque association/Owner identity. class MmsStaticBrcbObjectBank final { public: - static constexpr std::size_t attributes_per_control_block = 8U; + // Golden IEDScout-compatible BRCB facade: + // RptID,RptEna,DatSet,ConfRev,OptFlds,BufTm,SqNum,TrgOps,IntgPd,GI, + // PurgeBuf,EntryID,TimeofEntry,ResvTms,Owner. + static constexpr std::size_t attributes_per_control_block = 15U; MmsStaticBrcbObjectBank( const MmsStaticBrcbDefinition& definition, diff --git a/include/ariec61850/mms/static_brcb_runtime.hpp b/include/ariec61850/mms/static_brcb_runtime.hpp index bb43ce04e..ea624d51f 100644 --- a/include/ariec61850/mms/static_brcb_runtime.hpp +++ b/include/ariec61850/mms/static_brcb_runtime.hpp @@ -41,6 +41,13 @@ enum class MmsStaticBrcbEventReason : std::uint8_t { data_update, }; +enum class MmsStaticBrcbCaptureReason : std::uint8_t { + none, + event, + general_interrogation, + integrity, +}; + struct MmsStaticBrcbDefinition final { std::string_view domain; std::string_view item; @@ -52,11 +59,14 @@ struct MmsStaticBrcbDefinition final { optional_fields{}; std::uint32_t buffer_time_ms{}; std::uint8_t trigger_options{}; + std::uint32_t integrity_period_ms{}; }; struct MmsStaticBrcbSlot final { std::span storage{}; std::array entry_id{}; + std::array + time_of_entry{}; std::size_t bytes{}; std::uint8_t sequence_number{}; bool buffer_overflow{}; @@ -74,9 +84,12 @@ struct MmsStaticBrcbPendingState final { struct MmsStaticBrcbCapturePlan final { std::uint32_t pending_revision{}; + std::uint32_t schedule_revision{}; std::uint32_t queue_revision{}; std::uint64_t entry_number{}; + std::uint64_t observed_now_ms{}; std::uint8_t sequence_number{}; + MmsStaticBrcbCaptureReason reason{MmsStaticBrcbCaptureReason::none}; bool buffer_overflow{}; }; @@ -101,6 +114,8 @@ struct MmsStaticBrcbEntryView final { class MmsStaticBrcbRuntime final { public: + static constexpr std::uint32_t minimum_integrity_period_ms = 100U; + MmsStaticBrcbRuntime( const MmsStaticBrcbDefinition& definition, MmsStaticBrcbPendingState& pending, @@ -114,16 +129,39 @@ class MmsStaticBrcbRuntime final { [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } [[nodiscard]] MmsStaticBrcbStatus set_enabled(bool enabled) noexcept; + [[nodiscard]] MmsStaticBrcbStatus set_optional_fields( + std::span optional_fields) noexcept; + [[nodiscard]] constexpr const auto& optional_fields() const noexcept { + return optional_fields_; + } + [[nodiscard]] MmsStaticBrcbStatus set_trigger_options( + std::uint8_t trigger_options) noexcept; + [[nodiscard]] constexpr std::uint8_t trigger_options() const noexcept { + return trigger_options_; + } + [[nodiscard]] MmsStaticBrcbStatus set_integrity_period( + std::uint32_t integrity_period_ms) noexcept; + [[nodiscard]] constexpr std::uint32_t integrity_period_ms() const noexcept { + return integrity_period_ms_; + } [[nodiscard]] constexpr bool enabled() const noexcept { return enabled_; } + [[nodiscard]] constexpr std::uint8_t sequence_number() const noexcept { + return sequence_number_; + } + [[nodiscard]] constexpr bool general_interrogation_pending() const noexcept { + return general_interrogation_pending_; + } [[nodiscard]] MmsStaticBrcbStatus notify( std::size_t data_set_member_index, MmsStaticBrcbEventReason reason, std::uint64_t now_ms) noexcept; + [[nodiscard]] MmsStaticBrcbStatus request_general_interrogation() noexcept; + [[nodiscard]] bool next_due( std::uint64_t now_ms, - MmsStaticBrcbCapturePlan& plan) const noexcept; + MmsStaticBrcbCapturePlan& plan) noexcept; [[nodiscard]] MmsStaticBrcbCaptureResult capture( const MmsStaticBrcbCapturePlan& plan, @@ -146,8 +184,6 @@ class MmsStaticBrcbRuntime final { [[nodiscard]] MmsStaticBrcbStatus purge_buffer() noexcept; - // EntryID attribute readback: all zero when no report is buffered, - // otherwise the newest retained report EntryID. [[nodiscard]] std::array latest_entry_id() const noexcept { std::array latest_time_of_entry() const noexcept { + std::array empty{}; + if (!initialized_ || slots_.empty() || count_ == 0U || + count_ > slots_.size() || head_ >= slots_.size()) { + return empty; + } + const auto physical = (head_ + count_ - 1U) % slots_.size(); + const auto& slot = slots_[physical]; + return slot.occupied ? slot.time_of_entry : empty; + } + [[nodiscard]] constexpr std::size_t queue_size() const noexcept { return delivery_offset_ <= count_ ? count_ - delivery_offset_ : 0U; } @@ -194,9 +243,17 @@ class MmsStaticBrcbRuntime final { std::size_t delivery_offset_{}; std::uint64_t next_entry_number_{1U}; std::uint64_t dropped_reports_{}; + std::uint64_t next_integrity_due_ms_{}; std::uint32_t queue_revision_{1U}; + std::uint32_t schedule_revision_{1U}; std::uint8_t sequence_number_{}; + std::array + optional_fields_{}; + std::uint8_t trigger_options_{}; + std::uint32_t integrity_period_ms_{}; bool replay_gap_{}; + bool general_interrogation_pending_{}; + bool integrity_armed_{}; bool enabled_{}; bool initialized_{}; }; diff --git a/include/ariec61850/mms/static_direct_control.hpp b/include/ariec61850/mms/static_direct_control.hpp index 49f0f3279..b214b761e 100644 --- a/include/ariec61850/mms/static_direct_control.hpp +++ b/include/ariec61850/mms/static_direct_control.hpp @@ -4,75 +4,158 @@ #include "ariec61850/mms/static_object_table.hpp" #include "ariec61850/wire/encode_result.hpp" +#include +#include #include #include #include +#include namespace ar::iec61850::mms { +enum class MmsStaticControlModel : std::uint8_t { + status_only = 0U, + direct_normal = 1U, + sbo_normal = 2U, + direct_enhanced = 3U, + sbo_enhanced = 4U, +}; + struct MmsStaticDirectBooleanOperate final { bool control_value{}; std::uint8_t origin_category{}; + std::array origin_identifier{}; + std::size_t origin_identifier_size{}; std::uint8_t control_number{}; + std::array timestamp{}; bool test{}; bool synchro_check{}; bool interlock_check{}; }; +// Process/selection state shared by every association serving the same command +// Data Object. The atomics are intentionally limited to cross-association facts; +// command correlation and diagnostics remain association-local below. +struct MmsStaticDirectBooleanSharedState final { + std::atomic value{}; + std::atomic selected_association_id{}; + std::atomic selection_deadline_ms{}; +}; + +// Per-association control state. One instance belongs to exactly one MMS +// association and must not be shared between workers. struct MmsStaticDirectBooleanControlState final { std::uint8_t value{}; std::uint8_t last_control_number{}; bool last_test{}; std::size_t accepted_operations{}; std::size_t rejected_operations{}; + + bool selected{}; + bool selected_with_value{}; + MmsStaticDirectBooleanOperate selected_command{}; + + bool pending_termination{}; + MmsStaticDirectBooleanOperate termination_command{}; }; using MmsStaticBooleanApplyCallback = bool (*)( void* context, bool value) noexcept; +using MmsStaticControlNowCallback = std::uint64_t (*)( + const void* context) noexcept; + struct MmsStaticDirectBooleanControlPolicy final { bool allow_test{true}; bool allow_synchro_check{}; bool allow_interlock_check{}; - std::uint32_t malformed_failure_code{7U}; // type-inconsistent - std::uint32_t invalid_value_failure_code{11U}; // object-value-invalid - std::uint32_t backend_failure_code{10U}; // object-non-existent/backend + std::uint32_t malformed_failure_code{7U}; // type-inconsistent + std::uint32_t invalid_value_failure_code{11U}; // object-value-invalid + std::uint32_t backend_failure_code{10U}; // object-non-existent/backend + std::uint32_t temporarily_unavailable_code{2U}; // temporarily-unavailable }; struct MmsStaticDirectBooleanControlBinding final { + // Legacy fields stay first so existing aggregate initializers remain source + // compatible with the original Direct-Normal vertical slice. MmsStaticDirectBooleanControlState* state{}; MmsStaticBooleanApplyCallback apply{}; void* apply_context{}; MmsStaticDirectBooleanControlPolicy policy{}; + + // Extended control semantics. + MmsStaticDirectBooleanSharedState* shared_state{}; + MmsStaticControlModel model{MmsStaticControlModel::direct_normal}; + std::uint64_t association_id{}; + std::string_view selection_reference{}; + std::uint64_t sbo_timeout_ms{10'000U}; + MmsStaticControlNowCallback now_ms{}; + const void* now_context{}; }; -// Decode one IEC 61850 Direct-with-normal-security Oper value with the exact -// ordered structure used by the live TypeSpecification exposed by this server: -// ctlVal, origin(orCat,orIdent), ctlNum, T, Test, Check. -// The parser is allocation-free and accepts only the bounded scalar shape. +// Decode one IEC 61850 boolean control structure. Oper/SBOw use the exact +// ordered shape ctlVal, origin(orCat,orIdent), ctlNum, T, Test, Check. Cancel +// uses the same correlation fields without Check. [[nodiscard]] bool try_decode_static_direct_boolean_operate( std::span encoded_data, MmsStaticDirectBooleanOperate& decoded) noexcept; +[[nodiscard]] bool try_decode_static_boolean_cancel( + std::span encoded_data, + MmsStaticDirectBooleanOperate& decoded) noexcept; + // Read callbacks suitable for MmsStaticObjectEntry. [[nodiscard]] wire::EncodeResult mms_static_direct_boolean_read_state( const void* context, std::span destination) noexcept; +// Backward-compatible Direct-Normal ctlModel reader. [[nodiscard]] wire::EncodeResult mms_static_direct_normal_read_ctl_model( const void* context, std::span destination) noexcept; +// Configured ctlModel reader. context must point to a binding. +[[nodiscard]] wire::EncodeResult mms_static_control_read_ctl_model( + const void* context, + std::span destination) noexcept; + +// SBO-normal selection is a Read service. A successful selection returns the +// selected-object reference as MMS VisibleString; contention returns an empty +// VisibleString without mutating the current owner. +[[nodiscard]] wire::EncodeResult mms_static_sbo_normal_read( + const void* context, + std::span destination) noexcept; + [[nodiscard]] wire::EncodeResult mms_static_control_read_unavailable( const void* context, std::span destination) noexcept; -// Write callback suitable for the CO$...$Oper MmsStaticObjectEntry. -// Test=true is acknowledged without changing the live output state. Check bits -// are denied by default until the application explicitly enables them. +// Backward-compatible regular Write callback for Direct-Normal Oper. [[nodiscard]] MmsStaticWriteResult mms_static_direct_boolean_write_oper( void* context, std::span encoded_data) noexcept; +// Association-aware service callbacks used by the simulator/server. They +// validate that the callback is executed for the binding's owning association. +[[nodiscard]] MmsStaticWriteResult mms_static_boolean_write_oper_contextual( + void* context, + std::span encoded_data, + const MmsStaticRequestAccessContext& access) noexcept; + +[[nodiscard]] MmsStaticWriteResult mms_static_boolean_write_sbow_contextual( + void* context, + std::span encoded_data, + const MmsStaticRequestAccessContext& access) noexcept; + +[[nodiscard]] MmsStaticWriteResult mms_static_boolean_write_cancel_contextual( + void* context, + std::span encoded_data, + const MmsStaticRequestAccessContext& access) noexcept; + +// Release an SBO reservation when an association closes. Safe to call for all +// control models; Direct models are a no-op apart from local state cleanup. +void mms_static_control_on_association_closed( + MmsStaticDirectBooleanControlBinding& binding) noexcept; + } // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_dispatcher.hpp b/include/ariec61850/mms/static_dispatcher.hpp index 4aaee27f1..09b32ddb8 100644 --- a/include/ariec61850/mms/static_dispatcher.hpp +++ b/include/ariec61850/mms/static_dispatcher.hpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace ar::iec61850::mms { @@ -23,7 +24,10 @@ enum class MmsStaticDispatchStatus : std::uint8_t { }; struct MmsStaticDispatchPolicy final { - std::size_t maximum_names_per_response{32U}; + // The measured IEDScout server discovery profile emits at most 100 + // identifiers per GetNameList page. Keep the portable default aligned with + // that profile; tests/embedded users may still choose a smaller bound. + std::size_t maximum_names_per_response{100U}; std::size_t maximum_write_variables{1U}; std::uint32_t missing_object_failure_code{10U}; std::uint32_t access_denied_failure_code{3U}; @@ -35,6 +39,11 @@ struct MmsStaticDispatchPolicy final { bool advertise_flattened_child_aliases{}; }; +struct MmsStaticDirectoryEntry final { + std::string_view domain; + std::string_view item; +}; + struct MmsStaticDispatchResult final { MmsStaticDispatchStatus status{MmsStaticDispatchStatus::malformed_request}; MmsWireConfirmedService service{MmsWireConfirmedService::unknown}; @@ -49,16 +58,46 @@ struct MmsStaticDispatchResult final { class MmsStaticApplicationDispatcher final { public: - explicit constexpr MmsStaticApplicationDispatcher( + explicit MmsStaticApplicationDispatcher( const MmsStaticObjectTable& objects, const MmsStaticDispatchPolicy policy = {}) noexcept - : objects_{objects}, policy_{policy} {} + : objects_{objects}, + policy_{policy}, + model_valid_{data_sets_.valid_against(objects_)} {} - constexpr MmsStaticApplicationDispatcher( + MmsStaticApplicationDispatcher( const MmsStaticObjectTable& objects, const MmsStaticDataSetTable& data_sets, const MmsStaticDispatchPolicy policy = {}) noexcept - : objects_{objects}, data_sets_{data_sets}, policy_{policy} {} + : objects_{objects}, + data_sets_{data_sets}, + policy_{policy}, + model_valid_{data_sets_.valid_against(objects_)} {} + + // directory must be unique, grouped by domain in the desired wire order, + // and remain alive for the dispatcher lifetime. Host IED-simulator profiles + // preserve SCL/IEDScout declaration order; embedded profiles may omit the + // directory and retain the fixed-buffer scan path without host-side heap + // requirements. + MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const std::span directory, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, + data_sets_{data_sets}, + directory_{directory}, + policy_{policy}, + model_valid_{data_sets_.valid_against(objects_)} {} + + MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const std::span directory, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, + directory_{directory}, + policy_{policy}, + model_valid_{data_sets_.valid_against(objects_)} {} [[nodiscard]] MmsStaticDispatchResult dispatch( std::span mms_request, @@ -83,7 +122,12 @@ class MmsStaticApplicationDispatcher final { private: const MmsStaticObjectTable& objects_; MmsStaticDataSetTable data_sets_{}; + std::span directory_{}; MmsStaticDispatchPolicy policy_{}; + // Structural object/DataSet validation is immutable for the dispatcher + // lifetime. Compute it once at construction so large SCL models do not pay + // the O(N^2) duplicate scan again for every MMS request. + bool model_valid_{}; }; } // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_object_table.hpp b/include/ariec61850/mms/static_object_table.hpp index 9852feb57..88ce422f5 100644 --- a/include/ariec61850/mms/static_object_table.hpp +++ b/include/ariec61850/mms/static_object_table.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -41,6 +42,13 @@ using MmsStaticContextualWriteCallback = MmsStaticWriteResult (*)( std::span encoded_data, const MmsStaticRequestAccessContext& access) noexcept; +enum class MmsStaticWriteSemantic : std::uint8_t { + ordinary, + rcb_configuration, + rcb_enable, + rcb_general_interrogation, +}; + struct MmsStaticObjectEntry final { std::string_view domain; std::string_view item; @@ -55,6 +63,18 @@ struct MmsStaticObjectEntry final { // A contextual callback takes precedence over the legacy write callback. MmsStaticContextualWriteCallback contextual_write{}; + // Optional semantic transaction metadata. Objects sharing a non-null + // group can be applied in a safe state-machine order while WriteResult + // entries remain aligned with the original MMS variable list. + const void* write_transaction_group{}; + MmsStaticWriteSemantic write_semantic{MmsStaticWriteSemantic::ordinary}; + + // Optional declaration/source order used when the dispatcher must + // synthesize an ancestor Structure from flattened descendants. Keeping + // this at the end preserves existing aggregate initializers. Unspecified + // entries retain the legacy lexical fallback. + std::size_t declaration_order{std::numeric_limits::max()}; + [[nodiscard]] constexpr bool writable() const noexcept { return write != nullptr || contextual_write != nullptr; } @@ -62,7 +82,11 @@ struct MmsStaticObjectEntry final { class MmsStaticObjectTable final { public: - static constexpr std::size_t maximum_objects = 128U; + // Validation ceiling only: the table owns a span and does not allocate this + // capacity. A production desktop simulator needs room for complete SCL + // models containing thousands of leaf objects; embedded users still choose + // the actual backing storage and therefore retain deterministic memory use. + static constexpr std::size_t maximum_objects = 8'192U; explicit constexpr MmsStaticObjectTable( const std::span objects) noexcept diff --git a/include/ariec61850/mms/static_setting_group.hpp b/include/ariec61850/mms/static_setting_group.hpp new file mode 100644 index 000000000..c9194ea86 --- /dev/null +++ b/include/ariec61850/mms/static_setting_group.hpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +using MmsStaticSettingGroupUtcCallback = bool (*)( + const void* context, + std::span destination) noexcept; + +struct MmsStaticSettingGroupSharedState final { + explicit MmsStaticSettingGroupSharedState( + const std::uint32_t number_of_groups, + const std::uint32_t active_group) noexcept + : number_of_setting_groups{number_of_groups}, + active_setting_group{active_group} {} + + [[nodiscard]] bool valid() const noexcept { + const auto active = active_setting_group.load(std::memory_order_acquire); + return number_of_setting_groups != 0U && + active != 0U && active <= number_of_setting_groups; + } + + std::uint32_t number_of_setting_groups{}; + std::atomic active_setting_group{}; + // Eight encoded IEC 61850 UTC-time octets packed big-endian. Zero means + // the simulator has no source evidence for a previous activation time. + std::atomic last_activation_time_be{}; + // ActSG changes are rare configuration operations. Contention is rejected + // immediately instead of blocking a protocol worker. + std::atomic_flag activation_in_progress = ATOMIC_FLAG_INIT; +}; + +struct MmsStaticSettingGroupDefinition final { + std::string_view domain; + std::string_view item; // canonical SGCB root, e.g. LLN0$SP$SGCB +}; + +enum class MmsStaticSettingGroupAttribute : std::uint8_t { + active_setting_group, + confirm_edit, + edit_setting_group, + last_activation_time, + number_of_setting_groups, +}; + +struct MmsStaticSettingGroupObjectContext final { + MmsStaticSettingGroupSharedState* state{}; + MmsStaticSettingGroupAttribute attribute{ + MmsStaticSettingGroupAttribute::active_setting_group}; + MmsStaticSettingGroupUtcCallback now_utc{}; + const void* now_context{}; +}; + +// Exact five-attribute SGCB facade used by engineering clients: +// ActSG,CnfEdit,EditSG,LActTm,NumOfSG. Full SE/EditSG/CnfEdit transactions are +// intentionally outside this runtime; only guarded ActSG activation is writable. +class MmsStaticSettingGroupObjectBank final { +public: + static constexpr std::size_t attributes_per_control_block = 5U; + + MmsStaticSettingGroupObjectBank( + const MmsStaticSettingGroupDefinition& definition, + MmsStaticSettingGroupSharedState& state, + std::span base_objects, + std::span object_storage, + std::span context_storage, + std::span name_storage, + MmsStaticSettingGroupUtcCallback now_utc = nullptr, + const void* now_context = nullptr) noexcept + : definition_{&definition}, + state_{&state}, + base_objects_{base_objects}, + object_storage_{object_storage}, + context_storage_{context_storage}, + name_storage_{name_storage}, + now_utc_{now_utc}, + now_context_{now_context} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] std::size_t required_object_capacity() const noexcept; + [[nodiscard]] constexpr std::size_t required_context_capacity() const noexcept { + return attributes_per_control_block; + } + [[nodiscard]] std::size_t required_name_bytes() const noexcept; + + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + [[nodiscard]] constexpr const MmsStaticObjectTable& table() const noexcept { + return table_; + } + +private: + const MmsStaticSettingGroupDefinition* definition_{}; + MmsStaticSettingGroupSharedState* state_{}; + std::span base_objects_{}; + std::span object_storage_{}; + std::span context_storage_{}; + std::span name_storage_{}; + MmsStaticSettingGroupUtcCallback now_utc_{}; + const void* now_context_{}; + MmsStaticObjectTable table_{std::span{}}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_objects.hpp b/include/ariec61850/mms/static_urcb_objects.hpp index ec3f1917c..09526875a 100644 --- a/include/ariec61850/mms/static_urcb_objects.hpp +++ b/include/ariec61850/mms/static_urcb_objects.hpp @@ -20,10 +20,11 @@ enum class MmsStaticUrcbAttribute : std::uint8_t { conf_revision, optional_fields, buffer_time, + sequence_number, trigger_options, integrity_period, general_interrogation, - sequence_number, + owner, }; struct MmsStaticUrcbObjectContext final { @@ -36,7 +37,10 @@ struct MmsStaticUrcbObjectContext final { class MmsStaticUrcbObjectBank final { public: - static constexpr std::size_t attributes_per_control_block = 11U; + // IEC 61850 / IEDScout-compatible URCB facade: + // RptID,RptEna,Resv,DatSet,ConfRev,OptFlds,BufTm,SqNum, + // TrgOps,IntgPd,GI,Owner. + static constexpr std::size_t attributes_per_control_block = 12U; MmsStaticUrcbObjectBank( MmsStaticUrcbRuntime& runtime, diff --git a/include/ariec61850/mms/static_urcb_runtime.hpp b/include/ariec61850/mms/static_urcb_runtime.hpp index 10a6a9b88..6ef2d082d 100644 --- a/include/ariec61850/mms/static_urcb_runtime.hpp +++ b/include/ariec61850/mms/static_urcb_runtime.hpp @@ -28,10 +28,19 @@ enum class MmsStaticUrcbStatus : std::uint8_t { report_encode_failed, }; +enum class MmsStaticUrcbEventReason : std::uint8_t { + data_change, + quality_change, + data_update, +}; + enum class MmsStaticUrcbReportReason : std::uint8_t { none = 0x00U, - general_interrogation = 0x08U, - integrity = 0x10U, + general_interrogation = 0x04U, + integrity = 0x08U, + data_update = 0x10U, + quality_change = 0x20U, + data_change = 0x40U, }; struct MmsStaticUrcbDefinition final { @@ -62,17 +71,22 @@ struct MmsStaticUrcbState final { std::size_t data_set_item_size{}; std::array optional_fields{}; + std::array + member_reason_masks{}; std::uint32_t conf_revision{1U}; std::uint32_t buffer_time_ms{}; std::uint32_t integrity_period_ms{}; std::uint64_t next_integrity_due_ms{}; + std::uint64_t event_due_ms{}; std::uint32_t revision{}; + std::size_t pending_member_count{}; std::uint8_t trigger_options{}; std::uint8_t sequence_number{}; bool enabled{}; bool reserved{}; bool general_interrogation_pending{}; bool integrity_armed{}; + bool event_pending{}; [[nodiscard]] std::string_view report_id() const noexcept { return {report_id_storage.data(), report_id_size}; @@ -110,7 +124,11 @@ struct MmsStaticUrcbEncodeResult final { class MmsStaticUrcbRuntime final { public: - static constexpr std::size_t maximum_control_blocks = 16U; + // Validation ceiling only. Storage remains caller-owned and therefore + // embedded targets can still choose a smaller deterministic backing span. + // 64 matches the bounded host-side indexed ReportControl compiler and is + // sufficient for the measured 34-instance golden IED model. + static constexpr std::size_t maximum_control_blocks = 64U; static constexpr std::uint32_t minimum_integrity_period_ms = 100U; MmsStaticUrcbRuntime( @@ -167,6 +185,14 @@ class MmsStaticUrcbRuntime final { [[nodiscard]] MmsStaticUrcbStatus request_general_interrogation( std::size_t index) noexcept; + // Event updates are coalesced per DataSet member during BufTm. Repeated + // reasons are ORed, and no heap allocation or per-RCB worker is required. + [[nodiscard]] MmsStaticUrcbStatus notify( + std::size_t index, + std::size_t data_set_member_index, + MmsStaticUrcbEventReason reason, + std::uint64_t now_ms) noexcept; + [[nodiscard]] bool next_due( std::uint64_t now_ms, MmsStaticUrcbEmissionPlan& plan) const noexcept; diff --git a/include/ariec61850/osi/cotp_tpkt_stream.hpp b/include/ariec61850/osi/cotp_tpkt_stream.hpp new file mode 100644 index 000000000..13b4c2163 --- /dev/null +++ b/include/ariec61850/osi/cotp_tpkt_stream.hpp @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { + +struct CotpTpktDataStreamPlan final { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + std::size_t required_bytes{}; +}; + +class CotpTpktDataStreamSpanCodec final { +public: + [[nodiscard]] static bool try_plan( + const std::size_t user_data_bytes, + const std::size_t negotiated_tpdu_size_bytes, + CotpTpktDataStreamPlan& plan) noexcept { + plan = {}; + if (negotiated_tpdu_size_bytes <= 3U || + negotiated_tpdu_size_bytes > TpktSpanCodec::maximum_payload_bytes) { + return false; + } + + plan.maximum_user_data = negotiated_tpdu_size_bytes - 3U; + plan.segment_count = user_data_bytes == 0U + ? 1U + : 1U + ((user_data_bytes - 1U) / plan.maximum_user_data); + constexpr std::size_t per_segment_overhead = + TpktSpanCodec::header_length + 3U; + if (plan.segment_count > + (std::numeric_limits::max() - user_data_bytes) / + per_segment_overhead) { + plan = {}; + return false; + } + plan.required_bytes = user_data_bytes + + plan.segment_count * per_segment_overhead; + return true; + } + + // Encode one TSDU as one or more complete TPKT frames. Each frame contains + // one COTP Data TPDU whose total TPDU size never exceeds the negotiated C0 + // value. EOT is clear on intermediate segments and set only on the final + // segment. No heap allocation or hidden queue is used. + [[nodiscard]] static wire::EncodeResult encode_into( + const std::span user_data, + const std::size_t negotiated_tpdu_size_bytes, + const std::span destination) noexcept { + CotpTpktDataStreamPlan plan; + if (!try_plan(user_data.size(), negotiated_tpdu_size_bytes, plan)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < plan.required_bytes) { + return {wire::EncodeStatus::buffer_too_small, 0U, plan.required_bytes}; + } + + std::size_t input_offset{}; + std::size_t output_offset{}; + for (std::size_t segment = 0U; segment < plan.segment_count; ++segment) { + const auto remaining = user_data.size() - input_offset; + const auto chunk = std::min(remaining, plan.maximum_user_data); + const bool final_segment = segment + 1U == plan.segment_count; + const auto frame_bytes = TpktSpanCodec::header_length + 3U + chunk; + if (frame_bytes > TpktSpanCodec::maximum_frame_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, plan.required_bytes}; + } + + auto frame = destination.subspan(output_offset, frame_bytes); + const auto cotp = CotpSpanCodec::encode_data_into( + user_data.subspan(input_offset, chunk), + frame.subspan(TpktSpanCodec::header_length), + final_segment, + 0U); + if (!cotp.success() || cotp.bytes_written != chunk + 3U) { + return {wire::EncodeStatus::value_out_of_range, 0U, plan.required_bytes}; + } + + frame[0] = TpktSpanCodec::supported_version; + frame[1] = 0x00U; + frame[2] = static_cast((frame_bytes >> 8U) & 0xFFU); + frame[3] = static_cast(frame_bytes & 0xFFU); + input_offset += chunk; + output_offset += frame_bytes; + } + + if (input_offset != user_data.size() || output_offset != plan.required_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, plan.required_bytes}; + } + return {wire::EncodeStatus::ok, plan.required_bytes, plan.required_bytes}; + } +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/scl/exporter.hpp b/include/ariec61850/scl/exporter.hpp new file mode 100644 index 000000000..47519ba56 --- /dev/null +++ b/include/ariec61850/scl/exporter.hpp @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/scl/exporter_part_00.inc" +#include "ariec61850/scl/exporter_part_01.inc" +#include "ariec61850/scl/exporter_part_02.inc" diff --git a/include/ariec61850/scl/exporter_part_00.inc b/include/ariec61850/scl/exporter_part_00.inc new file mode 100644 index 000000000..d4378d836 --- /dev/null +++ b/include/ariec61850/scl/exporter_part_00.inc @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::scl { + +enum class SclExportProfile { + scd, + icd, + cid, +}; + +struct SclCanonicalExportOptions final { + SclEdition edition{SclEdition::unknown}; + SclExportProfile profile{SclExportProfile::scd}; +}; + +struct SclCanonicalExportResult final { + bool success{}; + std::string xml; + std::string error; + std::vector warnings; +}; + +namespace exporter_detail { + +inline std::string xml_escape(const std::string_view value) { + std::string escaped; + escaped.reserve(value.size()); + for (const char ch : value) { + switch (ch) { + case '&': escaped += "&"; break; + case '<': escaped += "<"; break; + case '>': escaped += ">"; break; + case '\"': escaped += """; break; + case '\'': escaped += "'"; break; + default: escaped.push_back(ch); break; + } + } + return escaped; +} + +inline std::string safe_id(const std::string_view value) { + std::string result; + result.reserve(value.size() + 4U); + for (const char ch : value) { + const auto byte = static_cast(ch); + if (std::isalnum(byte) != 0 || ch == '_') { + result.push_back(ch); + } else { + result.push_back('_'); + } + } + if (result.empty()) result = "X"; + const auto first = static_cast(result.front()); + if (std::isdigit(first) != 0) result.insert(result.begin(), 'X'); + return result; +} + +inline std::vector split_path(const std::string_view value) { + std::vector result; + std::size_t offset{}; + while (offset <= value.size()) { + const auto end = value.find('.', offset); + const auto count = end == std::string_view::npos ? value.size() - offset : end - offset; + const auto token = value.substr(offset, count); + if (!token.empty()) result.emplace_back(token); + if (end == std::string_view::npos) break; + offset = end + 1U; + } + return result; +} + +inline bool same_text(const std::string_view left, const std::string_view right) { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + const auto lhs = static_cast(left[index]); + const auto rhs = static_cast(right[index]); + if (std::tolower(lhs) != std::tolower(rhs)) return false; + } + return true; +} + +inline std::string bool_text(const bool value) { + return value ? "true" : "false"; +} + +inline std::string edition_attributes(const SclEdition edition) { + switch (edition) { + case SclEdition::edition1: + return " xmlns=\"http://www.iec.ch/61850/2003/SCL\" version=\"2003\" revision=\"A\""; + case SclEdition::edition2: + return " xmlns=\"http://www.iec.ch/61850/2003/SCL\" version=\"2007\" revision=\"B\""; + case SclEdition::edition21: + return " xmlns=\"http://www.iec.ch/61850/2003/SCL\" version=\"2007\" revision=\"B\" release=\"4\""; + case SclEdition::unknown: + break; + } + return {}; +} + +inline std::string profile_name(const SclExportProfile profile) { + switch (profile) { + case SclExportProfile::scd: return "SCD"; + case SclExportProfile::icd: return "ICD"; + case SclExportProfile::cid: return "CID"; + } + return "SCL"; +} + +inline std::string ln_key(const SclLogicalNode& ln) { + return ln.ied_name + "|" + ln.ld_inst + "|" + ln.prefix + "|" + ln.ln_class + "|" + ln.ln_inst; +} + +inline std::string ln_type_id(const SclLogicalNode& ln) { + return "AR_LNT_" + safe_id(ln.ied_name + "_" + ln.ld_inst + "_" + ln.name); +} + +inline bool entry_matches_ln(const SclDataSetEntry& entry, const SclLogicalNode& ln) { + return entry.ied_name == ln.ied_name && entry.ld_inst == ln.ld_inst && + entry.prefix == ln.prefix && entry.ln_class == ln.ln_class && + entry.ln_inst == ln.ln_inst; +} + +struct DaNode final { + std::string name; + std::string functional_constraint; + std::string basic_type; + std::string type_id; + std::string enum_type; + bool leaf{}; + std::vector children; +}; + +struct DoNode final { + std::string name; + std::string cdc; + std::vector children; + std::vector attributes; +}; + +struct LnTypeModel final { + const SclLogicalNode* logical_node{}; + std::vector data_objects; +}; + +template +inline Node& find_or_add(std::vector& nodes, const std::string& name) { + const auto found = std::find_if(nodes.begin(), nodes.end(), [&name](const Node& node) { + return node.name == name; + }); + if (found != nodes.end()) return *found; + nodes.push_back(Node{}); + nodes.back().name = name; + return nodes.back(); +} + +inline std::string do_type_id( + const SclLogicalNode& ln, + const std::string_view path) { + return "AR_DOT_" + safe_id(ln.ied_name + "_" + ln.ld_inst + "_" + ln.name + "_" + std::string{path}); +} + +inline std::string da_type_id( + const SclLogicalNode& ln, + const std::string_view do_path, + const std::string_view da_path) { + return "AR_DAT_" + safe_id( + ln.ied_name + "_" + ln.ld_inst + "_" + ln.name + "_" + std::string{do_path} + "_" + std::string{da_path}); +} + +inline void add_model_entry( + LnTypeModel& model, + const SclDataSetEntry& entry) { + const auto do_segments = split_path(entry.do_name); + const auto da_segments = split_path(entry.da_name); + if (do_segments.empty() || da_segments.empty()) return; + + DoNode* current_do{}; + for (const auto& segment : do_segments) { + if (current_do == nullptr) { + auto& node = find_or_add(model.data_objects, segment); + if (node.cdc.empty()) node.cdc = entry.cdc; + current_do = &node; + } else { + auto& node = find_or_add(current_do->children, segment); + if (node.cdc.empty()) node.cdc = entry.cdc; + current_do = &node; + } + } + if (current_do == nullptr) return; + + DaNode* current_da{}; + for (std::size_t index = 0U; index < da_segments.size(); ++index) { + const auto& segment = da_segments[index]; + if (current_da == nullptr) { + auto& node = find_or_add(current_do->attributes, segment); + if (node.functional_constraint.empty()) node.functional_constraint = entry.functional_constraint; + current_da = &node; + } else { + auto& node = find_or_add(current_da->children, segment); + current_da = &node; + } + if (index + 1U == da_segments.size()) { + current_da->leaf = true; + current_da->basic_type = entry.basic_type; + current_da->type_id = entry.type_id; + current_da->enum_type = entry.enum_type; + } + } +} + +inline std::vector build_ln_models(const SclDocument& document) { + std::vector models; + models.reserve(document.logical_nodes.size()); + for (const auto& logical_node : document.logical_nodes) { + LnTypeModel model; + model.logical_node = &logical_node; diff --git a/include/ariec61850/scl/exporter_part_01.inc b/include/ariec61850/scl/exporter_part_01.inc new file mode 100644 index 000000000..024834fd6 --- /dev/null +++ b/include/ariec61850/scl/exporter_part_01.inc @@ -0,0 +1,230 @@ + for (const auto& entry : document.model_entries) { + if (entry_matches_ln(entry, logical_node)) add_model_entry(model, entry); + } + models.push_back(std::move(model)); + } + return models; +} + +inline bool validate_model( + const SclDocument& document, + const SclCanonicalExportOptions& options, + std::string& error) { + if (options.edition == SclEdition::unknown) { + error = "Canonical SCL export requires an explicit supported edition."; + return false; + } + if (document.ieds.empty()) { + error = "Canonical SCL export requires at least one IED."; + return false; + } + if (document.logical_nodes.empty()) { + error = "Canonical SCL export requires at least one logical node."; + return false; + } + if (document.model_entries.empty()) { + error = "Canonical SCL export requires a resolved DataTypeTemplates leaf model."; + return false; + } + if ((options.profile == SclExportProfile::icd || options.profile == SclExportProfile::cid) && + document.ieds.size() != 1U) { + error = profile_name(options.profile) + " export requires exactly one modeled IED."; + return false; + } + for (const auto& conflict : document.conflicts) { + if (same_text(conflict.kind, "IED")) { + error = "Canonical SCL export rejected an ambiguous IED model: " + conflict.description; + return false; + } + } + for (const auto& entry : document.model_entries) { + if (entry.ied_name.empty() || entry.ld_inst.empty() || entry.ln_class.empty() || + entry.do_name.empty() || entry.da_name.empty() || + entry.functional_constraint.empty() || entry.cdc.empty() || entry.basic_type.empty()) { + error = "Canonical SCL export rejected an unresolved structural leaf: " + entry.signal_reference; + return false; + } + if (entry.do_name.find('.') != std::string::npos) { + error = "Canonical SCL export cannot reconstruct nested SDO parent CDC/type hierarchy for " + + entry.signal_reference + "; use exact source preservation for this document."; + return false; + } + if (same_text(entry.basic_type, "Enum")) { + const auto is_control_model = same_text(entry.da_name, "ctlModel") || + same_text(entry.enum_type, "CtlModelKind") || same_text(entry.type_id, "CtlModelKind"); + if (!is_control_model) { + error = "Canonical SCL export cannot reconstruct EnumType ordinals for " + entry.signal_reference + + "; only the modeled ctlModel domain is currently losslessly reconstructable."; + return false; + } + } + } + return true; +} + +inline std::string scalar_type_attribute(const DaNode& node) { + if (same_text(node.basic_type, "Enum")) { + const auto type = !node.enum_type.empty() ? node.enum_type : node.type_id; + return type.empty() ? std::string{} : " type=\"" + xml_escape(type) + "\""; + } + return node.type_id.empty() ? std::string{} : " type=\"" + xml_escape(node.type_id) + "\""; +} + +inline void write_da_type( + std::ostringstream& out, + const SclLogicalNode& ln, + const std::string& do_path, + const DaNode& parent, + const std::string& da_path, + const int indent) { + const auto id = da_type_id(ln, do_path, da_path); + out << std::string(static_cast(indent), ' ') + << "\n"; + for (const auto& child : parent.children) { + const auto child_path = da_path.empty() ? child.name : da_path + "." + child.name; + out << std::string(static_cast(indent + 2), ' ') + << "\n"; + } else { + out << " bType=\"" << xml_escape(child.basic_type) << "\"" + << scalar_type_attribute(child) << " />\n"; + } + } + out << std::string(static_cast(indent), ' ') << "\n"; + for (const auto& child : parent.children) { + if (!child.children.empty()) { + const auto child_path = da_path.empty() ? child.name : da_path + "." + child.name; + write_da_type(out, ln, do_path, child, child_path, indent); + } + } +} + +inline void write_do_type( + std::ostringstream& out, + const SclLogicalNode& ln, + const DoNode& node, + const std::string& path, + const int indent) { + out << std::string(static_cast(indent), ' ') + << "\n"; + for (const auto& child : node.children) { + const auto child_path = path.empty() ? child.name : path + "." + child.name; + out << std::string(static_cast(indent + 2), ' ') + << "\n"; + } + for (const auto& attribute : node.attributes) { + out << std::string(static_cast(indent + 2), ' ') + << "\n"; + } + out << std::string(static_cast(indent), ' ') << "\n"; + + for (const auto& child : node.children) { + const auto child_path = path.empty() ? child.name : path + "." + child.name; + write_do_type(out, ln, child, child_path, indent); + } + for (const auto& attribute : node.attributes) { + if (!attribute.children.empty()) write_da_type(out, ln, path, attribute, attribute.name, indent); + } +} + +inline void write_data_type_templates( + std::ostringstream& out, + const SclDocument& document, + const std::vector& models) { + out << " \n"; + for (const auto& model : models) { + const auto& ln = *model.logical_node; + out << " \n"; + for (const auto& data_object : model.data_objects) { + out << " \n"; + } + out << " \n"; + } + for (const auto& model : models) { + const auto& ln = *model.logical_node; + for (const auto& data_object : model.data_objects) { + write_do_type(out, ln, data_object, data_object.name, 4); + } + } + + std::vector control_model_enum_ids; + for (const auto& entry : document.model_entries) { + if (!same_text(entry.basic_type, "Enum") || + !(same_text(entry.da_name, "ctlModel") || same_text(entry.enum_type, "CtlModelKind") || + same_text(entry.type_id, "CtlModelKind"))) { + continue; + } + const auto id = !entry.enum_type.empty() ? entry.enum_type : + (!entry.type_id.empty() ? entry.type_id : std::string{"CtlModelKind"}); + if (std::find(control_model_enum_ids.begin(), control_model_enum_ids.end(), id) == + control_model_enum_ids.end()) { + control_model_enum_ids.push_back(id); + } + } + for (const auto& enum_id : control_model_enum_ids) { + out << " \n" + << " status-only\n" + << " direct-with-normal-security\n" + << " sbo-with-normal-security\n" + << " direct-with-enhanced-security\n" + << " sbo-with-enhanced-security\n" + << " \n"; + } + out << " \n"; +} + +inline void write_address( + std::ostringstream& out, + const SclStreamAddress& address, + const int indent) { + const auto spaces = std::string(static_cast(indent), ' '); + out << spaces << "
\n"; + if (!address.destination_mac_text.empty()) { + out << spaces << "

" + << xml_escape(address.destination_mac_text) << "

\n"; + } + if (!address.app_id_text.empty()) { + out << spaces << "

" << xml_escape(address.app_id_text) << "

\n"; + } + if (address.vlan_id) { + out << spaces << "

" << *address.vlan_id << "

\n"; + } + if (address.vlan_priority) { + out << spaces << "

" + << static_cast(*address.vlan_priority) << "

\n"; + } + out << spaces << "
\n"; +} + +inline void write_communication(std::ostringstream& out, const SclDocument& document) { + if (document.goose_streams.empty() && document.sampled_values_streams.empty()) return; + out << " \n" + << " \n"; + for (const auto& ied : document.ieds) { + bool has_stream = false; + for (const auto& stream : document.goose_streams) { + if (stream.ied_name == ied.name) { has_stream = true; break; } + } + if (!has_stream) { + for (const auto& stream : document.sampled_values_streams) { + if (stream.ied_name == ied.name) { has_stream = true; break; } + } + } + if (!has_stream) continue; + out << " \n"; + for (const auto& stream : document.goose_streams) { + if (stream.ied_name != ied.name) continue; diff --git a/include/ariec61850/scl/exporter_part_02.inc b/include/ariec61850/scl/exporter_part_02.inc new file mode 100644 index 000000000..cfb65e8d2 --- /dev/null +++ b/include/ariec61850/scl/exporter_part_02.inc @@ -0,0 +1,234 @@ + out << " \n"; + write_address(out, stream.address, 10); + out << " \n"; + } + for (const auto& stream : document.sampled_values_streams) { + if (stream.ied_name != ied.name) continue; + out << " \n"; + write_address(out, stream.address, 10); + out << " \n"; + } + out << " \n"; + } + out << " \n" + << " \n"; +} + +inline void write_fcda(std::ostringstream& out, const SclDataSetEntry& entry) { + out << " \n"; +} + +inline void write_data_sets( + std::ostringstream& out, + const SclDocument& document, + const SclLogicalNode& ln) { + for (const auto& data_set : document.data_sets) { + if (data_set.ied_name != ln.ied_name || data_set.ld_inst != ln.ld_inst || + data_set.logical_node_path != ln.name) continue; + out << " \n"; + for (const auto& entry : data_set.entries) write_fcda(out, entry); + out << " \n"; + } +} + +inline void write_reports( + std::ostringstream& out, + const SclDocument& document, + const SclLogicalNode& ln) { + for (const auto& report : document.report_controls) { + if (report.ied_name != ln.ied_name || report.ld_inst != ln.ld_inst || + report.logical_node_path != ln.name) continue; + out << " \n"; + } +} + +inline void write_goose_and_smv( + std::ostringstream& out, + const SclDocument& document, + const SclLogicalNode& ln) { + if (!same_text(ln.ln_class, "LLN0")) return; + for (const auto& stream : document.goose_streams) { + if (stream.ied_name != ln.ied_name || stream.ld_inst != ln.ld_inst) continue; + out << " \n"; + } + for (const auto& stream : document.sampled_values_streams) { + if (stream.ied_name != ln.ied_name || stream.ld_inst != ln.ld_inst) continue; + out << " \n"; + } else { + out << " multicast=\"true\">\n" + << " \n" + << " \n"; + } + } +} + +struct InstanceNode final { + std::map> children; + std::map> values; +}; + +inline void write_instance_node( + std::ostringstream& out, + const InstanceNode& node, + const int indent, + const bool top_level) { + for (const auto& [name, child] : node.children) { + const auto spaces = std::string(static_cast(indent), ' '); + out << spaces << '<' << (top_level ? "DOI" : "SDI") << " name=\"" + << xml_escape(name) << "\">\n"; + for (const auto& [da_name, value] : child.values) { + out << spaces << " " + << xml_escape(value) << "\n"; + } + write_instance_node(out, child, indent + 2, false); + out << spaces << "\n"; + } +} + +inline void write_configured_values( + std::ostringstream& out, + const SclDocument& document, + const SclLogicalNode& ln) { + InstanceNode root; + for (const auto& entry : document.model_entries) { + if (!entry_matches_ln(entry, ln) || entry.configured_value.empty()) continue; + const auto do_segments = split_path(entry.do_name); + const auto da_segments = split_path(entry.da_name); + if (do_segments.empty() || da_segments.size() != 1U) continue; + auto* node = &root; + for (const auto& segment : do_segments) node = &node->children[segment]; + node->values[da_segments.front()] = entry.configured_value; + } + write_instance_node(out, root, 12, true); +} + +inline void write_logical_node( + std::ostringstream& out, + const SclDocument& document, + const SclLogicalNode& ln) { + const auto zero = same_text(ln.ln_class, "LLN0"); + out << " <" << (zero ? "LN0" : "LN"); + if (!zero && !ln.prefix.empty()) out << " prefix=\"" << xml_escape(ln.prefix) << "\""; + out << " lnClass=\"" << xml_escape(ln.ln_class) << "\""; + if (!zero && !ln.ln_inst.empty()) out << " inst=\"" << xml_escape(ln.ln_inst) << "\""; + out << " lnType=\"" << xml_escape(ln_type_id(ln)) << "\">\n"; + write_data_sets(out, document, ln); + write_goose_and_smv(out, document, ln); + write_reports(out, document, ln); + write_configured_values(out, document, ln); + out << " \n"; +} + +inline void write_ieds(std::ostringstream& out, const SclDocument& document) { + for (const auto& ied : document.ieds) { + out << " \n \n \n"; + + std::vector ld_order; + for (const auto& ln : document.logical_nodes) { + if (ln.ied_name != ied.name) continue; + if (std::find(ld_order.begin(), ld_order.end(), ln.ld_inst) == ld_order.end()) { + ld_order.push_back(ln.ld_inst); + } + } + for (const auto& ld_inst : ld_order) { + out << " \n"; + for (const auto& ln : document.logical_nodes) { + if (ln.ied_name == ied.name && ln.ld_inst == ld_inst) { + write_logical_node(out, document, ln); + } + } + out << " \n"; + } + out << " \n \n \n"; + } +} + +} // namespace exporter_detail + +class SclExporter final { +public: + [[nodiscard]] static SclCanonicalExportResult validate( + const SclDocument& document, + const SclCanonicalExportOptions& options) { + SclCanonicalExportResult result; + result.success = exporter_detail::validate_model(document, options, result.error); + return result; + } + + [[nodiscard]] static SclCanonicalExportResult canonical( + const SclDocument& document, + const SclCanonicalExportOptions& options) { + using namespace exporter_detail; + SclCanonicalExportResult result; + if (!validate_model(document, options, result.error)) return result; + + const auto models = build_ln_models(document); + std::ostringstream out; + out << "\n\n"; + out << "
\n"; + write_communication(out, document); + write_ieds(out, document); + write_data_type_templates(out, document, models); + out << "\n"; + + result.xml = out.str(); + result.success = true; + result.warnings.push_back( + "Canonical " + profile_name(options.profile) + + " export preserves the modeled ARStack semantic surface, not unknown vendor extension XML or unmodeled Services/Substation content."); + return result; + } +}; + +} // namespace ar::iec61850::scl diff --git a/include/ariec61850/scl/model.hpp b/include/ariec61850/scl/model.hpp index b1ecdd523..37f49e449 100644 --- a/include/ariec61850/scl/model.hpp +++ b/include/ariec61850/scl/model.hpp @@ -27,6 +27,49 @@ struct SclIed final { friend bool operator==(const SclIed&, const SclIed&) = default; }; +// MMS/OSI communication context carried by Communication/SubNetwork/ConnectedAP. +// The server-side selectors are kept separately from the client compatibility +// defaults because SCL describes the selected IED AccessPoint, not the local +// engineering station identity. +struct SclMmsAccessPoint final { + std::string ied_name; + std::string access_point_name; + std::string ip_address; + std::uint16_t tcp_port{102U}; + + // AP-title is stored as OID arcs rather than pre-encoded BER so the SCL + // model remains transport/codec independent. Empty means not supplied. + std::vector ap_title; + std::optional ae_qualifier; + std::vector p_selector; + std::vector s_selector; + std::vector t_selector; + + // A malformed explicitly supplied OSI field must not silently degrade to a + // generic association profile. Parser users can still inspect the SCL, while + // online consumers fail closed when selecting this AccessPoint. + bool association_parameters_present{}; + bool association_parameters_valid{true}; + std::string association_error; + + friend bool operator==(const SclMmsAccessPoint&, const SclMmsAccessPoint&) = default; +}; + +struct SclLogicalNode final { + std::string ied_name; + std::string ld_inst; + std::string prefix; + std::string ln_class; + std::string ln_inst; + std::string name; + + [[nodiscard]] std::string mms_domain() const { + return ied_name + ld_inst; + } + + friend bool operator==(const SclLogicalNode&, const SclLogicalNode&) = default; +}; + struct SclDataSetEntry final { std::size_t index{}; std::string signal_reference; @@ -45,6 +88,11 @@ struct SclDataSetEntry final { bool is_quality{}; bool is_timestamp{}; + // Instance-level DAI/Val value from SCL when one is explicitly configured. + // This is intentionally separate from runtime state: simulator adapters use it + // to seed configured semantics such as CF$...$ctlModel without inventing defaults. + std::string configured_value; + friend bool operator==(const SclDataSetEntry&, const SclDataSetEntry&) = default; }; @@ -55,8 +103,16 @@ struct SclDataSet final { std::string logical_node_path; std::string name; std::string reference; + + // Canonical SCL FCDA membership. A whole-DataObject FCDA intentionally keeps + // da_name empty and therefore remains one MMS DataSet member. std::vector entries; + // Ordered leaf projection used by payload-oriented profiles such as SV and + // the current GOOSE publisher path. This view may contain several entries + // for one configured whole-DataObject FCDA. + std::vector expanded_entries; + friend bool operator==(const SclDataSet&, const SclDataSet&) = default; }; @@ -139,6 +195,15 @@ struct SclReportControl final { std::string control_block_reference; bool buffered{}; bool indexed{true}; + + // IEC 61850 SCL ReportControl/RptEnabled@max. This is the maximum number + // of simultaneously addressable client instances represented by an indexed + // ReportControl. Keep the SCL definition count separate from the compiled + // runtime RCB instance count; an indexed definition with max=N exposes N + // concrete RCB object instances. Missing/zero max is normalized to one by + // the parser so downstream compilers never need to invent a value. + std::uint32_t max_clients{1U}; + std::uint32_t configuration_revision{}; std::uint32_t buffer_time_milliseconds{}; std::uint32_t integrity_period_milliseconds{}; @@ -147,6 +212,25 @@ struct SclReportControl final { friend bool operator==(const SclReportControl&, const SclReportControl&) = default; }; +struct SclSettingControl final { + std::string ied_name; + std::string ld_inst; + std::string logical_node_path; + std::string control_block_reference; + std::optional number_of_setting_groups; + std::optional active_setting_group; + + [[nodiscard]] bool valid() const noexcept { + return number_of_setting_groups.has_value() && + active_setting_group.has_value() && + *number_of_setting_groups != 0U && + *active_setting_group != 0U && + *active_setting_group <= *number_of_setting_groups; + } + + friend bool operator==(const SclSettingControl&, const SclSettingControl&) = default; +}; + struct SclConflict final { std::string kind; std::string key; @@ -163,10 +247,28 @@ struct SclDocument final { std::string header_revision; SclEdition edition{SclEdition::unknown}; std::vector ieds; + + // Communication/SubNetwork/ConnectedAP MMS endpoint and OSI association + // context. This stays in the canonical SCL model so desktop/client layers do + // not reparse XML or guess vendor-specific addressing independently. + std::vector mms_access_points; + + // Structural logical-node inventory from the same bounded parser used for + // all other SCL data. UI/server layers must consume this instead of + // reparsing the source XML independently. + std::vector logical_nodes; + + // Complete structural LD/LN/DO/DA(BDA) leaf projection derived from + // DataTypeTemplates. Unlike DataSet entries this inventory is not reduced + // to signals referenced by service bindings; simulator/server consumers + // use it as the authoritative SCL data-model leaf catalog. + std::vector model_entries; + std::vector data_sets; std::vector goose_streams; std::vector sampled_values_streams; std::vector report_controls; + std::vector setting_controls; std::vector warnings; std::vector conflicts; diff --git a/include/ariec61850/simulation/ied_simulator_engine.hpp b/include/ariec61850/simulation/ied_simulator_engine.hpp new file mode 100644 index 000000000..a4e98e471 --- /dev/null +++ b/include/ariec61850/simulation/ied_simulator_engine.hpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/simulation/ied_simulator_profile.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::simulation { + +struct IedSimulatorPointState final { + std::string reference; + std::string functional_constraint; + SimulatorPointKind kind{SimulatorPointKind::status}; + std::string unit; + std::string value; + std::string quality{"good"}; + std::string origin{"Simulator"}; + std::uint64_t timestamp_unix_milliseconds{}; + std::string reason{"init"}; + + friend bool operator==(const IedSimulatorPointState&, const IedSimulatorPointState&) = default; +}; + +struct IedSimulatorEvent final { + std::uint64_t timestamp_unix_milliseconds{}; + std::string reference; + std::string functional_constraint; + std::string previous_value; + std::string new_value; + std::string previous_quality; + std::string new_quality; + std::string reason; + + friend bool operator==(const IedSimulatorEvent&, const IedSimulatorEvent&) = default; +}; + +struct IedSimulatorSnapshot final { + std::uint64_t generated_at_unix_milliseconds{}; + std::string profile_name; + std::size_t logical_device_count{}; + std::size_t logical_node_count{}; + std::size_t point_count{}; + std::size_t data_set_count{}; + std::size_t report_control_block_count{}; + std::vector points; +}; + +class IedSimulatorEngine final { +public: + explicit IedSimulatorEngine(IedSimulatorProfile profile); + + [[nodiscard]] const IedSimulatorProfile& profile() const noexcept { return profile_; } + [[nodiscard]] bool running() const noexcept { return running_; } + [[nodiscard]] std::uint64_t step_index() const noexcept { return step_index_; } + [[nodiscard]] const std::vector& point_states() const noexcept { + return states_; + } + + void start() noexcept { running_ = true; } + void stop() noexcept { running_ = false; } + void reset(std::uint64_t timestamp_unix_milliseconds = 0U); + + [[nodiscard]] const IedSimulatorPointState* find_point_state( + const std::string& reference) const noexcept; + + [[nodiscard]] std::optional set_point_value( + const std::string& reference, + std::string value, + std::string quality, + std::string origin, + std::uint64_t timestamp_unix_milliseconds); + + [[nodiscard]] std::vector step( + std::uint64_t now_unix_milliseconds); + + [[nodiscard]] IedSimulatorSnapshot snapshot( + std::uint64_t now_unix_milliseconds) const; + +private: + struct CaseInsensitiveHash final { + [[nodiscard]] std::size_t operator()(const std::string& value) const noexcept; + }; + + struct CaseInsensitiveEqual final { + [[nodiscard]] bool operator()( + const std::string& left, + const std::string& right) const noexcept; + }; + + [[nodiscard]] static bool should_simulate(const IedSimulatorPoint& point) noexcept; + [[nodiscard]] std::string compute_value( + const IedSimulatorPoint& point, + double angle) const; + + void rebuild_index(); + + IedSimulatorProfile profile_; + std::vector points_; + std::vector states_; + std::unordered_map index_; + std::uint64_t step_index_{}; + bool running_{}; +}; + +} // namespace ar::iec61850::simulation diff --git a/include/ariec61850/simulation/ied_simulator_profile.hpp b/include/ariec61850/simulation/ied_simulator_profile.hpp new file mode 100644 index 000000000..a5b518c20 --- /dev/null +++ b/include/ariec61850/simulation/ied_simulator_profile.hpp @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::simulation { + +enum class SimulatorPointKind : std::uint8_t { + measurement, + status, + quality, + timestamp, +}; + +struct IedSimulatorPoint final { + std::string reference; + std::string relative_reference; + std::string ied_name; + std::string logical_device; + std::string logical_node; + std::string ln_class; + std::string data_object; + std::string data_attribute; + std::string functional_constraint; + std::string cdc; + std::string basic_type; + std::string enum_type; + std::string mms_domain; + std::string mms_item; + std::string display_type; + std::string unit; + std::string initial_value; + double base_value{}; + double amplitude{}; + double phase_degrees{}; + std::size_t source_order{}; + bool dynamic{}; + SimulatorPointKind kind{SimulatorPointKind::status}; + + friend bool operator==(const IedSimulatorPoint&, const IedSimulatorPoint&) = default; +}; + +struct IedSimulatorLogicalNode final { + std::string name; + std::string ln_class; + std::vector points; + + friend bool operator==(const IedSimulatorLogicalNode&, const IedSimulatorLogicalNode&) = default; +}; + +struct IedSimulatorLogicalDevice final { + std::string name; + std::string ld_inst; + std::vector logical_nodes; + + friend bool operator==(const IedSimulatorLogicalDevice&, const IedSimulatorLogicalDevice&) = default; +}; + +struct IedSimulatorDataSet final { + std::string reference; + std::string mms_domain; + std::string mms_item; + std::vector members; + + friend bool operator==(const IedSimulatorDataSet&, const IedSimulatorDataSet&) = default; +}; + +struct IedSimulatorReportControlBlock final { + std::string reference; + std::string mms_domain; + std::string mms_item; + bool buffered{}; + std::string data_set_reference; + std::string report_id; + std::uint32_t configuration_revision{1U}; + std::uint32_t buffer_time_milliseconds{}; + std::uint32_t integrity_period_milliseconds{}; + std::string trigger_options; + std::string optional_fields; + + [[nodiscard]] std::string mode() const; + + friend bool operator==(const IedSimulatorReportControlBlock&, const IedSimulatorReportControlBlock&) = default; +}; + +struct IedSimulatorProfile final { + std::string name{"AR Demo IED"}; + std::string vendor{"ARIEC61850"}; + std::string edition{"IEC 61850 Ed2-style lab profile"}; + std::vector logical_devices; + std::vector data_sets; + std::vector report_control_blocks; + + [[nodiscard]] std::size_t logical_node_count() const noexcept; + [[nodiscard]] std::size_t point_count() const noexcept; + [[nodiscard]] std::size_t data_set_member_count() const noexcept; + + [[nodiscard]] static IedSimulatorProfile create_default_feeder_profile(); +}; + +struct IedSimulatorProfileFromSclOptions final { + std::string ied_name; + std::string runtime_ied_name; + bool include_quality_and_timestamp_points{true}; + double nominal_frequency_hz{50.0}; + + // Zero captures system_clock once at profile-build time. Tests and replay + // harnesses can supply an exact epoch-millisecond seed so every default + // Timestamp in one compiled simulator model is deterministic and coherent. + std::uint64_t simulation_start_unix_ms{}; + + // Defensive host-side bound. The canonical SCL model preserves the exact + // RptEnabled@max value; the runtime profile compiler limits materialized + // client instances and emits a finding instead of accepting unbounded input. + std::uint32_t maximum_report_clients_per_definition{64U}; +}; + +struct IedSimulatorProfileFromSclResult final { + IedSimulatorProfile profile; + std::string selected_ied_name; + std::string source_ied_name; + std::size_t data_set_member_count{}; + std::size_t structural_data_attribute_count{}; + std::size_t skipped_member_count{}; + std::size_t report_control_definition_count{}; + std::size_t report_control_instance_count{}; + std::vector findings; +}; + +class IedSimulatorProfileBuilder final { +public: + [[nodiscard]] static IedSimulatorProfileFromSclResult build( + const scl::SclDocument& document, + const IedSimulatorProfileFromSclOptions& options = {}); +}; + +} // namespace ar::iec61850::simulation diff --git a/scripts/agent_apply_control_slice.py b/scripts/agent_apply_control_slice.py new file mode 100644 index 000000000..0881c5812 --- /dev/null +++ b/scripts/agent_apply_control_slice.py @@ -0,0 +1,837 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit( + f"{path}: expected exactly one anchor, found {count}: {old[:100]!r}" + ) + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def insert_before(path: str, anchor: str, addition: str) -> None: + replace_once(path, anchor, addition + anchor) + + +# 1) Preserve configured SCL instance values on structural leaves. +replace_once( + "include/ariec61850/scl/model.hpp", + " bool is_quality{};\n bool is_timestamp{};\n\n" + " friend bool operator==(const SclDataSetEntry&, const SclDataSetEntry&) = default;\n", + " bool is_quality{};\n bool is_timestamp{};\n\n" + " // Instance-level DAI/Val value from SCL when one is explicitly configured.\n" + " // This is intentionally separate from runtime state: simulator adapters use it\n" + " // to seed configured semantics such as CF$...$ctlModel without inventing defaults.\n" + " std::string configured_value;\n\n" + " friend bool operator==(const SclDataSetEntry&, const SclDataSetEntry&) = default;\n", +) + +replace_once( + "src/scl/parser_part_03_01.inc", + " return {true, resolved_fc, cdc, basic_type, current_type_id, enum_type};\n" + " }\n\nprivate:\n", + ''' return {true, resolved_fc, cdc, basic_type, current_type_id, enum_type}; + } + + // Return an explicitly configured instance value for a structural leaf. + // The first control slice needs the common DOI/DAI/Val form used for + // ctlModel. Structured BDA instance values remain runtime/profile work and + // deliberately do not fall back to a guessed template value. + [[nodiscard]] std::string configured_value( + const std::string& ied_name, + const std::string& ld_inst, + const std::string& prefix, + const std::string& ln_class, + const std::string& ln_inst, + const std::string& do_name, + const std::string& da_name) const { + const auto* logical_node = find_logical_node( + ied_name, ld_inst, prefix, ln_class, ln_inst); + if (logical_node == nullptr) return {}; + + const auto do_segments = split_name(do_name); + if (do_segments.empty()) return {}; + const XmlNode* instance = logical_node; + for (std::size_t index = 0U; index < do_segments.size(); ++index) { + const auto tag = index == 0U ? std::string_view{"DOI"} : std::string_view{"SDI"}; + const XmlNode* next{}; + for (const auto* candidate : direct_children(*instance, tag)) { + if (same(attribute(candidate, "name"), do_segments[index])) { + next = candidate; + break; + } + } + if (next == nullptr) return {}; + instance = next; + } + + const auto da_segments = split_name(da_name); + if (da_segments.size() != 1U) return {}; + for (const auto* dai : direct_children(*instance, "DAI")) { + if (!same(attribute(dai, "name"), da_segments.front())) continue; + const auto* value = first_child(*dai, "Val"); + return value == nullptr ? std::string{} : trim_copy(value->text); + } + return {}; + } + +private: +''', +) + +replace_once( + "src/scl/parser_part_05_00.inc", + " is_quality_attribute(leaf.da_name),\n" + " is_timestamp_attribute(leaf.da_name),\n" + " });\n", + ''' is_quality_attribute(leaf.da_name), + is_timestamp_attribute(leaf.da_name), + type_index.configured_value( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + leaf.do_name, + leaf.da_name), + }); +''', +) + +# 2) Extend the E2E fixture with one SCL-configured SPC control. +replace_once( + "tests/fixtures/scl/minimal-station-brcb.scd", + ' \n', + ''' + + + direct-with-normal-security + + +''', +) +replace_once( + "tests/fixtures/scl/minimal-station-brcb.scd", + ' \n' + ' \n' + ' \n' + ' \n' + ' \n', + ''' + + + + + + + + + + + + + + + status-only + direct-with-normal-security + sbo-with-normal-security + direct-with-enhanced-security + sbo-with-enhanced-security + +''', +) + +# 3) Parser regression: prove DAI/Val survives type projection. +insert_before( + "tests/test_scl.cpp", + "void parser_detects_duplicate_ieds_and_missing_dataset_references() {\n", + '''void parser_preserves_configured_control_model_value() { + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("minimal-station-brcb.scd")); + const auto configured = std::find_if( + document.model_entries.begin(), + document.model_entries.end(), + [](const SclDataSetEntry& entry) { + return entry.ln_class == "GGIO" && entry.ln_inst == "1" && + entry.do_name == "SPCSO1" && entry.da_name == "ctlModel"; + }); + CHECK(configured != document.model_entries.end()); + CHECK(configured->functional_constraint == "CF"); + CHECK(configured->cdc == "SPC"); + CHECK(configured->basic_type == "Enum"); + CHECK(configured->configured_value == "direct-with-normal-security"); +} + +''', +) +replace_once( + "tests/test_scl.cpp", + ' {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms},\n', + ' {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms},\n' + ' {"SCL configured control model", parser_preserves_configured_control_model_value},\n', +) + +# 4) Qt bridge: seed ctlModel from SCL and emit explicit CTL metadata. +replace_once( + "apps/ied_simulator/src/IedSimulatorController.cpp", + '''QString initialValue(const QString& type, const QString& dataAttribute) { + if (type == QStringLiteral("Boolean")) return QStringLiteral("false"); + if (type == QStringLiteral("Enumeration")) return QStringLiteral("0"); + if (type == QStringLiteral("Quality")) return QStringLiteral("good"); + if (type == QStringLiteral("Timestamp")) { + return QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs); + } + if (type == QStringLiteral("Number")) return QStringLiteral("0"); + if (dataAttribute.compare(QStringLiteral("stVal"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("on"); + } + return QStringLiteral("—"); +} +''', + '''std::optional controlModelCode(QString value) { + value = value.trimmed(); + bool numericOk{}; + const auto numeric = value.toInt(&numericOk); + if (numericOk && numeric >= 0 && numeric <= 4) return numeric; + + QString token; + token.reserve(value.size()); + for (const auto character : value.toLower()) { + if (character.isLetterOrNumber()) token.append(character); + } + if (token == QStringLiteral("statusonly")) return 0; + if (token == QStringLiteral("directwithnormalsecurity") || + token == QStringLiteral("directnormal")) return 1; + if (token == QStringLiteral("sbowithnormalsecurity") || + token == QStringLiteral("selectbeforeoperatewithnormalsecurity") || + token == QStringLiteral("sbonormal")) return 2; + if (token == QStringLiteral("directwithenhancedsecurity") || + token == QStringLiteral("directenhanced")) return 3; + if (token == QStringLiteral("sbowithenhancedsecurity") || + token == QStringLiteral("selectbeforeoperatewithenhancedsecurity") || + token == QStringLiteral("sboenhanced")) return 4; + return std::nullopt; +} + +QString initialValue( + const ar::iec61850::scl::SclDataSetEntry& entry, + const QString& type) { + const auto configured = qstring(entry.configured_value).trimmed(); + const auto dataAttribute = qstring(entry.da_name); + if (!configured.isEmpty()) { + if (dataAttribute.compare(QStringLiteral("ctlModel"), Qt::CaseInsensitive) == 0) { + if (const auto model = controlModelCode(configured); model.has_value()) { + return QString::number(*model); + } + } + return configured; + } + if (type == QStringLiteral("Boolean")) return QStringLiteral("false"); + if (type == QStringLiteral("Enumeration")) return QStringLiteral("0"); + if (type == QStringLiteral("Quality")) return QStringLiteral("good"); + if (type == QStringLiteral("Timestamp")) { + return QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs); + } + if (type == QStringLiteral("Number")) return QStringLiteral("0"); + if (dataAttribute.compare(QStringLiteral("stVal"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("on"); + } + return QStringLiteral("—"); +} +''', +) +replace_once( + "apps/ied_simulator/src/IedSimulatorController.cpp", + ' item.insert(QStringLiteral("value"), initialValue(type, qstring(entry.da_name)));\n', + ' item.insert(QStringLiteral("value"), initialValue(entry, type));\n', +) +insert_before( + "apps/ied_simulator/src/IedSimulatorController.cpp", + " QSet emittedDataSetMembers;\n", + ''' // Control service objects are virtual MMS objects. Preserve the configured + // SCL ctlModel explicitly instead of pretending CO$Oper is a structural DA. + QSet emittedControls; + for (const auto& entry : loaded.document.model_entries) { + if (qstring(entry.ied_name) != activeIedName || + qstring(entry.functional_constraint).compare(QStringLiteral("CF"), Qt::CaseInsensitive) != 0 || + qstring(entry.da_name).compare(QStringLiteral("ctlModel"), Qt::CaseInsensitive) != 0) { + continue; + } + const auto model = controlModelCode(qstring(entry.configured_value)); + if (!model.has_value()) continue; + const auto domain = mmsDomainFor(entry); + const auto logicalNode = qstring(entry.prefix) + qstring(entry.ln_class) + qstring(entry.ln_inst); + auto dataObject = qstring(entry.do_name); + dataObject.replace(QLatin1Char('.'), QLatin1Char('$')); + const auto cdc = qstring(entry.cdc).trimmed().toUpper(); + if (domain.isEmpty() || logicalNode.isEmpty() || dataObject.isEmpty() || cdc.isEmpty()) continue; + const auto key = domain + QLatin1Char('\n') + logicalNode + QLatin1Char('\n') + dataObject; + if (emittedControls.contains(key)) continue; + emittedControls.insert(key); + manifest += "CTL\t" + manifestField(domain) + "\t" + manifestField(logicalNode) + + "\t" + manifestField(dataObject) + "\t" + manifestField(cdc) + "\t" + + QByteArray::number(*model) + "\n"; + } + +''', +) + +# 5) Server build graph: reuse native Direct-Normal primitive. +replace_once( + "CMakeLists.txt", + " src/mms/static_data_set_table.cpp\n src/mms/static_dispatcher.cpp\n", + " src/mms/static_data_set_table.cpp\n src/mms/static_direct_control.cpp\n" + " src/mms/static_dispatcher.cpp\n", +) +insert_before( + "CMakeLists.txt", + " add_executable(ariec61850_rcb_contention_probe tools/rcb_contention_probe.cpp)\n", + ''' add_executable(ariec61850_control_interop_probe 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) + ariec61850_apply_warnings(ariec61850_control_interop_probe) + ariec61850_apply_sanitizers(ariec61850_control_interop_probe) + +''', +) +replace_once( + "tools/control_interop_probe/CMakeLists.txt", + '''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(NOT TARGET ariec61850_control_interop_probe) + 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) +endif() +''', +) +replace_once( + "apps/ied_simulator/CMakeLists.txt", + " ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_data_set_table.cpp\n" + " ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_dispatcher.cpp\n", + " ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_data_set_table.cpp\n" + " ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_direct_control.cpp\n" + " ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/static_dispatcher.cpp\n", +) +replace_once( + "apps/ied_simulator/CMakeLists.txt", + " ariec61850_mms_urcb_gi_probe\n ariec61850_mms_brcb_event_probe)\n", + " ariec61850_mms_urcb_gi_probe\n ariec61850_mms_brcb_event_probe\n" + " ariec61850_control_interop_probe)\n", +) + +# 6) Integrate configured SPC Direct-Normal into the simulator endpoint. +replace_once( + "tools/static_ied_server.cpp", + '#include "ariec61850/mms/simulator_manifest_codec.hpp"\n', + '#include "ariec61850/mms/simulator_manifest_codec.hpp"\n' + '#include "ariec61850/mms/static_direct_control.hpp"\n', +) +insert_before( + "tools/static_ied_server.cpp", + "[[nodiscard]] std::vector encode_ber_length(\n", + '''[[nodiscard]] wire::EncodeResult read_atomic_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = static_cast*>(context)->load( + std::memory_order_relaxed) != 0U; + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool apply_atomic_boolean(void* context, const bool value) noexcept { + if (context == nullptr) return false; + static_cast*>(context)->store( + value ? 1U : 0U, std::memory_order_relaxed); + return true; +} + +''', +) +insert_before( + "tools/static_ied_server.cpp", + "struct ConnectionBuffers final {\n", + '''[[nodiscard]] mms::MmsTypeSpecification control_scalar( + const mms::MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt) { + mms::MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification control_structure( + std::string name, + std::vector children) { + mms::MmsTypeSpecification result; + result.kind = mms::MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +[[nodiscard]] std::vector direct_boolean_oper_type_specification() { + return mms::MmsServiceCodec::encode_type_specification(control_structure("Oper", { + control_scalar(mms::MmsTypeKind::boolean, "ctlVal"), + control_structure("origin", { + control_scalar(mms::MmsTypeKind::unsigned_integer, "orCat"), + control_scalar(mms::MmsTypeKind::octet_string, "orIdent", 64U), + }), + control_scalar(mms::MmsTypeKind::unsigned_integer, "ctlNum"), + control_scalar(mms::MmsTypeKind::utc_time, "T"), + control_scalar(mms::MmsTypeKind::boolean, "Test"), + control_scalar(mms::MmsTypeKind::bit_string, "Check", 2U), + })); +} + +''', +) +insert_before( + "tools/static_ied_server.cpp", + "constexpr std::size_t kMaximumSimulatorBrcbs = 16U;\n", + '''struct ManifestDirectControlStorage final { + std::string domain; + std::string logical_node; + std::string data_object; + std::string cdc; + std::uint8_t control_model{}; + std::string status_item; + std::string ctl_model_item; + std::string oper_item; + std::vector oper_type_specification; + std::shared_ptr> process_value; +}; + +constexpr std::size_t kMaximumSimulatorDirectControls = 64U; +''', +) +replace_once( + "tools/static_ied_server.cpp", + " std::vector data_set_storage;\n", + " std::vector direct_control_storage;\n" + " std::size_t omitted_direct_controls{};\n" + " std::vector data_set_storage;\n", +) +replace_once( + "tools/static_ied_server.cpp", + ''' struct ParsedDataSetMember final { + std::string domain; + std::string item; + std::string member_domain; + std::string member_item; + }; +''', + ''' struct ParsedControl final { + std::string domain; + std::string logical_node; + std::string data_object; + std::string cdc; + std::uint8_t control_model{}; + }; + struct ParsedDataSetMember final { + std::string domain; + std::string item; + std::string member_domain; + std::string member_item; + }; +''', +) +replace_once( + "tools/static_ied_server.cpp", + " std::vector> roots;\n" + " std::vector parsed_objects;\n", + " std::vector> roots;\n" + " std::vector parsed_objects;\n" + " std::vector parsed_controls;\n", +) +replace_once( + "tools/static_ied_server.cpp", + ''' } else if (fields.size() >= 5U && fields[0] == "DS") { + parsed_members.push_back({fields[1], fields[2], fields[3], fields[4]}); +''', + ''' } else if (fields.size() >= 6U && fields[0] == "CTL") { + ++model.declared_entries; + if (fields[1].empty() || fields[2].empty() || fields[3].empty() || fields[4].empty()) { + throw std::runtime_error("Model manifest contains a malformed CTL entry."); + } + parsed_controls.push_back({ + fields[1], + fields[2], + fields[3], + fields[4], + static_cast(parse_u32("CTL ctlModel", fields[5], 4U))}); + } else if (fields.size() >= 5U && fields[0] == "DS") { + parsed_members.push_back({fields[1], fields[2], fields[3], fields[4]}); +''', +) +insert_before( + "tools/static_ied_server.cpp", + " std::map, std::vector>>\n" + " grouped_members;\n", + ''' // Compile virtual service objects from SCL configured ctlModel metadata. + // Phase one intentionally exposes only SPC Direct-with-normal-security; + // other configured models remain visible as structural CF data but are not + // falsely advertised as executable server controls. + const auto oper_type = direct_boolean_oper_type_specification(); + std::set> unique_direct_controls; + for (const auto& parsed : parsed_controls) { + if (parsed.control_model != 1U || parsed.cdc != "SPC") { + ++model.omitted_direct_controls; + continue; + } + if (model.direct_control_storage.size() >= kMaximumSimulatorDirectControls) { + ++model.omitted_direct_controls; + continue; + } + const auto status_item = parsed.logical_node + "$ST$" + parsed.data_object + "$stVal"; + const auto ctl_model_item = parsed.logical_node + "$CF$" + parsed.data_object + "$ctlModel"; + const auto oper_item = parsed.logical_node + "$CO$" + parsed.data_object + "$Oper"; + 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) { + ++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; + ManifestDirectControlStorage control; + control.domain = parsed.domain; + control.logical_node = parsed.logical_node; + control.data_object = parsed.data_object; + control.cdc = parsed.cdc; + control.control_model = parsed.control_model; + control.status_item = status_item; + control.ctl_model_item = ctl_model_item; + control.oper_item = oper_item; + control.oper_type_specification = oper_type; + control.process_value = std::make_shared>(initial ? 1U : 0U); + model.direct_control_storage.push_back(std::move(control)); + } + +''', +) +replace_once( + "tools/static_ied_server.cpp", + " auto remaining_object_slots =\n" + " mms::MmsStaticObjectTable::maximum_objects - model.objects.size();\n", + ''' if (model.objects.size() + model.direct_control_storage.size() > + mms::MmsStaticObjectTable::maximum_objects) { + throw std::runtime_error("Configured Direct-Normal controls exceed MMS object capacity."); + } + auto remaining_object_slots = mms::MmsStaticObjectTable::maximum_objects - + model.objects.size() - model.direct_control_storage.size(); +''', +) +replace_once( + "tools/static_ied_server.cpp", + ''' std::vector> brcb_runtimes; + + mms::MmsStaticDispatchPolicy dispatch_policy; + dispatch_policy.maximum_write_variables = 1U; + const mms::MmsStaticObjectTable* dispatch_objects = &object_table; +''', + ''' std::vector> brcb_runtimes; + std::vector direct_control_states; + std::vector direct_control_bindings; + std::vector direct_control_objects; + std::unique_ptr direct_control_table; + + mms::MmsStaticDispatchPolicy dispatch_policy; + dispatch_policy.maximum_write_variables = 1U; + const mms::MmsStaticObjectTable* dispatch_objects = &object_table; + 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_objects.assign(object_table.objects().begin(), object_table.objects().end()); + constexpr std::array unsigned_type{0x86U, 0x00U}; + + for (std::size_t index = 0U; index < manifest_model->direct_control_storage.size(); ++index) { + auto& control = manifest_model->direct_control_storage[index]; + auto& state = direct_control_states[index]; + auto& binding = direct_control_bindings[index]; + state.value = control.process_value->load(std::memory_order_relaxed); + binding.state = &state; + binding.apply = apply_atomic_boolean; + binding.apply_context = control.process_value.get(); + + 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.process_value.get(); + object.write = nullptr; + object.write_context = nullptr; + object.contextual_write = nullptr; + status_found = true; + } else if (object.item == control.ctl_model_item) { + object.type_specification = std::span{unsigned_type}; + object.read = mms::mms_static_direct_normal_read_ctl_model; + object.context = nullptr; + object.write = nullptr; + object.write_context = nullptr; + object.contextual_write = nullptr; + ctl_model_found = true; + } + } + if (!status_found || !ctl_model_found) { + throw std::runtime_error("Configured Direct-Normal control is missing ST/CF backing objects."); + } + direct_control_objects.push_back(mms::MmsStaticObjectEntry{ + control.domain, + control.oper_item, + control.oper_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + mms::mms_static_direct_boolean_write_oper, + &binding, + nullptr}); + } + direct_control_table = std::make_unique( + std::span{direct_control_objects}); + if (!direct_control_table->valid()) { + throw std::runtime_error("Configured Direct-Normal MMS object table is invalid."); + } + dispatch_objects = direct_control_table.get(); + dispatch_policy.advertise_flattened_child_aliases = true; + } + const auto* process_objects = dispatch_objects; +''', +) + +p = Path("tools/static_ied_server.cpp") +server = p.read_text(encoding="utf-8") +needle = " object_table,\n data_sets);" +if server.count(needle) != 2: + raise SystemExit( + "static_ied_server.cpp: expected two report runtime object_table anchors, " + f"found {server.count(needle)}" + ) +server = server.replace(needle, " *process_objects,\n data_sets);", 2) +needle = ( + " object_table.objects(),\n" + " std::span{urcb_object_storage}," +) +if server.count(needle) != 1: + raise SystemExit("static_ied_server.cpp: URCB object-bank base anchor mismatch") +server = server.replace( + needle, + " process_objects->objects(),\n" + " std::span{urcb_object_storage},", + 1, +) +p.write_text(server, encoding="utf-8") + +# 7) External wire regression using the existing guarded client. +insert_before( + "apps/ied_simulator/test_gui_live_value.py", + "\ndef prove_concurrent_associations(read_probe: str, port: int, item: str) -> float:\n", + ''' +def resolve_control_probe(argument: str) -> str: + return resolve_named_probe(argument, "ariec61850_control_interop_probe", "control interoperability probe") + + +def run_direct_normal_control_regression( + control_probe: str, + read_probe: str, + port: int, +) -> str: + common = [ + control_probe, + "127.0.0.1", + str(port), + "--object", + "MU01LD0/GGIO1.SPCSO1", + "--timeout-ms", + "5000", + ] + discovery = subprocess.run( + common, + capture_output=True, + text=True, + timeout=8, + check=False, + creationflags=creation_flags(), + ) + if ( + discovery.returncode != 0 + or "ctlModel=direct-normal" not in discovery.stdout + or "cdc=SPC" not in discovery.stdout + or "STATUS_BEFORE false" not in discovery.stdout + or "status=DISCOVERY_PASS" not in discovery.stdout + ): + raise RuntimeError( + "configured Direct-Normal discovery failed: " + f"exit={discovery.returncode} stdout={discovery.stdout!r} stderr={discovery.stderr!r}" + ) + + # Prove fail-closed Check handling before the accepted command. + rejected = subprocess.run( + common + + [ + "--action", + "operate", + "--value", + "on", + "--value-kind", + "bool", + "--arm", + "IEC61850-LAB-CONTROL", + ], + capture_output=True, + text=True, + timeout=8, + check=False, + 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 + ): + raise RuntimeError( + "Direct-Normal fail-closed check-bit regression failed: " + f"exit={rejected.returncode} stdout={rejected.stdout!r} stderr={rejected.stderr!r}" + ) + + accepted = subprocess.run( + common + + [ + "--action", + "operate", + "--value", + "on", + "--value-kind", + "bool", + "--interlock-check", + "off", + "--synchro-check", + "off", + "--arm", + "IEC61850-LAB-CONTROL", + ], + capture_output=True, + text=True, + timeout=8, + check=False, + creationflags=creation_flags(), + ) + if ( + accepted.returncode != 0 + or "completion=accepted" not in accepted.stdout + or "accepted=true" not in accepted.stdout + or "STATUS_AFTER true" not in accepted.stdout + or "NO_RETRY_EVIDENCE controlWrites=1" not in accepted.stdout + ): + raise RuntimeError( + "Direct-Normal Oper wire regression failed: " + f"exit={accepted.returncode} stdout={accepted.stdout!r} stderr={accepted.stderr!r}" + ) + + status = run_probe(read_probe, port, "GGIO1$ST$SPCSO1$stVal") + if status.returncode != 0 or "value=true" not in status.stdout: + raise RuntimeError( + "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() + +''', +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + " brcb_probe = resolve_brcb_probe(args.read_probe)\n", + " brcb_probe = resolve_brcb_probe(args.read_probe)\n" + " control_probe = resolve_control_probe(args.read_probe)\n", +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + ''' brcb_manifest = ( + "RCB\\tMU01LD0\\tLLN0$BR$BRCB01\\t1\\t" + "MU01LD0/LLN0$BR$BRCB01\\tMU01LD0\\tLLN0$dsGO\\t2\\t75\\t1000\\t108\\t121\\t128" + ) +''', + ''' brcb_manifest = ( + "RCB\\tMU01LD0\\tLLN0$BR$BRCB01\\t1\\t" + "MU01LD0/LLN0$BR$BRCB01\\tMU01LD0\\tLLN0$dsGO\\t2\\t75\\t1000\\t108\\t121\\t128" + ) + direct_control_manifest = "CTL\\tMU01LD0\\tGGIO1\\tSPCSO1\\tSPC\\t1" +''', +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + " and brcb_manifest in manifest_text\n", + " and brcb_manifest in manifest_text\n" + " and direct_control_manifest in manifest_text\n" + ' and "GGIO1$CF$SPCSO1$ctlModel\\tEnum\\tEnumeration\\t1" in manifest_text\n', +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + ' "GUI did not publish revision 2 with edited/full model leaves and URCB/BRCB metadata"\n', + ' "GUI did not publish revision 2 with edited/full model leaves, reporting metadata, and configured control metadata"\n', +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + " concurrent_seconds = prove_concurrent_associations(\n", + " control_output = run_direct_normal_control_regression(\n" + " control_probe,\n read_probe,\n port,\n )\n" + " concurrent_seconds = prove_concurrent_associations(\n", +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + ' "urcb_gi=pass "\n "brcb_event=pass "\n', + ' "control_direct_normal=pass "\n' + ' "urcb_gi=pass "\n "brcb_event=pass "\n', +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + " print(urcb_output)\n", + " print(control_output)\n print(urcb_output)\n", +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + ' "--exit-after-ms",\n "30000",\n', + ' "--exit-after-ms",\n "45000",\n', +) +replace_once( + "apps/ied_simulator/test_gui_live_value.py", + " app.wait(timeout=32)\n", + " app.wait(timeout=47)\n", +) + +# 8) Keep Qt CI path coverage explicit for the external control client. +p = Path(".github/workflows/ied-simulator-qt.yml") +wf = p.read_text(encoding="utf-8") +anchor = " - 'tools/mms_brcb_event_probe.cpp'\n" +if wf.count(anchor) != 2: + raise SystemExit(f"Qt workflow expected two BRCB path anchors, found {wf.count(anchor)}") +wf = wf.replace(anchor, anchor + " - 'tools/control_interop_probe.cpp'\n") +p.write_text(wf, encoding="utf-8") + +print("Configured Direct-Normal slice patch applied.") diff --git a/scripts/agent_apply_control_slice_fix.py b/scripts/agent_apply_control_slice_fix.py new file mode 100644 index 000000000..27c01196a --- /dev/null +++ b/scripts/agent_apply_control_slice_fix.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +from pathlib import Path + +p = Path("scripts/agent_apply_control_slice.py") +text = p.read_text(encoding="utf-8") +old = '''needle = " object_table,\\n data_sets);" +if server.count(needle) != 2: + raise SystemExit( + "static_ied_server.cpp: expected two report runtime object_table anchors, " + f"found {server.count(needle)}" + ) +server = server.replace(needle, " *process_objects,\\n data_sets);", 2) +''' +new = '''urcb_runtime_anchor = " object_table,\\n data_sets);" +if server.count(urcb_runtime_anchor) != 1: + raise SystemExit( + "static_ied_server.cpp: URCB runtime object_table anchor mismatch: " + f"{server.count(urcb_runtime_anchor)}" + ) +server = server.replace( + urcb_runtime_anchor, + " *process_objects,\\n data_sets);", + 1, +) +brcb_runtime_anchor = " object_table,\\n data_sets);" +if server.count(brcb_runtime_anchor) != 1: + raise SystemExit( + "static_ied_server.cpp: BRCB runtime object_table anchor mismatch: " + f"{server.count(brcb_runtime_anchor)}" + ) +server = server.replace( + brcb_runtime_anchor, + " *process_objects,\\n data_sets);", + 1, +) +''' +if text.count(old) != 1: + raise SystemExit(f"control patch driver anchor mismatch: {text.count(old)}") +text = text.replace(old, new, 1) + +marker = "# 2) Extend the E2E fixture with one SCL-configured SPC control.\n" +if text.count(marker) != 1: + raise SystemExit(f"control patch driver parser marker mismatch: {text.count(marker)}") +aggregate_patch = '''# Keep strict -Wmissing-field-initializers builds explicit for the new field. +replace_once( + "src/scl/parser_part_04.inc", + " is_quality_attribute(da_name),\\n" + " is_timestamp_attribute(da_name),\\n" + " };\\n", + " is_quality_attribute(da_name),\\n" + " is_timestamp_attribute(da_name),\\n" + " {},\\n" + " };\\n", +) +replace_once( + "src/scl/parser_part_04.inc", + " is_quality_attribute(leaf.da_name),\\n" + " is_timestamp_attribute(leaf.da_name),\\n" + " });\\n", + " is_quality_attribute(leaf.da_name),\\n" + " is_timestamp_attribute(leaf.da_name),\\n" + " {},\\n" + " });\\n", +) + +''' +text = text.replace(marker, aggregate_patch + marker, 1) +p.write_text(text, encoding="utf-8") +print("Adjusted report-runtime anchors and strict SCL aggregate initializers in patch driver.") diff --git a/scripts/agent_apply_sbo_enhanced_slice.py b/scripts/agent_apply_sbo_enhanced_slice.py new file mode 100644 index 000000000..0edaa2651 --- /dev/null +++ b/scripts/agent_apply_sbo_enhanced_slice.py @@ -0,0 +1,1083 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one anchor, found {count}: {old[:120]!r}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def insert_before(path: str, anchor: str, addition: str) -> None: + replace_once(path, anchor, addition + anchor) + + +# --------------------------------------------------------------------------- +# Static IED server: compile every configured SPC control model into the same +# production static control state machine and emit positive CommandTermination +# for enhanced-security commands. +# --------------------------------------------------------------------------- +server_path = "tools/static_ied_server.cpp" +replace_once( + server_path, + '#include "ariec61850/mms/services.hpp"\n#include "ariec61850/mms/simulator_manifest_codec.hpp"\n', + '#include "ariec61850/mms/services.hpp"\n#include "ariec61850/mms/reporting.hpp"\n#include "ariec61850/mms/simulator_manifest_codec.hpp"\n', +) +replace_once( + server_path, + '#include "ariec61850/mms/static_urcb_runtime.hpp"\n\n#include \n', + '#include "ariec61850/mms/static_urcb_runtime.hpp"\n#include "ariec61850/osi/cotp.hpp"\n#include "ariec61850/osi/tpkt.hpp"\n\n#include \n', +) + +replace_once( + server_path, + '''[[nodiscard]] std::vector direct_boolean_oper_type_specification() { + return mms::MmsServiceCodec::encode_type_specification(control_structure("Oper", { + control_scalar(mms::MmsTypeKind::boolean, "ctlVal"), + control_structure("origin", { + control_scalar(mms::MmsTypeKind::unsigned_integer, "orCat"), + control_scalar(mms::MmsTypeKind::octet_string, "orIdent", 64U), + }), + control_scalar(mms::MmsTypeKind::unsigned_integer, "ctlNum"), + control_scalar(mms::MmsTypeKind::utc_time, "T"), + control_scalar(mms::MmsTypeKind::boolean, "Test"), + control_scalar(mms::MmsTypeKind::bit_string, "Check", 2U), + })); +} +''', + '''[[nodiscard]] std::vector boolean_control_type_specification( + std::string name, + const bool include_check) { + std::vector fields; + 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::octet_string, "orIdent", 64U), + })); + fields.push_back(control_scalar(mms::MmsTypeKind::unsigned_integer, "ctlNum")); + fields.push_back(control_scalar(mms::MmsTypeKind::utc_time, "T")); + fields.push_back(control_scalar(mms::MmsTypeKind::boolean, "Test")); + if (include_check) { + fields.push_back(control_scalar(mms::MmsTypeKind::bit_string, "Check", 2U)); + } + return mms::MmsServiceCodec::encode_type_specification( + control_structure(std::move(name), std::move(fields))); +} + +[[nodiscard]] std::vector direct_boolean_oper_type_specification() { + return boolean_control_type_specification("Oper", true); +} + +[[nodiscard]] std::vector direct_boolean_sbow_type_specification() { + return boolean_control_type_specification("SBOw", true); +} + +[[nodiscard]] std::vector direct_boolean_cancel_type_specification() { + return boolean_control_type_specification("Cancel", false); +} + +[[nodiscard]] std::vector sbo_reference_type_specification() { + return mms::MmsServiceCodec::encode_type_specification( + control_scalar(mms::MmsTypeKind::visible_string, "SBO", 129U)); +} +''', +) + +replace_once( + server_path, + '''struct ManifestDirectControlStorage final { + std::string domain; + std::string logical_node; + std::string data_object; + std::string cdc; + std::uint8_t control_model{}; + std::string status_item; + std::string ctl_model_item; + std::string oper_item; + std::vector oper_type_specification; + std::shared_ptr> process_value; +}; +''', + '''struct ManifestDirectControlStorage final { + std::string domain; + std::string logical_node; + std::string data_object; + std::string cdc; + std::uint8_t control_model{}; + std::string status_item; + std::string ctl_model_item; + std::string sbo_item; + std::string sbow_item; + std::string oper_item; + std::string cancel_item; + std::string selection_reference; + std::vector sbo_type_specification; + std::vector sbow_type_specification; + std::vector oper_type_specification; + std::vector cancel_type_specification; + std::shared_ptr shared_state; + std::size_t service_object_count{}; +}; +''', +) + +old_compile = ''' // Compile virtual service objects from SCL configured ctlModel metadata. + // Phase one intentionally exposes only SPC Direct-with-normal-security; + // other configured models remain visible as structural CF data but are not + // falsely advertised as executable server controls. + const auto oper_type = direct_boolean_oper_type_specification(); + std::set> unique_direct_controls; + for (const auto& parsed : parsed_controls) { + if (parsed.control_model != 1U || parsed.cdc != "SPC") { + ++model.omitted_direct_controls; + continue; + } + if (model.direct_control_storage.size() >= kMaximumSimulatorDirectControls) { + ++model.omitted_direct_controls; + continue; + } + const auto status_item = parsed.logical_node + "$ST$" + parsed.data_object + "$stVal"; + const auto ctl_model_item = parsed.logical_node + "$CF$" + parsed.data_object + "$ctlModel"; + const auto oper_item = parsed.logical_node + "$CO$" + parsed.data_object + "$Oper"; + 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) { + ++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; + ManifestDirectControlStorage control; + control.domain = parsed.domain; + control.logical_node = parsed.logical_node; + control.data_object = parsed.data_object; + control.cdc = parsed.cdc; + control.control_model = parsed.control_model; + control.status_item = status_item; + control.ctl_model_item = ctl_model_item; + control.oper_item = oper_item; + control.oper_type_specification = oper_type; + control.process_value = std::make_shared>(initial ? 1U : 0U); + model.direct_control_storage.push_back(std::move(control)); + } +''' +new_compile = ''' // Compile configured SPC 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. + 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") { + ++model.omitted_direct_controls; + continue; + } + if (model.direct_control_storage.size() >= kMaximumSimulatorDirectControls) { + ++model.omitted_direct_controls; + continue; + } + const auto status_item = parsed.logical_node + "$ST$" + parsed.data_object + "$stVal"; + const auto ctl_model_item = parsed.logical_node + "$CF$" + parsed.data_object + "$ctlModel"; + const auto sbo_item = parsed.logical_node + "$CO$" + parsed.data_object + "$SBO"; + const auto sbow_item = parsed.logical_node + "$CO$" + parsed.data_object + "$SBOw"; + const auto oper_item = parsed.logical_node + "$CO$" + parsed.data_object + "$Oper"; + const auto cancel_item = parsed.logical_node + "$CO$" + parsed.data_object + "$Cancel"; + 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) { + ++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; + ManifestDirectControlStorage control; + control.domain = parsed.domain; + control.logical_node = parsed.logical_node; + control.data_object = parsed.data_object; + control.cdc = parsed.cdc; + control.control_model = parsed.control_model; + control.status_item = status_item; + control.ctl_model_item = ctl_model_item; + control.sbo_item = sbo_item; + control.sbow_item = sbow_item; + control.oper_item = oper_item; + control.cancel_item = cancel_item; + control.selection_reference = parsed.domain + "/" + parsed.logical_node + "." + parsed.data_object; + std::replace( + control.selection_reference.begin(), + control.selection_reference.end(), + '$', + '.'); + control.sbo_type_specification = sbo_type; + control.sbow_type_specification = sbow_type; + control.oper_type_specification = oper_type; + control.cancel_type_specification = cancel_type; + control.shared_state = std::make_shared(); + control.shared_state->value.store(initial ? 1U : 0U, std::memory_order_relaxed); + control.service_object_count = + (parsed.control_model == 2U || parsed.control_model == 4U) ? 3U : 1U; + model.direct_control_storage.push_back(std::move(control)); + } +''' +replace_once(server_path, old_compile, new_compile) + +replace_once( + server_path, + ''' if (model.objects.size() + model.direct_control_storage.size() > + mms::MmsStaticObjectTable::maximum_objects) { + throw std::runtime_error("Configured Direct-Normal controls exceed MMS object capacity."); + } + auto remaining_object_slots = mms::MmsStaticObjectTable::maximum_objects - + model.objects.size() - model.direct_control_storage.size(); +''', + ''' std::size_t control_service_objects{}; + for (const auto& control : model.direct_control_storage) { + if (control.service_object_count > + mms::MmsStaticObjectTable::maximum_objects - control_service_objects) { + throw std::runtime_error("Configured controls exceed MMS object capacity."); + } + control_service_objects += control.service_object_count; + } + if (model.objects.size() > mms::MmsStaticObjectTable::maximum_objects - control_service_objects) { + throw std::runtime_error("Configured controls exceed MMS object capacity."); + } + auto remaining_object_slots = mms::MmsStaticObjectTable::maximum_objects - + model.objects.size() - control_service_objects; +''', +) + +old_binding = ''' std::vector direct_control_states; + std::vector direct_control_bindings; + std::vector direct_control_objects; + std::unique_ptr direct_control_table; + + mms::MmsStaticDispatchPolicy dispatch_policy; + dispatch_policy.maximum_write_variables = 1U; + const mms::MmsStaticObjectTable* dispatch_objects = &object_table; + 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_objects.assign(object_table.objects().begin(), object_table.objects().end()); + constexpr std::array unsigned_type{0x86U, 0x00U}; + + for (std::size_t index = 0U; index < manifest_model->direct_control_storage.size(); ++index) { + auto& control = manifest_model->direct_control_storage[index]; + auto& state = direct_control_states[index]; + auto& binding = direct_control_bindings[index]; + state.value = control.process_value->load(std::memory_order_relaxed); + binding.state = &state; + binding.apply = apply_atomic_boolean; + binding.apply_context = control.process_value.get(); + + 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.process_value.get(); + object.write = nullptr; + object.write_context = nullptr; + object.contextual_write = nullptr; + status_found = true; + } else if (object.item == control.ctl_model_item) { + object.type_specification = std::span{unsigned_type}; + object.read = mms::mms_static_direct_normal_read_ctl_model; + object.context = nullptr; + object.write = nullptr; + object.write_context = nullptr; + object.contextual_write = nullptr; + ctl_model_found = true; + } + } + if (!status_found || !ctl_model_found) { + throw std::runtime_error("Configured Direct-Normal control is missing ST/CF backing objects."); + } + direct_control_objects.push_back(mms::MmsStaticObjectEntry{ + control.domain, + control.oper_item, + control.oper_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + mms::mms_static_direct_boolean_write_oper, + &binding, + nullptr}); + } + direct_control_table = std::make_unique( + std::span{direct_control_objects}); + if (!direct_control_table->valid()) { + throw std::runtime_error("Configured Direct-Normal MMS object table is invalid."); + } + dispatch_objects = direct_control_table.get(); + dispatch_policy.advertise_flattened_child_aliases = true; + } +''' +new_binding = ''' std::vector direct_control_states; + std::vector direct_control_bindings; + std::vector direct_control_objects; + std::unique_ptr direct_control_table; + + mms::MmsStaticDispatchPolicy dispatch_policy; + dispatch_policy.maximum_write_variables = 1U; + const mms::MmsStaticObjectTable* dispatch_objects = &object_table; + 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_objects.assign(object_table.objects().begin(), object_table.objects().end()); + constexpr std::array unsigned_type{0x86U, 0x00U}; + + for (std::size_t index = 0U; index < manifest_model->direct_control_storage.size(); ++index) { + auto& control = manifest_model->direct_control_storage[index]; + auto& state = direct_control_states[index]; + auto& binding = direct_control_bindings[index]; + state.value = control.shared_state->value.load(std::memory_order_relaxed); + binding.state = &state; + binding.shared_state = control.shared_state.get(); + binding.model = static_cast(control.control_model); + binding.association_id = association_id; + binding.selection_reference = control.selection_reference; + binding.sbo_timeout_ms = 10'000U; + binding.now_ms = report_now_ms; + binding.now_context = nullptr; + + 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; + status_found = true; + } else if (object.item == control.ctl_model_item) { + object.type_specification = std::span{unsigned_type}; + object.read = mms::mms_static_control_read_ctl_model; + object.context = &binding; + object.write = nullptr; + object.write_context = nullptr; + object.contextual_write = nullptr; + ctl_model_found = true; + } + } + if (!status_found || !ctl_model_found) { + throw std::runtime_error("Configured command control is missing ST/CF backing objects."); + } + + if (control.control_model == 2U) { + direct_control_objects.push_back(mms::MmsStaticObjectEntry{ + control.domain, + control.sbo_item, + control.sbo_type_specification, + mms::mms_static_sbo_normal_read, + &binding}); + } else if (control.control_model == 4U) { + direct_control_objects.push_back(mms::MmsStaticObjectEntry{ + control.domain, + control.sbow_item, + control.sbow_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + nullptr, + &binding, + mms::mms_static_boolean_write_sbow_contextual}); + } + + direct_control_objects.push_back(mms::MmsStaticObjectEntry{ + control.domain, + control.oper_item, + control.oper_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + nullptr, + &binding, + mms::mms_static_boolean_write_oper_contextual}); + + if (control.control_model == 2U || control.control_model == 4U) { + direct_control_objects.push_back(mms::MmsStaticObjectEntry{ + control.domain, + control.cancel_item, + control.cancel_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + nullptr, + &binding, + mms::mms_static_boolean_write_cancel_contextual}); + } + } + direct_control_table = std::make_unique( + std::span{direct_control_objects}); + if (!direct_control_table->valid()) { + throw std::runtime_error("Configured command-control MMS object table is invalid."); + } + dispatch_objects = direct_control_table.get(); + dispatch_policy.advertise_flattened_child_aliases = true; + } +''' +replace_once(server_path, old_binding, new_binding) + +replace_once( + server_path, + ''' const auto close_brcbs = [&] { + const auto now_ms = monotonic_ms(); + for (auto& brcb : brcb_runtimes) { + if (brcb != nullptr && brcb->control != nullptr) { + brcb->control->on_association_closed(association_id, now_ms); + } + } + }; +''', + ''' const auto close_brcbs = [&] { + const auto now_ms = monotonic_ms(); + for (auto& brcb : brcb_runtimes) { + if (brcb != nullptr && brcb->control != nullptr) { + brcb->control->on_association_closed(association_id, now_ms); + } + } + for (auto& binding : direct_control_bindings) { + mms::mms_static_control_on_association_closed(binding); + } + }; +''', +) + +termination_anchor = ''' const auto now_ms = monotonic_ms(); + if (!brcb_runtimes.empty()) { +''' +termination_code = ''' 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) { + auto& binding = direct_control_bindings[index]; + if (binding.state == nullptr || !binding.state->pending_termination) continue; + if (index >= manifest_model->direct_control_storage.size()) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=command_termination_error association=" + << association_id << " message=control-index-mismatch\\n"; + close_brcbs(); + return; + } + const auto& control = manifest_model->direct_control_storage[index]; + const auto command = binding.state->termination_command; + 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::MmsDataValue::structure({ + mms::MmsDataValue::unsigned_integer(command.origin_category), + mms::MmsDataValue::octet_string(origin), + }), + mms::MmsDataValue::unsigned_integer(command.control_number), + mms::MmsDataValue::integer(25), + }); + 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}); + 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); + const auto frame = ar::iec61850::osi::TpktFrameCodec::encode(cotp); + if (!send_all(socket, frame)) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=command_termination_send_error association=" + << association_id << " object=" << control.selection_reference << '\\n'; + close_brcbs(); + return; + } + total_sent += frame.size(); + binding.state->pending_termination = false; + binding.state->termination_command = {}; + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=command_termination association=" + << association_id << " object=" << control.selection_reference + << " ctlNum=" << static_cast(command.control_number) + << " positive=true bytes=" << frame.size() << '\\n'; + } catch (const std::exception& exception) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=command_termination_error association=" + << association_id << " object=" << control.selection_reference + << " message=" << exception.what() << '\\n'; + close_brcbs(); + return; + } + break; + } + } + if (!brcb_runtimes.empty()) { +''' +replace_once(server_path, termination_anchor, termination_code) + +# --------------------------------------------------------------------------- +# SCL fixture: four configured SPC command Data Objects cover ctlModel 1..4. +# --------------------------------------------------------------------------- +fixture_path = "tests/fixtures/scl/minimal-station-brcb.scd" +replace_once( + fixture_path, + ''' + + direct-with-normal-security + + +''', + ''' + + direct-with-normal-security + + + sbo-with-normal-security + + + direct-with-enhanced-security + + + sbo-with-enhanced-security + + +''', +) +replace_once( + fixture_path, + ''' + + +''', + ''' + + + + + +''', +) + +# SCL regression protects configured ctlModel projection for all four models. +replace_once( + "tests/test_scl.cpp", + '''void parser_preserves_configured_control_model_value() { + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("minimal-station-brcb.scd")); + const auto configured = std::find_if( + document.model_entries.begin(), + document.model_entries.end(), + [](const SclDataSetEntry& entry) { + return entry.ln_class == "GGIO" && entry.ln_inst == "1" && + entry.do_name == "SPCSO1" && entry.da_name == "ctlModel"; + }); + CHECK(configured != document.model_entries.end()); + CHECK(configured->functional_constraint == "CF"); + CHECK(configured->cdc == "SPC"); + CHECK(configured->basic_type == "Enum"); + CHECK(configured->configured_value == "direct-with-normal-security"); +} +''', + '''void parser_preserves_configured_control_model_value() { + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("minimal-station-brcb.scd")); + const std::array expected{ + std::pair{"SPCSO1", "direct-with-normal-security"}, + std::pair{"SPCSO2", "sbo-with-normal-security"}, + std::pair{"SPCSO3", "direct-with-enhanced-security"}, + std::pair{"SPCSO4", "sbo-with-enhanced-security"}, + }; + for (const auto& [data_object, configured_value] : expected) { + const auto configured = std::find_if( + document.model_entries.begin(), + document.model_entries.end(), + [&](const SclDataSetEntry& entry) { + return entry.ln_class == "GGIO" && entry.ln_inst == "1" && + entry.do_name == data_object && entry.da_name == "ctlModel"; + }); + CHECK(configured != document.model_entries.end()); + CHECK(configured->functional_constraint == "CF"); + CHECK(configured->cdc == "SPC"); + CHECK(configured->basic_type == "Enum"); + CHECK(configured->configured_value == configured_value); + } +} +''', +) +replace_once( + "tests/test_scl.cpp", + '#include \n#include \n', + '#include \n#include \n#include \n', +) +replace_once( + "tests/test_scl.cpp", + '#include \n#include \n', + '#include \n#include \n#include \n', +) + +# --------------------------------------------------------------------------- +# Core state-machine regression for SBO ownership, timeout, enhanced sequence +# correlation and pending CommandTermination. +# --------------------------------------------------------------------------- +direct_test = "tests/test_mms_static_direct_control.cpp" +replace_once( + direct_test, + '''[[nodiscard]] std::vector make_oper( +''', + '''struct TestClock final { + std::uint64_t now_ms{}; +}; + +[[nodiscard]] std::uint64_t test_now_ms(const void* context) noexcept { + return context == nullptr ? 0U : static_cast(context)->now_ms; +} + +[[nodiscard]] MmsStaticRequestAccessContext access_for(const std::uint64_t association_id) { + static constexpr std::array owner{0xA5U}; + return {association_id, owner}; +} + +[[nodiscard]] std::vector make_oper( +''', +) +insert_before( + direct_test, + '''void valid_oper_updates_live_state() { +''', + '''[[nodiscard]] std::vector make_cancel( + const bool value, + const std::uint8_t ctl_num, + const bool test, + const std::uint8_t origin_category = 2U) { + constexpr std::array origin_id{'H', 'M', 'I'}; + auto cancel = MmsDataValue::structure({ + MmsDataValue::boolean(value), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(origin_category), + MmsDataValue::octet_string(origin_id), + }), + MmsDataValue::unsigned_integer(ctl_num), + MmsDataValue::utc_time(Iec61850UtcTime{}), + MmsDataValue::boolean(test), + }); + const auto required = MmsDataSpanCodec::encoded_size(cancel); + CHECK(required.has_value()); + std::vector bytes(*required); + const auto encoded = MmsDataSpanCodec::encode_into(cancel, bytes); + CHECK(encoded.success()); + return bytes; +} + +[[nodiscard]] MmsStaticDirectBooleanControlBinding make_binding( + MmsStaticDirectBooleanControlState& state, + MmsStaticDirectBooleanSharedState& shared, + const MmsStaticControlModel model, + const std::uint64_t association_id, + TestClock& clock) { + MmsStaticDirectBooleanControlBinding binding; + binding.state = &state; + binding.shared_state = &shared; + binding.model = model; + binding.association_id = association_id; + binding.selection_reference = "LD0/GGIO1.SPCSO1"; + binding.sbo_timeout_ms = 100U; + binding.now_ms = test_now_ms; + binding.now_context = &clock; + return binding; +} + +''', +) +insert_before( + direct_test, + '''void read_callbacks_match_mms_types() { +''', + '''void sbo_normal_enforces_owner_cancel_and_timeout() { + TestClock clock{10U}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state_a{}; + MmsStaticDirectBooleanControlState state_b{}; + auto a = make_binding(state_a, shared, MmsStaticControlModel::sbo_normal, 11U, clock); + auto b = make_binding(state_b, shared, MmsStaticControlModel::sbo_normal, 22U, clock); + std::array selected{}; + + const auto first = mms_static_sbo_normal_read(&a, selected); + CHECK(first.success()); + CHECK(first.bytes_written > 2U); + CHECK(selected[0] == 0x8AU); + CHECK(state_a.selected); + + selected.fill(0U); + const auto contended = mms_static_sbo_normal_read(&b, selected); + CHECK(contended.success()); + CHECK(contended.bytes_written == 2U); + CHECK(selected[0] == 0x8AU && selected[1] == 0U); + + const auto denied = mms_static_boolean_write_oper_contextual( + &b, make_oper(true, 7U, false), access_for(22U)); + CHECK(!denied.success); + CHECK(denied.failure_code == 2U); + + const auto cancelled = mms_static_boolean_write_cancel_contextual( + &a, make_cancel(false, 8U, false), access_for(11U)); + CHECK(cancelled.success); + CHECK(shared.selected_association_id.load() == 0U); + + CHECK(mms_static_sbo_normal_read(&a, selected).success()); + clock.now_ms = 111U; + selected.fill(0U); + const auto takeover = mms_static_sbo_normal_read(&b, selected); + CHECK(takeover.success()); + CHECK(selected[1] != 0U); + CHECK(shared.selected_association_id.load() == 22U); + + const auto operated = mms_static_boolean_write_oper_contextual( + &b, make_oper(true, 9U, false), access_for(22U)); + CHECK(operated.success); + CHECK(shared.value.load() == 1U); + CHECK(shared.selected_association_id.load() == 0U); +} + +void direct_enhanced_queues_one_termination() { + TestClock clock{}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state{}; + auto binding = make_binding( + state, shared, MmsStaticControlModel::direct_enhanced, 31U, clock); + + const auto first = mms_static_boolean_write_oper_contextual( + &binding, make_oper(true, 12U, false), access_for(31U)); + CHECK(first.success); + CHECK(state.pending_termination); + CHECK(state.termination_command.control_number == 12U); + CHECK(shared.value.load() == 1U); + + const auto second = mms_static_boolean_write_oper_contextual( + &binding, make_oper(false, 13U, false), access_for(31U)); + CHECK(!second.success); + CHECK(second.failure_code == 2U); + CHECK(shared.value.load() == 1U); +} + +void sbo_enhanced_requires_exact_selected_sequence() { + TestClock clock{50U}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state{}; + auto binding = make_binding( + state, shared, MmsStaticControlModel::sbo_enhanced, 41U, clock); + + const auto selected = mms_static_boolean_write_sbow_contextual( + &binding, make_oper(true, 21U, false), access_for(41U)); + CHECK(selected.success); + CHECK(state.selected && state.selected_with_value); + CHECK(shared.selected_association_id.load() == 41U); + + const auto mismatch = mms_static_boolean_write_oper_contextual( + &binding, make_oper(false, 21U, false), access_for(41U)); + CHECK(!mismatch.success); + CHECK(mismatch.failure_code == 11U); + CHECK(shared.value.load() == 0U); + + const auto operated = mms_static_boolean_write_oper_contextual( + &binding, make_oper(true, 21U, false), access_for(41U)); + CHECK(operated.success); + CHECK(shared.value.load() == 1U); + CHECK(state.pending_termination); + CHECK(shared.selected_association_id.load() == 0U); +} + +void sbo_enhanced_cancel_and_association_close_release_owner() { + TestClock clock{}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state{}; + auto binding = make_binding( + state, shared, MmsStaticControlModel::sbo_enhanced, 51U, clock); + + CHECK(mms_static_boolean_write_sbow_contextual( + &binding, make_oper(true, 30U, false), access_for(51U)).success); + CHECK(mms_static_boolean_write_cancel_contextual( + &binding, make_cancel(true, 30U, false), access_for(51U)).success); + CHECK(shared.selected_association_id.load() == 0U); + CHECK(shared.value.load() == 0U); + + CHECK(mms_static_boolean_write_sbow_contextual( + &binding, make_oper(true, 31U, false), access_for(51U)).success); + mms_static_control_on_association_closed(binding); + CHECK(shared.selected_association_id.load() == 0U); + CHECK(!state.selected); +} + +''', +) +replace_once( + direct_test, + ''' read = mms_static_control_read_unavailable(nullptr, bytes); + CHECK(!read.success()); +} +''', + ''' MmsStaticDirectBooleanControlState configured_state{}; + MmsStaticDirectBooleanControlBinding configured_binding; + configured_binding.state = &configured_state; + configured_binding.model = MmsStaticControlModel::sbo_enhanced; + bytes.fill(0U); + read = mms_static_control_read_ctl_model(&configured_binding, bytes); + CHECK(read.success()); + CHECK(bytes[0] == 0x86U && bytes[1] == 0x01U && bytes[2] == 0x04U); + + read = mms_static_control_read_unavailable(nullptr, bytes); + CHECK(!read.success()); +} +''', +) +replace_once( + direct_test, + ''' backend_failure_does_not_publish_state(); + read_callbacks_match_mms_types(); +''', + ''' backend_failure_does_not_publish_state(); + sbo_normal_enforces_owner_cancel_and_timeout(); + direct_enhanced_queues_one_termination(); + sbo_enhanced_requires_exact_selected_sequence(); + sbo_enhanced_cancel_and_association_close_release_owner(); + read_callbacks_match_mms_types(); +''', +) + +# --------------------------------------------------------------------------- +# Qt E2E wire regression: exercise ctlModel 2/3/4 through the same public +# guarded control client used against third-party IEDs. +# --------------------------------------------------------------------------- +gui_test = "apps/ied_simulator/test_gui_live_value.py" +insert_before( + gui_test, + '''def prove_concurrent_associations(read_probe: str, port: int, item: str) -> float: +''', + '''def run_control_action( + control_probe: str, + port: int, + object_reference: str, + action: str, + value: str, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + control_probe, + "127.0.0.1", + str(port), + "--object", + object_reference, + "--timeout-ms", + "5000", + "--termination-timeout-ms", + "5000", + "--action", + action, + "--value", + value, + "--value-kind", + "bool", + "--interlock-check", + "off", + "--synchro-check", + "off", + "--arm", + "IEC61850-LAB-CONTROL", + ], + capture_output=True, + text=True, + timeout=10, + check=False, + creationflags=creation_flags(), + ) + + +def run_sbo_enhanced_control_regressions( + control_probe: str, + read_probe: str, + port: int, +) -> str: + outputs: list[str] = [] + + # SBO normal: Select/Cancel must be non-mutating; Select/Operate must mutate. + sbo_cancel = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO2", "select-cancel", "on") + if ( + sbo_cancel.returncode != 0 + or "ctlModel=sbo-normal" not in sbo_cancel.stdout + or "completion=accepted" not in sbo_cancel.stdout + or "STATUS_AFTER false" not in sbo_cancel.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO2$Cancel" not in sbo_cancel.stdout + ): + raise RuntimeError( + "SBO-normal Select/Cancel wire regression failed: " + f"exit={sbo_cancel.returncode} stdout={sbo_cancel.stdout!r} stderr={sbo_cancel.stderr!r}" + ) + outputs.append(sbo_cancel.stdout.strip()) + + sbo_oper = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO2", "select-operate", "on") + if ( + sbo_oper.returncode != 0 + or "ctlModel=sbo-normal" not in sbo_oper.stdout + or "completion=accepted" not in sbo_oper.stdout + or "STATUS_AFTER true" not in sbo_oper.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO2$Oper" not in sbo_oper.stdout + ): + raise RuntimeError( + "SBO-normal Select/Operate wire regression failed: " + f"exit={sbo_oper.returncode} stdout={sbo_oper.stdout!r} stderr={sbo_oper.stderr!r}" + ) + outputs.append(sbo_oper.stdout.strip()) + + # Direct enhanced: confirmed Write is not completion; positive correlated + # CommandTermination is mandatory and no automatic retry is allowed. + direct_enhanced = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO3", "operate", "on") + if ( + direct_enhanced.returncode != 0 + or "ctlModel=direct-enhanced" not in direct_enhanced.stdout + or "completion=positive-termination" not in direct_enhanced.stdout + or "termination=true" not in direct_enhanced.stdout + or "STATUS_AFTER true" not in direct_enhanced.stdout + or "NO_RETRY_EVIDENCE controlWrites=1" not in direct_enhanced.stdout + ): + raise RuntimeError( + "Direct-enhanced CommandTermination regression failed: " + f"exit={direct_enhanced.returncode} stdout={direct_enhanced.stdout!r} stderr={direct_enhanced.stderr!r}" + ) + outputs.append(direct_enhanced.stdout.strip()) + + # SBO enhanced: first prove SBOw/Cancel is non-mutating, then prove exact + # SBOw -> Oper -> positive CommandTermination sequence. + enhanced_cancel = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO4", "select-cancel", "on") + if ( + enhanced_cancel.returncode != 0 + or "ctlModel=sbo-enhanced" not in enhanced_cancel.stdout + or "STATUS_AFTER false" not in enhanced_cancel.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$SBOw" not in enhanced_cancel.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$Cancel" not in enhanced_cancel.stdout + ): + raise RuntimeError( + "SBO-enhanced SBOw/Cancel regression failed: " + f"exit={enhanced_cancel.returncode} stdout={enhanced_cancel.stdout!r} stderr={enhanced_cancel.stderr!r}" + ) + outputs.append(enhanced_cancel.stdout.strip()) + + enhanced_oper = run_control_action( + control_probe, port, "MU01LD0/GGIO1.SPCSO4", "select-operate", "on") + if ( + enhanced_oper.returncode != 0 + or "ctlModel=sbo-enhanced" not in enhanced_oper.stdout + or "completion=positive-termination" not in enhanced_oper.stdout + or "termination=true" not in enhanced_oper.stdout + or "STATUS_AFTER true" not in enhanced_oper.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$SBOw" not in enhanced_oper.stdout + or "write=MU01LD0/GGIO1$CO$SPCSO4$Oper" not in enhanced_oper.stdout + or "NO_RETRY_EVIDENCE controlWrites=2" not in enhanced_oper.stdout + ): + raise RuntimeError( + "SBO-enhanced SBOw/Oper/CommandTermination regression failed: " + f"exit={enhanced_oper.returncode} stdout={enhanced_oper.stdout!r} stderr={enhanced_oper.stderr!r}" + ) + outputs.append(enhanced_oper.stdout.strip()) + + for item in ("SPCSO2", "SPCSO3", "SPCSO4"): + status = run_probe(read_probe, port, f"GGIO1$ST${item}$stVal") + if status.returncode != 0 or "value=true" not in status.stdout: + raise RuntimeError( + f"{item} status was not persistent across a second association: " + f"exit={status.returncode} stdout={status.stdout!r} stderr={status.stderr!r}" + ) + return "\\n".join(outputs) + + +''', +) +replace_once( + gui_test, + ''' direct_control_manifest = "CTL\\tMU01LD0\\tGGIO1\\tSPCSO1\\tSPC\\t1" + if ( +''', + ''' configured_controls = ( + "CTL\\tMU01LD0\\tGGIO1\\tSPCSO1\\tSPC\\t1", + "CTL\\tMU01LD0\\tGGIO1\\tSPCSO2\\tSPC\\t2", + "CTL\\tMU01LD0\\tGGIO1\\tSPCSO3\\tSPC\\t3", + "CTL\\tMU01LD0\\tGGIO1\\tSPCSO4\\tSPC\\t4", + ) + if ( +''', +) +replace_once( + gui_test, + ''' and brcb_manifest in manifest_text + and direct_control_manifest in manifest_text + and "GGIO1$CF$SPCSO1$ctlModel\\tEnum\\tEnumeration\\t1" in manifest_text +''', + ''' and brcb_manifest in manifest_text + and all(control in manifest_text for control in configured_controls) + and "GGIO1$CF$SPCSO1$ctlModel\\tEnum\\tEnumeration\\t1" in manifest_text + and "GGIO1$CF$SPCSO2$ctlModel\\tEnum\\tEnumeration\\t2" in manifest_text + and "GGIO1$CF$SPCSO3$ctlModel\\tEnum\\tEnumeration\\t3" in manifest_text + and "GGIO1$CF$SPCSO4$ctlModel\\tEnum\\tEnumeration\\t4" in manifest_text +''', +) +replace_once( + gui_test, + ''' control_output = run_direct_normal_control_regression( + control_probe, + read_probe, + port, + ) + concurrent_seconds = prove_concurrent_associations( +''', + ''' control_output = run_direct_normal_control_regression( + control_probe, + read_probe, + port, + ) + sbo_enhanced_output = run_sbo_enhanced_control_regressions( + control_probe, + read_probe, + port, + ) + concurrent_seconds = prove_concurrent_associations( +''', +) +replace_once( + gui_test, + ''' "control_direct_normal=pass " + "urcb_gi=pass " +''', + ''' "control_direct_normal=pass " + "control_sbo_normal=pass " + "control_direct_enhanced=pass " + "control_sbo_enhanced=pass " + "urcb_gi=pass " +''', +) +replace_once( + gui_test, + ''' print(control_output) + print(urcb_output) +''', + ''' print(control_output) + print(sbo_enhanced_output) + print(urcb_output) +''', +) + +print("SBO normal/direct enhanced/SBO enhanced simulator slice patch applied.") diff --git a/scripts/agent_diagnose_iedsim_brcb.py b/scripts/agent_diagnose_iedsim_brcb.py new file mode 100644 index 000000000..92b799c2c --- /dev/null +++ b/scripts/agent_diagnose_iedsim_brcb.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("tools/static_ied_server.cpp") +text = path.read_text(encoding="utf-8") +old = ''' if (!brcb->reports->initialize()) { + throw std::runtime_error("Could not initialize per-association BRCB runtime."); + } +''' +new = ''' if (!brcb->reports->initialize()) { + std::ostringstream detail; + detail << "Could not initialize per-association BRCB runtime" + << " objectsValid=" << (process_objects->valid() ? "true" : "false") + << " dataSetsValid=" << (data_sets.valid() ? "true" : "false") + << " dataSetsAgainstObjects=" + << (data_sets.valid_against(*process_objects) ? "true" : "false") + << " domain='" << definition.domain << "'" + << " item='" << definition.item << "'" + << " rptId='" << definition.report_id << "'" + << " dataSet='" << definition.data_set_domain << "/" + << definition.data_set_item << "'" + << " optFlds=" + << static_cast(definition.optional_fields[0]) << ',' + << static_cast(definition.optional_fields[1]) + << " trgOps=" << static_cast(definition.trigger_options) + << " slots=" << brcb->slots.size() + << " slot0Bytes=" + << (brcb->slots.empty() ? 0U : brcb->slots.front().storage.size()) + << " dsMembers=" << brcb->data_set->members.size(); + throw std::runtime_error(detail.str()); + } +''' +count = text.count(old) +if count != 1: + raise SystemExit(f"expected one BRCB initialize failure anchor, found {count}") +path.write_text(text.replace(old, new, 1), encoding="utf-8") +print("Added invariant-level BRCB runtime initialization diagnostics.") diff --git a/scripts/agent_finalize_simulation_runtime_port.py b/scripts/agent_finalize_simulation_runtime_port.py new file mode 100644 index 000000000..c488d049f --- /dev/null +++ b/scripts/agent_finalize_simulation_runtime_port.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one anchor, found {count}: {old[:140]!r}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "tests/test_simulation.cpp", + "#include \n#include \n", + "#include \n#include \n#include \n", +) + +replace_once( + "MIGRATION_CHECKLIST.md", + "## Phase 5 — simulator and applications\n\n" + "- [ ] Deterministic IED simulation and read-only MMS server integration parity.\n", + "## Phase 5 — simulator and applications\n\n" + "### Phase 5A — native deterministic simulator domain runtime\n\n" + "- [x] Native `IedSimulatorProfile` domain model ported from the C# simulation surface.\n" + "- [x] Native SCL-to-simulator profile builder uses the complete structural `DataTypeTemplates` projection; DataSets enrich but never shrink the model.\n" + "- [x] Selected/runtime IED identity remap, structural-only leaves, DataSets, URCB/BRCB metadata, configured `ctlModel`, Quality, and Timestamp defaults are regression-tested.\n" + "- [x] Deterministic `IedSimulatorEngine` reset/start/stop/manual mutation/step/snapshot state model ported without wall-clock dependencies in the core.\n" + "- [x] Qt simulator value presentation consumes the portable profile builder instead of maintaining a second leaf-discovery/default-classification implementation.\n" + "- [x] GCC strict regression plus Qt public-wire simulator regression guards the ported runtime adapter.\n\n" + "- [ ] Deterministic IED simulation and read-only MMS server integration parity.\n", +) + +print("Simulator Phase 5A milestone metadata staged.") diff --git a/scripts/agent_fix_control_slice_source.py b/scripts/agent_fix_control_slice_source.py new file mode 100644 index 000000000..4850cb3d0 --- /dev/null +++ b/scripts/agent_fix_control_slice_source.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one source-fix anchor, found {count}: {old!r}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +controller = "apps/ied_simulator/src/IedSimulatorController.cpp" +replace_once( + controller, + "#include \n#include \n", + "#include \n#include \n#include \n", +) +replace_once( + controller, + " const auto key = domain + QLatin1Char('\n') + logicalNode + QLatin1Char('\n') + dataObject;\n", + " const auto key = domain + QLatin1Char('\\n') + logicalNode + QLatin1Char('\\n') + dataObject;\n", +) +replace_once( + controller, + ' QByteArray::number(*model) + "\n";\n', + ' QByteArray::number(*model) + "\\n";\n', +) + +regression = "apps/ied_simulator/test_gui_live_value.py" +replace_once( + regression, + ' return discovery.stdout.strip() + "\n" + rejected.stdout.strip() + "\n" + accepted.stdout.strip()\n', + ' return discovery.stdout.strip() + "\\n" + rejected.stdout.strip() + "\\n" + accepted.stdout.strip()\n', +) + +print("Configured-control C++/Python source syntax normalized.") diff --git a/scripts/agent_fix_control_type_lifetime.py b/scripts/agent_fix_control_type_lifetime.py new file mode 100644 index 000000000..0baca6b60 --- /dev/null +++ b/scripts/agent_fix_control_type_lifetime.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("tools/static_ied_server.cpp") +text = path.read_text(encoding="utf-8") +old = " constexpr std::array unsigned_type{0x86U, 0x00U};\n" +new = " static constexpr std::array unsigned_type{0x86U, 0x00U};\n" +if old in text: + if text.count(old) != 1: + raise SystemExit(f"expected exactly one ctlModel type-storage anchor, found {text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + print("Pinned ctlModel MMS type specification to static lifetime across URCB/BRCB composition.") +elif new in text: + print("ctlModel MMS type specification already has static lifetime.") +else: + raise SystemExit("ctlModel type-storage anchor not found") diff --git a/scripts/agent_fix_cross_association_control_state.py b/scripts/agent_fix_cross_association_control_state.py new file mode 100644 index 000000000..6df8e3daf --- /dev/null +++ b/scripts/agent_fix_cross_association_control_state.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("tools/static_ied_server.cpp") +text = path.read_text(encoding="utf-8") +changed = False + +old_capture = ''' worker->thread = std::jthread([\n &options,\n &manifest_type,\n &manifest_value,\n &object_table,\n''' +new_capture = ''' worker->thread = std::jthread([\n &options,\n &manifest_type,\n &manifest_value,\n &manifest_model,\n &object_table,\n''' +if old_capture in text: + if text.count(old_capture) != 1: + raise SystemExit("expected exactly one worker capture anchor") + text = text.replace(old_capture, new_capture, 1) + changed = True +elif new_capture not in text: + raise SystemExit("worker capture anchor not found") + +old_load = ''' auto local_model = load_manifest_model(\n options.model_manifest, manifest_type, manifest_value);\n const auto local_object_span =\n''' +new_load = ''' auto local_model = load_manifest_model(\n options.model_manifest, manifest_type, manifest_value);\n\n // Process state belongs to the simulated IED, not to an MMS\n // association. Per-association model copies isolate mutable\n // report/control bookkeeping, but every configured control\n // must point at the canonical server-level process/selection\n // state so an Oper performed by one client is immediately\n // visible to a second client and SBO ownership is global.\n for (auto& local_control : local_model.direct_control_storage) {\n const auto shared = std::find_if(\n manifest_model.direct_control_storage.begin(),\n manifest_model.direct_control_storage.end(),\n [&](const auto& candidate) {\n return candidate.domain == local_control.domain &&\n candidate.status_item == local_control.status_item &&\n candidate.control_model == local_control.control_model;\n });\n if (shared == manifest_model.direct_control_storage.end() ||\n shared->shared_state == nullptr) {\n throw std::runtime_error(\n \"Per-association control has no canonical shared state.\");\n }\n local_control.shared_state = shared->shared_state;\n }\n\n const auto local_object_span =\n''' +if old_load in text: + if text.count(old_load) != 1: + raise SystemExit("expected exactly one per-association model-load anchor") + text = text.replace(old_load, new_load, 1) + changed = True +elif new_load not in text: + raise SystemExit("per-association model-load anchor not found") + +if changed: + path.write_text(text, encoding="utf-8") + print("Bound per-association controls to canonical simulated-process shared state.") +else: + print("Cross-association control shared state is already bound.") diff --git a/scripts/agent_fix_iedsim_urcb_control_composition.py b/scripts/agent_fix_iedsim_urcb_control_composition.py new file mode 100644 index 000000000..4a1add435 --- /dev/null +++ b/scripts/agent_fix_iedsim_urcb_control_composition.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +path = Path("tools/static_ied_server.cpp") +text = path.read_text(encoding="utf-8") +changed = False + +old_sizing = ''' mms::MmsStaticUrcbObjectBank sizing_bank{ + *urcb_runtime, + object_table.objects(), + std::span{}, +''' +new_sizing = ''' // Size the URCB object bank from the model that will actually be + // wrapped. Configured controls may already have extended the base + // object table with SBO/SBOw/Oper/Cancel objects. Sizing from the + // original table under-allocates the per-association bank and makes + // initialize() fail before the MMS association can be accepted. + mms::MmsStaticUrcbObjectBank sizing_bank{ + *urcb_runtime, + process_objects->objects(), + std::span{}, +''' +if old_sizing in text: + if text.count(old_sizing) != 1: + raise SystemExit("expected exactly one legacy URCB sizing anchor") + text = text.replace(old_sizing, new_sizing, 1) + changed = True +elif new_sizing not in text: + raise SystemExit("URCB sizing anchor not found in either legacy or repaired form") + +old_table = ''' direct_control_table = std::make_unique( + std::span{direct_control_objects}); +''' +new_table = ''' // MmsStaticObjectTable requires strict (domain,item) ordering. The + // structural model is already sorted, but control-service aliases are + // appended above and can sort before ST/CF leaves (for example $CO$ + // sorts before $ST$). Re-sort the composed per-association table before + // validating or exposing it to GetNameList/GVAA/Read/Write. + std::stable_sort( + direct_control_objects.begin(), + direct_control_objects.end(), + [](const mms::MmsStaticObjectEntry& left, + const mms::MmsStaticObjectEntry& right) noexcept { + if (left.domain != right.domain) return left.domain < right.domain; + return left.item < right.item; + }); + direct_control_table = std::make_unique( + std::span{direct_control_objects}); +''' +if new_table not in text: + if text.count(old_table) != 1: + raise SystemExit( + f"expected one direct-control table anchor, found {text.count(old_table)}") + text = text.replace(old_table, new_table, 1) + changed = True + +if changed: + path.write_text(text, encoding="utf-8") + print("Repaired control-object ordering and URCB composition for IED simulator associations.") +else: + print("IED simulator control/URCB composition is already repaired.") diff --git a/scripts/agent_fix_report_bank_live_refresh.py b/scripts/agent_fix_report_bank_live_refresh.py new file mode 100644 index 000000000..bdf8fb38a --- /dev/null +++ b/scripts/agent_fix_report_bank_live_refresh.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("tools/static_ied_server.cpp") +text = path.read_text(encoding="utf-8") +changed = False + +root_anchor = '''void rebuild_manifest_roots(ManifestModel& model) { + for (std::size_t index = 0U; index < model.root_trees.size(); ++index) { + const auto value_index = model.root_value_indices[index]; + const auto& tree = model.root_trees[index]; + if (tree.children.empty()) continue; + auto& root = model.values[value_index]; + root.type = node_type(tree, model, {}); + root.data = node_data(tree, model); + root.type_specification = mms::MmsServiceCodec::encode_type_specification(root.type); + root.encoded = mms::MmsDataCodec::encode(*root.data); + } +} +''' +root_replacement = root_anchor + ''' +void rebuild_manifest_root_values(ManifestModel& model) { + // Runtime edits change only leaf values. Keep the structural MMS type and + // its encoded type-specification storage stable for the lifetime of every + // composed URCB/BRCB object table. Those tables intentionally copy object + // entries (including spans into type-specification storage), while their + // read callbacks retain pointers to ManifestValue and therefore observe + // freshly encoded data without rebuilding the table hierarchy. + for (std::size_t index = 0U; index < model.root_trees.size(); ++index) { + const auto value_index = model.root_value_indices[index]; + const auto& tree = model.root_trees[index]; + if (tree.children.empty()) continue; + auto& root = model.values[value_index]; + root.data = node_data(tree, model); + root.encoded = mms::MmsDataCodec::encode(*root.data); + } +} +''' +if "void rebuild_manifest_root_values(ManifestModel& model)" not in text: + if text.count(root_anchor) != 1: + raise SystemExit("manifest root rebuild anchor not found exactly once") + text = text.replace(root_anchor, root_replacement, 1) + changed = True + +old_refresh = ''' if (changed != 0U) { + rebuild_manifest_roots(model); + for (const auto value_index : model.root_value_indices) { + model.objects[value_index].type_specification = + model.values[value_index].type_specification; + } + } +''' +new_refresh = ''' if (changed != 0U) { + // Value refresh is deliberately data-only. Re-encoding structural type + // specifications here can reallocate their backing vectors and leave + // the spans copied into composed control/URCB/BRCB tables dangling. + rebuild_manifest_root_values(model); + } +''' +if old_refresh in text: + if text.count(old_refresh) != 1: + raise SystemExit("expected exactly one legacy manifest refresh block") + text = text.replace(old_refresh, new_refresh, 1) + changed = True +elif new_refresh not in text: + raise SystemExit("manifest value-refresh block not found") + +old_banks = ''' if (changed != 0U) { + if (urcb_bank != nullptr && !urcb_bank->initialize()) { + throw std::runtime_error( + "URCB object bank could not refresh its base model views."); + } + for (auto& brcb : brcb_runtimes) { + if (brcb != nullptr && brcb->bank != nullptr && + !brcb->bank->initialize()) { + throw std::runtime_error( + "BRCB object bank could not refresh its base model views."); + } + } + notify_brcb_changes( +''' +new_banks = ''' if (changed != 0U) { + // Object-bank topology, callback contexts and MMS type + // specifications are structural and remain immutable after + // association setup. ManifestValue callbacks read the + // updated encoded payload directly, so reinitializing a + // composed bank here is both unnecessary and unsafe: a + // nested bank can copy aliases from its own storage while + // it is being rebuilt. Notify report runtimes only. + notify_brcb_changes( +''' +if old_banks in text: + if text.count(old_banks) != 1: + raise SystemExit("expected exactly one legacy report-bank refresh block") + text = text.replace(old_banks, new_banks, 1) + changed = True +elif new_banks not in text: + raise SystemExit("report-bank live-refresh block not found") + +if changed: + path.write_text(text, encoding="utf-8") + print("Made live manifest refresh data-only and kept composed report banks structurally stable.") +else: + print("Report-bank live refresh is already structurally stable.") diff --git a/scripts/agent_fix_sbo_enhanced_build.py b/scripts/agent_fix_sbo_enhanced_build.py new file mode 100644 index 000000000..d67bc69d0 --- /dev/null +++ b/scripts/agent_fix_sbo_enhanced_build.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("tools/static_ied_server.cpp") +text = path.read_text(encoding="utf-8") +old = '''[[nodiscard]] bool apply_atomic_boolean(void* context, const bool value) noexcept { + if (context == nullptr) return false; + static_cast*>(context)->store( + value ? 1U : 0U, std::memory_order_relaxed); + return true; +} + +''' +count = text.count(old) +if count != 1: + raise SystemExit(f"static_ied_server.cpp: expected one obsolete apply_atomic_boolean helper, found {count}") +path.write_text(text.replace(old, "", 1), encoding="utf-8") +print("Removed obsolete Direct-Normal apply callback after shared control-state migration.") diff --git a/scripts/agent_integrate_simulation_runtime.py b/scripts/agent_integrate_simulation_runtime.py new file mode 100644 index 000000000..367bc8134 --- /dev/null +++ b/scripts/agent_integrate_simulation_runtime.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one anchor, found {count}: {old[:160]!r}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def replace_between(path: str, start: str, end: str, replacement: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + first = text.find(start) + if first < 0: + raise SystemExit(f"{path}: start anchor not found: {start!r}") + last = text.find(end, first) + if last < 0: + raise SystemExit(f"{path}: end anchor not found: {end!r}") + p.write_text(text[:first] + replacement + text[last:], encoding="utf-8") + + +# Core build integration. +replace_once( + "CMakeLists.txt", + " src/scl/dataset_reference.cpp\n src/scl/parser.cpp\n src/comtrade/model.cpp\n", + " src/scl/dataset_reference.cpp\n src/scl/parser.cpp\n" + " src/simulation/ied_simulator_profile.cpp\n" + " src/simulation/ied_simulator_engine.cpp\n" + " src/comtrade/model.cpp\n", +) +replace_once( + "CMakeLists.txt", + " add_test(NAME ariec61850_scl_tests COMMAND ariec61850_scl_tests)\n\n" + " add_executable(ariec61850_comtrade_tests tests/test_comtrade.cpp)\n", + " add_test(NAME ariec61850_scl_tests COMMAND ariec61850_scl_tests)\n\n" + " add_executable(ariec61850_simulation_tests tests/test_simulation.cpp)\n" + " target_link_libraries(ariec61850_simulation_tests PRIVATE ARIEC61850::core)\n" + " target_compile_definitions(ariec61850_simulation_tests PRIVATE\n" + " ARIEC61850_SOURCE_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}\")\n" + " target_compile_features(ariec61850_simulation_tests PRIVATE cxx_std_20)\n" + " ariec61850_apply_warnings(ariec61850_simulation_tests)\n" + " ariec61850_apply_sanitizers(ariec61850_simulation_tests)\n" + " add_test(NAME ariec61850_simulation_tests COMMAND ariec61850_simulation_tests)\n\n" + " add_executable(ariec61850_comtrade_tests tests/test_comtrade.cpp)\n", +) + +# Header/source strict-build cleanup and deterministic source ordering. +replace_once( + "include/ariec61850/simulation/ied_simulator_profile.hpp", + " double phase_degrees{};\n bool dynamic{};\n", + " double phase_degrees{};\n std::size_t source_order{};\n bool dynamic{};\n", +) +replace_once( + "src/simulation/ied_simulator_profile.cpp", + "#include \n#include \n#include \n", + "#include \n#include \n#include \n#include \n#include \n", +) +replace_once( + "src/simulation/ied_simulator_profile.cpp", + " std::set point_keys;\n const auto append_entry = [&](const scl::SclDataSetEntry& entry, const bool structural) {\n", + " std::set point_keys;\n std::size_t source_order{};\n" + " const auto append_entry = [&](const scl::SclDataSetEntry& entry, const bool structural) {\n", +) +replace_once( + "src/simulation/ied_simulator_profile.cpp", + " auto point = make_point(entry, source_ied, runtime_ied);\n const auto key = point.mms_domain + \"\\n\" + point.mms_item;\n", + " auto point = make_point(entry, source_ied, runtime_ied);\n" + " point.source_order = source_order++;\n" + " const auto key = point.mms_domain + \"\\n\" + point.mms_item;\n", +) + +# Qt adapter consumes the portable profile builder rather than independently +# deciding which SCL leaves exist or how their runtime defaults are classified. +replace_once( + "apps/ied_simulator/src/IedSimulatorController.hpp", + '#include "ariec61850/scl/model.hpp"\n', + '#include "ariec61850/scl/model.hpp"\n' + '#include "ariec61850/simulation/ied_simulator_profile.hpp"\n', +) +replace_once( + "apps/ied_simulator/src/IedSimulatorController.hpp", + " [[nodiscard]] static QVariantMap valueMap(\n" + " const ar::iec61850::scl::SclDataSetEntry& entry);\n", + " [[nodiscard]] static QVariantMap valueMap(\n" + " const ar::iec61850::simulation::IedSimulatorPoint& point);\n", +) + +replace_between( + "apps/ied_simulator/src/IedSimulatorController.cpp", + "QString normalizedType(", + "std::optional controlModelCode", + "", +) +replace_between( + "apps/ied_simulator/src/IedSimulatorController.cpp", + "QString initialValue(", + "QString referenceFor(", + "", +) + +new_runtime_adapter = r'''void IedSimulatorController::rebuildValues() { + values_.clear(); + selectedValueIndex_ = -1; + if (selectedIedIndex_ < 0 || selectedIedIndex_ >= ieds_.size()) { + emit valuesChanged(); + return; + } + const auto ied = ieds_.at(selectedIedIndex_).toMap(); + const int documentIndex = ied.value(QStringLiteral("documentIndex")).toInt(); + if (documentIndex < 0 || documentIndex >= static_cast(documents_.size())) { + emit valuesChanged(); + return; + } + + ar::iec61850::simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = ied.value(QStringLiteral("name")).toString().toStdString(); + options.runtime_ied_name = options.ied_name; + const auto built = ar::iec61850::simulation::IedSimulatorProfileBuilder::build( + documents_[static_cast(documentIndex)].document, + options); + + std::vector points; + points.reserve(built.profile.point_count()); + for (const auto& device : built.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + for (const auto& point : node.points) points.push_back(&point); + } + } + std::stable_sort( + points.begin(), points.end(), + [](const auto* left, const auto* right) { + return left->source_order < right->source_order; + }); + + for (const auto* point : points) { + const auto key = qstring(point->reference); + if (!runtimeValues_.contains(key)) runtimeValues_.insert(key, valueMap(*point)); + values_.push_back(runtimeValues_.value(key)); + } + + selectedValueIndex_ = values_.isEmpty() ? -1 : 0; + emit valuesChanged(); +} + +void IedSimulatorController::seedRuntimeValues() { + if (selectedIedIndex_ < 0 || selectedIedIndex_ >= ieds_.size()) return; + const auto ied = ieds_.at(selectedIedIndex_).toMap(); + const int documentIndex = ied.value(QStringLiteral("documentIndex")).toInt(); + if (documentIndex < 0 || documentIndex >= static_cast(documents_.size())) return; + + ar::iec61850::simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = ied.value(QStringLiteral("name")).toString().toStdString(); + options.runtime_ied_name = options.ied_name; + const auto built = ar::iec61850::simulation::IedSimulatorProfileBuilder::build( + documents_[static_cast(documentIndex)].document, + options); + for (const auto& device : built.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + for (const auto& point : node.points) { + const auto key = qstring(point.reference); + if (!runtimeValues_.contains(key)) runtimeValues_.insert(key, valueMap(point)); + } + } + } +} + +QVariantMap IedSimulatorController::valueMap( + const ar::iec61850::simulation::IedSimulatorPoint& point) { + QVariantMap item; + const auto dataObject = qstring(point.data_object); + const auto dataAttribute = qstring(point.data_attribute); + item.insert( + QStringLiteral("name"), + dataAttribute.isEmpty() ? dataObject : dataObject + QLatin1Char('.') + dataAttribute); + item.insert(QStringLiteral("reference"), qstring(point.reference)); + item.insert(QStringLiteral("logicalDevice"), qstring(point.logical_device)); + item.insert(QStringLiteral("logicalNode"), qstring(point.logical_node)); + item.insert(QStringLiteral("dataObject"), dataObject); + item.insert(QStringLiteral("dataAttribute"), dataAttribute); + item.insert(QStringLiteral("fc"), qstring(point.functional_constraint)); + item.insert(QStringLiteral("cdc"), qstring(point.cdc)); + item.insert(QStringLiteral("type"), qstring(point.display_type)); + item.insert(QStringLiteral("rawType"), qstring(point.basic_type)); + item.insert(QStringLiteral("iedName"), qstring(point.ied_name)); + item.insert(QStringLiteral("mmsDomain"), qstring(point.mms_domain)); + item.insert(QStringLiteral("mmsItem"), qstring(point.mms_item)); + item.insert(QStringLiteral("value"), qstring(point.initial_value)); + item.insert(QStringLiteral("quality"), QStringLiteral("Good")); + item.insert(QStringLiteral("origin"), QStringLiteral("Simulator")); + item.insert(QStringLiteral("writable"), true); + item.insert(QStringLiteral("changed"), false); + item.insert(QStringLiteral("updated"), QStringLiteral("—")); + if (point.display_type == "Enumeration" && point.cdc == "DPC") { + item.insert( + QStringLiteral("options"), + QStringList{ + QStringLiteral("intermediate-state"), + QStringLiteral("off"), + QStringLiteral("on"), + QStringLiteral("bad-state")}); + } else if (point.display_type == "Boolean") { + item.insert( + QStringLiteral("options"), + QStringList{QStringLiteral("false"), QStringLiteral("true")}); + } + return item; +} + +''' +replace_between( + "apps/ied_simulator/src/IedSimulatorController.cpp", + "void IedSimulatorController::rebuildValues() {", + "void IedSimulatorController::appendActivity(", + new_runtime_adapter, +) + +# The old implementation used this include only for a per-function de-dup set. +replace_once( + "apps/ied_simulator/src/IedSimulatorController.cpp", + "#include \n", + "", +) + +print("Native ARIEC simulator profile/engine integrated into ARStack core and Qt adapter.") diff --git a/scripts/agent_trace_iedsim_server.py b/scripts/agent_trace_iedsim_server.py new file mode 100644 index 000000000..6440af705 --- /dev/null +++ b/scripts/agent_trace_iedsim_server.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("apps/ied_simulator/src/IedSimulatorController.cpp") +text = path.read_text(encoding="utf-8") + +old_include = "#include \n#include \n" +new_include = "#include \n#include \n#include \n" +if text.count(old_include) != 1: + raise SystemExit("expected one Qt include anchor") +text = text.replace(old_include, new_include, 1) + +old = '''void IedSimulatorController::processServerLine( + const QString& line, + const bool standardError) { + if (!line.startsWith(QStringLiteral("IEDSIM_EVENT "))) { +''' +new = '''void IedSimulatorController::processServerLine( + const QString& line, + const bool standardError) { + // The GUI normally turns child-server protocol events into activity rows. + // For deterministic CI/interoperability diagnostics, opt in to mirroring + // those exact child lines to the parent process log. This keeps normal UI + // output quiet while making association failures observable in headless QA. + if (qEnvironmentVariableIsSet("ARSTACK_IEDSIM_TRACE_SERVER")) { + if (standardError) { + qWarning().noquote() << line; + } else { + qInfo().noquote() << line; + } + } + if (!line.startsWith(QStringLiteral("IEDSIM_EVENT "))) { +''' +if text.count(old) != 1: + raise SystemExit("expected one processServerLine anchor") +text = text.replace(old, new, 1) +path.write_text(text, encoding="utf-8") +print("Enabled opt-in child MMS server trace forwarding for headless regression diagnostics.") diff --git a/scripts/test-mms-server-live-discovery.py b/scripts/test-mms-server-live-discovery.py index 196b23ac1..d8154d38d 100644 --- a/scripts/test-mms-server-live-discovery.py +++ b/scripts/test-mms-server-live-discovery.py @@ -249,11 +249,11 @@ def main() -> int: ("--mask", "0x3C"), 2, 9, - 19, + 20, 1, 1, 2, - 19, + 20, ), ) for profile in profiles: 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/goose/publisher_runtime.cpp b/src/goose/publisher_runtime.cpp index 079f88512..3f770cca8 100644 --- a/src/goose/publisher_runtime.cpp +++ b/src/goose/publisher_runtime.cpp @@ -4,6 +4,7 @@ #include "ariec61850/goose/frame_codec.hpp" +#include #include #include #include @@ -31,7 +32,7 @@ GoosePublication GoosePublisherSession::publish_initial( test_ = test; needs_commissioning_ = needs_commissioning; has_state_ = true; - return emit(); + return emit_publication(); } GoosePublication GoosePublisherSession::publish_state_change( @@ -48,17 +49,17 @@ GoosePublication GoosePublisherSession::publish_state_change( state_timestamp_ = timestamp; test_ = test; needs_commissioning_ = needs_commissioning; - return emit(); + return emit_publication(); } GoosePublication GoosePublisherSession::publish_retransmission() { if (!has_state_) { throw std::logic_error("GOOSE publisher session must be started before retransmission."); } - return emit(); + return emit_publication(); } -GoosePublication GoosePublisherSession::emit() { +GoosePublication GoosePublisherSession::emit_publication() { auto frame = frame_template_; frame.pdu.values = values_; frame.pdu.timestamp = state_timestamp_; @@ -99,9 +100,10 @@ GoosePublication GoosePublisherRuntime::start( throw std::logic_error("GOOSE publisher runtime is already running."); } schedule_.reset(); + const auto delay = prepare_next_delay(); auto publication = session_.publish_initial(values, timestamp, test, needs_commissioning); running_ = true; - schedule_next(now); + schedule_next(now, delay); return publication; } @@ -112,10 +114,11 @@ GoosePublication GoosePublisherRuntime::state_change( const bool test, const bool needs_commissioning) { schedule_.reset(); + const auto delay = prepare_next_delay(); auto publication = session_.publish_state_change( values, timestamp, test, needs_commissioning); running_ = true; - schedule_next(now); + schedule_next(now, delay); return publication; } @@ -123,8 +126,9 @@ std::optional GoosePublisherRuntime::poll(const clock::time_po if (!running_ || !next_due_.has_value() || now < *next_due_) { return std::nullopt; } + const auto delay = prepare_next_delay(); auto publication = session_.publish_retransmission(); - schedule_next(now); + schedule_next(now, delay); return publication; } @@ -133,8 +137,22 @@ void GoosePublisherRuntime::stop() noexcept { next_due_.reset(); } -void GoosePublisherRuntime::schedule_next(const clock::time_point now) noexcept { - next_due_ = now + std::chrono::milliseconds{schedule_.next_delay_milliseconds()}; +std::uint32_t GoosePublisherRuntime::prepare_next_delay() noexcept { + const auto delay = static_cast( + std::max(1, schedule_.next_delay_milliseconds())); + // IEC 61850 GOOSE timeAllowedToLive advertises the subscriber validity + // window. Keep it ahead of the next scheduled retransmission while + // saturating safely instead of wrapping for unusually large schedules. + const auto maximum = std::numeric_limits::max(); + const auto ttl = delay > maximum / 2U ? maximum : delay * 2U; + session_.set_time_allowed_to_live(std::max(1U, ttl)); + return delay; +} + +void GoosePublisherRuntime::schedule_next( + const clock::time_point now, + const std::uint32_t delay_milliseconds) noexcept { + next_due_ = now + std::chrono::milliseconds{delay_milliseconds}; } } // namespace ar::iec61850::goose diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index 951aeab54..65bdab596 100644 --- a/src/mms/association_runtime.cpp +++ b/src/mms/association_runtime.cpp @@ -4,6 +4,7 @@ #include "ariec61850/acse/association.hpp" #include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/presentation.hpp" #include "ariec61850/osi/session.hpp" #include @@ -16,7 +17,7 @@ namespace ar::iec61850::mms { namespace { struct AssociationAttemptProfile final { - const char* name; + std::string name; std::vector payload; }; @@ -118,13 +119,111 @@ struct AssociationAttemptProfile final { }; } -[[nodiscard]] std::array -csharp_association_profiles() { - return {{ +void append_oid_subidentifier( + std::vector& destination, + std::uint64_t value) { + std::array encoded{}; + std::size_t count{}; + do { + encoded[count++] = static_cast(value & 0x7FU); + value >>= 7U; + } while (value != 0U); + while (count > 0U) { + --count; + destination.push_back(static_cast( + encoded[count] | (count == 0U ? 0x00U : 0x80U))); + } +} + +[[nodiscard]] std::vector encode_oid_arcs( + const std::span arcs) { + if (arcs.size() < 2U || arcs.size() > 32U || arcs[0] > 2U || + (arcs[0] < 2U && arcs[1] > 39U)) { + throw std::invalid_argument("Called AP-title OID arcs are invalid."); + } + std::vector encoded; + encoded.reserve(arcs.size() * 2U); + append_oid_subidentifier( + encoded, + static_cast(arcs[0]) * 40U + arcs[1]); + for (std::size_t index = 2U; index < arcs.size(); ++index) { + append_oid_subidentifier(encoded, arcs[index]); + } + if (encoded.size() > acse::AcseAssociationCodec::maximum_oid_bytes) { + throw std::length_error("Called AP-title OID exceeds the bounded BER length."); + } + return encoded; +} + +[[nodiscard]] std::vector session_parameters( + const MmsAssociationAddressing& addressing) { + auto parameters = osi::SessionCodec::default_parameters(); + if (addressing.called_s_selector.empty()) return parameters; + const auto found = std::find_if( + parameters.begin(), parameters.end(), [](const auto& parameter) { + return parameter.code == 0x34U; // called Session selector + }); + if (found == parameters.end()) { + parameters.push_back({0x34U, addressing.called_s_selector}); + } else { + found->value = addressing.called_s_selector; + } + return parameters; +} + +[[nodiscard]] std::vector scl_association_request( + const MmsAssociationAddressing& addressing) { + auto aarq = acse::AcseAssociationCodec::default_balanced_aarq(); + if (!addressing.called_ap_title.empty()) { + aarq.called_ap_title = encode_oid_arcs(addressing.called_ap_title); + } + if (addressing.called_ae_qualifier) { + aarq.called_ae_qualifier = addressing.called_ae_qualifier; + } + const auto encoded_aarq = acse::AcseAssociationCodec::encode_aarq(aarq); + const auto contexts = osi::PresentationCodec::default_contexts(); + const auto cp = osi::PresentationCodec::encode_cp( + contexts, + 1U, + encoded_aarq, + {}, + addressing.called_p_selector); + const auto parameters = session_parameters(addressing); + return osi::SessionCodec::encode_connect(cp, parameters); +} + +[[nodiscard]] std::vector association_profiles( + const MmsAssociationOptions& options) { + if (options.addressing) { + return {{"SclEngineering", scl_association_request(*options.addressing)}}; + } + return { {"BalancedApTitle", acse::AcseAssociationCodec::build_default_association_request()}, {"LegacyMinimal", csharp_legacy_minimal_association_request()}, - }}; + }; +} + +[[nodiscard]] std::vector cotp_connection_request( + const MmsAssociationOptions& options, + const std::uint16_t source_reference) { + const std::array default_selector{0x00U, 0x01U}; + const auto destination_selector = + options.addressing && !options.addressing->called_t_selector.empty() + ? std::span{options.addressing->called_t_selector} + : std::span{default_selector}; + const std::array parameters{ + osi::CotpParameter{ + osi::CotpFrameCodec::tpdu_size_parameter, + {options.tpdu_size_code}}, + osi::CotpParameter{ + osi::CotpFrameCodec::source_tsap_parameter, + {0x00U, 0x01U}}, + osi::CotpParameter{ + osi::CotpFrameCodec::destination_tsap_parameter, + std::vector{destination_selector.begin(), destination_selector.end()}}, + }; + return osi::CotpFrameCodec::encode_connection_request(source_reference, parameters); } } // namespace @@ -144,6 +243,17 @@ MmsAssociationRuntime::MmsAssociationRuntime( if (options_.presentation_context_id == 0U) { throw std::invalid_argument("MMS presentation context ID must be positive."); } + if (options_.addressing) { + const auto& addressing = *options_.addressing; + if (addressing.called_p_selector.size() > osi::PresentationCodec::maximum_selector_bytes || + addressing.called_s_selector.size() > 64U || + addressing.called_t_selector.size() > 64U) { + throw std::invalid_argument("SCL-derived OSI selector exceeds the bounded limit."); + } + if (!addressing.called_ap_title.empty()) { + static_cast(encode_oid_arcs(addressing.called_ap_title)); + } + } negotiated_.tpdu_size_code = options_.tpdu_size_code; negotiated_.presentation_context_id = options_.presentation_context_id; } @@ -220,16 +330,12 @@ std::vector MmsAssociationRuntime::receive_application_payload( std::size_t receive_chunks = 0U; while (!reassembler.is_complete()) { require_not_cancelled(stop_token); - if (receive_chunks >= options_.maximum_receive_chunks_per_operation) { - throw MmsAssociationRuntimeError( - "MMS receive chunk limit exceeded before a complete COTP payload arrived."); - } - auto bytes = transport_.receive(deadline, stop_token); - ++receive_chunks; - if (bytes.empty()) { - throw MmsAssociationRuntimeError("MMS transport returned an empty receive chunk."); - } - tpkt_decoder_.append(bytes); + + // A single TCP recv() may contain more than one complete TPKT frame. + // Always consume frames already buffered by the previous receive before + // waiting for new socket bytes. Otherwise a confirmed response followed + // immediately by an InformationReport can leave the report stranded in + // tpkt_decoder_ until unrelated network traffic arrives. osi::TpktFrame frame; while (tpkt_decoder_.try_pop(frame)) { const auto tpdu = osi::CotpFrameCodec::decode(frame.payload); @@ -248,6 +354,20 @@ std::vector MmsAssociationRuntime::receive_application_payload( break; } } + if (reassembler.is_complete()) { + break; + } + + if (receive_chunks >= options_.maximum_receive_chunks_per_operation) { + throw MmsAssociationRuntimeError( + "MMS receive chunk limit exceeded before a complete COTP payload arrived."); + } + auto bytes = transport_.receive(deadline, stop_token); + ++receive_chunks; + if (bytes.empty()) { + throw MmsAssociationRuntimeError("MMS transport returned an empty receive chunk."); + } + tpkt_decoder_.append(bytes); } return reassembler.complete(); } @@ -276,7 +396,7 @@ void MmsAssociationRuntime::connect( negotiated_.tpdu_size_code = options_.tpdu_size_code; negotiated_.presentation_context_id = options_.presentation_context_id; - const auto profiles = csharp_association_profiles(); + const auto profiles = association_profiles(options_); for (std::size_t profile_index = 0U; profile_index < profiles.size(); ++profile_index) { @@ -286,8 +406,8 @@ void MmsAssociationRuntime::connect( require_not_cancelled(stop_token); // Match the C# oracle: every association profile gets a fresh TCP/COTP - // transport. Do not reuse an association that was rejected, malformed, - // timed out, or otherwise faulted. + // transport. An explicit SCL engineering profile intentionally has no + // silent generic retry; generic mode retains the proven two-profile path. transport_.close(); invoke_router_.clear(); information_reports_.clear(); @@ -312,14 +432,14 @@ void MmsAssociationRuntime::connect( state_ = MmsAssociationRuntimeState::transport_connected; add_event( MmsAssociationEventKind::transport_connected, - std::string{profile.name} + ": byte transport connected."); + profile.name + ": byte transport connected."); state_ = MmsAssociationRuntimeState::cotp_connecting; add_event( MmsAssociationEventKind::state_changed, - std::string{profile.name} + ": sending COTP Connection Request."); + profile.name + ": sending COTP Connection Request."); const auto connection_request = - osi::CotpFrameCodec::encode_default_connection_request(); + cotp_connection_request(options_, local_cotp_reference_); send_tpkt_payload(connection_request, deadline, stop_token); std::size_t chunks = 0U; @@ -364,13 +484,13 @@ void MmsAssociationRuntime::connect( state_ = MmsAssociationRuntimeState::cotp_connected; add_event( MmsAssociationEventKind::cotp_connected, - std::string{profile.name} + ": COTP connected with TPDU size code " + + profile.name + ": COTP connected with TPDU size code " + std::to_string(negotiated_.tpdu_size_code) + "."); state_ = MmsAssociationRuntimeState::acse_associating; add_event( MmsAssociationEventKind::state_changed, - std::string{profile.name} + + profile.name + ": sending ISO Session/Presentation/ACSE association request."); send_application_payload(profile.payload, deadline, stop_token); const auto association_payload = @@ -389,7 +509,7 @@ void MmsAssociationRuntime::connect( // Preserve the existing safety rule inside one profile: a strict, // explicit AARE rejection is never reinterpreted as acceptance by the // tolerant marker-based compatibility path. It is, however, eligible - // for the next C# oracle profile after a full reconnect. + // for the next C# oracle profile after a full reconnect in generic mode. if (response && !response->aare.accepted()) { throw MmsAssociationRuntimeError( "ACSE association was rejected with result=" + @@ -436,13 +556,13 @@ void MmsAssociationRuntime::connect( } used_csharp_compatibility = true; acceptance_message = - std::string{profile.name} + ": " + compatibility_message + + profile.name + ": " + compatibility_message + (strict_failure.empty() ? std::string{} : " Strict decoder diagnostic: " + strict_failure); } else { acceptance_message = - std::string{profile.name} + + profile.name + ": MMS association accepted; maximum PDU=" + std::to_string(negotiated_.maximum_mms_pdu_size) + "."; } @@ -466,7 +586,7 @@ void MmsAssociationRuntime::connect( throw; } catch (const MmsTransportTimeoutError& exception) { const auto message = - std::string{profile.name} + ": timeout: " + exception.what(); + profile.name + ": timeout: " + exception.what(); association_attempts_.push_back({profile.name, false, message}); state_ = MmsAssociationRuntimeState::faulted; last_fault_ = message; @@ -475,7 +595,7 @@ void MmsAssociationRuntime::connect( } catch (const std::exception& exception) { if (stop_token.stop_requested()) { const auto message = - std::string{profile.name} + ": cancelled: " + exception.what(); + profile.name + ": cancelled: " + exception.what(); state_ = MmsAssociationRuntimeState::faulted; last_fault_ = message; add_event(MmsAssociationEventKind::cancelled, message); @@ -484,7 +604,7 @@ void MmsAssociationRuntime::connect( } const auto message = - std::string{profile.name} + ": " + exception.what(); + profile.name + ": " + exception.what(); association_attempts_.push_back({profile.name, false, message}); state_ = MmsAssociationRuntimeState::faulted; last_fault_ = message; @@ -500,7 +620,9 @@ void MmsAssociationRuntime::connect( remote_cotp_reference_ = 0U; state_ = MmsAssociationRuntimeState::faulted; - std::string message = "All C# MMS association profiles failed"; + std::string message = options_.addressing + ? "SCL MMS association profile failed" + : "All C# MMS association profiles failed"; if (!association_attempts_.empty()) { message += ": "; for (std::size_t index = 0U; index < association_attempts_.size(); ++index) { diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index 8d3caf4d0..34c55a123 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -7,6 +7,7 @@ #include "ariec61850/mms/pdu_span.hpp" #include "ariec61850/mms/services_span.hpp" #include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/cotp_tpkt_stream.hpp" #include "ariec61850/osi/presentation_span.hpp" #include "ariec61850/osi/session_span.hpp" #include "ariec61850/osi/tpkt_span.hpp" @@ -102,54 +103,43 @@ constexpr std::uint32_t kServerMaximumNestingLevel = 5U; const std::size_t consumed, const MmsStaticConnectionState state, const std::size_t negotiated_tpdu_size_bytes, - const std::span response, - const std::span workspace) noexcept { - std::size_t cotp_required{}; - if (!add_overhead(session_or_presentation.size(), 3U, cotp_required)) { - return make_result(MmsStaticConnectionStatus::backend_failure, state); - } - if (negotiated_tpdu_size_bytes == 0U || - cotp_required > negotiated_tpdu_size_bytes) { - return make_result( - MmsStaticConnectionStatus::peer_limit_exceeded, state, consumed); - } - - std::size_t required{}; - if (!add_overhead(cotp_required, osi::TpktSpanCodec::header_length, required)) { - return make_result(MmsStaticConnectionStatus::backend_failure, state); - } - if (response.size() < required) { - return make_response_capacity(state, required); - } - if (workspace.size() < required) { - return make_workspace_capacity(state, required); - } - - const auto cotp = osi::CotpSpanCodec::encode_data_into( - session_or_presentation, - response.first(required - osi::TpktSpanCodec::header_length)); - if (!cotp.success()) { - if (cotp.status == wire::EncodeStatus::buffer_too_small) { - return make_response_capacity( - state, cotp.required_bytes + osi::TpktSpanCodec::header_length); - } - return make_result(MmsStaticConnectionStatus::backend_failure, state); - } - - const auto tpkt = osi::TpktSpanCodec::encode_into( - response.first(cotp.bytes_written), workspace.first(required)); - if (!tpkt.success() || tpkt.bytes_written != required) { - if (tpkt.status == wire::EncodeStatus::buffer_too_small) { - return make_workspace_capacity(state, tpkt.required_bytes); + const std::span response) noexcept { + const auto encoded = osi::CotpTpktDataStreamSpanCodec::encode_into( + session_or_presentation, negotiated_tpdu_size_bytes, response); + if (!encoded.success()) { + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity(state, encoded.required_bytes); } - return make_result(MmsStaticConnectionStatus::backend_failure, state); + return make_result( + negotiated_tpdu_size_bytes == 0U + ? MmsStaticConnectionStatus::peer_limit_exceeded + : MmsStaticConnectionStatus::backend_failure, + state, + consumed); } - std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); return make_result( MmsStaticConnectionStatus::response_ready, state, consumed, - tpkt.bytes_written); + encoded.bytes_written); +} + +[[nodiscard]] bool framed_mms_response_size( + const std::uint32_t presentation_context_id, + const std::size_t mms_bytes, + const std::size_t negotiated_tpdu_size_bytes, + std::size_t& required) noexcept { + required = 0U; + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, mms_bytes); + osi::CotpTpktDataStreamPlan plan; + if (!fully_encoded || + !osi::CotpTpktDataStreamSpanCodec::try_plan( + *fully_encoded, negotiated_tpdu_size_bytes, plan)) { + return false; + } + required = plan.required_bytes; + return true; } [[nodiscard]] MmsConfirmedRequestRejectReason reject_reason_for( @@ -258,6 +248,7 @@ constexpr std::uint32_t kServerMaximumNestingLevel = 5U; const MmsConfirmedPduView& confirmed, const MmsStaticApplicationDispatcher& dispatcher, const std::uint32_t presentation_context_id, + const std::size_t negotiated_tpdu_size_bytes, std::size_t& mms_required, std::size_t& frame_required) noexcept { mms_required = 0U; @@ -288,11 +279,14 @@ constexpr std::uint32_t kServerMaximumNestingLevel = 5U; return false; } mms_required = probe.required_bytes; - const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( - presentation_context_id, probe.required_bytes); - return fully_encoded && add_overhead(*fully_encoded, 11U, frame_required); + return framed_mms_response_size( + presentation_context_id, + probe.required_bytes, + negotiated_tpdu_size_bytes, + frame_required); } + } // namespace void MmsStaticConnectionRuntime::notify_association_closed() noexcept { @@ -500,8 +494,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( peek.frame_bytes, MmsStaticConnectionState::established, negotiated_tpdu_size_bytes_, - response, - workspace); + response); if (!wrapped.response_ready()) { return wrapped; } @@ -546,13 +539,11 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( confirmed, dispatcher_, mms_presentation_context_id_, + negotiated_tpdu_size_bytes_, write_mms_required, write_frame_required)) { if (negotiated_mms_pdu_size_ == 0U || - write_mms_required > negotiated_mms_pdu_size_ || - write_frame_required < osi::TpktSpanCodec::header_length || - write_frame_required - osi::TpktSpanCodec::header_length > - negotiated_tpdu_size_bytes_) { + write_mms_required > negotiated_mms_pdu_size_) { return make_result( MmsStaticConnectionStatus::peer_limit_exceeded, state_, @@ -567,15 +558,44 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( } } - const auto application = dispatcher_.dispatch( - pdv.single_asn1_type, response, workspace, policy_.access_context()); + MmsStaticDispatchResult application; + bool extension_handled{}; + if (is_confirmed_request && policy_.confirmed_service != nullptr) { + const auto extension = policy_.confirmed_service( + policy_.confirmed_service_context, + confirmed.service(), + confirmed.invoke_id, + confirmed.service_constructed, + confirmed.service_value, + response); + extension_handled = extension.handled; + if (extension.handled) { + application.service = confirmed.service(); + application.invoke_id = confirmed.invoke_id; + application.bytes_written = extension.encoded.bytes_written; + application.required_bytes = extension.encoded.required_bytes; + if (extension.encoded.success()) { + application.status = MmsStaticDispatchStatus::response_ready; + } else if (extension.encoded.status == wire::EncodeStatus::buffer_too_small) { + application.status = MmsStaticDispatchStatus::response_buffer_too_small; + } else { + application.status = MmsStaticDispatchStatus::backend_failure; + } + } + } + if (!extension_handled) { + application = dispatcher_.dispatch( + pdv.single_asn1_type, response, workspace, policy_.access_context()); + } std::size_t mms_response_bytes = application.bytes_written; if (!application.success()) { if (application.status == MmsStaticDispatchStatus::response_buffer_too_small) { - const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( - mms_presentation_context_id_, application.required_bytes); std::size_t required{}; - if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) { + if (framed_mms_response_size( + mms_presentation_context_id_, + application.required_bytes, + negotiated_tpdu_size_bytes_, + required)) { auto result = make_response_capacity(state_, required); result.application_status = application.status; result.application_service = application.service; @@ -623,10 +643,12 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( response); if (!rejected.success()) { if (rejected.status == wire::EncodeStatus::buffer_too_small) { - const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( - mms_presentation_context_id_, rejected.required_bytes); std::size_t required{}; - if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) { + if (framed_mms_response_size( + mms_presentation_context_id_, + rejected.required_bytes, + negotiated_tpdu_size_bytes_, + required)) { auto result = make_response_capacity(state_, required); result.application_status = application.status; result.application_service = application.service; @@ -655,10 +677,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( true); if (!p_data.success()) { if (p_data.status == wire::EncodeStatus::buffer_too_small) { - std::size_t required{}; - if (add_overhead(p_data.required_bytes, 7U, required)) { - return make_workspace_capacity(state_, required); - } + return make_workspace_capacity(state_, p_data.required_bytes); } state_ = MmsStaticConnectionState::fault; return make_result(MmsStaticConnectionStatus::backend_failure, state_); @@ -669,8 +688,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( peek.frame_bytes, state_, negotiated_tpdu_size_bytes_, - response, - workspace); + response); if (wrapped.response_ready()) { wrapped.application_status = application.status; wrapped.application_service = application.service; diff --git a/src/mms/connection_runtime_iedsim.cpp b/src/mms/connection_runtime_iedsim.cpp index 6da4e90a8..87aa7790c 100644 --- a/src/mms/connection_runtime_iedsim.cpp +++ b/src/mms/connection_runtime_iedsim.cpp @@ -6,6 +6,7 @@ #include "ariec61850/mms/pdu_span.hpp" #include "ariec61850/mms/services_span.hpp" #include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/cotp_tpkt_stream.hpp" #include "ariec61850/osi/presentation_span.hpp" #include "ariec61850/osi/session_span.hpp" #include "ariec61850/osi/tpkt_span.hpp" @@ -29,11 +30,11 @@ constexpr std::uint32_t kServerMaximumNestingLevel = 5U; constexpr std::array kConservativeStructureType{ 0xA2U, 0x02U, 0xA1U, 0x00U}; -// FileDirectory-Response service value: listOfDirectoryEntry [0] empty, -// moreFollows [1] FALSE. Mirrors the deterministic behavior of the proven -// ARIEC61850 engineering-client simulator without claiming file-server support. -constexpr std::array kEmptyFileDirectoryFields{ - 0xA0U, 0x00U, 0x81U, 0x01U, 0x00U}; +// IEDScout golden empty FileDirectory response: [0] contains an empty +// SEQUENCE OF DirectoryEntry. moreFollows is DEFAULT FALSE and therefore omitted. +// Exact service value: A0 02 30 00. +constexpr std::array kEmptyFileDirectoryFields{ + 0xA0U, 0x02U, 0x30U, 0x00U}; [[nodiscard]] MmsStaticConnectionResult make_result( const MmsStaticConnectionStatus status, @@ -111,22 +112,17 @@ constexpr std::array kEmptyFileDirectoryFields{ std::size_t& maximum_user_data, std::size_t& segment_count, std::size_t& required) noexcept { - maximum_user_data = 0U; - segment_count = 0U; - required = 0U; - if (negotiated_tpdu_size_bytes <= 3U) { + osi::CotpTpktDataStreamPlan plan; + if (!osi::CotpTpktDataStreamSpanCodec::try_plan( + payload_bytes, negotiated_tpdu_size_bytes, plan)) { + maximum_user_data = 0U; + segment_count = 0U; + required = 0U; return false; } - - maximum_user_data = negotiated_tpdu_size_bytes - 3U; - segment_count = payload_bytes == 0U - ? 1U - : 1U + ((payload_bytes - 1U) / maximum_user_data); - if (segment_count > - (std::numeric_limits::max() - payload_bytes) / 7U) { - return false; - } - required = payload_bytes + segment_count * 7U; + maximum_user_data = plan.maximum_user_data; + segment_count = plan.segment_count; + required = plan.required_bytes; return true; } @@ -136,58 +132,24 @@ constexpr std::array kEmptyFileDirectoryFields{ const MmsStaticConnectionState state, const std::size_t negotiated_tpdu_size_bytes, const std::span response) noexcept { - std::size_t maximum_user_data{}; - std::size_t segment_count{}; - std::size_t required{}; - if (!cotp_stream_size( - session_or_presentation.size(), - negotiated_tpdu_size_bytes, - maximum_user_data, - segment_count, - required)) { - return make_result(MmsStaticConnectionStatus::peer_limit_exceeded, state, consumed); - } - if (response.size() < required) { - return make_response_capacity(state, required); - } - - std::size_t input_offset = 0U; - std::size_t output_offset = 0U; - for (std::size_t segment = 0U; segment < segment_count; ++segment) { - const auto remaining = session_or_presentation.size() - input_offset; - const auto chunk_size = std::min(remaining, maximum_user_data); - const auto final_segment = segment + 1U == segment_count; - const auto frame_bytes = chunk_size + 7U; - if (frame_bytes > std::numeric_limits::max()) { - return make_result(MmsStaticConnectionStatus::backend_failure, state); - } - - auto frame = response.subspan(output_offset, frame_bytes); - const auto cotp = osi::CotpSpanCodec::encode_data_into( - session_or_presentation.subspan(input_offset, chunk_size), - frame.subspan(osi::TpktSpanCodec::header_length), - final_segment, - 0U); - if (!cotp.success() || cotp.bytes_written != chunk_size + 3U) { - return make_result(MmsStaticConnectionStatus::backend_failure, state); + const auto encoded = osi::CotpTpktDataStreamSpanCodec::encode_into( + session_or_presentation, negotiated_tpdu_size_bytes, response); + if (!encoded.success()) { + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity(state, encoded.required_bytes); } - - frame[0] = 0x03U; - frame[1] = 0x00U; - frame[2] = static_cast((frame_bytes >> 8U) & 0xFFU); - frame[3] = static_cast(frame_bytes & 0xFFU); - input_offset += chunk_size; - output_offset += frame_bytes; - } - - if (input_offset != session_or_presentation.size() || output_offset != required) { - return make_result(MmsStaticConnectionStatus::backend_failure, state); + return make_result( + negotiated_tpdu_size_bytes == 0U + ? MmsStaticConnectionStatus::peer_limit_exceeded + : MmsStaticConnectionStatus::backend_failure, + state, + consumed); } return make_result( MmsStaticConnectionStatus::response_ready, state, consumed, - required); + encoded.bytes_written); } [[nodiscard]] MmsStaticConnectionResult application_rejected( @@ -746,6 +708,32 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( confirmed.invoke_id)); } + if (policy_.confirmed_service != nullptr) { + const auto extension = policy_.confirmed_service( + policy_.confirmed_service_context, + confirmed.service(), + confirmed.invoke_id, + confirmed.service_constructed, + confirmed.service_value, + response); + if (extension.handled) { + return finish(wrap_mms_response( + extension.encoded, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_mms_pdu_size_, + negotiated_tpdu_size_bytes_, + response, + workspace, + extension.encoded.success() + ? MmsStaticDispatchStatus::response_ready + : MmsStaticDispatchStatus::backend_failure, + confirmed.service(), + confirmed.invoke_id)); + } + } + if (confirmed.service() == MmsWireConfirmedService::file_directory) { const auto directory = confirmed.service_constructed ? MmsPduSpanCodec::encode_confirmed_response_into( diff --git a/src/mms/data_codec.cpp b/src/mms/data_codec.cpp index 9a6be509d..4031d75fc 100644 --- a/src/mms/data_codec.cpp +++ b/src/mms/data_codec.cpp @@ -30,6 +30,25 @@ std::uint32_t read_u32_be(const std::span bytes) noexcept { static_cast(bytes[3]); } +std::uint64_t read_u64_be(const std::span bytes) noexcept { + std::uint64_t value{}; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | static_cast(bytes[index]); + } + return value; +} + +std::vector encode_double_precision_float(const double value) { + std::vector result(9U); + result[0] = 0x0BU; // ISO 9506 floating-point exponent width for IEEE binary64. + const auto raw = std::bit_cast(value); + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + result[index + 1U] = static_cast((raw >> shift) & 0xFFU); + } + return result; +} + std::string bytes_to_hex(const std::span bytes) { std::ostringstream stream; stream << std::uppercase << std::hex << std::setfill('0'); @@ -92,7 +111,7 @@ MmsDataValue MmsDataCodec::decode(const asn1::BerTlv& tlv) { return MmsDataValue::unsigned_integer( asn1::BerReader::read_unsigned_integer(tlv).value_or(0)); case 7: - return MmsDataValue::floating_point(decode_floating_point(tlv.value)); + return decode_floating_point(tlv.value); case 9: return MmsDataValue::octet_string(tlv.value); case 10: @@ -190,17 +209,14 @@ std::vector MmsDataCodec::encode_content(const MmsDataValue& value case MmsDataKind::unsigned_integer: return asn1::BerWriter::encode_unsigned_integer( std::get(value.value())); - case MmsDataKind::floating_point: { - float result = 0.0F; + case MmsDataKind::floating_point: if (const auto* single = std::get_if(&value.value())) { - result = *single; - } else if (const auto* double_value = std::get_if(&value.value())) { - result = static_cast(*double_value); - } else { - throw std::invalid_argument("MMS floating-point scalar is missing."); + return asn1::BerWriter::encode_single_precision_float(*single); } - return asn1::BerWriter::encode_single_precision_float(result); - } + if (const auto* double_value = std::get_if(&value.value())) { + return encode_double_precision_float(*double_value); + } + throw std::invalid_argument("MMS floating-point scalar is missing."); case MmsDataKind::visible_string: case MmsDataKind::mms_string: return asn1::BerWriter::encode_ascii(std::get(value.value())); @@ -246,15 +262,25 @@ MmsDataValue MmsDataCodec::decode_bit_string( return MmsDataValue::bit_string(bytes[0], bytes.subspan(1U)); } -float MmsDataCodec::decode_floating_point( +MmsDataValue MmsDataCodec::decode_floating_point( const std::span bytes) noexcept { + if (bytes.size() == 9U) { + return MmsDataValue::floating_point( + std::bit_cast(read_u64_be(bytes.subspan(1U, 8U)))); + } + if (bytes.size() == 8U) { + return MmsDataValue::floating_point( + std::bit_cast(read_u64_be(bytes))); + } if (bytes.size() == 5U) { - return std::bit_cast(read_u32_be(bytes.subspan(1U, 4U))); + return MmsDataValue::floating_point( + std::bit_cast(read_u32_be(bytes.subspan(1U, 4U)))); } if (bytes.size() == 4U) { - return std::bit_cast(read_u32_be(bytes)); + return MmsDataValue::floating_point( + std::bit_cast(read_u32_be(bytes))); } - return std::numeric_limits::quiet_NaN(); + return MmsDataValue::floating_point(std::numeric_limits::quiet_NaN()); } } // namespace ar::iec61850::mms diff --git a/src/mms/data_set_span.cpp b/src/mms/data_set_span.cpp index 4deb74bc9..ed098eb08 100644 --- a/src/mms/data_set_span.cpp +++ b/src/mms/data_set_span.cpp @@ -225,7 +225,9 @@ bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( } MmsObjectNameView name; if (!MmsServiceSpanCodec::try_decode_object_name_view(confirmed.service_value, name) || - name.kind != MmsObjectNameViewKind::domain_specific) { + (name.kind != MmsObjectNameViewKind::domain_specific && + name.kind != MmsObjectNameViewKind::vmd_specific && + name.kind != MmsObjectNameViewKind::aa_specific)) { return false; } request.invoke_id = confirmed.invoke_id; diff --git a/src/mms/file_service.cpp b/src/mms/file_service.cpp index 79d26985f..b46f74caa 100644 --- a/src/mms/file_service.cpp +++ b/src/mms/file_service.cpp @@ -516,13 +516,14 @@ std::vector MmsFileServiceCodec::encode_file_directory_request_pdu BerWriter body; const auto directory = normalize_remote_path(request.directory_name, true); const auto continuation = normalize_remote_path(request.continue_after, true); - if (!directory.empty()) { - const auto name = graphic_string(directory); - body.write_tlv(BerClass::context_specific, true, 0, name); - } + // Captured IEDScout root FileDirectory request keeps fileSpecification + // present and encodes root as one empty GraphicString: A0 02 19 00. + // Do not omit [0] for root; several engineering clients use this wire shape. + const auto name = graphic_string(directory); + body.write_tlv(BerClass::context_specific, true, 0, name); if (!continuation.empty()) { - const auto name = graphic_string(continuation); - body.write_tlv(BerClass::context_specific, true, 1, name); + const auto continuation_name = graphic_string(continuation); + body.write_tlv(BerClass::context_specific, true, 1, continuation_name); } return MmsPduCodec::encode_confirmed_request( {request.invoke_id, file_directory_service_tag, true, body.to_vector()}); @@ -743,6 +744,47 @@ MmsFileCloseResponse MmsFileServiceCodec::decode_file_close_response( return {service.invoke_id}; } +std::vector MmsFileServiceCodec::encode_file_delete_request_pdu( + const MmsFileDeleteRequest& request) { + validate_invoke_id(request.invoke_id); + const auto normalized = normalize_remote_path(request.remote_path); + const auto wire_path = request.rooted_backslash + ? rooted_backslash_path(normalized) + : normalized; + // Captured IEDScout FileDelete [76] carries the GraphicString directly + // inside the constructed service value: BF 4C 19 "\path". + return MmsPduCodec::encode_confirmed_request( + {request.invoke_id, file_delete_service_tag, true, graphic_string(wire_path)}); +} + +std::vector MmsFileServiceCodec::encode_file_delete_request_p_data( + const MmsFileDeleteRequest& request, + const std::uint32_t presentation_context_id) { + return MmsPduCodec::wrap_p_data( + encode_file_delete_request_pdu(request), presentation_context_id); +} + +MmsFileDeleteResponse MmsFileServiceCodec::decode_file_delete_response( + const std::span payload, + const std::uint32_t expected_invoke_id) { + const auto service = decode_service_response( + payload, expected_invoke_id, file_delete_service_tag, "FileDelete"); + if (!service.service_value.empty()) { + throw_file_error({ + "FileDelete", + MmsFileFailureKind::malformed_response, + false, + "FileDelete successful response must be empty.", + service.invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + {}, + hex_preview(payload)}); + } + return {service.invoke_id}; +} + std::uint32_t MmsAssociationFileServiceChannel::next_invoke_id() { return association_.next_invoke_id(); } @@ -906,6 +948,83 @@ MmsFileDirectoryResult MmsFileTransferRuntime::list_directory( return result; } +MmsFileDeleteResult MmsFileTransferRuntime::remove( + const std::string& remote_path, + const bool rooted_backslash, + const std::stop_token stop_token) { + MmsFileDeleteResult result; + try { + result.remote_path = MmsFileServiceCodec::normalize_remote_path(remote_path); + } catch (const std::exception& exception) { + result.failure_kind = MmsFileFailureKind::invalid_argument; + result.message = exception.what(); + return result; + } + if (stop_token.stop_requested()) { + result.failure_kind = MmsFileFailureKind::cancelled; + result.message = "FileDelete cancelled before request dispatch."; + return result; + } + + const auto invoke_id = channel_.next_invoke_id(); + std::vector encoded; + try { + encoded = MmsFileServiceCodec::encode_file_delete_request_p_data( + {invoke_id, result.remote_path, rooted_backslash}, + channel_.presentation_context_id()); + const auto response_payload = channel_.exchange_confirmed( + encoded, invoke_id, stop_token); + static_cast(MmsFileServiceCodec::decode_file_delete_response( + response_payload, invoke_id)); + append_diagnostic(result.diagnostics, { + "FileDelete", + MmsFileFailureKind::none, + true, + "FileDelete completed for one physical remote path.", + invoke_id, + std::nullopt, + std::nullopt, + std::nullopt, + hex_preview(encoded), + hex_preview(response_payload)}, + 8U, codec_hex_preview_bytes); + result.success = true; + result.message = "FileDelete completed."; + return result; + } catch (const MmsFileServiceError& exception) { + auto diagnostic = exception.diagnostic(); + diagnostic.request_hex = hex_preview(encoded); + append_diagnostic(result.diagnostics, std::move(diagnostic), + 8U, codec_hex_preview_bytes); + result.failure_kind = exception.diagnostic().failure_kind; + result.message = exception.what(); + } catch (const MmsTransportCancelledError& exception) { + auto diagnostic = exception_diagnostic( + "FileDelete", invoke_id, hex_preview(encoded), exception, + MmsFileFailureKind::cancelled); + result.failure_kind = diagnostic.failure_kind; + result.message = diagnostic.message; + append_diagnostic(result.diagnostics, std::move(diagnostic), + 8U, codec_hex_preview_bytes); + } catch (const MmsTransportTimeoutError& exception) { + auto diagnostic = exception_diagnostic( + "FileDelete", invoke_id, hex_preview(encoded), exception, + MmsFileFailureKind::timed_out); + result.failure_kind = diagnostic.failure_kind; + result.message = diagnostic.message; + append_diagnostic(result.diagnostics, std::move(diagnostic), + 8U, codec_hex_preview_bytes); + } catch (const std::exception& exception) { + auto diagnostic = exception_diagnostic( + "FileDelete", invoke_id, hex_preview(encoded), exception); + result.failure_kind = diagnostic.failure_kind; + result.message = diagnostic.message; + append_diagnostic(result.diagnostics, std::move(diagnostic), + 8U, codec_hex_preview_bytes); + } + return result; +} + MmsFileTransferResult MmsFileTransferRuntime::download( const std::string& remote_path, MmsFileSink& sink, diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index d362fc628..2da6d4b47 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -117,10 +117,11 @@ void validate_options(const MmsLiveDiscoveryOptions& options) { } // Mirrors ARIEC61850 LiveIedVariableTypeProbePlanner: probe one MMS type tree at -// each Logical Node root instead of probing every LN$FC$DO$DA leaf. A normal -// IEC 61850 server can return the nested FC/DO/DA TypeSpecification from this -// root, reducing a large model from thousands of type requests to roughly the -// Logical Node count. +// each Logical Node root instead of probing every LN$FC$DO$DA leaf. Standards- +// shaped servers usually advertise the Logical Node root directly (for example +// "GGIO1"), while older/flat profiles may advertise only aliases such as +// "GGIO1$ST$Ind1$stVal". Accept both forms and de-duplicate by domain/LN so a +// hierarchical TypeSpecification is still requested exactly once per LN. [[nodiscard]] std::vector build_logical_node_type_probe_candidates( const MmsDiscoverySnapshot& snapshot, const std::size_t maximum_probes) { @@ -130,11 +131,13 @@ void validate_options(const MmsLiveDiscoveryOptions& options) { for (const auto& [domain, variables] : snapshot.domain_variables) { for (const auto& item : variables) { + if (item.empty()) continue; const auto separator = item.find('$'); - if (separator == std::string::npos || separator == 0U) { - continue; - } - const auto logical_node = item.substr(0U, separator); + if (separator == 0U) continue; + const auto logical_node = separator == std::string::npos + ? item + : item.substr(0U, separator); + if (logical_node.empty()) continue; const auto identity = lower_ascii(domain) + "\n" + lower_ascii(logical_node); if (!seen.insert(identity).second) { continue; diff --git a/src/mms/pdu.cpp b/src/mms/pdu.cpp index e10cef2c9..7bd172239 100644 --- a/src/mms/pdu.cpp +++ b/src/mms/pdu.cpp @@ -365,6 +365,11 @@ MmsConfirmedService MmsConfirmedRequest::service() const noexcept { case 5: return MmsConfirmedService::write; case 6: return MmsConfirmedService::get_variable_access_attributes; case 12: return MmsConfirmedService::get_named_variable_list_attributes; + case 72: return MmsConfirmedService::file_open; + case 73: return MmsConfirmedService::file_read; + case 74: return MmsConfirmedService::file_close; + case 76: return MmsConfirmedService::file_delete; + case 77: return MmsConfirmedService::file_directory; default: return MmsConfirmedService::unknown; } } @@ -377,6 +382,11 @@ MmsConfirmedService MmsConfirmedResponse::service() const noexcept { case 5: return MmsConfirmedService::write; case 6: return MmsConfirmedService::get_variable_access_attributes; case 12: return MmsConfirmedService::get_named_variable_list_attributes; + case 72: return MmsConfirmedService::file_open; + case 73: return MmsConfirmedService::file_read; + case 74: return MmsConfirmedService::file_close; + case 76: return MmsConfirmedService::file_delete; + case 77: return MmsConfirmedService::file_directory; default: return MmsConfirmedService::unknown; } } diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp index e58584ba8..09b212dc7 100644 --- a/src/mms/pdu_span.cpp +++ b/src/mms/pdu_span.cpp @@ -391,6 +391,10 @@ MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { case 5: return MmsWireConfirmedService::write; case 6: return MmsWireConfirmedService::get_variable_access_attributes; case 12: return MmsWireConfirmedService::get_named_variable_list_attributes; + case 72: return MmsWireConfirmedService::file_open; + case 73: return MmsWireConfirmedService::file_read; + case 74: return MmsWireConfirmedService::file_close; + case 76: return MmsWireConfirmedService::file_delete; case 77: return MmsWireConfirmedService::file_directory; default: return MmsWireConfirmedService::unknown; } diff --git a/src/mms/report_subscription_runtime.cpp b/src/mms/report_subscription_runtime.cpp index 016b2f5f1..750d73a90 100644 --- a/src/mms/report_subscription_runtime.cpp +++ b/src/mms/report_subscription_runtime.cpp @@ -12,6 +12,11 @@ namespace ar::iec61850::mms { namespace { +constexpr std::uint8_t kTriggerOptionsUnusedBits = 2U; +constexpr std::uint8_t kOptionalFieldsUnusedBits = 6U; +constexpr std::size_t kTriggerOptionsBytes = 1U; +constexpr std::size_t kOptionalFieldsBytes = 2U; + [[nodiscard]] bool contains_attribute( const MmsReportControlCandidate& candidate, const std::string& attribute) { @@ -30,6 +35,31 @@ namespace { return exchange.envelope.mms_payload; } +[[nodiscard]] bool valid_bit_payload( + const std::span payload, + const std::size_t expected_bytes, + const std::uint8_t unused_bits) noexcept { + if (payload.size() != expected_bytes || payload.empty() || unused_bits > 7U) { + return false; + } + if (unused_bits == 0U) { + return true; + } + const auto mask = static_cast((1U << unused_bits) - 1U); + return (payload.back() & mask) == 0U; +} + +[[nodiscard]] bool bit_field_matches( + const MmsReportBitField& live, + const std::span desired, + const std::uint8_t unused_bits) noexcept { + if (live.raw.size() != desired.size() + 1U || live.raw.empty() || + live.raw.front() != unused_bits) { + return false; + } + return std::equal(desired.begin(), desired.end(), live.raw.begin() + 1); +} + } // namespace MmsReportSubscriptionRuntime::MmsReportSubscriptionRuntime( @@ -51,6 +81,24 @@ MmsReportSubscriptionRuntime::MmsReportSubscriptionRuntime( throw std::invalid_argument( "Report subscription event limit must be positive."); } + if (options_.write_trigger_options && + !valid_bit_payload( + options_.trigger_options, + kTriggerOptionsBytes, + kTriggerOptionsUnusedBits)) { + throw std::invalid_argument( + "TrgOps must contain exactly six IEC 61850 bits in one octet; " + "the two unused low bits must be zero."); + } + if (options_.write_optional_fields && + !valid_bit_payload( + options_.optional_fields, + kOptionalFieldsBytes, + kOptionalFieldsUnusedBits)) { + throw std::invalid_argument( + "OptFlds must contain exactly ten IEC 61850 bits in two octets; " + "the six unused low bits must be zero."); + } } std::vector MmsReportSubscriptionRuntime::probe_attributes() const { @@ -162,6 +210,7 @@ void MmsReportSubscriptionRuntime::start( enabled_by_runtime_ = false; reservation_touched_ = false; + restore_enabled_required_ = false; cleanup_required_ = false; monitor_.clear(); received_reports_ = 0U; @@ -171,23 +220,56 @@ void MmsReportSubscriptionRuntime::start( set_state(MmsReportSubscriptionState::probing, "Probing RCB state before any write."); const auto current = probe(stop_token); - const auto enabled_elsewhere = current.report_enabled.value_or(false) && - current.availability != MmsRcbAvailability::used_by_caller; - if (enabled_elsewhere) { + const auto was_enabled = current.report_enabled.value_or(false); + const auto caller_owned_enabled = + was_enabled && options_.allow_reenable_caller_owned; + + if (was_enabled && !caller_owned_enabled) { throw MmsReportSubscriptionError( - "RCB is already enabled and is treated as owned by another session."); + "RCB is already enabled; explicit caller-owned re-enable permission is required."); } - if (current.availability == MmsRcbAvailability::in_use) { + if (current.availability == MmsRcbAvailability::in_use && + !caller_owned_enabled) { throw MmsReportSubscriptionError( "RCB is reserved or enabled by another session."); } - if (current.report_enabled.value_or(false) && - !options_.allow_reenable_caller_owned) { - throw MmsReportSubscriptionError( - "RCB is already enabled; explicit caller-owned re-enable permission is required."); + + std::string desired_data_set; + if (options_.write_data_set_reference) { + if (options_.data_set_reference.empty()) { + throw MmsReportSubscriptionError( + "DataSet write was requested without a DataSet reference."); + } + desired_data_set = MmsDataSetDirectoryCodec::to_report_attribute_value( + options_.data_set_reference); + } + + const auto data_set_changed = options_.write_data_set_reference && + current.data_set_reference != desired_data_set; + const auto trigger_options_changed = options_.write_trigger_options && + !bit_field_matches( + current.trigger_options, + options_.trigger_options, + kTriggerOptionsUnusedBits); + const auto optional_fields_changed = options_.write_optional_fields && + !bit_field_matches( + current.optional_fields, + options_.optional_fields, + kOptionalFieldsUnusedBits); + const auto configuration_changed = + data_set_changed || trigger_options_changed || optional_fields_changed; + + if (was_enabled && configuration_changed) { + set_state(MmsReportSubscriptionState::configuring, + "Disabling caller-owned RCB before configuration delta write."); + write_attribute("RptEna", MmsDataValue::boolean(false), stop_token); + restore_enabled_required_ = true; + add_event(MmsReportSubscriptionEventKind::disabled, + "Caller-owned RCB disabled for verified reconfiguration."); } - if (!candidate_.buffered && options_.reserve_unbuffered_rcb && + if (!was_enabled && !candidate_.buffered && + options_.reserve_unbuffered_rcb && contains_attribute(candidate_, "Resv")) { set_state(MmsReportSubscriptionState::reserving, "Reserving unbuffered RCB."); @@ -198,45 +280,99 @@ void MmsReportSubscriptionRuntime::start( } set_state(MmsReportSubscriptionState::configuring, - "Applying requested RCB configuration."); - if (options_.write_data_set_reference) { - if (options_.data_set_reference.empty()) { - throw MmsReportSubscriptionError( - "DataSet write was requested without a DataSet reference."); - } + configuration_changed + ? "Applying only changed RCB configuration attributes." + : "Live RCB configuration already matches requested values; no configuration write required."); + if (data_set_changed) { write_attribute( "DatSet", - MmsDataValue::visible_string( - MmsDataSetDirectoryCodec::to_report_attribute_value( - options_.data_set_reference)), + MmsDataValue::visible_string(desired_data_set), stop_token); } - if (options_.write_trigger_options) { + if (trigger_options_changed) { write_attribute( "TrgOps", - MmsDataValue::bit_string(0U, options_.trigger_options), + MmsDataValue::bit_string( + kTriggerOptionsUnusedBits, + options_.trigger_options), stop_token); } - if (options_.write_optional_fields) { + if (optional_fields_changed) { write_attribute( "OptFlds", - MmsDataValue::bit_string(0U, options_.optional_fields), + MmsDataValue::bit_string( + kOptionalFieldsUnusedBits, + options_.optional_fields), stop_token); } - set_state(MmsReportSubscriptionState::enabling, - "Enabling RCB with RptEna=true."); - write_attribute("RptEna", MmsDataValue::boolean(true), stop_token); - enabled_by_runtime_ = true; - add_event(MmsReportSubscriptionEventKind::enabled, - "RCB enabled by this runtime."); + if (configuration_changed || reservation_touched_) { + set_state(MmsReportSubscriptionState::probing, + "Verifying RCB state after configuration/reservation writes."); + const auto verified = probe(stop_token); + if (data_set_changed && verified.data_set_reference != desired_data_set) { + throw MmsReportSubscriptionError( + "RCB DataSet read-back verification failed."); + } + if (trigger_options_changed && + !bit_field_matches( + verified.trigger_options, + options_.trigger_options, + kTriggerOptionsUnusedBits)) { + throw MmsReportSubscriptionError( + "RCB TrgOps read-back verification failed."); + } + if (optional_fields_changed && + !bit_field_matches( + verified.optional_fields, + options_.optional_fields, + kOptionalFieldsUnusedBits)) { + throw MmsReportSubscriptionError( + "RCB OptFlds read-back verification failed."); + } + if (reservation_touched_ && + contains_attribute(candidate_, "Resv") && + !verified.reserved.value_or(false)) { + throw MmsReportSubscriptionError( + "URCB reservation read-back verification failed."); + } + } + + if (!was_enabled || configuration_changed) { + set_state(MmsReportSubscriptionState::enabling, + "Enabling RCB only after configuration read-back verification."); + write_attribute("RptEna", MmsDataValue::boolean(true), stop_token); + if (was_enabled) { + restore_enabled_required_ = true; + } else { + enabled_by_runtime_ = true; + } + + set_state(MmsReportSubscriptionState::probing, + "Verifying RptEna after enable write."); + const auto enabled_state = probe(stop_token); + if (!enabled_state.report_enabled.value_or(false)) { + throw MmsReportSubscriptionError( + "RCB enable read-back verification failed."); + } + if (was_enabled) { + restore_enabled_required_ = false; + } + add_event(MmsReportSubscriptionEventKind::enabled, + was_enabled + ? "Caller-owned RCB re-enabled after verified reconfiguration." + : "RCB enabled by this runtime after verified configuration."); + } else { + add_event(MmsReportSubscriptionEventKind::enabled, + "Caller-owned RCB was already enabled; redundant RptEna write skipped."); + } if (options_.trigger_general_interrogation && contains_attribute(candidate_, "GI")) { try { write_attribute("GI", MmsDataValue::boolean(true), stop_token); add_event(MmsReportSubscriptionEventKind::general_interrogation_sent, - "General interrogation requested after RCB enable."); + "General interrogation requested after verified RCB enable."); } catch (const std::exception& exception) { add_event(MmsReportSubscriptionEventKind::cleanup_deferred, "RCB remains enabled, but GI write failed: " + @@ -248,7 +384,15 @@ void MmsReportSubscriptionRuntime::start( "Persistent report subscription is active."); } catch (const std::exception& exception) { if (association_.associated()) { - if (enabled_by_runtime_) { + if (restore_enabled_required_) { + const auto restored = try_write_attribute_noexcept( + "RptEna", MmsDataValue::boolean(true), stop_token); + if (restored) { + restore_enabled_required_ = false; + add_event(MmsReportSubscriptionEventKind::enabled, + "Caller-owned RCB enable state restored after failed reconfiguration."); + } + } else if (enabled_by_runtime_) { const auto disabled = try_write_attribute_noexcept( "RptEna", MmsDataValue::boolean(false), stop_token); enabled_by_runtime_ = !disabled; @@ -259,7 +403,8 @@ void MmsReportSubscriptionRuntime::start( reservation_touched_ = !released; } } - cleanup_required_ = enabled_by_runtime_ || reservation_touched_; + cleanup_required_ = + enabled_by_runtime_ || reservation_touched_ || restore_enabled_required_; fail(exception.what()); throw; } @@ -275,7 +420,8 @@ void MmsReportSubscriptionRuntime::stop( "Stopping persistent report subscription."); if (!association_.associated()) { - cleanup_required_ = enabled_by_runtime_ || reservation_touched_; + cleanup_required_ = + enabled_by_runtime_ || reservation_touched_ || restore_enabled_required_; if (cleanup_required_) { state_ = MmsReportSubscriptionState::cleanup_required; add_event(MmsReportSubscriptionEventKind::cleanup_deferred, @@ -288,6 +434,16 @@ void MmsReportSubscriptionRuntime::stop( } bool cleanup_ok = true; + if (restore_enabled_required_) { + if (try_write_attribute_noexcept( + "RptEna", MmsDataValue::boolean(true), stop_token)) { + restore_enabled_required_ = false; + add_event(MmsReportSubscriptionEventKind::enabled, + "Caller-owned RCB enable state restored."); + } else { + cleanup_ok = false; + } + } if (enabled_by_runtime_) { if (try_write_attribute_noexcept( "RptEna", MmsDataValue::boolean(false), stop_token)) { @@ -309,7 +465,8 @@ void MmsReportSubscriptionRuntime::stop( } } - cleanup_required_ = !cleanup_ok || enabled_by_runtime_ || reservation_touched_; + cleanup_required_ = !cleanup_ok || enabled_by_runtime_ || + reservation_touched_ || restore_enabled_required_; if (cleanup_required_) { state_ = MmsReportSubscriptionState::cleanup_required; add_event(MmsReportSubscriptionEventKind::cleanup_deferred, @@ -331,7 +488,6 @@ bool MmsReportSubscriptionRuntime::poll_once( return envelope.information_report || ingested != 0U; } - void MmsReportSubscriptionRuntime::run(const std::stop_token stop_token) { if (!active()) { throw MmsReportSubscriptionError( diff --git a/src/mms/reporting.cpp b/src/mms/reporting.cpp index d935c758c..2d67cd21d 100644 --- a/src/mms/reporting.cpp +++ b/src/mms/reporting.cpp @@ -169,7 +169,6 @@ const MmsDataValue& require_value(const MmsInformationReport& report, const std: return *item.value; } - std::string normalize_data_set_item(const std::string& item) { auto parts = split(item, '$'); if (parts.size() < 2U) return item; @@ -217,9 +216,6 @@ MmsObjectName decode_variable_definition(const BerTlv& definition) { } MmsObjectName decode_variable_list_name(const BerTlv& specification) { - // VariableAccessSpecification.variableListName [1] is found in both - // explicit-wrapper and implicit ObjectName forms on real IEC 61850 IEDs. - // First accept a nested ObjectName, then reconstruct the implicit choice. try { return MmsServiceCodec::decode_object_name(specification.value); } catch (const std::exception&) { @@ -229,7 +225,6 @@ MmsObjectName decode_variable_list_name(const BerTlv& specification) { } } - std::string continuity_message(const MmsReportContinuityEventKind kind) { switch (kind) { case MmsReportContinuityEventKind::first_report: return "First report observed."; @@ -567,7 +562,7 @@ MmsReportHeader MmsReportFrameMapper::decode_header(const MmsInformationReport& MmsReportFrame MmsReportFrameMapper::map( const MmsInformationReport& report, const std::span members) { static constexpr std::array reason_names{ - "data-change", "quality-change", "data-update", "integrity", "general-interrogation", "application-trigger"}; + nullptr, "data-change", "quality-change", "data-update", "integrity", "general-interrogation"}; MmsReportFrame frame; frame.header = decode_header(report); frame.raw_access_result_count = report.items.size(); @@ -591,8 +586,10 @@ MmsReportFrame MmsReportFrameMapper::map( } const auto included_count = frame.included_data_set_indexes.size(); - std::vector value_items; - for (std::size_t i = 0U; i < included_count; ++i) value_items.push_back(require_item(report, cursor++)); + // IEC 61850-8-1 report access results are grouped after the + // inclusion bitstring: DataRef* (optional), Value*, then + // ReasonForInclusion* (optional). Keep the cursor in that exact + // order so metadata can never be projected as process values. std::vector data_references(included_count); if (frame.header.optional_fields.has("data-reference")) { for (auto& reference : data_references) { @@ -601,6 +598,11 @@ MmsReportFrame MmsReportFrameMapper::map( reference = *value; } } + std::vector value_items; + value_items.reserve(included_count); + for (std::size_t i = 0U; i < included_count; ++i) { + value_items.push_back(require_item(report, cursor++)); + } std::vector reasons(included_count); if (frame.header.optional_fields.has("reason-for-inclusion")) { for (auto& reason : reasons) reason = decode_bit_field(require_value(report, cursor++, "reason-for-inclusion"), reason_names); @@ -663,7 +665,8 @@ MmsReportControlState MmsReportControlStateMapper::map_read_response( else if (attribute == "EntryID") state.entry_id = octets(value); else if (attribute == "TimeOfEntry") state.time_of_entry = value; else if (attribute == "TrgOps") { - static constexpr std::array names{"data-change", "quality-change", "data-update", "integrity", "general-interrogation", "application-trigger"}; + static constexpr std::array names{ + nullptr, "data-change", "quality-change", "data-update", "integrity", "general-interrogation"}; state.trigger_options = decode_bit_field(value, names); } else if (attribute == "OptFlds") { static constexpr std::array names{"reserved", "sequence-number", "report-time-stamp", "reason-for-inclusion", "data-set-name", "data-reference", "buffer-overflow", "entry-id", "configuration-revision", "segmentation"}; diff --git a/src/mms/services.cpp b/src/mms/services.cpp index b4db1b406..5eb251c1d 100644 --- a/src/mms/services.cpp +++ b/src/mms/services.cpp @@ -190,7 +190,20 @@ std::vector encode_type_impl( } const auto size_content = [&type]() { - return type.size ? positive_integer_content(*type.size) : std::vector{}; + if (!type.size) return std::vector{}; + if (type.variable_length) { + if (*type.size == 0U) { + throw MmsFormatError("Variable-length MMS TypeSpecification bound must be positive."); + } + auto encoded = BerWriter::encode_signed_integer( + -static_cast(*type.size)); + // OMICRON IEDScout emits variable maximum lengths in at least a + // two-octet signed field. In the golden capture Octet64 is + // 89 02 FF C0 (not the mathematically minimal 89 01 C0). + if (encoded.size() == 1U) encoded.insert(encoded.begin(), 0xFFU); + return encoded; + } + return positive_integer_content(*type.size); }; switch (type.kind) { @@ -262,11 +275,28 @@ std::vector encode_type_impl( throw MmsFormatError("Cannot encode an unknown MMS TypeSpecification."); } -std::optional read_optional_size(const BerTlv& tlv) { - if (tlv.value.empty()) { - return std::nullopt; +struct DecodedTypeSize final { + std::optional size; + bool variable{}; +}; + +DecodedTypeSize read_optional_size(const BerTlv& tlv) { + if (tlv.value.empty()) return {}; + + const auto value = BerReader::read_signed_integer(tlv); + if (!value) { + throw MmsFormatError("MMS type size is not a signed INTEGER."); + } + const bool variable = *value < 0; + const auto magnitude = variable + ? static_cast(-(*value + 1)) + 1U + : static_cast(*value); + if (magnitude > std::numeric_limits::max()) { + throw MmsFormatError("MMS type size is outside the supported 32-bit range."); } - return read_u32(tlv, "MMS type size"); + return { + static_cast(magnitude), + variable}; } MmsTypeSpecification decode_type_impl( @@ -375,7 +405,17 @@ MmsTypeSpecification decode_type_impl( default: throw MmsFormatError("Unsupported MMS TypeSpecification tag."); } - result.size = read_optional_size(tlv); + const auto decoded_size = read_optional_size(tlv); + result.size = decoded_size.size; + result.variable_length = decoded_size.variable; + if (result.variable_length && + result.kind != MmsTypeKind::octet_string && + result.kind != MmsTypeKind::visible_string && + result.kind != MmsTypeKind::mms_string && + result.kind != MmsTypeKind::object_id) { + throw MmsFormatError( + "Negative MMS TypeSpecification size is only valid for variable-length string/octet types."); + } return result; } diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp index e7f3b9b66..59ffa0ae6 100644 --- a/src/mms/static_brcb_connection.cpp +++ b/src/mms/static_brcb_connection.cpp @@ -3,6 +3,7 @@ #include "ariec61850/mms/static_brcb_connection.hpp" #include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/cotp_tpkt_stream.hpp" #include "ariec61850/osi/presentation_span.hpp" #include "ariec61850/osi/tpkt_span.hpp" @@ -15,18 +16,6 @@ namespace ar::iec61850::mms { namespace { -[[nodiscard]] bool add_size( - const std::size_t base, - const std::size_t extra, - std::size_t& total) noexcept { - if (extra > std::numeric_limits::max() - base) { - total = 0U; - return false; - } - total = base + extra; - return true; -} - [[nodiscard]] MmsStaticBrcbConnectionResult make_result( const MmsStaticBrcbConnectionStatus status, const MmsStaticBrcbEntryView* entry = nullptr) noexcept { @@ -95,63 +84,36 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( if (p_data.status != wire::EncodeStatus::buffer_too_small) { return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); } - std::size_t final_required{}; - if (!add_size(p_data.required_bytes, 7U, final_required)) { - return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + osi::CotpTpktDataStreamPlan plan; + if (!osi::CotpTpktDataStreamSpanCodec::try_plan( + p_data.required_bytes, connection.negotiated_tpdu_size_bytes(), plan)) { + return make_result(MmsStaticBrcbConnectionStatus::peer_limit_exceeded, &entry); } auto result = make_result( MmsStaticBrcbConnectionStatus::workspace_too_small, &entry); - result.required_workspace_bytes = final_required; - result.required_response_bytes = final_required; - return result; - } - - std::size_t cotp_required{}; - if (!add_size(p_data.bytes_written, 3U, cotp_required)) { - return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); - } - const auto tpdu_limit = connection.negotiated_tpdu_size_bytes(); - if (tpdu_limit == 0U || cotp_required > tpdu_limit) { - return make_result(MmsStaticBrcbConnectionStatus::peer_limit_exceeded, &entry); - } - - std::size_t final_required{}; - if (!add_size(cotp_required, osi::TpktSpanCodec::header_length, final_required)) { - return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); - } - if (response.size() < final_required) { - auto result = make_result( - MmsStaticBrcbConnectionStatus::response_buffer_too_small, &entry); - result.required_response_bytes = final_required; - return result; - } - if (workspace.size() < final_required) { - auto result = make_result( - MmsStaticBrcbConnectionStatus::workspace_too_small, &entry); - result.required_response_bytes = final_required; - result.required_workspace_bytes = final_required; + result.required_workspace_bytes = p_data.required_bytes; + result.required_response_bytes = plan.required_bytes; return result; } - const auto cotp = osi::CotpSpanCodec::encode_data_into( + const auto framed = osi::CotpTpktDataStreamSpanCodec::encode_into( workspace.first(p_data.bytes_written), - response.first(final_required - osi::TpktSpanCodec::header_length)); - if (!cotp.success()) { - return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); - } - - const auto tpkt = osi::TpktSpanCodec::encode_into( - response.first(cotp.bytes_written), - workspace.first(final_required)); - if (!tpkt.success() || tpkt.bytes_written != final_required) { - return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + connection.negotiated_tpdu_size_bytes(), + response); + if (!framed.success()) { + if (framed.status == wire::EncodeStatus::buffer_too_small) { + auto result = make_result( + MmsStaticBrcbConnectionStatus::response_buffer_too_small, &entry); + result.required_response_bytes = framed.required_bytes; + return result; + } + return make_result(MmsStaticBrcbConnectionStatus::peer_limit_exceeded, &entry); } auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry); - std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); - result.bytes_written = tpkt.bytes_written; - result.required_response_bytes = final_required; - result.required_workspace_bytes = final_required; + result.bytes_written = framed.bytes_written; + result.required_response_bytes = framed.required_bytes; + result.required_workspace_bytes = p_data.bytes_written; return result; } diff --git a/src/mms/static_brcb_control.cpp b/src/mms/static_brcb_control.cpp index 3aeb051ce..821d11a55 100644 --- a/src/mms/static_brcb_control.cpp +++ b/src/mms/static_brcb_control.cpp @@ -167,6 +167,59 @@ MmsStaticBrcbControlStatus MmsStaticBrcbControl::set_report_enabled( return map_runtime_status(reports_->set_enabled(enabled)); } +MmsStaticBrcbControlStatus MmsStaticBrcbControl::set_optional_fields( + const MmsStaticBrcbClientIdentity& client, + const std::span optional_fields, + const std::uint64_t now_ms) noexcept { + const auto access = require_control_access(client, now_ms); + if (access != MmsStaticBrcbControlStatus::ok) { + return access; + } + if (reports_->enabled()) { + return MmsStaticBrcbControlStatus::temporarily_unavailable; + } + return map_runtime_status(reports_->set_optional_fields(optional_fields)); +} + +MmsStaticBrcbControlStatus MmsStaticBrcbControl::set_trigger_options( + const MmsStaticBrcbClientIdentity& client, + const std::uint8_t trigger_options, + const std::uint64_t now_ms) noexcept { + const auto access = require_control_access(client, now_ms); + if (access != MmsStaticBrcbControlStatus::ok) { + return access; + } + if (reports_->enabled()) { + return MmsStaticBrcbControlStatus::temporarily_unavailable; + } + return map_runtime_status(reports_->set_trigger_options(trigger_options)); +} + +MmsStaticBrcbControlStatus MmsStaticBrcbControl::set_integrity_period( + const MmsStaticBrcbClientIdentity& client, + const std::uint32_t integrity_period_ms, + const std::uint64_t now_ms) noexcept { + const auto access = require_control_access(client, now_ms); + if (access != MmsStaticBrcbControlStatus::ok) return access; + if (reports_->enabled()) { + return MmsStaticBrcbControlStatus::temporarily_unavailable; + } + return map_runtime_status(reports_->set_integrity_period(integrity_period_ms)); +} + +MmsStaticBrcbControlStatus MmsStaticBrcbControl::request_general_interrogation( + const MmsStaticBrcbClientIdentity& client, + const std::uint64_t now_ms) noexcept { + const auto access = require_control_access(client, now_ms); + if (access != MmsStaticBrcbControlStatus::ok) { + return access; + } + if (!reports_->enabled()) { + return MmsStaticBrcbControlStatus::temporarily_unavailable; + } + return map_runtime_status(reports_->request_general_interrogation()); +} + MmsStaticBrcbControlStatus MmsStaticBrcbControl::replay_from( const MmsStaticBrcbClientIdentity& client, const std::span entry_id, diff --git a/src/mms/static_brcb_objects.cpp b/src/mms/static_brcb_objects.cpp index 840f453a1..8eb061350 100644 --- a/src/mms/static_brcb_objects.cpp +++ b/src/mms/static_brcb_objects.cpp @@ -17,11 +17,16 @@ namespace ar::iec61850::mms { namespace { constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kUnsigned16Type{0x86U, 0x01U, 0x10U}; constexpr std::array kUnsigned32Type{0x86U, 0x01U, 0x20U}; constexpr std::array kInteger16Type{0x85U, 0x01U, 0x10U}; -constexpr std::array kOctetString8Type{0x89U, 0x01U, 0x08U}; -constexpr std::array kOctetString16Type{0x89U, 0x01U, 0x10U}; -constexpr std::array kVisible255Type{0x8AU, 0x02U, 0x00U, 0xFFU}; +constexpr std::array kVariableOctet8Type{0x89U, 0x02U, 0xFFU, 0xF8U}; +constexpr std::array kVariableOctet64Type{0x89U, 0x02U, 0xFFU, 0xC0U}; +constexpr std::array kVisible129Type{0x8AU, 0x02U, 0xFFU, 0x7FU}; +constexpr std::array kBitString10Type{0x84U, 0x01U, 0x0AU}; +constexpr std::array kBitString6Type{0x84U, 0x01U, 0x06U}; +// Golden IEDScout TypeSpecification for TimeofEntry is binary-time timeOfDay(1). +constexpr std::array kBinaryTimeType{0x8CU, 0x01U, 0x01U}; constexpr std::array @@ -30,8 +35,15 @@ constexpr std::array destination) noexcept { @@ -231,6 +278,32 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; return capacity; } +[[nodiscard]] wire::EncodeResult encode_bit_string( + const std::uint8_t unused_bits, + const std::span bytes, + const std::span destination) noexcept { + if (unused_bits > 7U || bytes.size() == std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content_size = bytes.size() + 1U; + const auto total = asn1::BerSpanWriter::tlv_size(4, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, content_size) || + !writer.write_byte(unused_bits) || !writer.write_bytes(bytes) || + writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + [[nodiscard]] wire::EncodeResult encode_octets( const std::span bytes, const std::span destination) noexcept { @@ -251,6 +324,29 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; return capacity; } +[[nodiscard]] wire::EncodeResult encode_binary_time( + const std::span bytes, + const std::span destination) noexcept { + if (bytes.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto total = asn1::BerSpanWriter::tlv_size(12, bytes.size()); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 12, bytes.size()) || + !writer.write_bytes(bytes) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + [[nodiscard]] wire::EncodeResult read_brcb_attribute( const void* raw_context, const std::span destination) noexcept { @@ -272,12 +368,30 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; destination); case MmsStaticBrcbAttribute::conf_revision: return encode_unsigned(context->definition->conf_revision, destination); + case MmsStaticBrcbAttribute::optional_fields: + return encode_bit_string(6U, context->reports->optional_fields(), destination); + case MmsStaticBrcbAttribute::buffer_time: + return encode_unsigned(context->definition->buffer_time_ms, destination); + case MmsStaticBrcbAttribute::sequence_number: + return encode_unsigned(context->reports->sequence_number(), destination); + case MmsStaticBrcbAttribute::trigger_options: { + const std::array trigger{context->reports->trigger_options()}; + return encode_bit_string(2U, trigger, destination); + } + case MmsStaticBrcbAttribute::integrity_period: + return encode_unsigned(context->reports->integrity_period_ms(), destination); + case MmsStaticBrcbAttribute::general_interrogation: + return encode_boolean(context->reports->general_interrogation_pending(), destination); case MmsStaticBrcbAttribute::purge_buffer: return encode_boolean(false, destination); case MmsStaticBrcbAttribute::entry_id: { const auto entry_id = context->reports->latest_entry_id(); return encode_octets(entry_id, destination); } + case MmsStaticBrcbAttribute::time_of_entry: { + const auto time_of_entry = context->reports->latest_time_of_entry(); + return encode_binary_time(time_of_entry, destination); + } case MmsStaticBrcbAttribute::reservation_time: { const auto state = context->control->state(now_ms(*context)); return encode_nonnegative_integer16(state.resv_tms_seconds, destination); @@ -303,6 +417,48 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; return true; } +[[nodiscard]] bool decode_bit_string( + const std::span encoded, + const std::uint8_t expected_unused_bits, + const std::size_t expected_bytes, + std::span& bytes) noexcept { + bytes = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 4 || tlv.constructed || + tlv.value.size() != expected_bytes + 1U || + tlv.value[0] != expected_unused_bits) { + return false; + } + bytes = tlv.value.subspan(1U); + if (!bytes.empty() && expected_unused_bits != 0U) { + const auto mask = static_cast((1U << expected_unused_bits) - 1U); + if ((bytes.back() & mask) != 0U) { + bytes = {}; + return false; + } + } + return true; +} + +[[nodiscard]] bool decode_unsigned32( + const std::span encoded, + std::uint32_t& value) noexcept { + value = 0U; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed || + tlv.value.empty() || tlv.value.size() > sizeof(std::uint32_t)) { + return false; + } + for (const auto byte : tlv.value) { + value = static_cast((value << 8U) | byte); + } + return true; +} + [[nodiscard]] bool decode_nonnegative_integer16( const std::span encoded, std::uint32_t& value) noexcept { @@ -418,6 +574,67 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; return map_control_status( context->control->set_report_enabled(client, value, now)); } + case MmsStaticBrcbAttribute::optional_fields: { + std::span bytes; + if (!decode_bit_string(encoded_data, 6U, 2U, bytes)) { + return {false, kTypeInconsistent}; + } + constexpr std::uint8_t allowed_first = 0x7FU; + constexpr std::uint8_t allowed_second = 0x80U; + if ((bytes[0] & static_cast(~allowed_first)) != 0U || + (bytes[1] & static_cast(~allowed_second)) != 0U) { + return {false, kObjectValueInvalid}; + } + const auto claim = ensure_claimed(*context, client, now); + if (claim != MmsStaticBrcbControlStatus::ok) { + return map_control_status(claim); + } + return map_control_status( + context->control->set_optional_fields(client, bytes, now)); + } + case MmsStaticBrcbAttribute::trigger_options: { + std::span bytes; + if (!decode_bit_string(encoded_data, 2U, 1U, bytes)) { + return {false, kTypeInconsistent}; + } + constexpr std::uint8_t allowed = 0xFCU; + if ((bytes[0] & static_cast(~allowed)) != 0U) { + return {false, kObjectValueInvalid}; + } + const auto claim = ensure_claimed(*context, client, now); + if (claim != MmsStaticBrcbControlStatus::ok) { + return map_control_status(claim); + } + return map_control_status( + context->control->set_trigger_options(client, bytes[0], now)); + } + case MmsStaticBrcbAttribute::integrity_period: { + std::uint32_t value{}; + if (!decode_unsigned32(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + const auto claim = ensure_claimed(*context, client, now); + if (claim != MmsStaticBrcbControlStatus::ok) { + return map_control_status(claim); + } + return map_control_status( + context->control->set_integrity_period(client, value, now)); + } + case MmsStaticBrcbAttribute::general_interrogation: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + if (!value) { + return {true, 0U}; + } + const auto claim = ensure_claimed(*context, client, now); + if (claim != MmsStaticBrcbControlStatus::ok) { + return map_control_status(claim); + } + return map_control_status( + context->control->request_general_interrogation(client, now)); + } case MmsStaticBrcbAttribute::purge_buffer: { bool value = false; if (!decode_boolean(encoded_data, value)) { @@ -461,6 +678,9 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; case MmsStaticBrcbAttribute::report_id: case MmsStaticBrcbAttribute::data_set: case MmsStaticBrcbAttribute::conf_revision: + case MmsStaticBrcbAttribute::buffer_time: + case MmsStaticBrcbAttribute::sequence_number: + case MmsStaticBrcbAttribute::time_of_entry: case MmsStaticBrcbAttribute::owner: return {false, kObjectAccessDenied}; } @@ -553,7 +773,9 @@ bool MmsStaticBrcbObjectBank::initialize() noexcept { false, nullptr, writable ? &context : nullptr, - writable ? write_brcb_attribute : nullptr}; + writable ? write_brcb_attribute : nullptr, + writable ? static_cast(control_) : nullptr, + writable ? write_semantic(attribute) : MmsStaticWriteSemantic::ordinary}; name_offset += name_size; ++object_offset; diff --git a/src/mms/static_brcb_runtime.cpp b/src/mms/static_brcb_runtime.cpp index 509b5beab..50d92dd19 100644 --- a/src/mms/static_brcb_runtime.cpp +++ b/src/mms/static_brcb_runtime.cpp @@ -18,14 +18,19 @@ namespace { constexpr std::uint8_t kTriggerDataChange = 0x40U; constexpr std::uint8_t kTriggerQualityChange = 0x20U; constexpr std::uint8_t kTriggerDataUpdate = 0x10U; -constexpr std::uint8_t kAllowedTriggers = 0x70U; - -constexpr std::uint8_t kReasonDataChange = 0x80U; -constexpr std::uint8_t kReasonQualityChange = 0x40U; -constexpr std::uint8_t kReasonDataUpdate = 0x20U; +constexpr std::uint8_t kTriggerIntegrity = 0x08U; +constexpr std::uint8_t kTriggerGeneralInterrogation = 0x04U; +constexpr std::uint8_t kTriggerTransient = 0x80U; +constexpr std::uint8_t kAllowedTriggers = static_cast( + kTriggerTransient | 0x7CU); + +constexpr std::uint8_t kReasonDataChange = 0x40U; +constexpr std::uint8_t kReasonQualityChange = 0x20U; +constexpr std::uint8_t kReasonDataUpdate = 0x10U; +constexpr std::uint8_t kReasonIntegrity = 0x08U; +constexpr std::uint8_t kReasonGeneralInterrogation = 0x04U; constexpr std::uint8_t kOptReasonForInclusion = 0x10U; -constexpr std::uint8_t kOptBufferOverflow = 0x02U; constexpr std::uint8_t kOptEntryId = 0x01U; constexpr std::uint8_t kAllowedOptionalFirst = 0x7FU; constexpr std::uint8_t kAllowedOptionalSecond = 0x80U; @@ -57,6 +62,14 @@ void bump_revision(std::uint32_t& revision) noexcept { : current + 1U; } +[[nodiscard]] std::uint32_t effective_integrity_period( + const std::uint32_t configured_ms) noexcept { + if (configured_ms == 0U) return 0U; + return configured_ms < MmsStaticBrcbRuntime::minimum_integrity_period_ms + ? MmsStaticBrcbRuntime::minimum_integrity_period_ms + : configured_ms; +} + [[nodiscard]] std::span as_bytes( const std::string_view text) noexcept { return { @@ -128,6 +141,20 @@ void clear_pending(MmsStaticBrcbPendingState& pending) noexcept { return false; } +[[nodiscard]] std::uint8_t scheduled_reason_mask( + const MmsStaticBrcbCaptureReason reason) noexcept { + switch (reason) { + case MmsStaticBrcbCaptureReason::integrity: + return kReasonIntegrity; + case MmsStaticBrcbCaptureReason::general_interrogation: + return kReasonGeneralInterrogation; + case MmsStaticBrcbCaptureReason::none: + case MmsStaticBrcbCaptureReason::event: + return 0U; + } + return 0U; +} + [[nodiscard]] std::array encode_entry_id(const std::uint64_t entry_number) noexcept { std::array result{}; @@ -166,9 +193,7 @@ bool MmsStaticBrcbRuntime::initialize() noexcept { static_cast(~kAllowedOptionalFirst)) != 0U || (definition_->optional_fields[1] & static_cast(~kAllowedOptionalSecond)) != 0U || - (definition_->optional_fields[0] & - static_cast(kOptBufferOverflow | kOptEntryId)) != - static_cast(kOptBufferOverflow | kOptEntryId) || + (definition_->optional_fields[0] & kOptEntryId) == 0U || definition_->trigger_options == 0U || (definition_->trigger_options & static_cast(~kAllowedTriggers)) != 0U) { return false; @@ -194,9 +219,16 @@ bool MmsStaticBrcbRuntime::initialize() noexcept { delivery_offset_ = 0U; next_entry_number_ = 1U; dropped_reports_ = 0U; + next_integrity_due_ms_ = 0U; queue_revision_ = 1U; + schedule_revision_ = 1U; sequence_number_ = 0U; + optional_fields_ = definition_->optional_fields; + trigger_options_ = definition_->trigger_options; + integrity_period_ms_ = definition_->integrity_period_ms; replay_gap_ = false; + general_interrogation_pending_ = false; + integrity_armed_ = false; enabled_ = false; initialized_ = true; return true; @@ -204,7 +236,7 @@ bool MmsStaticBrcbRuntime::initialize() noexcept { MmsStaticBrcbStatus MmsStaticBrcbRuntime::set_enabled( const bool enabled) noexcept { - if (!initialized_ || pending_ == nullptr) { + if (!initialized_ || pending_ == nullptr || definition_ == nullptr) { return MmsStaticBrcbStatus::invalid_runtime; } if (enabled_ == enabled) { @@ -212,7 +244,84 @@ MmsStaticBrcbStatus MmsStaticBrcbRuntime::set_enabled( } enabled_ = enabled; clear_pending(*pending_); + general_interrogation_pending_ = false; + next_integrity_due_ms_ = 0U; + const auto period = effective_integrity_period(integrity_period_ms_); + integrity_armed_ = enabled && period != 0U && + (trigger_options_ & kTriggerIntegrity) != 0U; + bump_revision(pending_->revision); + bump_revision(schedule_revision_); + return MmsStaticBrcbStatus::ok; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::set_optional_fields( + const std::span optional_fields) noexcept { + if (!initialized_ || definition_ == nullptr || pending_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (optional_fields.size() != optional_fields_.size() || + (optional_fields[0] & static_cast(~kAllowedOptionalFirst)) != 0U || + (optional_fields[1] & static_cast(~kAllowedOptionalSecond)) != 0U) { + return MmsStaticBrcbStatus::invalid_definition; + } + if (enabled_) { + return MmsStaticBrcbStatus::temporarily_unavailable; + } + if (std::equal(optional_fields.begin(), optional_fields.end(), optional_fields_.begin())) { + return MmsStaticBrcbStatus::ok; + } + std::copy(optional_fields.begin(), optional_fields.end(), optional_fields_.begin()); + clear_pending(*pending_); + general_interrogation_pending_ = false; + next_integrity_due_ms_ = 0U; + integrity_armed_ = false; + bump_revision(pending_->revision); + bump_revision(schedule_revision_); + return MmsStaticBrcbStatus::ok; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::set_trigger_options( + const std::uint8_t trigger_options) noexcept { + if (!initialized_ || definition_ == nullptr || pending_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if ((trigger_options & static_cast(~kAllowedTriggers)) != 0U) { + return MmsStaticBrcbStatus::invalid_definition; + } + if (enabled_) { + return MmsStaticBrcbStatus::temporarily_unavailable; + } + if (trigger_options_ == trigger_options) { + return MmsStaticBrcbStatus::ok; + } + trigger_options_ = trigger_options; + clear_pending(*pending_); + general_interrogation_pending_ = false; + next_integrity_due_ms_ = 0U; + integrity_armed_ = false; + bump_revision(pending_->revision); + bump_revision(schedule_revision_); + return MmsStaticBrcbStatus::ok; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::set_integrity_period( + const std::uint32_t integrity_period_ms) noexcept { + if (!initialized_ || definition_ == nullptr || pending_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (enabled_) { + return MmsStaticBrcbStatus::temporarily_unavailable; + } + if (integrity_period_ms_ == integrity_period_ms) { + return MmsStaticBrcbStatus::ok; + } + integrity_period_ms_ = integrity_period_ms; + clear_pending(*pending_); + general_interrogation_pending_ = false; + next_integrity_due_ms_ = 0U; + integrity_armed_ = false; bump_revision(pending_->revision); + bump_revision(schedule_revision_); return MmsStaticBrcbStatus::ok; } @@ -231,7 +340,7 @@ MmsStaticBrcbStatus MmsStaticBrcbRuntime::notify( std::uint8_t trigger_mask = 0U; std::uint8_t report_reason = 0U; if (!event_mapping(reason, trigger_mask, report_reason) || - (definition_->trigger_options & trigger_mask) == 0U) { + (trigger_options_ & trigger_mask) == 0U) { return MmsStaticBrcbStatus::trigger_not_selected; } @@ -258,19 +367,60 @@ MmsStaticBrcbStatus MmsStaticBrcbRuntime::notify( return MmsStaticBrcbStatus::ok; } +MmsStaticBrcbStatus MmsStaticBrcbRuntime::request_general_interrogation() noexcept { + if (!initialized_ || definition_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (!enabled_) { + return MmsStaticBrcbStatus::temporarily_unavailable; + } + if ((trigger_options_ & kTriggerGeneralInterrogation) == 0U) { + return MmsStaticBrcbStatus::trigger_not_selected; + } + if (!general_interrogation_pending_) { + general_interrogation_pending_ = true; + bump_revision(schedule_revision_); + } + return MmsStaticBrcbStatus::ok; +} + bool MmsStaticBrcbRuntime::next_due( const std::uint64_t now_ms, - MmsStaticBrcbCapturePlan& plan) const noexcept { + MmsStaticBrcbCapturePlan& plan) noexcept { plan = {}; - if (!initialized_ || pending_ == nullptr || !enabled_ || - !pending_->pending || pending_->pending_member_count == 0U || - now_ms < pending_->due_ms || slots_.empty()) { + if (!initialized_ || pending_ == nullptr || definition_ == nullptr || + !enabled_ || slots_.empty()) { + return false; + } + + if (integrity_armed_ && next_integrity_due_ms_ == 0U) { + const auto period = effective_integrity_period(integrity_period_ms_); + next_integrity_due_ms_ = period == 0U + ? 0U + : saturating_add(now_ms, period); + bump_revision(schedule_revision_); + } + + MmsStaticBrcbCaptureReason reason{MmsStaticBrcbCaptureReason::none}; + if (general_interrogation_pending_) { + reason = MmsStaticBrcbCaptureReason::general_interrogation; + } else if (pending_->pending && pending_->pending_member_count != 0U && + now_ms >= pending_->due_ms) { + reason = MmsStaticBrcbCaptureReason::event; + } else if (integrity_armed_ && next_integrity_due_ms_ != 0U && + now_ms >= next_integrity_due_ms_) { + reason = MmsStaticBrcbCaptureReason::integrity; + } else { return false; } + plan.pending_revision = pending_->revision; + plan.schedule_revision = schedule_revision_; plan.queue_revision = queue_revision_; plan.entry_number = next_entry_number_; + plan.observed_now_ms = now_ms; plan.sequence_number = next_sequence_number(sequence_number_); + plan.reason = reason; plan.buffer_overflow = count_ == slots_.size() && delivery_offset_ == 0U; return true; } @@ -289,12 +439,22 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( } const bool full = count_ == slots_.size(); const bool overflow = full && delivery_offset_ == 0U; - if (!enabled_ || !pending_->pending || pending_->pending_member_count == 0U || - plan.pending_revision != pending_->revision || + const bool event_plan = plan.reason == MmsStaticBrcbCaptureReason::event; + const bool integrity_plan = plan.reason == MmsStaticBrcbCaptureReason::integrity; + const bool gi_plan = plan.reason == MmsStaticBrcbCaptureReason::general_interrogation; + const bool scheduled_plan = integrity_plan || gi_plan; + + if (!enabled_ || (!event_plan && !scheduled_plan) || plan.queue_revision != queue_revision_ || plan.entry_number != next_entry_number_ || plan.sequence_number != next_sequence_number(sequence_number_) || - plan.buffer_overflow != overflow) { + plan.buffer_overflow != overflow || + (event_plan && (!pending_->pending || pending_->pending_member_count == 0U || + plan.pending_revision != pending_->revision)) || + (scheduled_plan && plan.schedule_revision != schedule_revision_) || + (gi_plan && !general_interrogation_pending_) || + (integrity_plan && (!integrity_armed_ || next_integrity_due_ms_ == 0U || + plan.observed_now_ms < next_integrity_due_ms_))) { result.status = MmsStaticBrcbStatus::stale_plan; return result; } @@ -306,6 +466,7 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( return result; } + const auto full_reason = scheduled_reason_mask(plan.reason); std::array indices{}; std::array references{}; @@ -318,7 +479,9 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( for (std::size_t member_index = 0U; member_index < data_set->members.size(); ++member_index) { - const auto reason_mask = pending_->member_reason_masks[member_index]; + const auto reason_mask = scheduled_plan + ? full_reason + : pending_->member_reason_masks[member_index]; if (reason_mask == 0U) { continue; } @@ -354,7 +517,10 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( workspace_offset += read.bytes_written; ++included; } - if (included == 0U || included != pending_->pending_member_count) { + const auto expected_included = scheduled_plan + ? data_set->members.size() + : pending_->pending_member_count; + if (included == 0U || included != expected_included) { result.status = MmsStaticBrcbStatus::stale_plan; result.included_member_count = included; return result; @@ -363,7 +529,7 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( const auto entry_id = encode_entry_id(plan.entry_number); MmsBufferedSelectiveInformationReportSnapshotInput report; report.report_id = definition_->report_id; - report.optional_fields = definition_->optional_fields; + report.optional_fields = optional_fields_; report.sequence_number = plan.sequence_number; report.report_time = report_time; report.data_set_reference = { @@ -378,7 +544,7 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( std::span{references}.first(included); report.included_member_results = std::span{results}.first(included); - if ((definition_->optional_fields[0] & kOptReasonForInclusion) != 0U) { + if ((optional_fields_[0] & kOptReasonForInclusion) != 0U) { report.included_reason_for_inclusion = std::span{reasons}.first(included); } @@ -410,16 +576,16 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( std::copy_n(encode_buffer.begin(), encoded.bytes_written, slot.storage.begin()); slot.entry_id = entry_id; + slot.time_of_entry.fill(0U); + if (report_time.size() == slot.time_of_entry.size()) { + std::copy(report_time.begin(), report_time.end(), slot.time_of_entry.begin()); + } slot.bytes = encoded.bytes_written; slot.sequence_number = plan.sequence_number; slot.buffer_overflow = plan.buffer_overflow; slot.occupied = true; if (full) { - // The physical oldest slot is being recycled. If the delivery cursor is - // already past it, retain the same logical next-to-deliver entry by - // shifting the cursor with the head. Otherwise an undelivered report is - // irrecoverably lost and the recovery gap must remain visible. if (delivery_offset_ == 0U) { ++dropped_reports_; replay_gap_ = true; @@ -432,8 +598,22 @@ MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( } sequence_number_ = plan.sequence_number; next_entry_number_ = next_entry_number(plan.entry_number); - clear_pending(*pending_); - bump_revision(pending_->revision); + + if (event_plan) { + clear_pending(*pending_); + bump_revision(pending_->revision); + } else if (gi_plan) { + general_interrogation_pending_ = false; + bump_revision(schedule_revision_); + } else if (integrity_plan) { + const auto period = effective_integrity_period(integrity_period_ms_); + next_integrity_due_ms_ = period == 0U + ? 0U + : saturating_add(plan.observed_now_ms, period); + integrity_armed_ = period != 0U && enabled_ && + (trigger_options_ & kTriggerIntegrity) != 0U; + bump_revision(schedule_revision_); + } bump_revision(queue_revision_); result.status = MmsStaticBrcbStatus::ok; @@ -558,4 +738,4 @@ MmsStaticBrcbStatus MmsStaticBrcbRuntime::purge_buffer() noexcept { return MmsStaticBrcbStatus::ok; } -} // namespace ar::iec61850::mms \ No newline at end of file +} // namespace ar::iec61850::mms diff --git a/src/mms/static_data_set_table.cpp b/src/mms/static_data_set_table.cpp index 3fa764748..6488f94f1 100644 --- a/src/mms/static_data_set_table.cpp +++ b/src/mms/static_data_set_table.cpp @@ -102,15 +102,36 @@ bool MmsStaticDataSetTable::valid_against( const MmsStaticDataSetEntry* MmsStaticDataSetTable::find( const MmsObjectNameView& name) const noexcept { - if (name.kind != MmsObjectNameViewKind::domain_specific) { + if (name.kind == MmsObjectNameViewKind::domain_specific) { + for (const auto& data_set : data_sets_) { + if (equals(name.domain, data_set.domain) && equals(name.item, data_set.item)) { + return &data_set; + } + } return nullptr; } + + if ((name.kind != MmsObjectNameViewKind::vmd_specific && + name.kind != MmsObjectNameViewKind::aa_specific) || + name.item.empty()) { + return nullptr; + } + + // IEDScout probes named-variable-list attributes with both VMD-specific and + // AA-specific ObjectName forms such as LLN0$Digital. Resolve either + // compatibility form only when the item is unique across logical-device + // domains. Ambiguity stays an explicit miss instead of picking arbitrarily. + const MmsStaticDataSetEntry* match = nullptr; for (const auto& data_set : data_sets_) { - if (equals(name.domain, data_set.domain) && equals(name.item, data_set.item)) { - return &data_set; + if (!equals(name.item, data_set.item)) { + continue; + } + if (match != nullptr) { + return nullptr; } + match = &data_set; } - return nullptr; + return match; } } // namespace ar::iec61850::mms diff --git a/src/mms/static_direct_control.cpp b/src/mms/static_direct_control.cpp index c14d958a3..de914f7fd 100644 --- a/src/mms/static_direct_control.cpp +++ b/src/mms/static_direct_control.cpp @@ -4,6 +4,7 @@ #include "ariec61850/asn1/ber_span_reader.hpp" +#include #include #include #include @@ -39,7 +40,7 @@ namespace { [[nodiscard]] bool decode_origin( const asn1::BerTlvView& origin, - std::uint8_t& category) noexcept { + MmsStaticDirectBooleanOperate& decoded) noexcept { if (!context_tag(origin, 2, true)) { return false; } @@ -47,20 +48,30 @@ 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; } if (!read_next(origin.value, offset, or_ident) || !context_tag(or_ident, 9, false) || - or_ident.value.size() > 64U || + or_ident.value.size() > decoded.origin_identifier.size() || offset != origin.value.size()) { return false; } - category = static_cast(*category_value); + decoded.origin_category = static_cast(*category_value); + decoded.origin_identifier_size = or_ident.value.size(); + std::copy(or_ident.value.begin(), or_ident.value.end(), decoded.origin_identifier.begin()); return true; } @@ -77,11 +88,10 @@ namespace { return true; } -} // namespace - -bool try_decode_static_direct_boolean_operate( +[[nodiscard]] bool decode_command( const std::span encoded_data, - MmsStaticDirectBooleanOperate& decoded) noexcept { + MmsStaticDirectBooleanOperate& decoded, + const bool require_check) noexcept { decoded = {}; asn1::BerTlvView outer; if (!asn1::BerSpanReader::try_read_exact(encoded_data, outer) || @@ -95,12 +105,11 @@ bool try_decode_static_direct_boolean_operate( asn1::BerTlvView ctl_num; asn1::BerTlvView timestamp; asn1::BerTlvView test; - asn1::BerTlvView check; if (!read_next(outer.value, offset, ctl_val) || !decode_boolean(ctl_val, decoded.control_value) || !read_next(outer.value, offset, origin) || - !decode_origin(origin, decoded.origin_category) || + !decode_origin(origin, decoded) || !read_next(outer.value, offset, ctl_num) || !context_tag(ctl_num, 6, false)) { return false; @@ -112,15 +121,286 @@ bool try_decode_static_direct_boolean_operate( decoded.control_number = static_cast(*control_number); if (!read_next(outer.value, offset, timestamp) || - !context_tag(timestamp, 17, false) || timestamp.value.size() != 8U || - !read_next(outer.value, offset, test) || - !decode_boolean(test, decoded.test) || - !read_next(outer.value, offset, check) || - !decode_check(check, decoded.synchro_check, decoded.interlock_check) || - offset != outer.value.size()) { + !context_tag(timestamp, 17, false) || timestamp.value.size() != decoded.timestamp.size()) { return false; } - return true; + std::copy(timestamp.value.begin(), timestamp.value.end(), decoded.timestamp.begin()); + if (!read_next(outer.value, offset, test) || + !decode_boolean(test, decoded.test)) { + return false; + } + + if (require_check) { + asn1::BerTlvView check; + if (!read_next(outer.value, offset, check) || + !decode_check(check, decoded.synchro_check, decoded.interlock_check)) { + return false; + } + } + return offset == outer.value.size(); +} + +[[nodiscard]] std::uint64_t binding_now_ms( + const MmsStaticDirectBooleanControlBinding& binding) noexcept { + return binding.now_ms == nullptr ? 0U : binding.now_ms(binding.now_context); +} + +[[nodiscard]] bool selection_expired( + const MmsStaticDirectBooleanControlBinding& binding, + const std::uint64_t now) noexcept { + if (binding.shared_state == nullptr) return true; + const auto deadline = binding.shared_state->selection_deadline_ms.load( + std::memory_order_acquire); + return deadline != 0U && now >= deadline; +} + +void clear_local_selection(MmsStaticDirectBooleanControlBinding& binding) noexcept { + if (binding.state == nullptr) return; + binding.state->selected = false; + binding.state->selected_with_value = false; + binding.state->selected_command = {}; +} + +void release_selection(MmsStaticDirectBooleanControlBinding& binding) noexcept { + if (binding.shared_state != nullptr && binding.association_id != 0U) { + auto owner = binding.association_id; + if (binding.shared_state->selected_association_id.compare_exchange_strong( + owner, + 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + binding.shared_state->selection_deadline_ms.store(0U, std::memory_order_release); + } + } + clear_local_selection(binding); +} + +[[nodiscard]] bool acquire_selection( + MmsStaticDirectBooleanControlBinding& binding) noexcept { + if (binding.shared_state == nullptr || binding.association_id == 0U || + binding.sbo_timeout_ms == 0U) { + return false; + } + + const auto now = binding_now_ms(binding); + for (std::size_t attempt = 0U; attempt < 4U; ++attempt) { + auto owner = binding.shared_state->selected_association_id.load( + std::memory_order_acquire); + if (owner == binding.association_id) { + binding.shared_state->selection_deadline_ms.store( + now + binding.sbo_timeout_ms, + std::memory_order_release); + return true; + } + if (owner != 0U) { + if (!selection_expired(binding, now)) return false; + if (!binding.shared_state->selected_association_id.compare_exchange_strong( + owner, + 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + continue; + } + binding.shared_state->selection_deadline_ms.store(0U, std::memory_order_release); + continue; + } + auto empty = std::uint64_t{0U}; + if (binding.shared_state->selected_association_id.compare_exchange_strong( + empty, + binding.association_id, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + binding.shared_state->selection_deadline_ms.store( + now + binding.sbo_timeout_ms, + std::memory_order_release); + return true; + } + } + return false; +} + +[[nodiscard]] bool owns_live_selection( + MmsStaticDirectBooleanControlBinding& binding) noexcept { + if (binding.shared_state == nullptr || binding.association_id == 0U) return false; + const auto owner = binding.shared_state->selected_association_id.load( + std::memory_order_acquire); + if (owner != binding.association_id) return false; + if (!selection_expired(binding, binding_now_ms(binding))) return true; + release_selection(binding); + return false; +} + +[[nodiscard]] bool same_selected_sequence( + const MmsStaticDirectBooleanOperate& left, + const MmsStaticDirectBooleanOperate& right, + const bool compare_check) noexcept { + // IEC 61850 enhanced SBO correlation is based on the selected command + // identity. OMICRON IEDScout emits a fresh T for Oper (and may do so for + // Cancel) rather than replaying the SBOw timestamp, so T is intentionally + // not part of selection identity. The accepted Oper itself is still kept + // intact for CommandTermination correlation. + if (left.control_value != right.control_value || + left.origin_category != right.origin_category || + left.origin_identifier_size != right.origin_identifier_size || + left.control_number != right.control_number || + left.test != right.test) { + return false; + } + if (!std::equal( + left.origin_identifier.begin(), + left.origin_identifier.begin() + static_cast(left.origin_identifier_size), + right.origin_identifier.begin())) { + return false; + } + return !compare_check || + (left.synchro_check == right.synchro_check && + left.interlock_check == right.interlock_check); +} + +[[nodiscard]] bool valid_command( + const MmsStaticDirectBooleanControlBinding& binding, + const MmsStaticDirectBooleanOperate& command) noexcept { + // ctlNum is an IEC 61850 wire value supplied by the remote client. Keep + // zero valid on the server path: OMICRON IEDScout uses ctlNum=0 in real + // SBOw/Oper traffic. ARStack's client-side auto-allocation policy (1..255) + // is separate and remains unchanged. + return command.origin_category <= 8U && + (!command.test || binding.policy.allow_test) && + (!command.synchro_check || binding.policy.allow_synchro_check) && + (!command.interlock_check || binding.policy.allow_interlock_check); +} + +[[nodiscard]] wire::EncodeResult encode_integer_model( + const std::uint8_t model, + const std::span destination) noexcept { + // IEDScout exposes ctlModel as MMS INTEGER(8), not UNSIGNED. Values 0..4 + // are positive one-octet INTEGER encodings, so no sign-extension is needed. + constexpr std::size_t required = 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = model; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult encode_visible_string( + const std::string_view value, + const std::span destination) noexcept { + std::array length{}; + std::size_t length_bytes{}; + if (value.size() < 0x80U) { + length[0] = static_cast(value.size()); + length_bytes = 1U; + } else if (value.size() <= 0xFFU) { + length[0] = 0x81U; + length[1] = static_cast(value.size()); + length_bytes = 2U; + } else if (value.size() <= 0xFFFFU) { + length[0] = 0x82U; + length[1] = static_cast((value.size() >> 8U) & 0xFFU); + length[2] = static_cast(value.size() & 0xFFU); + length_bytes = 3U; + } else { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = 1U + length_bytes + value.size(); + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x8AU; // MMS VisibleString data + std::copy_n(length.begin(), length_bytes, destination.begin() + 1); + std::copy(value.begin(), value.end(), destination.begin() + + static_cast(1U + length_bytes)); + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] MmsStaticWriteResult association_guard( + const MmsStaticDirectBooleanControlBinding* binding, + const MmsStaticRequestAccessContext& access) noexcept { + if (binding == nullptr || binding->state == nullptr) return {false, 10U}; + if (!access.valid() || binding->association_id == 0U || + access.association_id != binding->association_id) { + return {false, 3U}; + } + return {true, 0U}; +} + +[[nodiscard]] MmsStaticWriteResult apply_operate( + MmsStaticDirectBooleanControlBinding& binding, + const std::span encoded_data) noexcept { + if (binding.state == nullptr) return {false, 10U}; + + MmsStaticDirectBooleanOperate operate; + if (!try_decode_static_direct_boolean_operate(encoded_data, operate)) { + ++binding.state->rejected_operations; + return {false, binding.policy.malformed_failure_code}; + } + if (!valid_command(binding, operate)) { + ++binding.state->rejected_operations; + return {false, binding.policy.invalid_value_failure_code}; + } + + const auto requires_selection = + binding.model == MmsStaticControlModel::sbo_normal || + binding.model == MmsStaticControlModel::sbo_enhanced; + if (requires_selection && !owns_live_selection(binding)) { + ++binding.state->rejected_operations; + return {false, binding.policy.temporarily_unavailable_code}; + } + if (binding.model == MmsStaticControlModel::sbo_enhanced && + (!binding.state->selected_with_value || + !same_selected_sequence(binding.state->selected_command, operate, true))) { + ++binding.state->rejected_operations; + return {false, binding.policy.invalid_value_failure_code}; + } + const auto enhanced = + binding.model == MmsStaticControlModel::direct_enhanced || + binding.model == MmsStaticControlModel::sbo_enhanced; + if (enhanced && binding.state->pending_termination) { + ++binding.state->rejected_operations; + return {false, binding.policy.temporarily_unavailable_code}; + } + + if (!operate.test && binding.apply != nullptr && + !binding.apply(binding.apply_context, operate.control_value)) { + ++binding.state->rejected_operations; + return {false, binding.policy.backend_failure_code}; + } + + if (!operate.test) { + if (binding.shared_state != nullptr) { + binding.shared_state->value.store( + operate.control_value ? 1U : 0U, + std::memory_order_release); + } + binding.state->value = operate.control_value ? 1U : 0U; + } + binding.state->last_control_number = operate.control_number; + binding.state->last_test = operate.test; + ++binding.state->accepted_operations; + + if (requires_selection) release_selection(binding); + if (enhanced) { + binding.state->termination_command = operate; + binding.state->pending_termination = true; + } + return {true, 0U}; +} + +} // namespace + +bool try_decode_static_direct_boolean_operate( + const std::span encoded_data, + MmsStaticDirectBooleanOperate& decoded) noexcept { + return decode_command(encoded_data, decoded, true); +} + +bool try_decode_static_boolean_cancel( + const std::span encoded_data, + MmsStaticDirectBooleanOperate& decoded) noexcept { + return decode_command(encoded_data, decoded, false); } wire::EncodeResult mms_static_direct_boolean_read_state( @@ -143,14 +423,37 @@ wire::EncodeResult mms_static_direct_boolean_read_state( wire::EncodeResult mms_static_direct_normal_read_ctl_model( const void*, const std::span destination) noexcept { - constexpr std::size_t required = 3U; - if (destination.size() < required) { - return {wire::EncodeStatus::buffer_too_small, 0U, required}; + return encode_integer_model(1U, destination); +} + +wire::EncodeResult mms_static_control_read_ctl_model( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 3U}; } - destination[0] = 0x86U; // MMS unsigned - destination[1] = 0x01U; - destination[2] = 0x01U; // ctlModel = Direct-with-normal-security - return {wire::EncodeStatus::ok, required, required}; + const auto& binding = *static_cast(context); + return encode_integer_model(static_cast(binding.model), destination); +} + +wire::EncodeResult mms_static_sbo_normal_read( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + auto& binding = *const_cast( + static_cast(context)); + if (binding.model != MmsStaticControlModel::sbo_normal || + binding.state == nullptr || binding.selection_reference.empty()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (!acquire_selection(binding)) { + return encode_visible_string({}, destination); + } + binding.state->selected = true; + binding.state->selected_with_value = false; + return encode_visible_string(binding.selection_reference, destination); } wire::EncodeResult mms_static_control_read_unavailable( @@ -163,36 +466,89 @@ MmsStaticWriteResult mms_static_direct_boolean_write_oper( void* context, const std::span encoded_data) noexcept { auto* binding = static_cast(context); - if (binding == nullptr || binding->state == nullptr) { - return {false, 10U}; + if (binding == nullptr) return {false, 10U}; + return apply_operate(*binding, encoded_data); +} + +MmsStaticWriteResult mms_static_boolean_write_oper_contextual( + void* context, + const std::span encoded_data, + const MmsStaticRequestAccessContext& access) noexcept { + auto* binding = static_cast(context); + const auto guarded = association_guard(binding, access); + if (!guarded.success) return guarded; + return apply_operate(*binding, encoded_data); +} + +MmsStaticWriteResult mms_static_boolean_write_sbow_contextual( + void* context, + const std::span encoded_data, + const MmsStaticRequestAccessContext& access) noexcept { + auto* binding = static_cast(context); + const auto guarded = association_guard(binding, access); + if (!guarded.success) return guarded; + if (binding->model != MmsStaticControlModel::sbo_enhanced || + binding->state == nullptr) { + return {false, binding->policy.invalid_value_failure_code}; } - MmsStaticDirectBooleanOperate operate; - if (!try_decode_static_direct_boolean_operate(encoded_data, operate)) { + MmsStaticDirectBooleanOperate command; + if (!try_decode_static_direct_boolean_operate(encoded_data, command)) { ++binding->state->rejected_operations; return {false, binding->policy.malformed_failure_code}; } - if (operate.origin_category > 8U || operate.control_number == 0U || - (operate.test && !binding->policy.allow_test) || - (operate.synchro_check && !binding->policy.allow_synchro_check) || - (operate.interlock_check && !binding->policy.allow_interlock_check)) { + if (!valid_command(*binding, command)) { ++binding->state->rejected_operations; return {false, binding->policy.invalid_value_failure_code}; } - - if (!operate.test && binding->apply != nullptr && - !binding->apply(binding->apply_context, operate.control_value)) { + if (!acquire_selection(*binding)) { ++binding->state->rejected_operations; - return {false, binding->policy.backend_failure_code}; + return {false, binding->policy.temporarily_unavailable_code}; } + binding->state->selected = true; + binding->state->selected_with_value = true; + binding->state->selected_command = command; + return {true, 0U}; +} - if (!operate.test) { - binding->state->value = operate.control_value ? 1U : 0U; +MmsStaticWriteResult mms_static_boolean_write_cancel_contextual( + void* context, + const std::span encoded_data, + const MmsStaticRequestAccessContext& access) noexcept { + auto* binding = static_cast(context); + const auto guarded = association_guard(binding, access); + if (!guarded.success) return guarded; + if (binding->model != MmsStaticControlModel::sbo_normal && + binding->model != MmsStaticControlModel::sbo_enhanced) { + return {false, binding->policy.invalid_value_failure_code}; } - binding->state->last_control_number = operate.control_number; - binding->state->last_test = operate.test; - ++binding->state->accepted_operations; + + MmsStaticDirectBooleanOperate command; + if (!try_decode_static_boolean_cancel(encoded_data, command)) { + ++binding->state->rejected_operations; + return {false, binding->policy.malformed_failure_code}; + } + if (!valid_command(*binding, command) || !owns_live_selection(*binding)) { + ++binding->state->rejected_operations; + return {false, binding->policy.invalid_value_failure_code}; + } + if (binding->model == MmsStaticControlModel::sbo_enhanced && + (!binding->state->selected_with_value || + !same_selected_sequence(binding->state->selected_command, command, false))) { + ++binding->state->rejected_operations; + return {false, binding->policy.invalid_value_failure_code}; + } + release_selection(*binding); return {true, 0U}; } +void mms_static_control_on_association_closed( + MmsStaticDirectBooleanControlBinding& binding) noexcept { + release_selection(binding); + if (binding.state != nullptr) { + binding.state->pending_termination = false; + binding.state->termination_command = {}; + } +} + } // namespace ar::iec61850::mms diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp index 63d5a3c1f..6566ad9fc 100644 --- a/src/mms/static_dispatcher.cpp +++ b/src/mms/static_dispatcher.cpp @@ -3,11 +3,13 @@ #include "ariec61850/mms/static_dispatcher.hpp" #include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" #include #include #include #include +#include #include #include @@ -17,9 +19,7 @@ namespace { [[nodiscard]] bool span_equals( const std::span bytes, const std::string_view text) noexcept { - if (bytes.size() != text.size()) { - return false; - } + if (bytes.size() != text.size()) return false; for (std::size_t index = 0U; index < bytes.size(); ++index) { if (bytes[index] != static_cast( static_cast(text[index]))) { @@ -29,6 +29,20 @@ namespace { return true; } +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] std::string_view as_text( + const std::span bytes) noexcept { + return { + reinterpret_cast(bytes.data()), + bytes.size()}; +} + [[nodiscard]] bool valid_mms_data( const std::span encoded) noexcept { asn1::BerTlvView data; @@ -36,12 +50,18 @@ namespace { data.tag_class != asn1::BerClass::context_specific) { return false; } - if (data.tag_number == 1 || data.tag_number == 2) { - return data.constructed; - } + if (data.tag_number == 1 || data.tag_number == 2) return data.constructed; return data.tag_number >= 3 && data.tag_number <= 17 && !data.constructed; } +[[nodiscard]] bool valid_mms_type_specification( + const std::span encoded) noexcept { + asn1::BerTlvView type; + return !encoded.empty() && + asn1::BerSpanReader::try_read_exact(encoded, type) && + type.tag_class == asn1::BerClass::context_specific; +} + [[nodiscard]] MmsStaticDispatchResult make_status( const MmsStaticDispatchStatus status, const MmsConfirmedPduView& request, @@ -86,38 +106,22 @@ namespace { std::size_t& count, const std::string_view value) noexcept { for (std::size_t index = 0U; index < count; ++index) { - if (names[index] == value) { - return true; - } - } - if (count >= names.size()) { - return false; + if (names[index] == value) return true; } + if (count >= names.size()) return false; names[count++] = value; return true; } -// IEC 61850 engineering clients discover each Logical Node as one MMS -// NamedVariable and then walk its hierarchical TypeSpecification. Static -// profiles also keep flattened leaf aliases in the table so Read/Write can -// resolve exact FC/DO/DA paths. Do not advertise those aliases as additional -// top-level NamedVariables when their root Logical Node object is present. -// -// The fallback is intentional: a generic MMS profile that only supplies flat -// names (and no corresponding root entry) keeps the legacy directory behavior. [[nodiscard]] bool is_flattened_child_with_root( const MmsStaticObjectTable& objects, const std::string_view domain, const std::string_view item) noexcept { const auto separator = item.find('$'); - if (separator == std::string_view::npos || separator == 0U) { - return false; - } + if (separator == std::string_view::npos || separator == 0U) return false; const auto root = item.substr(0U, separator); for (const auto& candidate : objects.objects()) { - if (candidate.domain == domain && candidate.item == root) { - return true; - } + if (candidate.domain == domain && candidate.item == root) return true; } return false; } @@ -132,14 +136,10 @@ namespace { if (request.object_class == MmsNameListObjectClass::domain && request.scope == MmsNameScopeKind::vmd_specific) { for (const auto& object : objects.objects()) { - if (!append_unique(names, count, object.domain)) { - return names.size() + 1U; - } + if (!append_unique(names, count, object.domain)) return names.size() + 1U; } for (const auto& data_set : data_sets.data_sets()) { - if (!append_unique(names, count, data_set.domain)) { - return names.size() + 1U; - } + if (!append_unique(names, count, data_set.domain)) return names.size() + 1U; } return count; } @@ -152,9 +152,16 @@ namespace { is_flattened_child_with_root(objects, object.domain, object.item))) { continue; } - if (!append_unique(names, count, object.item)) { - return names.size() + 1U; - } + if (!append_unique(names, count, object.item)) return names.size() + 1U; + } + return count; + } + + if (request.object_class == MmsNameListObjectClass::named_variable_list && + (request.scope == MmsNameScopeKind::vmd_specific || + request.scope == MmsNameScopeKind::aa_specific)) { + for (const auto& data_set : data_sets.data_sets()) { + if (!append_unique(names, count, data_set.item)) return names.size() + 1U; } return count; } @@ -162,11 +169,9 @@ namespace { if (request.object_class == MmsNameListObjectClass::named_variable_list && request.scope == MmsNameScopeKind::domain_specific) { for (const auto& data_set : data_sets.data_sets()) { - if (span_equals(request.domain_id, data_set.domain)) { - if (count >= names.size()) { - return names.size() + 1U; - } - names[count++] = data_set.item; + if (span_equals(request.domain_id, data_set.domain) && + !append_unique(names, count, data_set.item)) { + return names.size() + 1U; } } return count; @@ -175,9 +180,193 @@ namespace { return names.size() + 1U; } +[[nodiscard]] bool insert_sorted_directory_name( + std::array& page, + std::size_t& page_count, + const std::size_t page_capacity, + const std::string_view value, + bool& more_follows) noexcept { + const auto begin = page.begin(); + const auto end = begin + static_cast(page_count); + const auto position = std::lower_bound(begin, end, value); + if (position != end && *position == value) return true; + + if (page_count < page_capacity) { + std::move_backward(position, end, end + 1); + *position = value; + ++page_count; + return true; + } + + more_follows = true; + if (page_capacity == 0U || position == end) return true; + std::move_backward(position, end - 1, end); + *position = value; + return true; +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_indexed_domain_named_variable_page( + const std::span directory, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const MmsGetNameListRequestView& request, + const std::span response) noexcept { + // The host IED-simulator directory intentionally preserves SCL/IEDScout + // declaration order instead of lexical item order. Continuation therefore + // follows the exact emitted sequence rather than lower_bound() semantics. + const auto domain = as_text(request.domain_id); + const auto continuation = as_text(request.continue_after); + bool emit = continuation.empty(); + bool continuation_found = emit; + bool more_follows = false; + + std::array page{}; + std::size_t page_count{}; + for (const auto& entry : directory) { + if (entry.domain != domain) continue; + if (!emit) { + if (entry.item == continuation) { + continuation_found = true; + emit = true; + } + continue; + } + if (!continuation.empty() && entry.item == continuation) continue; + if (page_count < policy.maximum_names_per_response) { + page[page_count++] = entry.item; + continue; + } + more_follows = true; + break; + } + + if (!continuation_found) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + if (page_count == 0U) { + const std::span empty; + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, empty, false, response)); + } + + auto encoded_count = page_count; + while (encoded_count > 0U) { + const auto encoded = MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, + std::span{page}.first(encoded_count), + more_follows || encoded_count < page_count, + response); + if (encoded.success()) return make_encoded(confirmed, encoded); + if (encoded.status != wire::EncodeStatus::buffer_too_small || encoded_count == 1U) { + return make_encoded(confirmed, encoded); + } + --encoded_count; + } + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_domain_named_variable_page( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const MmsGetNameListRequestView& request, + const std::span response) noexcept { + std::array page{}; + std::size_t page_count = 0U; + bool continuation_found = request.continue_after.empty(); + bool more_follows = false; + + if (!policy.advertise_flattened_child_aliases) { + bool emit = continuation_found; + for (const auto& object : objects.objects()) { + if (!span_equals(request.domain_id, object.domain) || + is_flattened_child_with_root(objects, object.domain, object.item)) { + continue; + } + if (!emit) { + if (span_equals(request.continue_after, object.item)) { + continuation_found = true; + emit = true; + } + continue; + } + if (page_count < policy.maximum_names_per_response) { + page[page_count++] = object.item; + continue; + } + more_follows = true; + break; + } + } else { + const auto continue_after = as_text(request.continue_after); + for (const auto& object : objects.objects()) { + if (!span_equals(request.domain_id, object.domain)) continue; + + std::size_t prefix_end = object.item.find('$'); + while (true) { + const auto prefix = object.item.substr( + 0U, + prefix_end == std::string_view::npos + ? object.item.size() + : prefix_end); + if (!prefix.empty()) { + if (!continue_after.empty() && prefix == continue_after) { + continuation_found = true; + } + if (continue_after.empty() || prefix > continue_after) { + if (!insert_sorted_directory_name( + page, + page_count, + policy.maximum_names_per_response, + prefix, + more_follows)) { + return make_status( + MmsStaticDispatchStatus::backend_failure, + confirmed); + } + } + } + + if (prefix_end == std::string_view::npos) break; + if (prefix_end + 1U >= object.item.size()) break; + prefix_end = object.item.find('$', prefix_end + 1U); + } + } + } + + if (!continuation_found) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + if (page_count == 0U) { + const std::span empty; + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, empty, false, response)); + } + + auto encoded_count = page_count; + while (encoded_count > 0U) { + const auto encoded = MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, + std::span{page}.first(encoded_count), + more_follows || encoded_count < page_count, + response); + if (encoded.success()) return make_encoded(confirmed, encoded); + if (encoded.status != wire::EncodeStatus::buffer_too_small || encoded_count == 1U) { + return make_encoded(confirmed, encoded); + } + --encoded_count; + } + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); +} + [[nodiscard]] MmsStaticDispatchResult dispatch_get_name_list( const MmsStaticObjectTable& objects, const MmsStaticDataSetTable& data_sets, + const std::span directory, const MmsStaticDispatchPolicy& policy, const MmsConfirmedPduView& confirmed, const std::span response) noexcept { @@ -186,6 +375,16 @@ namespace { return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); } + if (request.object_class == MmsNameListObjectClass::named_variable && + request.scope == MmsNameScopeKind::domain_specific) { + if (!directory.empty() && policy.advertise_flattened_child_aliases) { + return dispatch_indexed_domain_named_variable_page( + directory, policy, confirmed, request, response); + } + return dispatch_domain_named_variable_page( + objects, policy, confirmed, request, response); + } + std::array names{}; const auto name_count = collect_names(objects, data_sets, policy, request, names); if (name_count > names.size()) { @@ -202,9 +401,7 @@ namespace { break; } } - if (!found) { - return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); - } + if (!found) return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); } const auto available = name_count - start; @@ -224,9 +421,7 @@ namespace { std::span{names}.subspan(start, page_count), more_follows, response); - if (encoded.success()) { - return make_encoded(confirmed, encoded); - } + if (encoded.success()) return make_encoded(confirmed, encoded); if (encoded.status != wire::EncodeStatus::buffer_too_small || page_count == 1U) { return make_encoded(confirmed, encoded); } @@ -235,28 +430,6 @@ namespace { return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); } -[[nodiscard]] MmsStaticDispatchResult dispatch_attributes( - const MmsStaticObjectTable& objects, - const MmsConfirmedPduView& confirmed, - const std::span response) noexcept { - MmsVariableAccessAttributesRequestView request; - if (!MmsServiceSpanCodec::try_decode_variable_access_attributes_request( - confirmed, request)) { - return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); - } - const auto* object = objects.find(request.name); - if (object == nullptr) { - return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); - } - return make_encoded( - confirmed, - MmsServiceSpanCodec::encode_variable_access_attributes_response_into( - confirmed.invoke_id, - object->mms_deletable, - object->type_specification, - response)); -} - [[nodiscard]] MmsStaticDispatchResult dispatch_data_set_attributes( const MmsStaticDataSetTable& data_sets, const MmsConfirmedPduView& confirmed, @@ -287,69 +460,957 @@ namespace { response)); } -[[nodiscard]] MmsStaticDispatchResult dispatch_read( - const MmsStaticObjectTable& objects, - const MmsStaticDispatchPolicy& policy, +struct ReadCompatibilityRequest final { + MmsReadRequestView variables{}; + MmsObjectNameView variable_list_name{}; + bool uses_variable_list_name{}; +}; + +[[nodiscard]] bool populate_relaxed_variable_list( const MmsConfirmedPduView& confirmed, - const std::span response, - const std::span workspace) noexcept { - MmsReadRequestView request; - if (!MmsServiceSpanCodec::try_decode_read_request(confirmed, request)) { - return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + const bool specification_with_result, + const std::span variable_list, + MmsReadRequestView& request) noexcept { + std::size_t offset = 0U; + std::size_t count = 0U; + while (offset < variable_list.size()) { + if (count >= MmsServiceSpanCodec::maximum_variables) return false; + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + ++count; } - if (request.specification_with_result) { - // This bounded profile does not encode variableAccessSpecification in - // Read responses. Reject the optional request form explicitly before - // invoking any object callbacks instead of returning an incomplete PDU. - return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + if (count == 0U) return false; + request = {}; + request.invoke_id = confirmed.invoke_id; + request.specification_with_result = specification_with_result; + request.variable_list = variable_list; + request.variable_count = count; + return true; +} + +[[nodiscard]] bool try_decode_read_compatibility_request( + const MmsConfirmedPduView& confirmed, + ReadCompatibilityRequest& request) noexcept { + request = {}; + if (MmsServiceSpanCodec::try_decode_read_request(confirmed, request.variables)) return true; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 4 || !confirmed.service_constructed) { + return false; } - std::array results{}; - std::size_t workspace_offset = 0U; - for (std::size_t index = 0U; index < request.variable_count; ++index) { - MmsObjectNameView name; - if (!request.try_variable(index, name)) { - return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + bool specification_with_result = false; + bool have_flag = false; + bool have_specification = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + return false; } - const auto* object = objects.find(name); - if (object == nullptr) { - results[index] = MmsReadAccessResultInput{ - false, {}, policy.missing_object_failure_code}; + + if (field.tag_number == 0 && !field.constructed) { + if (have_flag || field.value.size() != 1U) return false; + specification_with_result = field.value[0] != 0U; + have_flag = true; continue; } + if (have_specification) return false; + + if (field.tag_number == 1 && field.constructed) { + asn1::BerTlvView specification; + if (!asn1::BerSpanReader::try_read_exact(field.value, specification) || + specification.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (specification.tag_number == 0 && specification.constructed) { + if (!populate_relaxed_variable_list( + confirmed, + specification_with_result, + specification.value, + request.variables)) { + return false; + } + have_specification = true; + continue; + } + if (specification.tag_number == 1 && specification.constructed) { + if (!MmsServiceSpanCodec::try_decode_object_name_view( + specification.value, request.variable_list_name)) { + return false; + } + request.variables.invoke_id = confirmed.invoke_id; + request.variables.specification_with_result = specification_with_result; + request.uses_variable_list_name = true; + have_specification = true; + continue; + } + return false; + } + + if (field.tag_number == 0 && field.constructed) { + if (!populate_relaxed_variable_list( + confirmed, + specification_with_result, + field.value, + request.variables)) { + return false; + } + have_specification = true; + continue; + } + return false; + } + return have_specification; +} + +constexpr std::size_t kMaximumSyntheticReadDepth = 16U; +constexpr std::array kUrcbAttributeOrder{ + "RptID", "RptEna", "Resv", "DatSet", "ConfRev", "OptFlds", + "BufTm", "SqNum", "TrgOps", "IntgPd", "GI", "Owner"}; +constexpr std::array kBrcbAttributeOrder{ + "RptID", "RptEna", "DatSet", "ConfRev", "OptFlds", "BufTm", "SqNum", + "TrgOps", "IntgPd", "GI", "PurgeBuf", "EntryID", "TimeofEntry", + "ResvTms", "Owner"}; +constexpr std::array kIedScoutControlServiceOrder{ + "SBO", "SBOw", "Oper", "Cancel"}; + +enum class SyntheticReadStatus : std::uint8_t { + ok, + not_found, + value_unavailable, + workspace_too_small, + backend_failure, +}; + +struct SyntheticReadResult final { + SyntheticReadStatus status{SyntheticReadStatus::not_found}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; +}; + +struct SyntheticMeasureResult final { + SyntheticReadStatus status{SyntheticReadStatus::not_found}; + std::size_t content_bytes{}; + std::size_t encoded_bytes{}; +}; + +struct SyntheticChild final { + std::string_view prefix{}; + const MmsStaticObjectEntry* exact{}; + std::size_t declaration_order{std::numeric_limits::max()}; + bool found{}; +}; + +struct SyntheticChildRank final { + bool known{}; + std::size_t value{}; +}; + +[[nodiscard]] bool is_descendant_item( + const std::string_view item, + const std::string_view prefix) noexcept { + return item.size() > prefix.size() && + item.compare(0U, prefix.size(), prefix) == 0 && + item[prefix.size()] == '$'; +} + +[[nodiscard]] std::string_view immediate_child_prefix( + const std::string_view item, + const std::string_view prefix) noexcept { + if (!is_descendant_item(item, prefix)) return {}; + const auto next = item.find('$', prefix.size() + 1U); + return item.substr(0U, next == std::string_view::npos ? item.size() : next); +} + +[[nodiscard]] std::span semantic_child_order( + const std::string_view prefix) noexcept { + if (prefix.find("$RP$") != std::string_view::npos) { + return {kUrcbAttributeOrder}; + } + if (prefix.find("$BR$") != std::string_view::npos) { + return {kBrcbAttributeOrder}; + } + // OMICRON IEDScout exposes control-service alternatives in this exact + // order at LN$CO$DO. Deeper service structures remain positional. + if (prefix.find("$CO$") != std::string_view::npos && + static_cast(std::count( + prefix.begin(), prefix.end(), static_cast(0x24))) == 2U) { + return {kIedScoutControlServiceOrder}; + } + return {}; +} + +[[nodiscard]] SyntheticChildRank synthetic_child_rank( + const std::string_view prefix, + const std::string_view child) noexcept { + if (!is_descendant_item(child, prefix)) return {}; + const auto suffix = child.substr(prefix.size() + 1U); + const auto order = semantic_child_order(prefix); + for (std::size_t index = 0U; index < order.size(); ++index) { + if (suffix == order[index]) return {true, index}; + } + return {}; +} + +[[nodiscard]] bool synthetic_child_less( + const std::string_view prefix, + const std::string_view left, + const std::string_view right) noexcept { + const auto left_rank = synthetic_child_rank(prefix, left); + const auto right_rank = synthetic_child_rank(prefix, right); + if (left_rank.known || right_rank.known) { + if (left_rank.known && right_rank.known) { + return left_rank.value < right_rank.value; + } + return left_rank.known; + } + return left < right; +} + +[[nodiscard]] bool use_declaration_order( + const std::string_view prefix) noexcept { + // IEDScout preserves the engineering declaration order at the LN root, + // functional-constraint namespace, DO and DA levels. Every structural + // object imported from SCL carries source_order; runtime-only service + // objects receive an explicit order from their owning control/RCB. + return !prefix.empty(); +} - const auto remaining = workspace.subspan(workspace_offset); - const auto read = object->read(object->context, remaining); - if (read.status == wire::EncodeStatus::buffer_too_small) { +[[nodiscard]] bool declaration_order_less( + const std::size_t left_order, + const std::string_view left, + const std::size_t right_order, + const std::string_view right) noexcept { + constexpr auto unspecified = std::numeric_limits::max(); + if (left_order != unspecified || right_order != unspecified) { + if (left_order == unspecified) return false; + if (right_order == unspecified) return true; + if (left_order != right_order) return left_order < right_order; + } + return left < right; +} + +struct SyntheticChildren final { + std::array values{}; + std::size_t count{}; + bool overflow{}; +}; + +[[nodiscard]] SyntheticChildren collect_synthetic_children( + const MmsStaticObjectTable& objects, + const std::string_view domain, + const std::string_view prefix) noexcept { + SyntheticChildren result; + const bool semantic_order = !semantic_child_order(prefix).empty(); + const bool declaration_order = !semantic_order && use_declaration_order(prefix); + + for (const auto& candidate : objects.objects()) { + if (candidate.domain != domain || !is_descendant_item(candidate.item, prefix)) { + continue; + } + const auto child = immediate_child_prefix(candidate.item, prefix); + if (child.empty()) continue; + + SyntheticChild* existing = nullptr; + for (std::size_t index = 0U; index < result.count; ++index) { + if (result.values[index].prefix == child) { + existing = &result.values[index]; + break; + } + } + if (existing != nullptr) { + existing->declaration_order = + std::min(existing->declaration_order, candidate.declaration_order); + if (candidate.item == child) existing->exact = &candidate; + continue; + } + if (result.count >= result.values.size()) { + result.overflow = true; + return result; + } + auto& inserted = result.values[result.count++]; + inserted.found = true; + inserted.prefix = child; + inserted.exact = candidate.item == child ? &candidate : nullptr; + inserted.declaration_order = candidate.declaration_order; + } + + auto begin = result.values.begin(); + auto end = begin + static_cast(result.count); + std::sort(begin, end, [&](const SyntheticChild& left, const SyntheticChild& right) noexcept { + if (semantic_order) { + return synthetic_child_less(prefix, left.prefix, right.prefix); + } + if (declaration_order) { + // Golden IEDScout places CO immediately after ST (or after MX/ST + // when MX is present) at controlled LN roots, before CF. + if (prefix.find(static_cast(0x24)) == std::string_view::npos) { + const auto left_name = left.prefix.substr(prefix.size() + 1U); + const auto right_name = right.prefix.substr(prefix.size() + 1U); + if (left_name == "CO" && right_name != "CO") { + return right_name != "MX" && right_name != "ST"; + } + if (right_name == "CO" && left_name != "CO") { + return left_name == "MX" || left_name == "ST"; + } + } + return declaration_order_less( + left.declaration_order, + left.prefix, + right.declaration_order, + right.prefix); + } + return left.prefix < right.prefix; + }); + return result; +} + +[[nodiscard]] SyntheticMeasureResult measure_exact_object( + const MmsStaticObjectEntry& object) noexcept { + const auto probe = object.read(object.context, {}); + if (probe.status == wire::EncodeStatus::buffer_too_small && probe.required_bytes > 0U) { + return {SyntheticReadStatus::ok, 0U, probe.required_bytes}; + } + if (probe.success() && probe.bytes_written > 0U) { + return {SyntheticReadStatus::ok, 0U, probe.bytes_written}; + } + return {SyntheticReadStatus::value_unavailable, 0U, 0U}; +} + +[[nodiscard]] SyntheticMeasureResult measure_synthetic_subtree( + const MmsStaticObjectTable& objects, + const std::string_view domain, + const std::string_view prefix, + const std::size_t depth) noexcept { + if (depth >= kMaximumSyntheticReadDepth || domain.empty() || prefix.empty()) { + return {SyntheticReadStatus::backend_failure, 0U, 0U}; + } + + std::size_t content_bytes = 0U; + const auto children = collect_synthetic_children(objects, domain, prefix); + if (children.overflow) { + return {SyntheticReadStatus::backend_failure, 0U, 0U}; + } + if (children.count == 0U) { + return {SyntheticReadStatus::not_found, 0U, 0U}; + } + for (std::size_t index = 0U; index < children.count; ++index) { + const auto& child = children.values[index]; + const auto measured = child.exact != nullptr + ? measure_exact_object(*child.exact) + : measure_synthetic_subtree(objects, domain, child.prefix, depth + 1U); + if (measured.status != SyntheticReadStatus::ok) return measured; + if (measured.encoded_bytes > + std::numeric_limits::max() - content_bytes) { + return {SyntheticReadStatus::backend_failure, 0U, 0U}; + } + content_bytes += measured.encoded_bytes; + } + const auto encoded = asn1::BerSpanWriter::tlv_size(2, content_bytes); + if (!encoded) return {SyntheticReadStatus::backend_failure, 0U, 0U}; + return {SyntheticReadStatus::ok, content_bytes, *encoded}; +} + +[[nodiscard]] SyntheticReadResult encode_synthetic_subtree( + const MmsStaticObjectTable& objects, + const std::string_view domain, + const std::string_view prefix, + const std::span destination, + const std::size_t depth) noexcept { + const auto measured = measure_synthetic_subtree(objects, domain, prefix, depth); + if (measured.status != SyntheticReadStatus::ok) { + return {measured.status, 0U, measured.encoded_bytes}; + } + if (destination.size() < measured.encoded_bytes) { + return {SyntheticReadStatus::workspace_too_small, 0U, measured.encoded_bytes}; + } + + asn1::BerSpanWriter writer{destination.first(measured.encoded_bytes)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, measured.content_bytes)) { + return {SyntheticReadStatus::backend_failure, 0U, measured.encoded_bytes}; + } + std::size_t offset = writer.size(); + const auto children = collect_synthetic_children(objects, domain, prefix); + if (children.overflow) { + return {SyntheticReadStatus::backend_failure, 0U, measured.encoded_bytes}; + } + + for (std::size_t index = 0U; index < children.count; ++index) { + const auto& child = children.values[index]; + if (child.exact != nullptr) { + const auto read = child.exact->read( + child.exact->context, + destination.subspan(offset, measured.encoded_bytes - offset)); + if (read.status == wire::EncodeStatus::buffer_too_small) { + const auto required = read.required_bytes > + std::numeric_limits::max() - offset + ? std::numeric_limits::max() + : offset + read.required_bytes; + return {SyntheticReadStatus::workspace_too_small, 0U, required}; + } + if (!read.success()) return {SyntheticReadStatus::value_unavailable, 0U, 0U}; + if (read.bytes_written == 0U || + read.bytes_written > measured.encoded_bytes - offset || + !valid_mms_data(destination.subspan(offset, read.bytes_written))) { + return {SyntheticReadStatus::backend_failure, 0U, measured.encoded_bytes}; + } + offset += read.bytes_written; + } else { + const auto nested = encode_synthetic_subtree( + objects, + domain, + child.prefix, + destination.subspan(offset, measured.encoded_bytes - offset), + depth + 1U); + if (nested.status != SyntheticReadStatus::ok) { + if (nested.status == SyntheticReadStatus::workspace_too_small && + nested.required_bytes <= + std::numeric_limits::max() - offset) { + return {nested.status, 0U, offset + nested.required_bytes}; + } + return nested; + } + offset += nested.bytes_written; + } + } + + if (offset != measured.encoded_bytes || !valid_mms_data(destination.first(offset))) { + return {SyntheticReadStatus::backend_failure, 0U, measured.encoded_bytes}; + } + return {SyntheticReadStatus::ok, offset, offset}; +} + +enum class SyntheticTypeStatus : std::uint8_t { + ok, + not_found, + workspace_too_small, + backend_failure, +}; + +struct SyntheticTypeMeasureResult final { + SyntheticTypeStatus status{SyntheticTypeStatus::not_found}; + std::size_t component_bytes{}; + std::size_t list_bytes{}; + std::size_t encoded_bytes{}; +}; + +struct SyntheticTypeEncodeResult final { + SyntheticTypeStatus status{SyntheticTypeStatus::not_found}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; +}; + +[[nodiscard]] bool checked_add( + std::size_t& total, + const std::size_t value) noexcept { + if (value > std::numeric_limits::max() - total) return false; + total += value; + return true; +} + +[[nodiscard]] SyntheticTypeMeasureResult measure_synthetic_type_subtree( + const MmsStaticObjectTable& objects, + const std::string_view domain, + const std::string_view prefix, + const std::size_t depth) noexcept { + if (depth >= kMaximumSyntheticReadDepth || domain.empty() || prefix.empty()) { + return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + } + + std::size_t component_bytes = 0U; + const auto children = collect_synthetic_children(objects, domain, prefix); + if (children.overflow) { + return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + } + if (children.count == 0U) { + return {SyntheticTypeStatus::not_found, 0U, 0U, 0U}; + } + for (std::size_t index = 0U; index < children.count; ++index) { + const auto& child = children.values[index]; + + std::size_t child_type_bytes = 0U; + if (child.exact != nullptr) { + if (!valid_mms_type_specification(child.exact->type_specification)) { + return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + } + child_type_bytes = child.exact->type_specification.size(); + } else { + const auto nested = measure_synthetic_type_subtree( + objects, domain, child.prefix, depth + 1U); + if (nested.status != SyntheticTypeStatus::ok) return nested; + child_type_bytes = nested.encoded_bytes; + } + + const auto component_name = child.prefix.substr(prefix.size() + 1U); + const auto name_tlv = asn1::BerSpanWriter::tlv_size(0, component_name.size()); + const auto type_wrapper = asn1::BerSpanWriter::tlv_size(1, child_type_bytes); + if (!name_tlv || !type_wrapper) { + return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + } + std::size_t component_content = *name_tlv; + if (!checked_add(component_content, *type_wrapper)) { + return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + } + const auto component = asn1::BerSpanWriter::tlv_size(16, component_content); + if (!component || !checked_add(component_bytes, *component)) { + return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + } + } + const auto list = asn1::BerSpanWriter::tlv_size(1, component_bytes); + if (!list) return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + const auto structure = asn1::BerSpanWriter::tlv_size(2, *list); + if (!structure) return {SyntheticTypeStatus::backend_failure, 0U, 0U, 0U}; + return {SyntheticTypeStatus::ok, component_bytes, *list, *structure}; +} + +[[nodiscard]] bool write_header( + const std::span destination, + std::size_t& offset, + const asn1::BerClass tag_class, + const bool constructed, + const std::int32_t tag_number, + const std::size_t value_length) noexcept { + if (offset > destination.size()) return false; + asn1::BerSpanWriter writer{destination.subspan(offset)}; + if (!writer.write_tlv_header(tag_class, constructed, tag_number, value_length)) { + return false; + } + offset += writer.size(); + return true; +} + +[[nodiscard]] bool write_bytes( + const std::span destination, + std::size_t& offset, + const std::span source) noexcept { + if (offset > destination.size() || source.size() > destination.size() - offset) return false; + std::copy(source.begin(), source.end(), destination.begin() + static_cast(offset)); + offset += source.size(); + return true; +} + +[[nodiscard]] SyntheticTypeEncodeResult encode_synthetic_type_subtree( + const MmsStaticObjectTable& objects, + const std::string_view domain, + const std::string_view prefix, + const std::span destination, + const std::size_t depth) noexcept { + const auto measured = measure_synthetic_type_subtree(objects, domain, prefix, depth); + if (measured.status != SyntheticTypeStatus::ok) { + return {measured.status, 0U, measured.encoded_bytes}; + } + if (destination.size() < measured.encoded_bytes) { + return {SyntheticTypeStatus::workspace_too_small, 0U, measured.encoded_bytes}; + } + + std::size_t offset = 0U; + if (!write_header( + destination, offset, asn1::BerClass::context_specific, true, 2, + measured.list_bytes) || + !write_header( + destination, offset, asn1::BerClass::context_specific, true, 1, + measured.component_bytes)) { + return {SyntheticTypeStatus::backend_failure, 0U, measured.encoded_bytes}; + } + + const auto children = collect_synthetic_children(objects, domain, prefix); + if (children.overflow) { + return {SyntheticTypeStatus::backend_failure, 0U, measured.encoded_bytes}; + } + for (std::size_t index = 0U; index < children.count; ++index) { + const auto& child = children.values[index]; + + std::size_t child_type_bytes = 0U; + SyntheticTypeMeasureResult nested_measure; + if (child.exact != nullptr) { + child_type_bytes = child.exact->type_specification.size(); + } else { + nested_measure = measure_synthetic_type_subtree( + objects, domain, child.prefix, depth + 1U); + if (nested_measure.status != SyntheticTypeStatus::ok) { + return {nested_measure.status, 0U, nested_measure.encoded_bytes}; + } + child_type_bytes = nested_measure.encoded_bytes; + } + + const auto component_name = child.prefix.substr(prefix.size() + 1U); + const auto name_tlv = asn1::BerSpanWriter::tlv_size(0, component_name.size()); + const auto type_wrapper = asn1::BerSpanWriter::tlv_size(1, child_type_bytes); + if (!name_tlv || !type_wrapper) { + return {SyntheticTypeStatus::backend_failure, 0U, measured.encoded_bytes}; + } + std::size_t component_content = *name_tlv; + if (!checked_add(component_content, *type_wrapper) || + !write_header( + destination, offset, asn1::BerClass::universal, true, 16, + component_content) || + !write_header( + destination, offset, asn1::BerClass::context_specific, false, 0, + component_name.size()) || + !write_bytes(destination, offset, as_bytes(component_name)) || + !write_header( + destination, offset, asn1::BerClass::context_specific, true, 1, + child_type_bytes)) { + return {SyntheticTypeStatus::backend_failure, 0U, measured.encoded_bytes}; + } + + if (child.exact != nullptr) { + if (!write_bytes(destination, offset, child.exact->type_specification)) { + return {SyntheticTypeStatus::backend_failure, 0U, measured.encoded_bytes}; + } + } else { + const auto nested = encode_synthetic_type_subtree( + objects, + domain, + child.prefix, + destination.subspan(offset), + depth + 1U); + if (nested.status != SyntheticTypeStatus::ok) { + return nested; + } + offset += nested.bytes_written; + } + } + + if (offset != measured.encoded_bytes || + !valid_mms_type_specification(destination.first(offset))) { + return {SyntheticTypeStatus::backend_failure, 0U, measured.encoded_bytes}; + } + return {SyntheticTypeStatus::ok, offset, offset}; +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_attributes( + const MmsStaticObjectTable& objects, + const MmsConfirmedPduView& confirmed, + const std::span response, + const std::span workspace) noexcept { + MmsVariableAccessAttributesRequestView request; + if (!MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + const auto* exact = objects.find(request.name); + const auto request_item = as_text(request.name.item); + + // Exact non-LN objects carry the authoritative positional MMS + // TypeSpecification. Read uses the same exact object, so GVAA must describe + // its Data with identical component order. Re-synthesizing ordinary + // descendants can reorder IEC 61850 structures such as {stVal,q,t}. + // + // Logical Node roots are synthesized from the final per-association table + // because runtime service objects (CO/RP/BR/SG) may extend the SCL tree. + if (exact != nullptr && + request.name.kind == MmsObjectNameViewKind::domain_specific && + request_item.find(static_cast(0x24)) != std::string_view::npos) { + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + exact->mms_deletable, + exact->type_specification, + response)); + } + + if (request.name.kind == MmsObjectNameViewKind::domain_specific && + !request.name.domain.empty() && !request.name.item.empty()) { + // A Logical Node/root object may have been encoded before dynamic + // service objects (RP/BR/SG/CO aliases) were composed into the final + // per-association table. Build GVAA from that final table first whenever + // descendants exist so discovery sees the complete final hierarchy. + const auto synthetic = encode_synthetic_type_subtree( + objects, + as_text(request.name.domain), + as_text(request.name.item), + workspace, + 0U); + switch (synthetic.status) { + case SyntheticTypeStatus::ok: + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + exact != nullptr ? exact->mms_deletable : false, + workspace.first(synthetic.bytes_written), + response)); + case SyntheticTypeStatus::workspace_too_small: return make_status( MmsStaticDispatchStatus::workspace_too_small, confirmed, - workspace_offset + read.required_bytes); + synthetic.required_bytes); + case SyntheticTypeStatus::backend_failure: + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); + case SyntheticTypeStatus::not_found: + break; } - if (!read.success()) { - results[index] = MmsReadAccessResultInput{ - false, {}, policy.backend_failure_code}; - continue; + } + + if (exact != nullptr) { + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + exact->mms_deletable, + exact->type_specification, + response)); + } + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); +} + +enum class ReadObjectStatus : std::uint8_t { + ok, + workspace_too_small, + backend_failure, +}; + +struct ReadObjectResult final { + ReadObjectStatus status{ReadObjectStatus::ok}; + std::size_t required_bytes{}; +}; + +[[nodiscard]] ReadObjectResult read_object_into_result( + const MmsStaticObjectEntry* object, + const MmsStaticDispatchPolicy& policy, + const std::span workspace, + std::size_t& workspace_offset, + MmsReadAccessResultInput& result) noexcept { + if (object == nullptr) { + result = MmsReadAccessResultInput{false, {}, policy.missing_object_failure_code}; + return {}; + } + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + return {ReadObjectStatus::workspace_too_small, workspace_offset + read.required_bytes}; + } + if (!read.success()) { + result = MmsReadAccessResultInput{false, {}, policy.backend_failure_code}; + return {}; + } + if (read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return {ReadObjectStatus::backend_failure, 0U}; + } + result = MmsReadAccessResultInput{true, remaining.first(read.bytes_written), 0U}; + workspace_offset += read.bytes_written; + return {}; +} + +[[nodiscard]] ReadObjectResult read_name_into_result( + const MmsStaticObjectTable& objects, + const MmsObjectNameView& name, + const MmsStaticDispatchPolicy& policy, + const std::span workspace, + std::size_t& workspace_offset, + MmsReadAccessResultInput& result) noexcept { + if (const auto* exact = objects.find(name); exact != nullptr) { + return read_object_into_result(exact, policy, workspace, workspace_offset, result); + } + if (name.kind != MmsObjectNameViewKind::domain_specific || + name.domain.empty() || name.item.empty()) { + result = MmsReadAccessResultInput{false, {}, policy.missing_object_failure_code}; + return {}; + } + + const auto remaining = workspace.subspan(workspace_offset); + const auto synthetic = encode_synthetic_subtree( + objects, + as_text(name.domain), + as_text(name.item), + remaining, + 0U); + switch (synthetic.status) { + case SyntheticReadStatus::ok: + if (synthetic.bytes_written == 0U || + synthetic.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(synthetic.bytes_written))) { + return {ReadObjectStatus::backend_failure, 0U}; } - if (read.bytes_written > remaining.size() || - !valid_mms_data(remaining.first(read.bytes_written))) { - return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); + result = MmsReadAccessResultInput{ + true, remaining.first(synthetic.bytes_written), 0U}; + workspace_offset += synthetic.bytes_written; + return {}; + case SyntheticReadStatus::not_found: + result = MmsReadAccessResultInput{false, {}, policy.missing_object_failure_code}; + return {}; + case SyntheticReadStatus::value_unavailable: + result = MmsReadAccessResultInput{false, {}, policy.backend_failure_code}; + return {}; + case SyntheticReadStatus::workspace_too_small: + return { + ReadObjectStatus::workspace_too_small, + synthetic.required_bytes > + std::numeric_limits::max() - workspace_offset + ? std::numeric_limits::max() + : workspace_offset + synthetic.required_bytes}; + case SyntheticReadStatus::backend_failure: + return {ReadObjectStatus::backend_failure, 0U}; + } + return {ReadObjectStatus::backend_failure, 0U}; +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_read( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response, + const std::span workspace) noexcept { + ReadCompatibilityRequest request; + if (!try_decode_read_compatibility_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + std::array results{}; + std::size_t result_count = 0U; + std::size_t workspace_offset = 0U; + + if (request.uses_variable_list_name) { + const auto* data_set = data_sets.find(request.variable_list_name); + if (data_set == nullptr) { + results[0] = MmsReadAccessResultInput{false, {}, policy.missing_object_failure_code}; + result_count = 1U; + } else { + result_count = data_set->members.size(); + for (std::size_t index = 0U; index < result_count; ++index) { + const auto& member = data_set->members[index]; + const MmsObjectNameView name{ + MmsObjectNameViewKind::domain_specific, + as_bytes(member.domain), + as_bytes(member.item)}; + const auto read = read_name_into_result( + objects, name, policy, workspace, workspace_offset, results[index]); + if (read.status == ReadObjectStatus::workspace_too_small) { + return make_status( + MmsStaticDispatchStatus::workspace_too_small, + confirmed, + read.required_bytes); + } + if (read.status == ReadObjectStatus::backend_failure) { + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); + } + } + } + } else { + result_count = request.variables.variable_count; + for (std::size_t index = 0U; index < result_count; ++index) { + MmsObjectNameView name; + if (!request.variables.try_variable(index, name)) { + results[index] = MmsReadAccessResultInput{ + false, {}, policy.missing_object_failure_code}; + continue; + } + const auto read = read_name_into_result( + objects, name, policy, workspace, workspace_offset, results[index]); + if (read.status == ReadObjectStatus::workspace_too_small) { + return make_status( + MmsStaticDispatchStatus::workspace_too_small, + confirmed, + read.required_bytes); + } + if (read.status == ReadObjectStatus::backend_failure) { + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); + } } - results[index] = MmsReadAccessResultInput{ - true, - remaining.first(read.bytes_written), - 0U}; - workspace_offset += read.bytes_written; } + if (result_count == 0U || result_count > results.size()) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } return make_encoded( confirmed, MmsServiceSpanCodec::encode_read_response_into( confirmed.invoke_id, - std::span{results}.first(request.variable_count), + std::span{results}.first(result_count), response)); } +[[nodiscard]] bool decode_write_boolean( + const std::span encoded, + bool& value) noexcept { + value = false; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 3 || tlv.constructed || tlv.value.size() != 1U) { + return false; + } + value = tlv.value[0] != 0U; + return true; +} + +[[nodiscard]] std::uint8_t write_phase( + const MmsStaticObjectEntry& object, + const std::span value) noexcept { + switch (object.write_semantic) { + case MmsStaticWriteSemantic::rcb_enable: { + bool enable = true; + if (decode_write_boolean(value, enable) && !enable) return 0U; + return 2U; + } + case MmsStaticWriteSemantic::rcb_configuration: + return 1U; + case MmsStaticWriteSemantic::rcb_general_interrogation: + return 3U; + case MmsStaticWriteSemantic::ordinary: + return 1U; + } + return 1U; +} + +void build_write_execution_order( + const std::span resolved, + const std::span> values, + const std::span order) noexcept { + for (std::size_t index = 0U; index < order.size(); ++index) order[index] = index; + for (std::size_t first = 0U; first < order.size(); ++first) { + const auto* object = resolved[first]; + if (object == nullptr || object->write_transaction_group == nullptr) continue; + bool seen = false; + for (std::size_t previous = 0U; previous < first; ++previous) { + const auto* candidate = resolved[previous]; + if (candidate != nullptr && + candidate->write_transaction_group == object->write_transaction_group) { + seen = true; + break; + } + } + if (seen) continue; + + std::array positions{}; + std::array members{}; + std::size_t count = 0U; + for (std::size_t index = first; index < order.size(); ++index) { + const auto* candidate = resolved[index]; + if (candidate != nullptr && + candidate->write_transaction_group == object->write_transaction_group) { + positions[count] = index; + members[count] = index; + ++count; + } + } + for (std::size_t i = 1U; i < count; ++i) { + const auto member = members[i]; + const auto phase = write_phase(*resolved[member], values[member]); + std::size_t j = i; + while (j > 0U && + write_phase(*resolved[members[j - 1U]], values[members[j - 1U]]) > phase) { + members[j] = members[j - 1U]; + --j; + } + members[j] = member; + } + for (std::size_t i = 0U; i < count; ++i) order[positions[i]] = members[i]; + } +} + [[nodiscard]] MmsStaticDispatchResult dispatch_write( const MmsStaticObjectTable& objects, const MmsStaticDispatchPolicy& policy, @@ -375,16 +1436,20 @@ namespace { } std::array results{}; - for (std::size_t index = 0U; index < request.variable_count; ++index) { + std::array execution_order{}; + build_write_execution_order( + std::span{resolved}.first(request.variable_count), + std::span>{values}.first(request.variable_count), + std::span{execution_order}.first(request.variable_count)); + for (std::size_t execution = 0U; execution < request.variable_count; ++execution) { + const auto index = execution_order[execution]; const auto* object = resolved[index]; if (object == nullptr) { - results[index] = MmsWriteAccessResultInput{ - false, policy.missing_object_failure_code}; + results[index] = MmsWriteAccessResultInput{false, policy.missing_object_failure_code}; continue; } if (!object->writable()) { - results[index] = MmsWriteAccessResultInput{ - false, policy.access_denied_failure_code}; + results[index] = MmsWriteAccessResultInput{false, policy.access_denied_failure_code}; continue; } const auto applied = object->contextual_write != nullptr @@ -427,7 +1492,7 @@ MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( const std::span response, const std::span workspace, const MmsStaticRequestAccessContext& access) const noexcept { - if (!objects_.valid() || !data_sets_.valid_against(objects_) || !policy_valid(policy_)) { + if (!model_valid_ || !policy_valid(policy_)) { return make_status(MmsStaticDispatchStatus::invalid_object_table, request); } if (request.kind != MmsWirePduKind::confirmed_request) { @@ -436,13 +1501,14 @@ MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( switch (request.service()) { case MmsWireConfirmedService::get_name_list: - return dispatch_get_name_list(objects_, data_sets_, policy_, request, response); + return dispatch_get_name_list( + objects_, data_sets_, directory_, policy_, request, response); case MmsWireConfirmedService::get_variable_access_attributes: - return dispatch_attributes(objects_, request, response); + return dispatch_attributes(objects_, request, response, workspace); case MmsWireConfirmedService::get_named_variable_list_attributes: return dispatch_data_set_attributes(data_sets_, request, response); case MmsWireConfirmedService::read: - return dispatch_read(objects_, policy_, request, response, workspace); + return dispatch_read(objects_, data_sets_, policy_, request, response, workspace); case MmsWireConfirmedService::write: return dispatch_write(objects_, policy_, request, response, access); default: diff --git a/src/mms/static_report_connection.cpp b/src/mms/static_report_connection.cpp index b65961490..578638ec1 100644 --- a/src/mms/static_report_connection.cpp +++ b/src/mms/static_report_connection.cpp @@ -3,6 +3,7 @@ #include "ariec61850/mms/static_report_connection.hpp" #include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/cotp_tpkt_stream.hpp" #include "ariec61850/osi/presentation_span.hpp" #include "ariec61850/osi/tpkt_span.hpp" @@ -15,18 +16,6 @@ namespace ar::iec61850::mms { namespace { -[[nodiscard]] bool add_size( - const std::size_t base, - const std::size_t extra, - std::size_t& total) noexcept { - if (extra > std::numeric_limits::max() - base) { - total = 0U; - return false; - } - total = base + extra; - return true; -} - [[nodiscard]] MmsStaticReportConnectionResult make_result( const MmsStaticReportConnectionStatus status, const MmsStaticUrcbStatus urcb_status, @@ -43,10 +32,19 @@ namespace { [[nodiscard]] bool final_frame_size( const std::uint32_t presentation_context_id, const std::size_t mms_bytes, + const std::size_t negotiated_tpdu_size_bytes, std::size_t& required) noexcept { + required = 0U; const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( presentation_context_id, mms_bytes); - return fully_encoded && add_size(*fully_encoded, 11U, required); + osi::CotpTpktDataStreamPlan plan; + if (!fully_encoded || + !osi::CotpTpktDataStreamSpanCodec::try_plan( + *fully_encoded, negotiated_tpdu_size_bytes, plan)) { + return false; + } + required = plan.required_bytes; + return true; } [[nodiscard]] MmsStaticReportConnectionResult response_capacity( @@ -108,6 +106,7 @@ MmsStaticReportConnectionResult MmsStaticReportConnection::poll( if (!final_frame_size( connection.mms_presentation_context_id(), encoded.required_bytes, + connection.negotiated_tpdu_size_bytes(), final_required)) { return make_result( MmsStaticReportConnectionStatus::report_encode_failed, @@ -132,16 +131,20 @@ MmsStaticReportConnectionResult MmsStaticReportConnection::poll( true); if (!p_data.success()) { if (p_data.status == wire::EncodeStatus::buffer_too_small) { - std::size_t final_required{}; - if (!add_size(p_data.required_bytes, 7U, final_required)) { + osi::CotpTpktDataStreamPlan stream_plan; + if (!osi::CotpTpktDataStreamSpanCodec::try_plan( + p_data.required_bytes, + connection.negotiated_tpdu_size_bytes(), + stream_plan)) { return make_result( MmsStaticReportConnectionStatus::report_encode_failed, MmsStaticUrcbStatus::report_encode_failed, plan); } auto result = workspace_capacity( - plan, MmsStaticUrcbStatus::workspace_too_small, final_required); - result.required_response_bytes = final_required; + plan, MmsStaticUrcbStatus::workspace_too_small, + p_data.required_bytes); + result.required_response_bytes = stream_plan.required_bytes; return result; } return make_result( @@ -150,48 +153,23 @@ MmsStaticReportConnectionResult MmsStaticReportConnection::poll( plan); } - std::size_t final_required{}; - if (!add_size(p_data.bytes_written, 7U, final_required)) { - return make_result( - MmsStaticReportConnectionStatus::report_encode_failed, - MmsStaticUrcbStatus::report_encode_failed, - plan); - } - if (response.size() < final_required) { - return response_capacity( - plan, MmsStaticUrcbStatus::response_buffer_too_small, final_required); - } - if (workspace.size() < final_required) { - auto result = workspace_capacity( - plan, MmsStaticUrcbStatus::workspace_too_small, final_required); - result.required_response_bytes = final_required; - return result; - } - - // P-DATA currently resides in workspace. COTP consumes it into response; - // after that source is no longer needed, so workspace can safely become - // the TPKT staging destination. - const auto cotp = osi::CotpSpanCodec::encode_data_into( + const auto framed = osi::CotpTpktDataStreamSpanCodec::encode_into( workspace.first(p_data.bytes_written), - response.first(final_required - osi::TpktSpanCodec::header_length)); - if (!cotp.success()) { - return make_result( - MmsStaticReportConnectionStatus::report_encode_failed, - MmsStaticUrcbStatus::report_encode_failed, - plan); - } - - const auto tpkt = osi::TpktSpanCodec::encode_into( - response.first(cotp.bytes_written), - workspace.first(final_required)); - if (!tpkt.success() || tpkt.bytes_written != final_required) { + connection.negotiated_tpdu_size_bytes(), + response); + if (!framed.success()) { + if (framed.status == wire::EncodeStatus::buffer_too_small) { + return response_capacity( + plan, MmsStaticUrcbStatus::response_buffer_too_small, + framed.required_bytes); + } return make_result( MmsStaticReportConnectionStatus::report_encode_failed, MmsStaticUrcbStatus::report_encode_failed, plan); } - // Commit only after the complete TPKT image exists. If capacity failed at + // Commit only after the complete segmented COTP/TPKT stream exists. If capacity failed at // any earlier stage, the same plan/SqNum can be retried without a gap. const auto committed = reports.commit(plan, now_ms); if (committed != MmsStaticUrcbStatus::ok) { @@ -201,14 +179,13 @@ MmsStaticReportConnectionResult MmsStaticReportConnection::poll( plan); } - std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); auto result = make_result( MmsStaticReportConnectionStatus::response_ready, MmsStaticUrcbStatus::ok, plan); - result.bytes_written = tpkt.bytes_written; - result.required_response_bytes = final_required; - result.required_workspace_bytes = final_required; + result.bytes_written = framed.bytes_written; + result.required_response_bytes = framed.required_bytes; + result.required_workspace_bytes = p_data.bytes_written; return result; } diff --git a/src/mms/static_server_session.cpp b/src/mms/static_server_session.cpp index 76dee6357..357400efe 100644 --- a/src/mms/static_server_session.cpp +++ b/src/mms/static_server_session.cpp @@ -119,9 +119,21 @@ MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { response_offset_ = 0U; response_size_ = connection.bytes_written; { + // The IED-simulator compatibility runtime intentionally converts + // malformed/unsupported/object-not-found confirmed requests into + // a standards-level Confirmed-Error while keeping the association + // open. Historically this session surfaced that as response_pending, + // causing the desktop Activity feed to claim "Read ... answered". + // Preserve the queued response, but expose a rejected application + // status for Read compatibility errors so diagnostics tell the truth. + const auto read_confirmed_error = + connection.application_service == MmsWireConfirmedService::read && + connection.application_status != MmsStaticDispatchStatus::response_ready; auto result = make_result( - MmsStaticServerSessionStatus::response_pending, - connection.status); + read_confirmed_error + ? MmsStaticServerSessionStatus::application_rejected + : MmsStaticServerSessionStatus::response_pending, + connection.status); result.application_status = connection.application_status; result.application_service = connection.application_service; result.invoke_id = connection.invoke_id; diff --git a/src/mms/static_setting_group.cpp b/src/mms/static_setting_group.cpp new file mode 100644 index 000000000..779d195a3 --- /dev/null +++ b/src/mms/static_setting_group.cpp @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_setting_group.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kUnsigned8Type{0x86U, 0x01U, 0x08U}; +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kUtcTimeType{0x91U, 0x00U}; +constexpr std::uint32_t kTemporarilyUnavailable = 2U; +constexpr std::uint32_t kTypeInconsistent = 7U; +constexpr std::uint32_t kObjectValueInvalid = 11U; + +constexpr std::array + kAttributes{ + MmsStaticSettingGroupAttribute::active_setting_group, + MmsStaticSettingGroupAttribute::confirm_edit, + MmsStaticSettingGroupAttribute::edit_setting_group, + MmsStaticSettingGroupAttribute::last_activation_time, + MmsStaticSettingGroupAttribute::number_of_setting_groups}; + +constexpr std::array + kSuffixes{"ActSG", "CnfEdit", "EditSG", "LActTm", "NumOfSG"}; + +[[nodiscard]] std::span type_for( + const MmsStaticSettingGroupAttribute attribute) noexcept { + switch (attribute) { + case MmsStaticSettingGroupAttribute::active_setting_group: + case MmsStaticSettingGroupAttribute::edit_setting_group: + case MmsStaticSettingGroupAttribute::number_of_setting_groups: + return kUnsigned8Type; + case MmsStaticSettingGroupAttribute::confirm_edit: + return kBooleanType; + case MmsStaticSettingGroupAttribute::last_activation_time: + return kUtcTimeType; + } + return {}; +} + +[[nodiscard]] wire::EncodeResult encode_unsigned8( + const std::uint32_t value, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (value > 0xFFU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x86U; + destination[1] = 0x01U; + destination[2] = static_cast(value); + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult encode_boolean( + const bool value, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] std::uint64_t pack_utc( + const std::span bytes) noexcept { + std::uint64_t packed{}; + for (const auto byte : bytes) { + packed = (packed << 8U) | static_cast(byte); + } + return packed; +} + +void unpack_utc( + std::uint64_t packed, + const std::span destination) noexcept { + for (std::size_t index = destination.size(); index-- > 0U;) { + destination[index] = static_cast(packed & 0xFFU); + packed >>= 8U; + } +} + +[[nodiscard]] wire::EncodeResult encode_utc( + const std::uint64_t packed, + const std::span destination) noexcept { + constexpr std::size_t required = 10U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x91U; + destination[1] = 0x08U; + std::array bytes{}; + unpack_utc(packed, bytes); + std::copy(bytes.begin(), bytes.end(), destination.begin() + 2); + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_attribute( + const void* raw_context, + const std::span destination) noexcept { + const auto* context = static_cast(raw_context); + if (context == nullptr || context->state == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + switch (context->attribute) { + case MmsStaticSettingGroupAttribute::active_setting_group: + return encode_unsigned8( + context->state->active_setting_group.load(std::memory_order_acquire), + destination); + case MmsStaticSettingGroupAttribute::confirm_edit: + return encode_boolean(false, destination); + case MmsStaticSettingGroupAttribute::edit_setting_group: + return encode_unsigned8(0U, destination); + case MmsStaticSettingGroupAttribute::last_activation_time: + return encode_utc( + context->state->last_activation_time_be.load(std::memory_order_acquire), + destination); + case MmsStaticSettingGroupAttribute::number_of_setting_groups: + return encode_unsigned8(context->state->number_of_setting_groups, destination); + } + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +[[nodiscard]] bool decode_unsigned8( + const std::span encoded, + std::uint32_t& value) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed || tlv.value.empty() || + tlv.value.size() > sizeof(std::uint32_t)) { + return false; + } + const auto parsed = asn1::BerSpanReader::read_unsigned_integer(tlv); + if (!parsed || *parsed > 0xFFU) return false; + value = static_cast(*parsed); + return true; +} + +struct ActivationGuard final { + std::atomic_flag* flag{}; + ~ActivationGuard() { + if (flag != nullptr) flag->clear(std::memory_order_release); + } +}; + +[[nodiscard]] MmsStaticWriteResult write_active_setting_group( + void* raw_context, + const std::span encoded_data) noexcept { + auto* context = static_cast(raw_context); + if (context == nullptr || context->state == nullptr) { + return {false, kObjectValueInvalid}; + } + std::uint32_t requested{}; + if (!decode_unsigned8(encoded_data, requested)) { + return {false, kTypeInconsistent}; + } + if (requested == 0U || requested > context->state->number_of_setting_groups) { + return {false, kObjectValueInvalid}; + } + if (context->state->activation_in_progress.test_and_set(std::memory_order_acquire)) { + return {false, kTemporarilyUnavailable}; + } + ActivationGuard guard{&context->state->activation_in_progress}; + + std::array utc{}; + const bool have_time = context->now_utc != nullptr && + context->now_utc(context->now_context, utc); + context->state->active_setting_group.store(requested, std::memory_order_release); + if (have_time) { + context->state->last_activation_time_be.store( + pack_utc(utc), std::memory_order_release); + } + return {true, 0U}; +} + +} // namespace + +std::size_t MmsStaticSettingGroupObjectBank::required_object_capacity() const noexcept { + if (base_objects_.size() > + std::numeric_limits::max() - attributes_per_control_block) { + return std::numeric_limits::max(); + } + return base_objects_.size() + attributes_per_control_block; +} + +std::size_t MmsStaticSettingGroupObjectBank::required_name_bytes() const noexcept { + if (definition_ == nullptr) return std::numeric_limits::max(); + std::size_t total{}; + for (const auto suffix : kSuffixes) { + if (definition_->item.size() > + MmsServiceSpanCodec::maximum_identifier_bytes - 1U - suffix.size()) { + return std::numeric_limits::max(); + } + const auto required = definition_->item.size() + 1U + suffix.size(); + if (required > std::numeric_limits::max() - total) { + return std::numeric_limits::max(); + } + total += required; + } + return total; +} + +bool MmsStaticSettingGroupObjectBank::initialize() noexcept { + initialized_ = false; + table_ = MmsStaticObjectTable{std::span{}}; + if (definition_ == nullptr || state_ == nullptr || !state_->valid() || + state_->number_of_setting_groups > 0xFFU) { + return false; + } + + const auto required_objects = required_object_capacity(); + const auto required_names = required_name_bytes(); + if (required_objects == std::numeric_limits::max() || + required_names == std::numeric_limits::max() || + required_objects > object_storage_.size() || + required_objects > MmsStaticObjectTable::maximum_objects || + attributes_per_control_block > context_storage_.size() || + required_names > name_storage_.size()) { + return false; + } + + std::copy(base_objects_.begin(), base_objects_.end(), object_storage_.begin()); + std::size_t object_offset = base_objects_.size(); + std::size_t name_offset{}; + for (std::size_t index = 0U; index < attributes_per_control_block; ++index) { + const auto suffix = kSuffixes[index]; + const auto name_size = definition_->item.size() + 1U + suffix.size(); + auto* name = name_storage_.data() + name_offset; + std::copy(definition_->item.begin(), definition_->item.end(), name); + name[definition_->item.size()] = '$'; + std::copy(suffix.begin(), suffix.end(), name + definition_->item.size() + 1U); + + auto& context = context_storage_[index]; + context = MmsStaticSettingGroupObjectContext{ + state_, kAttributes[index], now_utc_, now_context_}; + const bool writable = + kAttributes[index] == MmsStaticSettingGroupAttribute::active_setting_group; + object_storage_[object_offset++] = MmsStaticObjectEntry{ + definition_->domain, + std::string_view{name, name_size}, + type_for(kAttributes[index]), + read_attribute, + &context, + false, + writable ? write_active_setting_group : nullptr, + writable ? &context : nullptr}; + name_offset += name_size; + } + + std::stable_sort( + object_storage_.begin(), + object_storage_.begin() + static_cast(object_offset), + [](const MmsStaticObjectEntry& left, const MmsStaticObjectEntry& right) noexcept { + if (left.domain != right.domain) return left.domain < right.domain; + return left.item < right.item; + }); + table_ = MmsStaticObjectTable{ + std::span{object_storage_.data(), object_offset}}; + if (!table_.valid()) { + table_ = MmsStaticObjectTable{std::span{}}; + return false; + } + initialized_ = true; + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_objects.cpp b/src/mms/static_urcb_objects.cpp index 0f5d4f733..3fb7b7a1c 100644 --- a/src/mms/static_urcb_objects.cpp +++ b/src/mms/static_urcb_objects.cpp @@ -17,8 +17,11 @@ namespace ar::iec61850::mms { namespace { constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kUnsigned8Type{0x86U, 0x01U, 0x08U}; constexpr std::array kUnsigned32Type{0x86U, 0x01U, 0x20U}; -constexpr std::array kVisible255Type{0x8AU, 0x02U, 0x00U, 0xFFU}; +// Golden IEDScout server uses the variable-length VisibleString(129) form. +constexpr std::array kVisible129Type{0x8AU, 0x02U, 0xFFU, 0x7FU}; +constexpr std::array kVariableOctet64Type{0x89U, 0x02U, 0xFFU, 0xC0U}; constexpr std::array kBitString10Type{0x84U, 0x01U, 0x0AU}; constexpr std::array kBitString6Type{0x84U, 0x01U, 0x06U}; @@ -32,10 +35,11 @@ constexpr std::array @@ -47,10 +51,11 @@ constexpr std::array destination) noexcept { + if (domain.empty() || item.empty()) { + return domain.empty() && item.empty() + ? encode_visible({}, destination) + : wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } if (domain.size() > std::numeric_limits::max() - 1U - item.size()) { return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; } @@ -241,6 +268,26 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; return capacity; } +[[nodiscard]] wire::EncodeResult encode_octets( + const std::span bytes, + const std::span destination) noexcept { + const auto total = asn1::BerSpanWriter::tlv_size(9, bytes.size()); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 9, bytes.size()) || + !writer.write_bytes(bytes) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + [[nodiscard]] wire::EncodeResult read_urcb_attribute( const void* raw_context, const std::span destination) noexcept { @@ -269,6 +316,8 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; return encode_bit_string(6U, state->optional_fields, destination); case MmsStaticUrcbAttribute::buffer_time: return encode_unsigned(state->buffer_time_ms, destination); + case MmsStaticUrcbAttribute::sequence_number: + return encode_unsigned(state->sequence_number, destination); case MmsStaticUrcbAttribute::trigger_options: { const std::array trigger{state->trigger_options}; return encode_bit_string(2U, trigger, destination); @@ -277,8 +326,13 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; return encode_unsigned(state->integrity_period_ms, destination); case MmsStaticUrcbAttribute::general_interrogation: return encode_boolean(state->general_interrogation_pending, destination); - case MmsStaticUrcbAttribute::sequence_number: - return encode_unsigned(state->sequence_number, destination); + case MmsStaticUrcbAttribute::owner: { + // The portable URCB runtime is per-association, so no cross-association + // Owner identity is retained here. Expose the standards-compatible + // empty Owner value instead of omitting the required field. + const std::span empty_owner; + return encode_octets(empty_owner, destination); + } } return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; } @@ -494,6 +548,7 @@ constexpr std::uint32_t kObjectValueInvalid = 11U; } case MmsStaticUrcbAttribute::conf_revision: case MmsStaticUrcbAttribute::sequence_number: + case MmsStaticUrcbAttribute::owner: return {false, kObjectAccessDenied}; } return {false, kHardwareFault}; @@ -598,6 +653,9 @@ bool MmsStaticUrcbObjectBank::initialize() noexcept { now_context_}; const auto attribute = kAttributes[attribute_index]; + const auto writable = writable_attribute(attribute); + const auto* transaction_group = static_cast( + &context_storage_[urcb_index * attributes_per_control_block]); object_storage_[object_offset] = MmsStaticObjectEntry{ definition->domain, std::string_view{name, name_size}, @@ -605,8 +663,11 @@ bool MmsStaticUrcbObjectBank::initialize() noexcept { read_urcb_attribute, &context, false, - writable_attribute(attribute) ? write_urcb_attribute : nullptr, - writable_attribute(attribute) ? &context : nullptr}; + writable ? write_urcb_attribute : nullptr, + writable ? &context : nullptr, + nullptr, + writable ? transaction_group : nullptr, + writable ? write_semantic(attribute) : MmsStaticWriteSemantic::ordinary}; name_offset += name_size; ++context_offset; diff --git a/src/mms/static_urcb_runtime.cpp b/src/mms/static_urcb_runtime.cpp index 968cd8a2c..a7268a6c3 100644 --- a/src/mms/static_urcb_runtime.cpp +++ b/src/mms/static_urcb_runtime.cpp @@ -2,7 +2,11 @@ #include "ariec61850/mms/static_urcb_runtime.hpp" +#include "ariec61850/mms/buffered_selective_information_report_span.hpp" +#include "ariec61850/mms/buffered_selective_report_detail.hpp" + #include +#include #include #include #include @@ -12,10 +16,16 @@ namespace ar::iec61850::mms { namespace { -constexpr std::uint8_t kAllowedOptionalFirst = 0x7CU; +constexpr std::uint8_t kEffectiveOptionalFirst = 0x7CU; +constexpr std::uint8_t kAcceptedOptionalFirst = 0x7FU; constexpr std::uint8_t kAllowedOptionalSecond = 0x80U; -constexpr std::uint8_t kAllowedTriggerOptions = 0x7CU; +constexpr std::uint8_t kAllowedTriggerOptions = 0xFCU; +constexpr std::uint8_t kTriggerDataChange = 0x40U; +constexpr std::uint8_t kTriggerQualityChange = 0x20U; +constexpr std::uint8_t kTriggerDataUpdate = 0x10U; constexpr std::uint8_t kTriggerIntegrity = 0x08U; +constexpr std::uint8_t kTriggerGeneralInterrogation = 0x04U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; [[nodiscard]] bool visible_ascii( const std::string_view text, @@ -51,14 +61,31 @@ constexpr std::uint8_t kTriggerIntegrity = 0x08U; MmsInformationReportSpanCodec::maximum_reference_bytes; } +[[nodiscard]] bool valid_optional_reference( + const std::string_view domain, + const std::string_view item) noexcept { + if (domain.empty() || item.empty()) { + return domain.empty() && item.empty(); + } + return valid_reference(domain, item); +} + [[nodiscard]] bool valid_optional_fields( const std::span optional_fields) noexcept { return optional_fields.size() == MmsInformationReportSpanCodec::optional_field_bytes && - (optional_fields[0] & static_cast(~kAllowedOptionalFirst)) == 0U && + (optional_fields[0] & static_cast(~kAcceptedOptionalFirst)) == 0U && (optional_fields[1] & static_cast(~kAllowedOptionalSecond)) == 0U; } +[[nodiscard]] std::array effective_optional_fields( + const std::span optional_fields) noexcept { + return { + static_cast(optional_fields[0] & kEffectiveOptionalFirst), + static_cast(optional_fields[1] & kAllowedOptionalSecond)}; +} + [[nodiscard]] bool valid_trigger_options(const std::uint8_t options) noexcept { return (options & static_cast(~kAllowedTriggerOptions)) == 0U; } @@ -68,7 +95,7 @@ template const std::string_view source, std::array& destination, std::size_t& length) noexcept { - if (source.empty() || source.size() > destination.size()) { + if (source.size() > destination.size()) { length = 0U; return false; } @@ -135,6 +162,61 @@ void refresh_integrity_arm( : 0U; } +void clear_pending_event(MmsStaticUrcbState& state) noexcept { + std::fill( + state.member_reason_masks.begin(), + state.member_reason_masks.end(), + std::uint8_t{0U}); + state.event_due_ms = 0U; + state.pending_member_count = 0U; + state.event_pending = false; +} + +[[nodiscard]] bool event_mapping( + const MmsStaticUrcbEventReason reason, + std::uint8_t& trigger_mask, + std::uint8_t& report_reason) noexcept { + switch (reason) { + case MmsStaticUrcbEventReason::data_change: + trigger_mask = kTriggerDataChange; + report_reason = static_cast(MmsStaticUrcbReportReason::data_change); + return true; + case MmsStaticUrcbEventReason::quality_change: + trigger_mask = kTriggerQualityChange; + report_reason = static_cast(MmsStaticUrcbReportReason::quality_change); + return true; + case MmsStaticUrcbEventReason::data_update: + trigger_mask = kTriggerDataUpdate; + report_reason = static_cast(MmsStaticUrcbReportReason::data_update); + return true; + } + return false; +} + +[[nodiscard]] bool event_reason(const MmsStaticUrcbReportReason reason) noexcept { + return reason == MmsStaticUrcbReportReason::data_change || + reason == MmsStaticUrcbReportReason::quality_change || + reason == MmsStaticUrcbReportReason::data_update; +} + +[[nodiscard]] MmsStaticUrcbReportReason dominant_event_reason( + const MmsStaticUrcbState& state) noexcept { + std::uint8_t combined{}; + for (const auto reason : state.member_reason_masks) { + combined = static_cast(combined | reason); + } + if ((combined & static_cast(MmsStaticUrcbReportReason::data_change)) != 0U) { + return MmsStaticUrcbReportReason::data_change; + } + if ((combined & static_cast(MmsStaticUrcbReportReason::quality_change)) != 0U) { + return MmsStaticUrcbReportReason::quality_change; + } + if ((combined & static_cast(MmsStaticUrcbReportReason::data_update)) != 0U) { + return MmsStaticUrcbReportReason::data_update; + } + return MmsStaticUrcbReportReason::none; +} + [[nodiscard]] std::uint8_t next_sequence_number( const std::uint8_t current) noexcept { return static_cast( @@ -152,7 +234,11 @@ void refresh_integrity_arm( if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { return state.general_interrogation_pending; } - return plan.reason == MmsStaticUrcbReportReason::integrity && state.integrity_armed; + if (plan.reason == MmsStaticUrcbReportReason::integrity) { + return state.integrity_armed; + } + return event_reason(plan.reason) && state.event_pending && + state.pending_member_count != 0U; } [[nodiscard]] MmsStaticUrcbEncodeResult map_report_result( @@ -184,6 +270,110 @@ void refresh_integrity_arm( return {status, result.bytes_written, result.required_bytes, result.member_count}; } +[[nodiscard]] MmsStaticUrcbEncodeResult encode_selective_event( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsStaticUrcbState& state, + const MmsStaticUrcbEmissionPlan& plan, + const std::span report_time, + const std::span destination, + const std::span workspace) noexcept { + const auto data_set_name = object_name(state.data_set_domain(), state.data_set_item()); + const auto* data_set = data_sets.find(data_set_name); + if (data_set == nullptr) { + return {MmsStaticUrcbStatus::data_set_not_found, 0U, 0U, 0U}; + } + if (data_set->members.empty() || + data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return {MmsStaticUrcbStatus::report_encode_failed, 0U, 0U, 0U}; + } + + std::array indices{}; + std::array references{}; + std::array results{}; + std::array reasons{}; + + std::size_t included{}; + std::size_t workspace_offset{}; + for (std::size_t member_index = 0U; + member_index < data_set->members.size(); + ++member_index) { + const auto reason_mask = state.member_reason_masks[member_index]; + if (reason_mask == 0U) continue; + const auto& member = data_set->members[member_index]; + const auto* object = objects.find(object_name(member.domain, member.item)); + if (object == nullptr || object->read == nullptr) { + return {MmsStaticUrcbStatus::backend_failure, 0U, 0U, included}; + } + if (workspace_offset > workspace.size()) { + return {MmsStaticUrcbStatus::workspace_too_small, 0U, workspace_offset, included}; + } + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + const auto required = read.required_bytes > + std::numeric_limits::max() - workspace_offset + ? std::numeric_limits::max() + : workspace_offset + read.required_bytes; + return {MmsStaticUrcbStatus::workspace_too_small, 0U, required, included}; + } + if (!read.success() || read.bytes_written > remaining.size() || + !detail::valid_mms_data(remaining.first(read.bytes_written))) { + return {MmsStaticUrcbStatus::backend_failure, 0U, 0U, included}; + } + + indices[included] = member_index; + references[included] = {member.domain, member.item}; + results[included] = {true, remaining.first(read.bytes_written), 0U}; + reasons[included] = reason_mask; + workspace_offset += read.bytes_written; + ++included; + } + if (included == 0U || included != state.pending_member_count) { + return {MmsStaticUrcbStatus::stale_plan, 0U, 0U, included}; + } + + MmsBufferedSelectiveInformationReportSnapshotInput report; + report.report_id = state.report_id(); + report.optional_fields = state.optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.data_set_reference = {state.data_set_domain(), state.data_set_item()}; + report.buffer_overflow = false; + report.entry_id = {}; + report.conf_revision = state.conf_revision; + report.data_set_member_count = data_set->members.size(); + report.included_member_indices = + std::span{indices}.first(included); + report.included_member_references = + std::span{references}.first(included); + report.included_member_results = + std::span{results}.first(included); + if ((state.optional_fields[0] & kOptReasonForInclusion) != 0U) { + report.included_reason_for_inclusion = + std::span{reasons}.first(included); + } + + const auto encoded = + MmsBufferedSelectiveInformationReportSpanCodec::encode_snapshot_into( + report, destination); + if (encoded.success()) { + return {MmsStaticUrcbStatus::ok, + encoded.bytes_written, + encoded.required_bytes, + included}; + } + return { + encoded.status == wire::EncodeStatus::buffer_too_small + ? MmsStaticUrcbStatus::response_buffer_too_small + : MmsStaticUrcbStatus::report_encode_failed, + 0U, + encoded.required_bytes, + included}; +} + } // namespace bool MmsStaticUrcbRuntime::initialize() noexcept { @@ -200,14 +390,20 @@ bool MmsStaticUrcbRuntime::initialize() noexcept { const auto& definition = definitions_[index]; if (!valid_reference(definition.domain, definition.item) || !valid_report_id(definition.report_id) || - !valid_reference(definition.data_set_domain, definition.data_set_item) || + !valid_optional_reference( + definition.data_set_domain, definition.data_set_item) || !valid_optional_fields(definition.optional_fields) || - !valid_trigger_options(definition.trigger_options) || - data_sets_->find(object_name( - definition.data_set_domain, - definition.data_set_item)) == nullptr) { + !valid_trigger_options(definition.trigger_options)) { return false; } + if (!definition.data_set_domain.empty()) { + const auto* data_set = data_sets_->find(object_name( + definition.data_set_domain, definition.data_set_item)); + if (data_set == nullptr || data_set->members.empty() || + data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return false; + } + } for (std::size_t earlier = 0U; earlier < index; ++earlier) { if (same_name(definitions_[earlier], definition)) { return false; @@ -233,7 +429,7 @@ bool MmsStaticUrcbRuntime::initialize() noexcept { state_ref.data_set_item_size)) { return false; } - state_ref.optional_fields = definition.optional_fields; + state_ref.optional_fields = effective_optional_fields(definition.optional_fields); state_ref.conf_revision = definition.conf_revision; state_ref.buffer_time_ms = definition.buffer_time_ms; state_ref.trigger_options = definition.trigger_options; @@ -302,9 +498,20 @@ MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_enabled( if (state_ref->enabled == enabled) { return MmsStaticUrcbStatus::ok; } + if (enabled) { + if (state_ref->data_set_domain().empty() || state_ref->data_set_item().empty()) { + return MmsStaticUrcbStatus::data_set_not_found; + } + const auto* data_set = data_sets_->find(object_name( + state_ref->data_set_domain(), state_ref->data_set_item())); + if (data_set == nullptr || data_set->members.empty()) { + return MmsStaticUrcbStatus::data_set_not_found; + } + } state_ref->enabled = enabled; state_ref->general_interrogation_pending = false; + clear_pending_event(*state_ref); if (enabled) { state_ref->sequence_number = 0U; refresh_integrity_arm(*state_ref, now_ms); @@ -376,11 +583,14 @@ MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_data_set( if (state_ref->enabled) { return MmsStaticUrcbStatus::object_access_denied; } - if (!valid_reference(domain, item)) { + if (!valid_optional_reference(domain, item)) { return MmsStaticUrcbStatus::invalid_value; } - if (data_sets_->find(object_name(domain, item)) == nullptr) { - return MmsStaticUrcbStatus::data_set_not_found; + if (!domain.empty()) { + const auto* data_set = data_sets_->find(object_name(domain, item)); + if (data_set == nullptr || data_set->members.empty()) { + return MmsStaticUrcbStatus::data_set_not_found; + } } if (state_ref->data_set_domain() == domain && state_ref->data_set_item() == item) { return MmsStaticUrcbStatus::ok; @@ -415,8 +625,7 @@ MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_optional_fields( if (!valid_optional_fields(optional_fields)) { return MmsStaticUrcbStatus::invalid_value; } - const std::array - next{optional_fields[0], optional_fields[1]}; + const auto next = effective_optional_fields(optional_fields); if (state_ref->optional_fields != next) { state_ref->optional_fields = next; bump_revision(*state_ref); @@ -499,6 +708,9 @@ MmsStaticUrcbStatus MmsStaticUrcbRuntime::request_general_interrogation( if (!state_ref->enabled) { return MmsStaticUrcbStatus::temporarily_unavailable; } + if ((state_ref->trigger_options & kTriggerGeneralInterrogation) == 0U) { + return MmsStaticUrcbStatus::invalid_value; + } if (!state_ref->general_interrogation_pending) { state_ref->general_interrogation_pending = true; bump_revision(*state_ref); @@ -506,6 +718,56 @@ MmsStaticUrcbStatus MmsStaticUrcbRuntime::request_general_interrogation( return MmsStaticUrcbStatus::ok; } +MmsStaticUrcbStatus MmsStaticUrcbRuntime::notify( + const std::size_t index, + const std::size_t data_set_member_index, + const MmsStaticUrcbEventReason reason, + const std::uint64_t now_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_ || data_sets_ == nullptr) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (!state_ref->enabled) { + return MmsStaticUrcbStatus::temporarily_unavailable; + } + + std::uint8_t trigger_mask{}; + std::uint8_t report_reason{}; + if (!event_mapping(reason, trigger_mask, report_reason)) { + return MmsStaticUrcbStatus::invalid_value; + } + if ((state_ref->trigger_options & trigger_mask) == 0U) { + // A value update outside selected TrgOps is intentionally a no-op. + return MmsStaticUrcbStatus::ok; + } + + const auto* data_set = data_sets_->find(object_name( + state_ref->data_set_domain(), state_ref->data_set_item())); + if (data_set == nullptr) { + return MmsStaticUrcbStatus::data_set_not_found; + } + if (data_set_member_index >= data_set->members.size() || + data_set_member_index >= state_ref->member_reason_masks.size()) { + return MmsStaticUrcbStatus::invalid_value; + } + + if (!state_ref->event_pending) { + state_ref->event_pending = true; + state_ref->event_due_ms = saturating_add(now_ms, state_ref->buffer_time_ms); + } + if (state_ref->member_reason_masks[data_set_member_index] == 0U) { + ++state_ref->pending_member_count; + } + state_ref->member_reason_masks[data_set_member_index] = + static_cast( + state_ref->member_reason_masks[data_set_member_index] | report_reason); + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + bool MmsStaticUrcbRuntime::next_due( const std::uint64_t now_ms, MmsStaticUrcbEmissionPlan& plan) const noexcept { @@ -524,6 +786,20 @@ bool MmsStaticUrcbRuntime::next_due( return true; } } + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& state_ref = states_[index]; + if (state_ref.enabled && state_ref.event_pending && + state_ref.pending_member_count != 0U && + now_ms >= state_ref.event_due_ms) { + const auto reason = dominant_event_reason(state_ref); + if (reason == MmsStaticUrcbReportReason::none) continue; + plan.index = index; + plan.revision = state_ref.revision; + plan.sequence_number = next_sequence_number(state_ref.sequence_number); + plan.reason = reason; + return true; + } + } for (std::size_t index = 0U; index < definitions_.size(); ++index) { const auto& state_ref = states_[index]; if (state_ref.enabled && state_ref.integrity_armed && @@ -554,6 +830,17 @@ MmsStaticUrcbEncodeResult MmsStaticUrcbRuntime::encode( return {MmsStaticUrcbStatus::stale_plan, 0U, 0U, 0U}; } + if (event_reason(plan.reason)) { + return encode_selective_event( + *objects_, + *data_sets_, + state_ref, + plan, + report_time, + destination, + workspace); + } + const auto data_set_name = object_name( state_ref.data_set_domain(), state_ref.data_set_item()); @@ -595,6 +882,8 @@ MmsStaticUrcbStatus MmsStaticUrcbRuntime::commit( state_ref.sequence_number = plan.sequence_number; if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { state_ref.general_interrogation_pending = false; + } else if (event_reason(plan.reason)) { + clear_pending_event(state_ref); } else if (plan.reason == MmsStaticUrcbReportReason::integrity) { const auto period = effective_integrity_period(state_ref); state_ref.next_integrity_due_ms = period == 0U diff --git a/src/scl/parser_part_02.inc b/src/scl/parser_part_02.inc index 7c3e3875a..6cddb7453 100644 --- a/src/scl/parser_part_02.inc +++ b/src/scl/parser_part_02.inc @@ -44,6 +44,20 @@ std::optional parse_u8(const std::string_view text) { return static_cast(*value); } +std::optional optional_uint_attribute( + const XmlNode& node, + const std::string_view name) { + const auto text = trim_copy(attribute(&node, name)); + if (text.empty()) return std::nullopt; + std::uint32_t value{}; + const auto result = std::from_chars( + text.data(), text.data() + text.size(), value, 10); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) { + return std::nullopt; + } + return value; +} + std::uint32_t uint_attribute(const XmlNode& node, const std::string_view name) { const auto text = attribute(&node, name); std::uint32_t value{}; @@ -208,6 +222,12 @@ public: Resolution resolution; }; + struct ExpandedModelLeaf final { + std::string do_name; + std::string da_name; + Resolution resolution; + }; + explicit SclTypeIndex(const XmlNode& root) { for (const auto* ied : direct_children(root, "IED")) { add_first(ieds_, attribute(ied, "name"), ied); @@ -373,4 +393,123 @@ public: } return expanded; - } \ No newline at end of file + } + + // Project an entire logical node from DataTypeTemplates. This is the + // simulator/server structural source of truth and deliberately does not + // depend on DataSet, ReportControl, GSEControl, or SampledValueControl + // membership. Nested SDO and structured DA/BDA paths are flattened into + // canonical dotted DO/DA paths while preserving their FC/type semantics. + [[nodiscard]] std::vector expand_logical_node( + const std::string& ied_name, + const std::string& ld_inst, + const std::string& prefix, + const std::string& ln_class, + const std::string& ln_inst) const { + std::vector expanded; + const auto* logical_node = find_logical_node( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst); + if (logical_node == nullptr) { + return expanded; + } + const auto* logical_node_type = find( + logical_node_types_, attribute(logical_node, "lnType")); + if (logical_node_type == nullptr) { + return expanded; + } + + const auto append_bdas = [this, &expanded]( + auto&& self, + const XmlNode& container, + const std::string& do_path, + const std::string& da_path, + const std::string& fc, + const std::string& cdc) -> void { + for (const auto* bda : direct_children(container, "BDA")) { + const auto name = attribute(bda, "name"); + if (name.empty()) continue; + const auto child_path = da_path.empty() ? name : da_path + "." + name; + const auto basic_type = attribute(bda, "bType"); + const auto type_id = attribute(bda, "type"); + if (same(basic_type, "Struct")) { + const auto* nested = find(data_attribute_types_, type_id); + if (nested == nullptr) { + expanded.push_back({ + do_path, + child_path, + {false, fc, cdc, basic_type, type_id, {}}}); + } else { + self(self, *nested, do_path, child_path, fc, cdc); + } + continue; + } + std::string enum_type; + if (same(basic_type, "Enum") && + enum_type_ids_.contains(normalized_key(type_id))) { + enum_type = type_id; + } + expanded.push_back({ + do_path, + child_path, + {true, fc, cdc, basic_type, type_id, enum_type}}); + } + }; + + const auto append_data_object = [this, &expanded, &append_bdas]( + auto&& self, + const XmlNode& data_object_type, + const std::string& do_path) -> void { + const auto cdc = attribute(&data_object_type, "cdc"); + for (const auto* data_attribute : direct_children(data_object_type, "DA")) { + const auto name = attribute(data_attribute, "name"); + const auto fc = attribute(data_attribute, "fc"); + if (name.empty() || fc.empty()) continue; + const auto basic_type = attribute(data_attribute, "bType"); + const auto type_id = attribute(data_attribute, "type"); + if (same(basic_type, "Struct")) { + const auto* nested = find(data_attribute_types_, type_id); + if (nested == nullptr) { + expanded.push_back({ + do_path, + name, + {false, fc, cdc, basic_type, type_id, {}}}); + } else { + append_bdas(append_bdas, *nested, do_path, name, fc, cdc); + } + continue; + } + std::string enum_type; + if (same(basic_type, "Enum") && + enum_type_ids_.contains(normalized_key(type_id))) { + enum_type = type_id; + } + expanded.push_back({ + do_path, + name, + {true, fc, cdc, basic_type, type_id, enum_type}}); + } + + for (const auto* structured_object : direct_children(data_object_type, "SDO")) { + const auto name = attribute(structured_object, "name"); + const auto type_id = attribute(structured_object, "type"); + if (name.empty() || type_id.empty()) continue; + const auto* nested = find(data_object_types_, type_id); + if (nested == nullptr) continue; + self(self, *nested, do_path + "." + name); + } + }; + + for (const auto* data_object : direct_children(*logical_node_type, "DO")) { + const auto name = attribute(data_object, "name"); + const auto type_id = attribute(data_object, "type"); + if (name.empty() || type_id.empty()) continue; + const auto* data_object_type = find(data_object_types_, type_id); + if (data_object_type == nullptr) continue; + append_data_object(append_data_object, *data_object_type, name); + } + return expanded; + } diff --git a/src/scl/parser_part_03_01.inc b/src/scl/parser_part_03_01.inc index 1e27c2298..4032cb1f6 100644 --- a/src/scl/parser_part_03_01.inc +++ b/src/scl/parser_part_03_01.inc @@ -58,6 +58,48 @@ return {true, resolved_fc, cdc, basic_type, current_type_id, enum_type}; } + // Return an explicitly configured instance value for a structural leaf. + // The first control slice needs the common DOI/DAI/Val form used for + // ctlModel. Structured BDA instance values remain runtime/profile work and + // deliberately do not fall back to a guessed template value. + [[nodiscard]] std::string configured_value( + const std::string& ied_name, + const std::string& ld_inst, + const std::string& prefix, + const std::string& ln_class, + const std::string& ln_inst, + const std::string& do_name, + const std::string& da_name) const { + const auto* logical_node = find_logical_node( + ied_name, ld_inst, prefix, ln_class, ln_inst); + if (logical_node == nullptr) return {}; + + const auto do_segments = split_name(do_name); + if (do_segments.empty()) return {}; + const XmlNode* instance = logical_node; + for (std::size_t index = 0U; index < do_segments.size(); ++index) { + const auto tag = index == 0U ? std::string_view{"DOI"} : std::string_view{"SDI"}; + const XmlNode* next{}; + for (const auto* candidate : direct_children(*instance, tag)) { + if (same(attribute(candidate, "name"), do_segments[index])) { + next = candidate; + break; + } + } + if (next == nullptr) return {}; + instance = next; + } + + const auto da_segments = split_name(da_name); + if (da_segments.size() != 1U) return {}; + for (const auto* dai : direct_children(*instance, "DAI")) { + if (!same(attribute(dai, "name"), da_segments.front())) continue; + const auto* value = first_child(*dai, "Val"); + return value == nullptr ? std::string{} : trim_copy(value->text); + } + return {}; + } + private: std::unordered_map ieds_; std::unordered_map logical_node_types_; diff --git a/src/scl/parser_part_04.inc b/src/scl/parser_part_04.inc index 27da4a265..fc222e4c5 100644 --- a/src/scl/parser_part_04.inc +++ b/src/scl/parser_part_04.inc @@ -1,7 +1,10 @@ da_name, functional_constraint); - if (!type.resolved && !signal_reference.empty()) { + // A configured FCDA may intentionally name the whole DataObject and leave + // daName empty. That is a valid IEC 61850 DataSet member, not an unresolved + // scalar leaf. Payload-oriented users can expand it separately below. + if (!type.resolved && !signal_reference.empty() && !da_name.empty()) { warnings.push_back("Type unresolved for " + signal_reference + "."); } @@ -22,6 +25,7 @@ type.enum_type, is_quality_attribute(da_name), is_timestamp_attribute(da_name), + {}, }; } @@ -116,6 +120,7 @@ std::vector build_data_set_entries( leaf.resolution.enum_type, is_quality_attribute(leaf.da_name), is_timestamp_attribute(leaf.da_name), + {}, }); } return result; @@ -279,6 +284,19 @@ SclDocument SclParser::parse( continue; } const auto logical_node_path = build_logical_node_path(logical_node); + const auto prefix = attribute(&logical_node, "prefix"); + const auto ln_class = is(logical_node, "LN0") + ? std::string{"LLN0"} + : attribute(&logical_node, "lnClass"); + const auto ln_inst = attribute(&logical_node, "inst"); + document.logical_nodes.push_back({ + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + logical_node_path, + }); for (const auto* data_set_node : direct_children(logical_node, "DataSet")) { SclDataSet data_set; data_set.ied_name = ied_name; @@ -296,17 +314,39 @@ SclDocument SclParser::parse( logical_node_path, data_set.name); - std::size_t entry_index = 1U; + std::size_t configured_entry_index = 1U; + std::size_t expanded_entry_index = 1U; for (const auto* fcda : direct_children(*data_set_node, "FCDA")) { - auto entries = build_data_set_entries( + const auto explicit_da_name = attribute(fcda, "daName"); + if (!explicit_da_name.empty()) { + auto configured = build_data_set_entry( + ied_name, + *fcda, + configured_entry_index++, + type_index, + document.warnings); + auto expanded = configured; + expanded.index = expanded_entry_index++; + data_set.entries.push_back(std::move(configured)); + data_set.expanded_entries.push_back(std::move(expanded)); + continue; + } + + data_set.entries.push_back(build_data_set_entry( + ied_name, + *fcda, + configured_entry_index++, + type_index, + document.warnings)); + auto expanded = build_data_set_entries( ied_name, *fcda, - entry_index, + expanded_entry_index, type_index, document.warnings); - entry_index += entries.size(); - for (auto& entry : entries) { - data_set.entries.push_back(std::move(entry)); + expanded_entry_index += expanded.size(); + for (auto& entry : expanded) { + data_set.expanded_entries.push_back(std::move(entry)); } } document.data_sets.push_back(std::move(data_set)); diff --git a/src/scl/parser_part_05_00.inc b/src/scl/parser_part_05_00.inc index 0e42d7ccd..016aa1fba 100644 --- a/src/scl/parser_part_05_00.inc +++ b/src/scl/parser_part_05_00.inc @@ -1,3 +1,82 @@ + // Build the complete structural leaf inventory from DataTypeTemplates before + // resolving service bindings. This mirrors the C# simulator profile builder: + // DataSets/GOOSE/reports enrich the model, but they never define which + // LD/LN/DO/DA leaves exist in the simulated IED. + std::size_t model_entry_index = 1U; + for (const auto* ied : direct_children(root, "IED")) { + const auto ied_name = attribute(ied, "name"); + for (const auto* logical_device : descendants(*ied, "LDevice")) { + const auto ld_inst = attribute(logical_device, "inst"); + for (const auto& logical_node : logical_device->children) { + if (!is(logical_node, "LN0") && !is(logical_node, "LN")) { + continue; + } + const auto prefix = attribute(&logical_node, "prefix"); + const auto ln_class = is(logical_node, "LN0") + ? std::string{"LLN0"} + : attribute(&logical_node, "lnClass"); + const auto ln_inst = attribute(&logical_node, "inst"); + const auto leaves = type_index.expand_logical_node( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst); + for (const auto& leaf : leaves) { + if (!leaf.resolution.resolved || + leaf.resolution.functional_constraint.empty()) { + document.warnings.push_back( + "Structural SCL leaf omitted because its type/FC could not be resolved: " + + build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + leaf.do_name, + leaf.da_name, + leaf.resolution.functional_constraint) + "."); + continue; + } + document.model_entries.push_back({ + model_entry_index++, + build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + leaf.do_name, + leaf.da_name, + leaf.resolution.functional_constraint), + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + leaf.do_name, + leaf.da_name, + leaf.resolution.functional_constraint, + leaf.resolution.cdc, + leaf.resolution.basic_type, + leaf.resolution.type_id, + leaf.resolution.enum_type, + is_quality_attribute(leaf.da_name), + is_timestamp_attribute(leaf.da_name), + type_index.configured_value( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + leaf.do_name, + leaf.da_name), + }); + } + } + } + } + if (const auto* communication = first_child(root, "Communication"); communication != nullptr) { for (const auto* connected_ap : descendants(*communication, "ConnectedAP")) { const auto ied_name = attribute(connected_ap, "iedName"); diff --git a/src/scl/parser_part_05_01.inc b/src/scl/parser_part_05_01.inc index eb5c74d2e..ed0e466c2 100644 --- a/src/scl/parser_part_05_01.inc +++ b/src/scl/parser_part_05_01.inc @@ -6,7 +6,9 @@ stream.address = address->second; } if (data_set != nullptr) { - stream.entries = data_set->entries; + stream.entries = data_set->expanded_entries.empty() + ? data_set->entries + : data_set->expanded_entries; } stream.min_time_milliseconds = uint_attribute(*control, "minTime"); stream.max_time_milliseconds = uint_attribute(*control, "maxTime"); diff --git a/src/scl/parser_part_05_02.inc b/src/scl/parser_part_05_02.inc index b2795ad2c..3046c5978 100644 --- a/src/scl/parser_part_05_02.inc +++ b/src/scl/parser_part_05_02.inc @@ -4,7 +4,9 @@ stream.address = address->second; } if (data_set != nullptr) { - stream.entries = data_set->entries; + stream.entries = data_set->expanded_entries.empty() + ? data_set->entries + : data_set->expanded_entries; } stream.sample_rate = ushort_attribute(*control, "smpRate"); stream.sample_mode = attribute(control, "smpMod"); @@ -53,4 +55,9 @@ report.control_block_reference = ied_name + ld_inst + "/" + logical_node_path + (buffered ? "$BR$" : "$RP$") + report.name; report.buffered = buffered; - report.indexed = !same(attribute(control, "indexed"), "false"); \ No newline at end of file + report.indexed = !same(attribute(control, "indexed"), "false"); + if (const auto* enabled = first_child(*control, "RptEnabled"); enabled != nullptr) { + report.max_clients = std::max( + 1U, + uint_attribute(*enabled, "max")); + } diff --git a/src/scl/parser_part_05_03.inc b/src/scl/parser_part_05_03.inc index 8d6537283..adb32799e 100644 --- a/src/scl/parser_part_05_03.inc +++ b/src/scl/parser_part_05_03.inc @@ -6,6 +6,28 @@ } document.report_controls.push_back(std::move(report)); } + + if (is(logical_node, "LN0")) { + for (const auto* control : direct_children(logical_node, "SettingControl")) { + SclSettingControl setting; + setting.ied_name = ied_name; + setting.ld_inst = ld_inst; + setting.logical_node_path = logical_node_path; + setting.control_block_reference = + ied_name + ld_inst + "/" + logical_node_path + "$SP$SGCB"; + setting.number_of_setting_groups = + optional_uint_attribute(*control, "numOfSGs"); + setting.active_setting_group = + optional_uint_attribute(*control, "actSG"); + if (!setting.valid()) { + document.warnings.push_back( + "SettingControl '" + setting.control_block_reference + + "' has invalid or missing numOfSGs/actSG and will not be " + "operational in simulator service projection."); + } + document.setting_controls.push_back(std::move(setting)); + } + } } } } diff --git a/src/scl/parser_part_05_04.inc b/src/scl/parser_part_05_04.inc index d65f63cf2..ed9b8f1e5 100644 --- a/src/scl/parser_part_05_04.inc +++ b/src/scl/parser_part_05_04.inc @@ -1,5 +1,185 @@ detect_address_conflicts("SV", document.sampled_values_streams); + // Parse MMS/OSI addressing from the same bounded XML tree as every other SCL + // projection. Do not make desktop/client code re-open XML to discover + // ConnectedAP parameters. + if (const auto* communication = first_child(root, "Communication"); communication != nullptr) { + const auto parse_decimal_u32 = [](const std::string_view text) + -> std::optional { + const auto cleaned = trim_copy(text); + if (cleaned.empty()) return std::nullopt; + std::uint32_t value{}; + const auto parsed = std::from_chars( + cleaned.data(), cleaned.data() + cleaned.size(), value, 10); + if (parsed.ec != std::errc{} || parsed.ptr != cleaned.data() + cleaned.size()) { + return std::nullopt; + } + return value; + }; + + const auto parse_oid_arcs = [&](const std::string_view text) + -> std::optional> { + const auto cleaned = trim_copy(text); + if (cleaned.empty()) return std::nullopt; + std::vector arcs; + std::size_t offset{}; + while (offset < cleaned.size()) { + while (offset < cleaned.size() && + (std::isspace(static_cast(cleaned[offset])) != 0 || + cleaned[offset] == ',' || cleaned[offset] == '.')) { + ++offset; + } + if (offset >= cleaned.size()) break; + const auto begin = offset; + while (offset < cleaned.size() && + std::isdigit(static_cast(cleaned[offset])) != 0) { + ++offset; + } + if (begin == offset) return std::nullopt; + std::uint32_t arc{}; + const auto parsed = std::from_chars( + cleaned.data() + static_cast(begin), + cleaned.data() + static_cast(offset), + arc, + 10); + if (parsed.ec != std::errc{}) return std::nullopt; + arcs.push_back(arc); + if (arcs.size() > 32U) return std::nullopt; + while (offset < cleaned.size() && + std::isspace(static_cast(cleaned[offset])) != 0) { + ++offset; + } + if (offset < cleaned.size() && cleaned[offset] != ',' && cleaned[offset] != '.') { + return std::nullopt; + } + } + if (arcs.size() < 2U || arcs[0] > 2U || + (arcs[0] < 2U && arcs[1] > 39U)) { + return std::nullopt; + } + return arcs; + }; + + const auto parse_selector = [](const std::string_view text) + -> std::optional> { + auto cleaned = trim_copy(text); + if (cleaned.empty()) return std::nullopt; + if (cleaned.size() >= 2U && cleaned[0] == '0' && + (cleaned[1] == 'x' || cleaned[1] == 'X')) { + cleaned.erase(0U, 2U); + } + std::string hex; + hex.reserve(cleaned.size()); + for (const auto ch : cleaned) { + if (std::isspace(static_cast(ch)) != 0 || + ch == ':' || ch == '-') { + continue; + } + if (std::isxdigit(static_cast(ch)) == 0) { + return std::nullopt; + } + hex.push_back(ch); + } + if (hex.empty() || (hex.size() % 2U) != 0U || hex.size() / 2U > 64U) { + return std::nullopt; + } + std::vector bytes; + bytes.reserve(hex.size() / 2U); + for (std::size_t offset = 0U; offset < hex.size(); offset += 2U) { + unsigned value{}; + const auto parsed = std::from_chars( + hex.data() + static_cast(offset), + hex.data() + static_cast(offset + 2U), + value, + 16); + if (parsed.ec != std::errc{} || value > 0xFFU) return std::nullopt; + bytes.push_back(static_cast(value)); + } + return bytes; + }; + + for (const auto* connected_ap : descendants(*communication, "ConnectedAP")) { + const auto* address = first_child(*connected_ap, "Address"); + if (address == nullptr) continue; + + std::unordered_map parameters; + for (const auto* parameter : direct_children(*address, "P")) { + const auto key = normalized_key(attribute(parameter, "type")); + if (!key.empty()) parameters[key] = trim_copy(parameter->text); + } + const auto get = [¶meters](const std::string_view type) { + const auto found = parameters.find(normalized_key(type)); + return found == parameters.end() ? std::string{} : found->second; + }; + const auto get_first = [&get](const std::initializer_list names) { + for (const auto name : names) { + auto value = get(name); + if (!value.empty()) return value; + } + return std::string{}; + }; + + SclMmsAccessPoint endpoint; + endpoint.ied_name = attribute(connected_ap, "iedName"); + endpoint.access_point_name = attribute(connected_ap, "apName"); + endpoint.ip_address = get("IP"); + + const auto port_text = get_first({"IP-Port", "MMS-Port", "Port"}); + if (!port_text.empty()) { + if (const auto parsed = parse_u16(port_text, false); parsed && *parsed != 0U) { + endpoint.tcp_port = *parsed; + } else { + endpoint.association_parameters_valid = false; + endpoint.association_error = "Invalid MMS TCP port '" + port_text + "'."; + } + } + + const auto ap_title_text = get_first({"OSI-AP-Title", "AP-Title"}); + const auto ae_text = get_first({"OSI-AE-Qualifier", "AE-Qualifier"}); + const auto psel_text = get_first({"OSI-PSEL", "PSEL"}); + const auto ssel_text = get_first({"OSI-SSEL", "SSEL"}); + const auto tsel_text = get_first({"OSI-TSEL", "TSEL"}); + endpoint.association_parameters_present = + !ap_title_text.empty() || !ae_text.empty() || !psel_text.empty() || + !ssel_text.empty() || !tsel_text.empty(); + + const auto invalidate = [&endpoint](std::string message) { + endpoint.association_parameters_valid = false; + if (!endpoint.association_error.empty()) endpoint.association_error += " "; + endpoint.association_error += std::move(message); + }; + + if (!ap_title_text.empty()) { + const auto parsed = parse_oid_arcs(ap_title_text); + if (parsed) endpoint.ap_title = *parsed; + else invalidate("Invalid OSI AP-title '" + ap_title_text + "'."); + } + if (!ae_text.empty()) { + const auto parsed = parse_decimal_u32(ae_text); + if (parsed) endpoint.ae_qualifier = *parsed; + else invalidate("Invalid OSI AE-qualifier '" + ae_text + "'."); + } + const auto assign_selector = [&](const std::string& text, + const char* label, + std::vector& destination) { + if (text.empty()) return; + const auto parsed = parse_selector(text); + if (parsed) destination = *parsed; + else invalidate(std::string{"Invalid "} + label + " selector '" + text + "'."); + }; + assign_selector(psel_text, "OSI P", endpoint.p_selector); + assign_selector(ssel_text, "OSI S", endpoint.s_selector); + assign_selector(tsel_text, "OSI T", endpoint.t_selector); + + if (!endpoint.association_parameters_valid) { + document.warnings.push_back( + "MMS ConnectedAP " + endpoint.ied_name + "/" + endpoint.access_point_name + + " has invalid association addressing: " + endpoint.association_error); + } + document.mms_access_points.push_back(std::move(endpoint)); + } + } + // A focused engineering file may intentionally describe only one service // family (for example, only Sampled Values). Absence of unrelated GOOSE, // SV, or ReportControl blocks is therefore not a parser warning. Callers @@ -12,4 +192,4 @@ return document; } -} // namespace ar::iec61850::scl +} // namespace ar::iec61850::scl \ No newline at end of file diff --git a/src/simulation/ied_simulator_engine.cpp b/src/simulation/ied_simulator_engine.cpp new file mode 100644 index 000000000..ba7f56ded --- /dev/null +++ b/src/simulation/ied_simulator_engine.cpp @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/simulation/ied_simulator_engine.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::simulation { +namespace { + +[[nodiscard]] char ascii_lower(const char value) noexcept { + return value >= 'A' && value <= 'Z' + ? static_cast(value - 'A' + 'a') + : value; +} + +[[nodiscard]] bool ascii_ends_with( + const std::string_view value, + const std::string_view 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]] std::string format_number(const double value) { + std::ostringstream stream; + stream.imbue(std::locale::classic()); + stream << std::fixed << std::setprecision(3) << value; + auto result = stream.str(); + while (!result.empty() && result.back() == '0') result.pop_back(); + if (!result.empty() && result.back() == '.') result.pop_back(); + return result.empty() ? std::string{"0"} : result; +} + +} // namespace + +IedSimulatorEngine::IedSimulatorEngine(IedSimulatorProfile profile) + : profile_(std::move(profile)) { + for (const auto& device : profile_.logical_devices) { + for (const auto& node : device.logical_nodes) { + points_.insert(points_.end(), node.points.begin(), node.points.end()); + } + } + reset(); +} + +std::size_t IedSimulatorEngine::CaseInsensitiveHash::operator()( + const std::string& value) const noexcept { + std::size_t hash = static_cast(1469598103934665603ULL); + constexpr std::size_t prime = static_cast(1099511628211ULL); + for (const auto character : value) { + hash ^= static_cast( + static_cast(ascii_lower(character))); + hash *= prime; + } + return hash; +} + +bool IedSimulatorEngine::CaseInsensitiveEqual::operator()( + const std::string& left, + const std::string& right) const 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; +} + +void IedSimulatorEngine::rebuild_index() { + index_.clear(); + index_.reserve(states_.size() * 3U); + for (std::size_t index = 0U; index < states_.size(); ++index) { + const auto& point = points_[index]; + index_.try_emplace(point.reference, index); + if (!point.relative_reference.empty()) index_.try_emplace(point.relative_reference, index); + if (!point.mms_domain.empty() && !point.mms_item.empty()) { + index_.try_emplace(point.mms_domain + "/" + point.mms_item, index); + } + } +} + +void IedSimulatorEngine::reset(const std::uint64_t timestamp_unix_milliseconds) { + states_.clear(); + states_.reserve(points_.size()); + for (const auto& point : points_) { + IedSimulatorPointState state; + state.reference = point.reference; + state.functional_constraint = point.functional_constraint; + state.kind = point.kind; + state.unit = point.unit; + state.value = point.initial_value; + state.quality = "good"; + state.origin = "Simulator"; + state.timestamp_unix_milliseconds = timestamp_unix_milliseconds; + state.reason = "init"; + states_.push_back(std::move(state)); + } + step_index_ = 0U; + rebuild_index(); +} + +const IedSimulatorPointState* IedSimulatorEngine::find_point_state( + const std::string& reference) const noexcept { + const auto found = index_.find(reference); + if (found == index_.end() || found->second >= states_.size()) return nullptr; + return &states_[found->second]; +} + +std::optional IedSimulatorEngine::set_point_value( + const std::string& reference, + std::string value, + std::string quality, + std::string origin, + const std::uint64_t timestamp_unix_milliseconds) { + const auto found = index_.find(reference); + if (found == index_.end() || found->second >= states_.size()) return std::nullopt; + + auto& state = states_[found->second]; + const auto previous_value = state.value; + const auto previous_quality = state.quality; + const bool value_changed = previous_value != value; + const bool quality_changed = previous_quality != quality; + const bool origin_changed = state.origin != origin; + + state.value = std::move(value); + state.quality = std::move(quality); + state.origin = std::move(origin); + state.timestamp_unix_milliseconds = timestamp_unix_milliseconds; + state.reason = value_changed ? "data-change" + : (quality_changed ? "quality-change" + : (origin_changed ? "origin-change" : "manual")); + + if (!value_changed && !quality_changed && !origin_changed) return std::nullopt; + + IedSimulatorEvent event; + event.timestamp_unix_milliseconds = timestamp_unix_milliseconds; + event.reference = state.reference; + event.functional_constraint = state.functional_constraint; + event.previous_value = previous_value; + event.new_value = state.value; + event.previous_quality = previous_quality; + event.new_quality = state.quality; + event.reason = state.reason; + return event; +} + +bool IedSimulatorEngine::should_simulate(const IedSimulatorPoint& point) noexcept { + return point.dynamic || + ascii_ends_with(point.relative_reference, "PTOC1.Str.general") || + ascii_ends_with(point.relative_reference, "PTOC1.Op.general") || + ascii_ends_with(point.relative_reference, "XCBR1.Pos.stVal") || + ascii_ends_with(point.relative_reference, "CSWI1.Pos.stVal"); +} + +std::string IedSimulatorEngine::compute_value( + const IedSimulatorPoint& point, + const double angle) const { + if (point.kind == SimulatorPointKind::measurement) { + constexpr double pi = 3.14159265358979323846; + const auto radians = angle + point.phase_degrees * pi / 180.0; + return format_number(point.base_value + std::sin(radians) * point.amplitude); + } + if (ascii_ends_with(point.relative_reference, "PTOC1.Str.general")) { + const auto phase = step_index_ % 40U; + return phase >= 25U && phase <= 31U ? "true" : "false"; + } + if (ascii_ends_with(point.relative_reference, "PTOC1.Op.general")) { + const auto phase = step_index_ % 40U; + return phase >= 30U && phase <= 31U ? "true" : "false"; + } + if (ascii_ends_with(point.relative_reference, "XCBR1.Pos.stVal") || + ascii_ends_with(point.relative_reference, "CSWI1.Pos.stVal")) { + return (step_index_ % 80U) >= 60U ? "open" : "closed"; + } + return point.initial_value; +} + +std::vector IedSimulatorEngine::step( + const std::uint64_t now_unix_milliseconds) { + ++step_index_; + std::vector events; + const auto angle = static_cast(step_index_) * 0.12; + for (std::size_t index = 0U; index < points_.size(); ++index) { + const auto& point = points_[index]; + if (!should_simulate(point)) continue; + auto& state = states_[index]; + const auto previous = state.value; + const auto next = compute_value(point, angle); + state.value = next; + state.timestamp_unix_milliseconds = now_unix_milliseconds; + state.reason = previous == next ? "sample" : "data-change"; + if (previous == next) continue; + + IedSimulatorEvent event; + event.timestamp_unix_milliseconds = now_unix_milliseconds; + event.reference = state.reference; + event.functional_constraint = state.functional_constraint; + event.previous_value = previous; + event.new_value = next; + event.previous_quality = state.quality; + event.new_quality = state.quality; + event.reason = state.reason; + events.push_back(std::move(event)); + } + return events; +} + +IedSimulatorSnapshot IedSimulatorEngine::snapshot( + const std::uint64_t now_unix_milliseconds) const { + IedSimulatorSnapshot result; + result.generated_at_unix_milliseconds = now_unix_milliseconds; + result.profile_name = profile_.name; + result.logical_device_count = profile_.logical_devices.size(); + result.logical_node_count = profile_.logical_node_count(); + result.point_count = profile_.point_count(); + result.data_set_count = profile_.data_sets.size(); + result.report_control_block_count = profile_.report_control_blocks.size(); + result.points = states_; + return result; +} + +} // namespace ar::iec61850::simulation diff --git a/src/simulation/ied_simulator_profile.cpp b/src/simulation/ied_simulator_profile.cpp new file mode 100644 index 000000000..744bd324e --- /dev/null +++ b/src/simulation/ied_simulator_profile.cpp @@ -0,0 +1,730 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/simulation/ied_simulator_profile.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::simulation { +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_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 ascii_ends_with( + const std::string_view value, + const std::string_view 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_contains( + const std::string_view value, + const std::string_view 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 replace_dots(std::string value, const char replacement) { + std::replace(value.begin(), value.end(), '.', replacement); + return value; +} + +[[nodiscard]] std::string logical_node_name(const scl::SclDataSetEntry& entry) { + return entry.prefix + entry.ln_class + entry.ln_inst; +} + +[[nodiscard]] std::string relative_reference(const scl::SclDataSetEntry& entry) { + std::string result = logical_node_name(entry); + if (!entry.do_name.empty()) result += "." + entry.do_name; + if (!entry.da_name.empty()) result += "." + entry.da_name; + return result; +} + +[[nodiscard]] std::string runtime_domain( + const scl::SclDataSetEntry& entry, + const std::string& source_ied_name, + const std::string& runtime_ied_name) { + const auto ied = entry.ied_name.empty() || ascii_equal(entry.ied_name, source_ied_name) + ? runtime_ied_name + : entry.ied_name; + return ied + entry.ld_inst; +} + +[[nodiscard]] std::string mms_item(const scl::SclDataSetEntry& entry) { + auto data_object = replace_dots(entry.do_name, '$'); + auto data_attribute = replace_dots(entry.da_name, '$'); + std::string result = logical_node_name(entry); + if (!entry.functional_constraint.empty()) result += "$" + entry.functional_constraint; + if (!data_object.empty()) result += "$" + data_object; + if (!data_attribute.empty()) result += "$" + data_attribute; + return result; +} + +[[nodiscard]] std::string source_stem(const std::string& source_name) { + auto name = source_name; + const auto slash = name.find_last_of("/\\"); + if (slash != std::string::npos) name.erase(0U, slash + 1U); + const auto dot = name.find_last_of('.'); + if (dot != std::string::npos && dot != 0U) name.erase(dot); + return name; +} + +[[nodiscard]] bool document_has_ied( + const scl::SclDocument& document, + const std::string& name) { + if (name.empty()) return false; + if (std::any_of( + document.ieds.begin(), document.ieds.end(), + [&name](const scl::SclIed& ied) { return ascii_equal(ied.name, name); })) { + return true; + } + return std::any_of( + document.model_entries.begin(), document.model_entries.end(), + [&name](const scl::SclDataSetEntry& entry) { + return ascii_equal(entry.ied_name, name); + }); +} + +[[nodiscard]] std::string resolve_source_ied( + const scl::SclDocument& document, + const IedSimulatorProfileFromSclOptions& options, + std::vector& findings) { + if (!options.ied_name.empty()) { + if (document_has_ied(document, options.ied_name)) return options.ied_name; + findings.push_back( + "Requested IED '" + options.ied_name + + "' was not found; the first available IED was selected."); + } + if (!document.ieds.empty()) return document.ieds.front().name; + for (const auto& entry : document.model_entries) { + if (!entry.ied_name.empty()) return entry.ied_name; + } + for (const auto& data_set : document.data_sets) { + if (!data_set.ied_name.empty()) return data_set.ied_name; + } + return {}; +} + +[[nodiscard]] std::string resolve_runtime_ied( + const scl::SclDocument& document, + const std::string& source_ied, + const IedSimulatorProfileFromSclOptions& options, + std::vector& findings) { + if (!options.runtime_ied_name.empty()) return options.runtime_ied_name; + if (ascii_equal(source_ied, "TEMPLATE")) { + const auto stem = source_stem(document.source_name); + if (!stem.empty()) { + findings.push_back( + "Instantiated TEMPLATE IED as runtime IED '" + stem + "'."); + return stem; + } + } + return source_ied; +} + +[[nodiscard]] std::string remap_reference( + std::string reference, + const std::string& source_ied, + const std::string& runtime_ied) { + if (source_ied.empty() || runtime_ied.empty() || ascii_equal(source_ied, runtime_ied)) { + return reference; + } + if (reference.size() >= source_ied.size() && + ascii_equal(std::string_view{reference}.substr(0U, source_ied.size()), source_ied)) { + reference.replace(0U, source_ied.size(), runtime_ied); + } + return reference; +} + +[[nodiscard]] bool matches_ied( + const std::string& candidate, + const std::string& source_ied) noexcept { + return candidate.empty() || ascii_equal(candidate, source_ied); +} + +[[nodiscard]] bool attribute_leaf_is( + const std::string_view path, + const std::string_view expected) noexcept { + const auto separator = path.find_last_of('.'); + const auto leaf = separator == std::string_view::npos + ? path + : path.substr(separator + 1U); + return ascii_equal(leaf, expected); +} + +[[nodiscard]] std::string display_type(const scl::SclDataSetEntry& entry) { + // q/t are semantic terminal DataAttribute names, not arbitrary suffixes. + // The former ends_with("t") rule misclassified orCat, orIdent, + // sboTimeout, operTimeout and Test as Timestamp, corrupting the live MMS + // TypeSpecification seen by OMICRON IEDScout. + if (entry.is_quality || attribute_leaf_is(entry.da_name, "q") || + ascii_equal(entry.basic_type, "Quality")) { + return "Quality"; + } + if (entry.is_timestamp || attribute_leaf_is(entry.da_name, "t") || + ascii_equal(entry.basic_type, "Timestamp")) { + return "Timestamp"; + } + if (ascii_contains(entry.basic_type, "Bool")) return "Boolean"; + if (ascii_equal(entry.basic_type, "Dbpos")) return "Enumeration"; + if (!entry.enum_type.empty() || ascii_contains(entry.basic_type, "Enum")) { + return "Enumeration"; + } + if (ascii_contains(entry.basic_type, "Float") || + ascii_contains(entry.basic_type, "INT") || + ascii_contains(entry.basic_type, "Integer")) { + return "Number"; + } + if (ascii_equal(entry.cdc, "DPC") && entry.basic_type.empty()) { + return "Enumeration"; + } + return entry.basic_type.empty() ? "Text" : entry.basic_type; +} + +[[nodiscard]] std::optional control_model_code(std::string value) { + std::string token; + token.reserve(value.size()); + for (const auto character : value) { + if (std::isalnum(static_cast(character)) != 0) { + token.push_back(ascii_lower(character)); + } + } + if (token == "0" || token == "statusonly") return 0; + if (token == "1" || token == "directwithnormalsecurity" || token == "directnormal") return 1; + if (token == "2" || token == "sbowithnormalsecurity" || + token == "selectbeforeoperatewithnormalsecurity" || token == "sbonormal") return 2; + if (token == "3" || token == "directwithenhancedsecurity" || token == "directenhanced") return 3; + if (token == "4" || token == "sbowithenhancedsecurity" || + token == "selectbeforeoperatewithenhancedsecurity" || token == "sboenhanced") return 4; + return std::nullopt; +} + +[[nodiscard]] bool measurement_cdc(const std::string& cdc) noexcept { + static constexpr std::string_view measurement_cdcs[] = { + "MV", "CMV", "WYE", "DEL", "SEQ", "SAV", "ASG", "BCR", "HMV", "HWYE", "HDEL"}; + return std::any_of( + std::begin(measurement_cdcs), std::end(measurement_cdcs), + [&cdc](const std::string_view candidate) { return ascii_equal(cdc, candidate); }); +} + +[[nodiscard]] bool is_measurement(const scl::SclDataSetEntry& entry) noexcept { + return ascii_equal(entry.functional_constraint, "MX") || measurement_cdc(entry.cdc); +} + +[[nodiscard]] bool is_integer_type(const std::string& basic_type) noexcept { + return ascii_contains(basic_type, "INT") || ascii_contains(basic_type, "Integer"); +} + +struct MeasurementShape final { + double base_value{}; + double amplitude{}; + std::string unit; +}; + +[[nodiscard]] MeasurementShape measurement_shape(const scl::SclDataSetEntry& entry) { + const bool voltage = ascii_equal(entry.ln_class, "TVTR") || + ascii_contains(entry.do_name, "Vol") || ascii_equal(entry.do_name, "PhV") || + ascii_equal(entry.do_name, "PPV"); + const bool current = ascii_equal(entry.ln_class, "TCTR") || + ascii_contains(entry.do_name, "Amp") || ascii_equal(entry.do_name, "A"); + const bool integer = is_integer_type(entry.basic_type); + if (voltage) return integer ? MeasurementShape{0.0, 100000.0, "V"} + : MeasurementShape{230000.0, 1500.0, "V"}; + if (current) return integer ? MeasurementShape{0.0, 10000.0, "A"} + : MeasurementShape{240.0, 18.0, "A"}; + return integer ? MeasurementShape{0.0, 1000.0, {}} + : MeasurementShape{0.0, 1.0, {}}; +} + +[[nodiscard]] double phase_degrees(const scl::SclDataSetEntry& entry) noexcept { + const auto reference = relative_reference(entry); + if (ascii_contains(reference, "phsB") || ascii_contains(reference, "phaseB")) return 120.0; + if (ascii_contains(reference, "phsC") || ascii_contains(reference, "phaseC")) return 240.0; + return 0.0; +} + +[[nodiscard]] bool dynamic_measurement(const scl::SclDataSetEntry& entry) noexcept { + return ascii_ends_with(entry.da_name, "cVal.mag.f") || + ascii_ends_with(entry.da_name, "instMag.f"); +} + +[[nodiscard]] std::string format_number(const double value) { + std::ostringstream stream; + stream.imbue(std::locale::classic()); + stream << std::fixed << std::setprecision(3) << value; + auto result = stream.str(); + while (!result.empty() && result.back() == '0') result.pop_back(); + if (!result.empty() && result.back() == '.') result.pop_back(); + return result.empty() ? std::string{"0"} : result; +} + +struct InitialValueContext final { + std::uint64_t simulation_start_unix_ms{}; + const scl::SclIed* ied{}; +}; + +[[nodiscard]] bool normal_process_state(const scl::SclDataSetEntry& entry) noexcept { + if (!ascii_equal(entry.da_name, "stVal")) return false; + return ascii_equal(entry.do_name, "Mod") || + ascii_equal(entry.do_name, "Beh") || + ascii_equal(entry.do_name, "Health"); +} + +[[nodiscard]] std::string initial_value( + const scl::SclDataSetEntry& entry, + const std::string& type, + const MeasurementShape& shape, + const bool measurement, + const InitialValueContext& context) { + if (!entry.configured_value.empty()) { + if (ascii_equal(entry.da_name, "ctlModel")) { + if (const auto code = control_model_code(entry.configured_value); code.has_value()) { + return std::to_string(*code); + } + } + return entry.configured_value; + } + + if (normal_process_state(entry) && type == "Enumeration") return "1"; + if (ascii_equal(entry.basic_type, "Dbpos") && + ascii_equal(entry.da_name, "stVal") && + (ascii_equal(entry.cdc, "DPC") || ascii_equal(entry.cdc, "DPS"))) { + return "intermediate-state"; + } + if (type == "Quality") return "good"; + if (type == "Timestamp") { + return "unix-ms:" + std::to_string(context.simulation_start_unix_ms); + } + + if (context.ied != nullptr && ascii_equal(entry.do_name, "NamPlt")) { + if (ascii_equal(entry.da_name, "vendor") && !context.ied->manufacturer.empty()) { + return context.ied->manufacturer; + } + if (ascii_equal(entry.da_name, "configRev") && !context.ied->config_version.empty()) { + return context.ied->config_version; + } + } + + if (measurement) return format_number(shape.base_value); + if (type == "Boolean") return "false"; + if (type == "Enumeration") return "0"; + if (type == "Number") return "0"; + return ""; +} + +[[nodiscard]] IedSimulatorPoint make_point( + const scl::SclDataSetEntry& entry, + const std::string& source_ied, + const std::string& runtime_ied, + const InitialValueContext& initial_context) { + IedSimulatorPoint point; + point.ied_name = runtime_ied; + point.logical_device = entry.ld_inst; + point.logical_node = logical_node_name(entry); + point.ln_class = entry.ln_class; + point.data_object = entry.do_name; + point.data_attribute = entry.da_name; + point.functional_constraint = entry.functional_constraint; + point.cdc = entry.cdc; + point.basic_type = entry.basic_type; + point.enum_type = entry.enum_type; + point.mms_domain = runtime_domain(entry, source_ied, runtime_ied); + point.mms_item = mms_item(entry); + point.relative_reference = relative_reference(entry); + point.reference = point.mms_domain + "/" + point.relative_reference; + point.display_type = display_type(entry); + + const auto shape = measurement_shape(entry); + const bool measurement = is_measurement(entry) && + point.display_type != "Quality" && point.display_type != "Timestamp"; + point.base_value = shape.base_value; + point.amplitude = shape.amplitude; + point.phase_degrees = phase_degrees(entry); + point.unit = shape.unit; + point.dynamic = measurement && dynamic_measurement(entry); + if (point.display_type == "Quality") point.kind = SimulatorPointKind::quality; + else if (point.display_type == "Timestamp") point.kind = SimulatorPointKind::timestamp; + else if (measurement) point.kind = SimulatorPointKind::measurement; + else point.kind = SimulatorPointKind::status; + point.initial_value = initial_value( + entry, point.display_type, shape, measurement, initial_context); + return point; +} + +struct NodeBuilder final { + std::string name; + std::string ln_class; + std::vector points; +}; + +struct DeviceBuilder final { + std::string name; + std::string ld_inst; + std::map nodes; +}; + +[[nodiscard]] std::string edition_name(const scl::SclEdition edition) { + switch (edition) { + case scl::SclEdition::edition1: return "IEC 61850 Edition 1"; + case scl::SclEdition::edition2: return "IEC 61850 Edition 2"; + case scl::SclEdition::edition21: return "IEC 61850 Edition 2.1"; + case scl::SclEdition::unknown: break; + } + return "IEC 61850 edition unknown"; +} + +void ensure_node( + std::map& devices, + const std::string& domain, + const std::string& ld_inst, + const std::string& node_name, + const std::string& ln_class) { + auto [device_it, inserted] = devices.try_emplace(domain); + if (inserted) { + device_it->second.name = domain; + device_it->second.ld_inst = ld_inst; + } + auto [node_it, node_inserted] = device_it->second.nodes.try_emplace(node_name); + if (node_inserted) { + node_it->second.name = node_name; + node_it->second.ln_class = ln_class; + } +} + +[[nodiscard]] std::string data_set_item(const scl::SclDataSet& data_set) { + auto logical_node = replace_dots(data_set.logical_node_path, '$'); + if (logical_node.empty()) logical_node = "LLN0"; + return logical_node + "$" + data_set.name; +} + +[[nodiscard]] std::string report_item(const scl::SclReportControl& report) { + auto logical_node = replace_dots(report.logical_node_path, '$'); + if (logical_node.empty()) logical_node = "LLN0"; + return logical_node + (report.buffered ? "$BR$" : "$RP$") + report.name; +} + +[[nodiscard]] std::string report_instance_suffix(const std::uint32_t instance) { + std::ostringstream stream; + stream.imbue(std::locale::classic()); + stream << std::setfill('0') << std::setw(2) << instance; + return stream.str(); +} + +} // namespace + +std::string IedSimulatorReportControlBlock::mode() const { + return buffered ? "BRCB" : "URCB"; +} + +std::size_t IedSimulatorProfile::logical_node_count() const noexcept { + std::size_t count{}; + for (const auto& device : logical_devices) count += device.logical_nodes.size(); + return count; +} + +std::size_t IedSimulatorProfile::point_count() const noexcept { + std::size_t count{}; + for (const auto& device : logical_devices) { + for (const auto& node : device.logical_nodes) count += node.points.size(); + } + return count; +} + +std::size_t IedSimulatorProfile::data_set_member_count() const noexcept { + std::size_t count{}; + for (const auto& data_set : data_sets) count += data_set.members.size(); + return count; +} + +IedSimulatorProfile IedSimulatorProfile::create_default_feeder_profile() { + IedSimulatorProfile profile; + profile.name = "AR Demo Feeder IED"; + + IedSimulatorLogicalDevice device; + device.name = "IED1LD0"; + device.ld_inst = "LD0"; + + IedSimulatorLogicalNode mmxu; + mmxu.name = "MMXU1"; + mmxu.ln_class = "MMXU"; + for (const auto& spec : std::vector>{ + {"PhV.phsA.cVal.mag.f", 0.0}, + {"PhV.phsB.cVal.mag.f", 120.0}, + {"PhV.phsC.cVal.mag.f", 240.0}}) { + IedSimulatorPoint point; + point.reference = "IED1LD0/MMXU1." + spec.first; + point.relative_reference = "MMXU1." + spec.first; + point.ied_name = "IED1"; + point.logical_device = "LD0"; + point.logical_node = "MMXU1"; + point.ln_class = "MMXU"; + point.data_object = "PhV"; + point.data_attribute = spec.first.substr(4U); + point.functional_constraint = "MX"; + point.cdc = "MV"; + point.basic_type = "FLOAT32"; + point.mms_domain = "IED1LD0"; + point.mms_item = "MMXU1$MX$" + replace_dots(spec.first, '$'); + point.display_type = "Number"; + point.unit = "V"; + point.initial_value = "230000"; + point.base_value = 230000.0; + point.amplitude = 1500.0; + point.phase_degrees = spec.second; + point.dynamic = true; + point.kind = SimulatorPointKind::measurement; + mmxu.points.push_back(std::move(point)); + } + + IedSimulatorLogicalNode xcbr; + xcbr.name = "XCBR1"; + xcbr.ln_class = "XCBR"; + IedSimulatorPoint position; + position.reference = "IED1LD0/XCBR1.Pos.stVal"; + position.relative_reference = "XCBR1.Pos.stVal"; + position.ied_name = "IED1"; + position.logical_device = "LD0"; + position.logical_node = "XCBR1"; + position.ln_class = "XCBR"; + position.data_object = "Pos"; + position.data_attribute = "stVal"; + position.functional_constraint = "ST"; + position.cdc = "DPC"; + position.basic_type = "BOOLEAN"; + position.mms_domain = "IED1LD0"; + position.mms_item = "XCBR1$ST$Pos$stVal"; + position.display_type = "Boolean"; + position.initial_value = "closed"; + position.kind = SimulatorPointKind::status; + xcbr.points.push_back(std::move(position)); + + device.logical_nodes.push_back(std::move(mmxu)); + device.logical_nodes.push_back(std::move(xcbr)); + profile.logical_devices.push_back(std::move(device)); + return profile; +} + +IedSimulatorProfileFromSclResult IedSimulatorProfileBuilder::build( + const scl::SclDocument& document, + const IedSimulatorProfileFromSclOptions& options) { + IedSimulatorProfileFromSclResult result; + result.source_ied_name = resolve_source_ied(document, options, result.findings); + result.selected_ied_name = resolve_runtime_ied( + document, result.source_ied_name, options, result.findings); + + if (result.source_ied_name.empty()) { + result.findings.push_back("The SCL document contains no selectable IED identity."); + result.profile.edition = "Imported from SCL (" + edition_name(document.edition) + ")"; + return result; + } + + const auto& source_ied = result.source_ied_name; + const auto& runtime_ied = result.selected_ied_name; + result.profile.name = runtime_ied.empty() ? "SCL IED" : runtime_ied; + result.profile.edition = "Imported from SCL (" + edition_name(document.edition) + ")"; + const auto ied_it = std::find_if( + document.ieds.begin(), document.ieds.end(), + [&source_ied](const scl::SclIed& ied) { return ascii_equal(ied.name, source_ied); }); + if (ied_it != document.ieds.end() && !ied_it->manufacturer.empty()) { + result.profile.vendor = ied_it->manufacturer; + } + + const auto now_ms = static_cast(std::max( + 1LL, + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + const InitialValueContext initial_context{ + options.simulation_start_unix_ms != 0U + ? options.simulation_start_unix_ms + : now_ms, + ied_it == document.ieds.end() ? nullptr : &*ied_it}; + + std::map devices; + for (const auto& logical_node : document.logical_nodes) { + if (!matches_ied(logical_node.ied_name, source_ied)) continue; + const auto domain = runtime_ied + logical_node.ld_inst; + ensure_node( + devices, domain, logical_node.ld_inst, + logical_node.name, logical_node.ln_class); + } + + std::set point_keys; + std::size_t source_order{}; + const auto append_entry = [&](const scl::SclDataSetEntry& entry, const bool structural) { + if (!matches_ied(entry.ied_name, source_ied)) return; + if (!options.include_quality_and_timestamp_points && + (entry.is_quality || entry.is_timestamp || ascii_equal(entry.basic_type, "Quality") || + ascii_equal(entry.basic_type, "Timestamp"))) { + return; + } + if (entry.functional_constraint.empty()) { + result.findings.push_back( + "Skipped SCL data attribute without a functional constraint: " + + relative_reference(entry)); + return; + } + auto point = make_point(entry, source_ied, runtime_ied, initial_context); + point.source_order = source_order++; + const auto key = point.mms_domain + "\n" + point.mms_item; + if (!point_keys.insert(key).second) return; + ensure_node( + devices, point.mms_domain, entry.ld_inst, + point.logical_node, point.ln_class); + devices.at(point.mms_domain).nodes.at(point.logical_node).points.push_back(std::move(point)); + if (structural) ++result.structural_data_attribute_count; + }; + + if (!document.model_entries.empty()) { + for (const auto& entry : document.model_entries) append_entry(entry, true); + } else { + result.findings.push_back( + "The SCL parser exposed no structural model entries; service bindings were used as a compatibility fallback."); + for (const auto& data_set : document.data_sets) { + for (const auto& entry : data_set.entries) append_entry(entry, false); + } + for (const auto& stream : document.goose_streams) { + for (const auto& entry : stream.entries) append_entry(entry, false); + } + for (const auto& report : document.report_controls) { + for (const auto& entry : report.entries) append_entry(entry, false); + } + } + + for (auto& [domain, device] : devices) { + IedSimulatorLogicalDevice output_device; + output_device.name = domain; + output_device.ld_inst = device.ld_inst; + for (auto& [node_name, node] : device.nodes) { + (void)node_name; + std::sort( + node.points.begin(), node.points.end(), + [](const IedSimulatorPoint& left, const IedSimulatorPoint& right) { + return left.mms_item < right.mms_item; + }); + output_device.logical_nodes.push_back(IedSimulatorLogicalNode{ + std::move(node.name), std::move(node.ln_class), std::move(node.points)}); + } + result.profile.logical_devices.push_back(std::move(output_device)); + } + + for (const auto& data_set : document.data_sets) { + if (!matches_ied(data_set.ied_name, source_ied)) continue; + IedSimulatorDataSet output; + output.reference = remap_reference(data_set.reference, source_ied, runtime_ied); + output.mms_domain = runtime_ied + data_set.ld_inst; + output.mms_item = data_set_item(data_set); + for (const auto& entry : data_set.entries) { + ++result.data_set_member_count; + if (!options.include_quality_and_timestamp_points && + (entry.is_quality || entry.is_timestamp || ascii_equal(entry.basic_type, "Quality") || + ascii_equal(entry.basic_type, "Timestamp"))) { + ++result.skipped_member_count; + continue; + } + output.members.push_back( + runtime_domain(entry, source_ied, runtime_ied) + "/" + relative_reference(entry)); + } + if (!output.members.empty()) result.profile.data_sets.push_back(std::move(output)); + } + + const auto report_client_bound = std::max( + 1U, options.maximum_report_clients_per_definition); + for (const auto& report : document.report_controls) { + if (!matches_ied(report.ied_name, source_ied)) continue; + ++result.report_control_definition_count; + + const auto requested_instances = report.indexed + ? std::max(1U, report.max_clients) + : 1U; + const auto compiled_instances = std::min(requested_instances, report_client_bound); + if (compiled_instances != requested_instances) { + result.findings.push_back( + "ReportControl " + report.control_block_reference + " requests " + + std::to_string(requested_instances) + + " client instances; simulator runtime bound reduced this to " + + std::to_string(compiled_instances) + "."); + } + + const auto base_reference = remap_reference( + report.control_block_reference, source_ied, runtime_ied); + const auto base_item = report_item(report); + for (std::uint32_t instance = 1U; instance <= compiled_instances; ++instance) { + const auto suffix = report.indexed + ? report_instance_suffix(instance) + : std::string{}; + IedSimulatorReportControlBlock output; + output.reference = base_reference + suffix; + output.mms_domain = runtime_ied + report.ld_inst; + output.mms_item = base_item + suffix; + output.buffered = report.buffered; + output.data_set_reference = remap_reference( + report.data_set_reference, source_ied, runtime_ied); + output.report_id = report.report_id.empty() ? report.name : report.report_id; + output.configuration_revision = report.configuration_revision; + output.buffer_time_milliseconds = report.buffer_time_milliseconds; + output.integrity_period_milliseconds = report.integrity_period_milliseconds; + output.trigger_options = report.buffered + ? "data-change, quality-change, integrity, GI" + : "data-change, quality-change, GI"; + output.optional_fields = report.buffered + ? "seqNum, entryId, timeStamp, reasonCode, dataSet, confRev" + : "seqNum, timeStamp, reasonCode, dataSet, confRev"; + result.profile.report_control_blocks.push_back(std::move(output)); + ++result.report_control_instance_count; + } + } + + if (result.profile.logical_devices.empty()) { + result.findings.push_back("The selected IED produced no logical devices."); + } + if (result.profile.point_count() == 0U) { + result.findings.push_back("The selected IED produced no readable simulator points."); + } + if (result.profile.data_sets.empty()) { + result.findings.push_back( + "No DataSet definitions were found for the selected IED; only the structural model is exposed."); + } + + return result; +} + +} // namespace ar::iec61850::simulation diff --git a/tests/fixtures/scl/client-workbench-incompatible.scd b/tests/fixtures/scl/client-workbench-incompatible.scd new file mode 100644 index 000000000..b6cce7996 --- /dev/null +++ b/tests/fixtures/scl/client-workbench-incompatible.scd @@ -0,0 +1,39 @@ + + +
+ + + +
+

127.0.0.1

+

1,3,9999,23

+

23

+

00 00 00 23

+

00 23

+

00 23

+
+
+
+
+ + + + + + + 7 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/scl/client-workbench.scd b/tests/fixtures/scl/client-workbench.scd new file mode 100644 index 000000000..75d670feb --- /dev/null +++ b/tests/fixtures/scl/client-workbench.scd @@ -0,0 +1,46 @@ + + +
+ + + +
+

127.0.0.1

+

1,3,9999,23

+

23

+

00 00 00 23

+

00 23

+

00 23

+
+
+
+
+ + + + + + + 7 + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/scl/commissioning-unresolved.scd b/tests/fixtures/scl/commissioning-unresolved.scd new file mode 100644 index 000000000..9e4e4dc72 --- /dev/null +++ b/tests/fixtures/scl/commissioning-unresolved.scd @@ -0,0 +1,28 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/scl/interop-golden-synthetic.cid b/tests/fixtures/scl/interop-golden-synthetic.cid new file mode 100644 index 000000000..4ec967dbc --- /dev/null +++ b/tests/fixtures/scl/interop-golden-synthetic.cid @@ -0,0 +1,69 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/scl/interop-indexed-reports.scd b/tests/fixtures/scl/interop-indexed-reports.scd new file mode 100644 index 000000000..3dda05505 --- /dev/null +++ b/tests/fixtures/scl/interop-indexed-reports.scd @@ -0,0 +1,38 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/scl/minimal-station-brcb.scd b/tests/fixtures/scl/minimal-station-brcb.scd new file mode 100644 index 000000000..1920a3284 --- /dev/null +++ b/tests/fixtures/scl/minimal-station-brcb.scd @@ -0,0 +1,106 @@ + + +
+ + + + +
+

01-0C-CD-04-00-01

+

4001

+

200

+

4

+
+
+ +
+

01-0C-CD-01-00-01

+

1001

+

100

+

4

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + direct-with-normal-security + + + sbo-with-normal-security + + + direct-with-enhanced-security + + + sbo-with-enhanced-security + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + status-only + direct-with-normal-security + sbo-with-normal-security + direct-with-enhanced-security + sbo-with-enhanced-security + + + diff --git a/tests/fixtures/scl/minimal-station.scd b/tests/fixtures/scl/minimal-station.scd index 2c5a68116..03ac2ddfb 100644 --- a/tests/fixtures/scl/minimal-station.scd +++ b/tests/fixtures/scl/minimal-station.scd @@ -51,6 +51,9 @@ + + diff --git a/tests/scl_assisted_profile/CMakeLists.txt b/tests/scl_assisted_profile/CMakeLists.txt new file mode 100644 index 000000000..1c66c006d --- /dev/null +++ b/tests/scl_assisted_profile/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850SclAssistedProfile LANGUAGES CXX) + +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_WARNINGS_AS_ERRORS ON CACHE BOOL "" FORCE) + +add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. arstack-core) + +function(add_scl_profile_test target source) + add_executable(${target} ${source}) + target_link_libraries(${target} PRIVATE ARIEC61850::core) + target_compile_features(${target} PRIVATE cxx_std_20) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /permissive- /WX) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endfunction() + +add_scl_profile_test( + scl_assisted_connect_profile + ${CMAKE_CURRENT_LIST_DIR}/../test_scl_assisted_connect.cpp) +add_scl_profile_test( + scl_association_addressing_profile + ${CMAKE_CURRENT_LIST_DIR}/../test_scl_association_addressing.cpp) +add_scl_profile_test( + scl_sync_health_profile + ${CMAKE_CURRENT_LIST_DIR}/../test_scl_sync_health.cpp) +add_scl_profile_test( + scl_projection_health_profile + ${CMAKE_CURRENT_LIST_DIR}/../test_scl_projection_health.cpp) 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_goose.cpp b/tests/test_goose.cpp index 6f55496ac..798aca534 100644 --- a/tests/test_goose.cpp +++ b/tests/test_goose.cpp @@ -3,6 +3,7 @@ #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/goose/frame_codec.hpp" #include "ariec61850/goose/pdu_codec.hpp" +#include "ariec61850/goose/raw_ethernet_publisher.hpp" #include "ariec61850/mms/data_value.hpp" #include "ariec61850/mms/utc_time.hpp" @@ -145,6 +146,16 @@ void malformed_pdu_and_wrong_ethertype_are_rejected() { goose::GooseFrame decoded_frame; CHECK(!goose::GooseFrameCodec::try_decode(bytes, decoded_frame)); } + +void raw_publisher_requires_explicit_interface() { + using namespace ar::iec61850; + goose::RawEthernetPublisher publisher; + std::string error; + CHECK(!publisher.active()); + CHECK(!publisher.open({}, error)); + CHECK(!error.empty()); + CHECK(!publisher.active()); +} } int main() { @@ -152,7 +163,8 @@ int main() { {"GOOSE PDU golden vector", pdu_matches_csharp_vector_and_roundtrips}, {"GOOSE Ethernet golden vector", ethernet_frame_matches_vector_and_roundtrips}, {"GOOSE dataset count validation", mismatched_data_set_count_is_rejected}, - {"GOOSE malformed input", malformed_pdu_and_wrong_ethertype_are_rejected}}; + {"GOOSE malformed input", malformed_pdu_and_wrong_ethertype_are_rejected}, + {"GOOSE raw publisher explicit binding", raw_publisher_requires_explicit_interface}}; std::size_t passed = 0U; for (const auto& [name, test] : tests) { try { test(); ++passed; std::cout << "[PASS] " << name << '\n'; } diff --git a/tests/test_mms_file_service.cpp b/tests/test_mms_file_service.cpp index aa4609ce2..bffb105d6 100644 --- a/tests/test_mms_file_service.cpp +++ b/tests/test_mms_file_service.cpp @@ -161,6 +161,11 @@ ByteVector close_response(const std::uint32_t invoke_id) { invoke_id, mms::MmsFileServiceCodec::file_close_service_tag, false, {}); } +ByteVector delete_response(const std::uint32_t invoke_id) { + return confirmed_response( + invoke_id, mms::MmsFileServiceCodec::file_delete_service_tag, false, {}); +} + class FakeChannel final : public mms::MmsFileServiceChannel { public: using Step = std::function([] { static_cast(mms::MmsFileServiceCodec::encode_file_open_request_pdu( {1U, "../secret.cfg", 0U, false})); @@ -329,6 +354,31 @@ void codec_normalizes_paths_and_round_trips_signed_frsm() { CHECK(opened.file_size_bytes == 4'096U); } +void codec_matches_iedscout_filedelete_golden_shape() { + const auto request = mms::MmsFileServiceCodec::encode_file_delete_request_pdu( + {0x95U, "30_248202310947607_FRA00030.cfg", true}); + const auto decoded = mms::MmsPduCodec::decode_confirmed_request(request); + CHECK(decoded.invoke_id == 0x95U); + CHECK(decoded.service_tag == 76); + CHECK(decoded.service() == mms::MmsConfirmedService::file_delete); + CHECK(decoded.service_constructed); + const auto fields = asn1::BerReader::read_children(decoded.service_value); + CHECK(fields.size() == 1U); + CHECK(fields.front().encoded_tag == 0x19U); + CHECK(asn1::BerReader::read_ascii_string(fields.front()) == + "\\30_248202310947607_FRA00030.cfg"); + + const auto response = delete_response(0x95U); + const auto decoded_response = mms::MmsPduCodec::decode_confirmed_response( + mms::MmsPduCodec::extract_mms_payload(response)); + CHECK(decoded_response.service_tag == 76); + CHECK(decoded_response.service() == mms::MmsConfirmedService::file_delete); + CHECK(!decoded_response.service_constructed); + CHECK(decoded_response.service_value.empty()); + CHECK(mms::MmsFileServiceCodec::decode_file_delete_response( + response, 0x95U).invoke_id == 0x95U); +} + void codec_rejects_invoke_mismatch_malformed_and_trailing_data() { const auto valid = read_response(5U, ByteVector{1U, 2U}, false); try { @@ -382,6 +432,31 @@ void directory_runtime_paginates_deduplicates_and_detects_no_progress() { CHECK(failed.failure_kind == mms::MmsFileFailureKind::protocol); } +void runtime_filedelete_is_single_shot_and_never_adaptive() { + FakeChannel channel; + channel.respond(delete_response(1U)); + mms::MmsFileTransferRuntime runtime{channel}; + const auto result = runtime.remove("FRA00030.dat"); + CHECK(result.success); + CHECK(result.remote_path == "FRA00030.dat"); + CHECK(channel.sent.size() == 1U); + CHECK(sent_service_tag(channel.sent.front()) == 76); + + const auto sent = mms::MmsPduCodec::decode_confirmed_request( + mms::MmsPduCodec::extract_mms_payload(channel.sent.front())); + const auto names = asn1::BerReader::read_children(sent.service_value); + CHECK(names.size() == 1U); + CHECK(asn1::BerReader::read_ascii_string(names.front()) == "\\FRA00030.dat"); + + FakeChannel missing; + missing.respond(confirmed_error(1U, 11, 7U)); + mms::MmsFileTransferRuntime missing_runtime{missing}; + const auto failed = missing_runtime.remove("missing.cfg"); + CHECK(!failed.success); + CHECK(failed.failure_kind == mms::MmsFileFailureKind::confirmed_error); + CHECK(missing.sent.size() == 1U); +} + void runtime_streams_multiple_blocks_and_closes_negative_frsm() { FakeChannel channel; channel.respond(open_response(1U, -17, 5U)); @@ -616,8 +691,10 @@ int main() { {"codec file-directory request", codec_encodes_file_directory_high_tag_and_continuation}, {"codec file-directory response", codec_decodes_directory_attributes_and_preserves_order}, {"codec path and signed FRSM", codec_normalizes_paths_and_round_trips_signed_frsm}, + {"codec IEDScout FileDelete", codec_matches_iedscout_filedelete_golden_shape}, {"codec malformed evidence", codec_rejects_invoke_mismatch_malformed_and_trailing_data}, {"directory bounded pagination", directory_runtime_paginates_deduplicates_and_detects_no_progress}, + {"runtime single-shot FileDelete", runtime_filedelete_is_single_shot_and_never_adaptive}, {"runtime streaming and close", runtime_streams_multiple_blocks_and_closes_negative_frsm}, {"runtime zero declared size", runtime_treats_zero_fileopen_size_as_unavailable}, {"runtime bounds and sink", runtime_bounds_empty_blocks_sizes_operations_and_sink_failures}, diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index 32edad863..995b148a9 100644 --- a/tests/test_mms_runtime.cpp +++ b/tests/test_mms_runtime.cpp @@ -212,8 +212,8 @@ void queue_handshake(ScriptedTransport& transport) { add(mms::MmsDataValue::octet_string(entry)); add(mms::MmsDataValue::unsigned_integer(1U)); add(mms::MmsDataValue::bit_string(7U, inclusion)); - add(mms::MmsDataValue::boolean(true)); add(mms::MmsDataValue::visible_string("LD0/PTOC1.Str.stVal")); + add(mms::MmsDataValue::boolean(true)); add(mms::MmsDataValue::bit_string(2U, reason)); return report; } @@ -261,6 +261,49 @@ void association_lifecycle_routes_reports_and_confirmed_results() { CHECK(transport.close_count() >= 1U); } + +void association_consumes_coalesced_buffered_tpkt_before_socket_wait() { + ScriptedTransport transport; + queue_handshake(transport); + mms::MmsAssociationRuntime runtime{transport}; + runtime.connect({"127.0.0.1", 102U}); + + const auto invoke_id = runtime.next_invoke_id(); + mms::MmsReadRequest request; + request.invoke_id = invoke_id; + request.variables.push_back( + mms::MmsObjectName::domain_specific("LD0", "LLN0$ST$Mod$stVal")); + const auto request_bytes = mms::MmsServiceCodec::encode_read_request_p_data(request); + + mms::MmsReadResponse response; + response.invoke_id = invoke_id; + response.results.push_back({mms::MmsDataValue::boolean(true), std::nullopt}); + const auto response_frame = wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(response)); + const auto report_frame = wrap_application( + mms::MmsInformationReportCodec::encode_p_data(make_report())); + + ByteVector coalesced = response_frame; + coalesced.insert(coalesced.end(), report_frame.begin(), report_frame.end()); + transport.push_receive(std::move(coalesced)); + + const auto exchange = runtime.exchange_confirmed(request_bytes, invoke_id); + CHECK(exchange.envelope.kind == mms::MmsPduKind::confirmed_response); + CHECK(runtime.queued_information_report_count() == 0U); + + // There are deliberately no further scripted socket bytes. The report is + // already a complete second TPKT frame buffered by the first receive(). + // This must succeed without waiting for another transport receive. + mms::MmsPduEnvelope envelope; + CHECK(runtime.try_poll_once_for(std::chrono::milliseconds{1}, envelope)); + CHECK(envelope.information_report); + CHECK(runtime.queued_information_report_count() == 1U); + + ByteVector queued_report; + CHECK(runtime.try_pop_information_report(queued_report)); + CHECK(mms::MmsInformationReportCodec::is_information_report(queued_report)); +} + void association_retries_legacy_profile_after_balanced_rejection() { ScriptedTransport transport; const auto balanced = acse::AcseAssociationCodec::build_default_association_request(); @@ -361,18 +404,20 @@ void confirmed_exchange_timeout_closes_transport_and_can_reconnect() { void queue_probe_response( ScriptedTransport& transport, - const std::uint32_t invoke_id) { + const std::uint32_t invoke_id, + const bool enabled = false, + const bool reserved = false) { mms::MmsReadResponse response; response.invoke_id = invoke_id; response.results = { {mms::MmsDataValue::visible_string("RPT-A"), std::nullopt}, - {mms::MmsDataValue::boolean(false), std::nullopt}, + {mms::MmsDataValue::boolean(enabled), std::nullopt}, {mms::MmsDataValue::visible_string("LD0/LLN0.DataSet"), std::nullopt}, {mms::MmsDataValue::unsigned_integer(1U), std::nullopt}, {mms::MmsDataValue::bit_string(0U, ByteVector{0x5AU, 0x00U}), std::nullopt}, {mms::MmsDataValue::bit_string(0U, ByteVector{0x40U}), std::nullopt}, {mms::MmsDataValue::boolean(false), std::nullopt}, - {mms::MmsDataValue::boolean(false), std::nullopt}, + {mms::MmsDataValue::boolean(reserved), std::nullopt}, }; transport.push_receive(wrap_application( mms::MmsServiceCodec::encode_read_response_p_data(response))); @@ -452,12 +497,13 @@ void queue_static_preclaim_response( void queue_static_subscription_probe_response( ScriptedTransport& transport, - const std::uint32_t invoke_id) { + const std::uint32_t invoke_id, + const bool enabled = false) { mms::MmsReadResponse response; response.invoke_id = invoke_id; response.results = { {mms::MmsDataValue::visible_string("RPT-FALLBACK"), std::nullopt}, - {mms::MmsDataValue::boolean(false), std::nullopt}, + {mms::MmsDataValue::boolean(enabled), std::nullopt}, {mms::MmsDataValue::visible_string("LD0/LLN0.DataSet"), std::nullopt}, {mms::MmsDataValue::unsigned_integer(1U), std::nullopt}, {mms::MmsDataValue::boolean(false), std::nullopt}, @@ -479,9 +525,10 @@ void static_session_skips_contended_preferred_and_subscribes_fallback() { queue_static_preclaim_response(transport, 1U, true); queue_static_preclaim_response(transport, 2U, false); - queue_static_subscription_probe_response(transport, 3U); + queue_static_subscription_probe_response(transport, 3U, false); queue_write_success(transport, 4U); // RptEna=true - queue_write_success(transport, 5U); // GI=true + queue_static_subscription_probe_response(transport, 5U, true); // verified enabled + queue_write_success(transport, 6U); // GI=true mms::MmsStaticReportSessionOptions options; options.selection.preferred_rcb_reference = preferred.reference; @@ -515,14 +562,14 @@ void static_session_skips_contended_preferred_and_subscribes_fallback() { CHECK(session.poll_once()); CHECK(session.snapshot().subscription->received_reports == 1U); - queue_write_success(transport, 6U); // RptEna=false + queue_write_success(transport, 7U); // RptEna=false session.stop(); const auto stopped = session.snapshot(); CHECK(!stopped.active); CHECK(stopped.subscription->state == mms::MmsReportSubscriptionState::stopped); CHECK(!stopped.subscription->cleanup_required); - CHECK(transport.sent().size() == 8U); // CR, AARQ, 3 reads, 3 writes + CHECK(transport.sent().size() == 9U); // CR, AARQ, 4 reads, 3 writes } void static_session_rejects_data_set_rebinding() { @@ -546,10 +593,12 @@ void subscription_runtime_reserves_enables_receives_and_cleans_up() { mms::MmsAssociationRuntime association{transport}; association.connect({"127.0.0.1", 102U}); - queue_probe_response(transport, 1U); + queue_probe_response(transport, 1U, false, false); queue_write_success(transport, 2U); // Resv=true - queue_write_success(transport, 3U); // RptEna=true - queue_write_success(transport, 4U); // GI=true + queue_probe_response(transport, 3U, false, true); // verified reservation + queue_write_success(transport, 4U); // RptEna=true + queue_probe_response(transport, 5U, true, true); // verified enabled + queue_write_success(transport, 6U); // GI=true mms::MmsReportSubscriptionRuntime subscription{ association, make_urcb_candidate(), make_directory()}; @@ -567,8 +616,8 @@ void subscription_runtime_reserves_enables_receives_and_cleans_up() { CHECK(observed.decode_failures == 0U); CHECK(observed.streams.size() == 1U); - queue_write_success(transport, 5U); // RptEna=false - queue_write_success(transport, 6U); // Resv=false + queue_write_success(transport, 7U); // RptEna=false + queue_write_success(transport, 8U); // Resv=false subscription.stop(); const auto stopped = subscription.snapshot(); CHECK(stopped.state == mms::MmsReportSubscriptionState::stopped); @@ -583,10 +632,12 @@ void subscription_marks_cleanup_required_when_association_is_lost() { mms::MmsAssociationRuntime association{transport}; association.connect({"127.0.0.1", 102U}); - queue_probe_response(transport, 1U); - queue_write_success(transport, 2U); - queue_write_success(transport, 3U); - queue_write_success(transport, 4U); + queue_probe_response(transport, 1U, false, false); + queue_write_success(transport, 2U); // Resv=true + queue_probe_response(transport, 3U, false, true); // verified reservation + queue_write_success(transport, 4U); // RptEna=true + queue_probe_response(transport, 5U, true, true); // verified enabled + queue_write_success(transport, 6U); // GI=true mms::MmsReportSubscriptionRuntime subscription{ association, make_urcb_candidate(), make_directory()}; @@ -633,6 +684,7 @@ void subscription_does_not_take_over_an_enabled_rcb() { int main() { const std::vector>> tests{ {"association lifecycle and routing", association_lifecycle_routes_reports_and_confirmed_results}, + {"coalesced TPKT buffered receive", association_consumes_coalesced_buffered_tpkt_before_socket_wait}, {"association profile fallback", association_retries_legacy_profile_after_balanced_rejection}, {"association cancellation and reconnect", association_rejects_cancelled_connect_and_can_reconnect}, {"confirmed exchange timeout closes transport and reconnects", confirmed_exchange_timeout_closes_transport_and_can_reconnect}, diff --git a/tests/test_mms_server_urcb_report.cpp b/tests/test_mms_server_urcb_report.cpp index e8941198a..9f4092d0b 100644 --- a/tests/test_mms_server_urcb_report.cpp +++ b/tests/test_mms_server_urcb_report.cpp @@ -10,6 +10,7 @@ #include "ariec61850/osi/session_span.hpp" #include "ariec61850/osi/tpkt_span.hpp" +#include #include #include #include @@ -76,7 +77,7 @@ template const std::span response, const std::span workspace, const std::span scratch) noexcept { - constexpr std::array tpdu_size{0x0AU}; + constexpr std::array tpdu_size{0x07U}; constexpr std::array source_tsap{0x00U, 0x01U}; constexpr std::array destination_tsap{0x00U, 0x01U}; const std::array parameters{ @@ -123,15 +124,37 @@ template [[nodiscard]] bool decode_report_frame( const std::span frame, - mms::MmsInformationReportView& report) noexcept { - osi::TpktFrameView tpkt; - osi::CotpTpduView cotp; + mms::MmsInformationReportView& report, + std::size_t* segment_count = nullptr) noexcept { + std::array reassembled{}; + std::size_t input_offset{}; + std::size_t reassembled_size{}; + std::size_t segments{}; + while (input_offset < frame.size()) { + const auto remaining = frame.subspan(input_offset); + const auto peek = osi::TpktSpanCodec::peek_frame(remaining); + if (!peek.ready() || peek.frame_bytes == 0U) return false; + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(remaining.first(peek.frame_bytes), tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) || + cotp.kind != osi::CotpWireKind::data || + cotp.user_data.size() > reassembled.size() - reassembled_size) { + return false; + } + std::copy(cotp.user_data.begin(), cotp.user_data.end(), + reassembled.begin() + static_cast(reassembled_size)); + reassembled_size += cotp.user_data.size(); + input_offset += peek.frame_bytes; + ++segments; + if ((input_offset == frame.size()) != cotp.end_of_transmission) return false; + } + if (segment_count != nullptr) *segment_count = segments; osi::SessionDataTransferView session; osi::PresentationPdvView pdv; - return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && - osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) && - cotp.kind == osi::CotpWireKind::data && cotp.end_of_transmission && - osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + return segments != 0U && + osi::SessionSpanCodec::try_decode_data_transfer_view( + std::span{reassembled}.first(reassembled_size), session) && osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( session.presentation_payload, pdv) && pdv.context_id == 3U && @@ -177,6 +200,42 @@ int main() { const std::array data_set_entries{ mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; const mms::MmsStaticDataSetTable data_sets{data_set_entries}; + + const std::array unconfigured_definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Spare01", + "LD0/LLN0$RP$Spare", + {}, + {}, + 1U, + {0x00U, 0x00U}, + 0U, + 0x04U, + 0U}}; + std::array unconfigured_states{}; + mms::MmsStaticUrcbRuntime unconfigured_reports{ + unconfigured_definitions, unconfigured_states, object_table, data_sets}; + if (!unconfigured_reports.initialize()) { + return 40; + } + const auto* unconfigured_state = unconfigured_reports.state(0U); + if (unconfigured_state == nullptr || + !unconfigured_state->data_set_domain().empty() || + !unconfigured_state->data_set_item().empty()) { + return 41; + } + if (unconfigured_reports.set_enabled(0U, true, 10U) != + mms::MmsStaticUrcbStatus::data_set_not_found) { + return 42; + } + if (unconfigured_reports.set_data_set(0U, "LD0", "LLN0$Events") != + mms::MmsStaticUrcbStatus::ok || + unconfigured_reports.set_enabled(0U, true, 10U) != + mms::MmsStaticUrcbStatus::ok) { + return 43; + } + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_sets}; mms::MmsStaticConnectionRuntime connection{dispatcher}; @@ -184,13 +243,13 @@ int main() { mms::MmsStaticUrcbDefinition{ "LD0", "LLN0$RP$Events", - "LD0/LLN0$RP$Events", + "LD0/LLN0$RP$Events_P0_3_Negotiated_TPDU_Segmentation_Proof", "LD0", "LLN0$Events", 7U, {0x7CU, 0x80U}, 0U, - 0x08U, + 0x0CU, 1'000U}}; std::array states{}; mms::MmsStaticUrcbRuntime reports{ @@ -199,6 +258,28 @@ int main() { return 1; } + constexpr std::array iedscout_generic_optflds{0x7BU, 0x80U}; + constexpr std::array effective_urcb_optflds{0x78U, 0x80U}; + constexpr std::array unsupported_segmentation{0x78U, 0xC0U}; + constexpr std::array original_optflds{0x7CU, 0x80U}; + if (reports.set_optional_fields(0U, iedscout_generic_optflds) != + mms::MmsStaticUrcbStatus::ok) { + return 44; + } + const auto* compatibility_state = reports.state(0U); + if (compatibility_state == nullptr || + compatibility_state->optional_fields != effective_urcb_optflds) { + return 45; + } + if (reports.set_optional_fields(0U, unsupported_segmentation) != + mms::MmsStaticUrcbStatus::invalid_value) { + return 46; + } + if (reports.set_optional_fields(0U, original_optflds) != + mms::MmsStaticUrcbStatus::ok) { + return 47; + } + std::array request{}; std::array response{}; std::array workspace{}; @@ -252,9 +333,12 @@ int main() { } mms::MmsInformationReportView report; + std::size_t report_segments{}; if (!decode_report_frame( - std::span{response}.first(poll.bytes_written), report) || - report.item_count != 13U) { + std::span{response}.first(poll.bytes_written), + report, + &report_segments) || + report_segments < 2U || report.item_count != 13U) { return 9; } mms::MmsReadAccessResultView item; diff --git a/tests/test_mms_services.cpp b/tests/test_mms_services.cpp index 576b575d4..6eb753043 100644 --- a/tests/test_mms_services.cpp +++ b/tests/test_mms_services.cpp @@ -5,6 +5,7 @@ #include "ariec61850/mms/invoke_router.hpp" #include "ariec61850/mms/pdu.hpp" #include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/simulator_manifest_codec.hpp" #include #include @@ -216,6 +217,63 @@ void type_specification_and_attributes_round_trip() { response_p_data, 31U) == response); } +void iedscout_variable_length_type_bounds_round_trip() { + MmsTypeSpecification visible255; + visible255.kind = MmsTypeKind::visible_string; + visible255.size = 255U; + visible255.variable_length = true; + CHECK(MmsServiceCodec::encode_type_specification(visible255) == + from_hex("8A 02 FF 01")); + CHECK(MmsServiceCodec::decode_type_specification( + MmsServiceCodec::encode_type_specification(visible255)) == visible255); + + MmsTypeSpecification octet64; + octet64.kind = MmsTypeKind::octet_string; + octet64.size = 64U; + octet64.variable_length = true; + CHECK(MmsServiceCodec::encode_type_specification(octet64) == + from_hex("89 02 FF C0")); + CHECK(MmsServiceCodec::decode_type_specification( + MmsServiceCodec::encode_type_specification(octet64)) == octet64); + + MmsTypeSpecification visible129; + visible129.kind = MmsTypeKind::visible_string; + visible129.size = 129U; + visible129.variable_length = true; + CHECK(MmsServiceCodec::encode_type_specification(visible129) == + from_hex("8A 02 FF 7F")); +} + +void simulator_manifest_type_mapping_prefers_scl_btype() { + using ar::iec61850::mms::MmsSimulatorManifestCodec; + + const auto timeout = MmsSimulatorManifestCodec::type("INT32U", "Timestamp"); + CHECK(timeout.kind == MmsTypeKind::unsigned_integer); + CHECK(timeout.size == 32U); + CHECK(!timeout.variable_length); + + const auto origin_category = MmsSimulatorManifestCodec::type("INT8", "Timestamp"); + CHECK(origin_category.kind == MmsTypeKind::integer); + CHECK(origin_category.size == 8U); + + const auto origin_identifier = MmsSimulatorManifestCodec::type("Octet64", "Timestamp"); + CHECK(origin_identifier.kind == MmsTypeKind::octet_string); + CHECK(origin_identifier.size == 64U); + CHECK(origin_identifier.variable_length); + + const auto test = MmsSimulatorManifestCodec::type("BOOLEAN", "Timestamp"); + CHECK(test.kind == MmsTypeKind::boolean); + + const auto enumeration = MmsSimulatorManifestCodec::type("Enum", "Enumeration"); + CHECK(enumeration.kind == MmsTypeKind::integer); + CHECK(enumeration.size == 8U); + + const auto vendor = MmsSimulatorManifestCodec::type("VisString255", "VisString255"); + CHECK(vendor.kind == MmsTypeKind::visible_string); + CHECK(vendor.size == 255U); + CHECK(vendor.variable_length); +} + void array_type_specification_round_trips() { MmsTypeSpecification element; element.kind = MmsTypeKind::visible_string; @@ -367,6 +425,8 @@ int main() { {"ObjectName choices", object_names_round_trip_all_choices}, {"GetNameList", get_name_list_request_response_round_trip}, {"VariableAccessAttributes and structure type", type_specification_and_attributes_round_trip}, + {"IEDScout variable-length TypeSpecification", iedscout_variable_length_type_bounds_round_trip}, + {"simulator SCL bType wire authority", simulator_manifest_type_mapping_prefers_scl_btype}, {"array TypeSpecification", array_type_specification_round_trips}, {"Read multi-access results", read_request_and_multi_access_response_round_trip}, {"Read direct primitive", read_decoder_accepts_direct_primitive_data}, diff --git a/tests/test_mms_static_direct_control.cpp b/tests/test_mms_static_direct_control.cpp index b59f8b07d..b05442ac4 100644 --- a/tests/test_mms_static_direct_control.cpp +++ b/tests/test_mms_static_direct_control.cpp @@ -4,6 +4,7 @@ #include "ariec61850/mms/static_direct_control.hpp" #include +#include #include #include #include @@ -23,24 +24,45 @@ using namespace ar::iec61850::mms; } \ } while (false) +struct TestClock final { + std::uint64_t now_ms{}; +}; + +[[nodiscard]] std::uint64_t test_now_ms(const void* context) noexcept { + return context == nullptr ? 0U : static_cast(context)->now_ms; +} + +[[nodiscard]] MmsStaticRequestAccessContext access_for(const std::uint64_t association_id) { + static constexpr std::array owner{0xA5U}; + return {association_id, owner}; +} + [[nodiscard]] std::vector make_oper( const bool value, 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, + const std::int64_t timestamp_ms = 0) { 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), - MmsDataValue::utc_time(Iec61850UtcTime{}), + MmsDataValue::utc_time(Iec61850UtcTime{ + std::chrono::system_clock::time_point{ + std::chrono::milliseconds{timestamp_ms}}, + 0U}), MmsDataValue::boolean(test), MmsDataValue::bit_string( 6U, @@ -58,6 +80,48 @@ using namespace ar::iec61850::mms; return bytes; } +[[nodiscard]] std::vector make_cancel( + const bool value, + const std::uint8_t ctl_num, + const bool test, + const std::uint8_t origin_category = 2U) { + constexpr std::array origin_id{'H', 'M', 'I'}; + auto cancel = MmsDataValue::structure({ + MmsDataValue::boolean(value), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(origin_category), + MmsDataValue::octet_string(origin_id), + }), + MmsDataValue::unsigned_integer(ctl_num), + MmsDataValue::utc_time(Iec61850UtcTime{}), + MmsDataValue::boolean(test), + }); + const auto required = MmsDataSpanCodec::encoded_size(cancel); + CHECK(required.has_value()); + std::vector bytes(*required); + const auto encoded = MmsDataSpanCodec::encode_into(cancel, bytes); + CHECK(encoded.success()); + return bytes; +} + +[[nodiscard]] MmsStaticDirectBooleanControlBinding make_binding( + MmsStaticDirectBooleanControlState& state, + MmsStaticDirectBooleanSharedState& shared, + const MmsStaticControlModel model, + const std::uint64_t association_id, + TestClock& clock) { + MmsStaticDirectBooleanControlBinding binding; + binding.state = &state; + binding.shared_state = &shared; + binding.model = model; + binding.association_id = association_id; + binding.selection_reference = "LD0/GGIO1.SPCSO1"; + binding.sbo_timeout_ms = 100U; + binding.now_ms = test_now_ms; + binding.now_context = &clock; + return binding; +} + void valid_oper_updates_live_state() { MmsStaticDirectBooleanControlState state{}; MmsStaticDirectBooleanControlBinding binding{&state}; @@ -81,6 +145,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; @@ -122,14 +197,17 @@ void invalid_shape_and_values_are_rejected() { CHECK(!result.success); CHECK(result.failure_code == 7U); + // ctlNum=0 is valid on the incoming server wire. OMICRON IEDScout uses it. result = mms_static_direct_boolean_write_oper(&binding, make_oper(true, 0U, false)); - CHECK(!result.success); - CHECK(result.failure_code == 11U); + CHECK(result.success); + CHECK(state.last_control_number == 0U); result = mms_static_direct_boolean_write_oper(&binding, make_oper(true, 11U, false, 0U, 9U)); CHECK(!result.success); CHECK(result.failure_code == 11U); - CHECK(state.value == 0U); + // The preceding valid ctlNum=0 Oper already published true; the invalid + // origin must not mutate that accepted state. + CHECK(state.value == 1U); } [[nodiscard]] bool reject_backend(void*, bool) noexcept { @@ -148,6 +226,182 @@ void backend_failure_does_not_publish_state() { CHECK(state.rejected_operations == 1U); } +void sbo_normal_enforces_owner_cancel_and_timeout() { + TestClock clock{10U}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state_a{}; + MmsStaticDirectBooleanControlState state_b{}; + auto a = make_binding(state_a, shared, MmsStaticControlModel::sbo_normal, 11U, clock); + auto b = make_binding(state_b, shared, MmsStaticControlModel::sbo_normal, 22U, clock); + std::array selected{}; + + const auto first = mms_static_sbo_normal_read(&a, selected); + CHECK(first.success()); + CHECK(first.bytes_written > 2U); + CHECK(selected[0] == 0x8AU); + CHECK(state_a.selected); + + selected.fill(0U); + const auto contended = mms_static_sbo_normal_read(&b, selected); + CHECK(contended.success()); + CHECK(contended.bytes_written == 2U); + CHECK(selected[0] == 0x8AU && selected[1] == 0U); + + const auto denied = mms_static_boolean_write_oper_contextual( + &b, make_oper(true, 7U, false), access_for(22U)); + CHECK(!denied.success); + CHECK(denied.failure_code == 2U); + + const auto cancelled = mms_static_boolean_write_cancel_contextual( + &a, make_cancel(false, 8U, false), access_for(11U)); + CHECK(cancelled.success); + CHECK(shared.selected_association_id.load() == 0U); + + CHECK(mms_static_sbo_normal_read(&a, selected).success()); + clock.now_ms = 111U; + selected.fill(0U); + const auto takeover = mms_static_sbo_normal_read(&b, selected); + CHECK(takeover.success()); + CHECK(selected[1] != 0U); + CHECK(shared.selected_association_id.load() == 22U); + + const auto operated = mms_static_boolean_write_oper_contextual( + &b, make_oper(true, 9U, false), access_for(22U)); + CHECK(operated.success); + CHECK(shared.value.load() == 1U); + CHECK(shared.selected_association_id.load() == 0U); +} + +void direct_enhanced_queues_one_termination() { + TestClock clock{}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state{}; + auto binding = make_binding( + state, shared, MmsStaticControlModel::direct_enhanced, 31U, clock); + + const auto first = mms_static_boolean_write_oper_contextual( + &binding, make_oper(true, 12U, false), access_for(31U)); + CHECK(first.success); + CHECK(state.pending_termination); + CHECK(state.termination_command.control_number == 12U); + CHECK(shared.value.load() == 1U); + + const auto second = mms_static_boolean_write_oper_contextual( + &binding, make_oper(false, 13U, false), access_for(31U)); + CHECK(!second.success); + CHECK(second.failure_code == 2U); + CHECK(shared.value.load() == 1U); +} + +void sbo_enhanced_requires_exact_selected_sequence() { + TestClock clock{50U}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state{}; + auto binding = make_binding( + state, shared, MmsStaticControlModel::sbo_enhanced, 41U, clock); + + const auto selected = mms_static_boolean_write_sbow_contextual( + &binding, make_oper(true, 21U, false), access_for(41U)); + CHECK(selected.success); + CHECK(state.selected && state.selected_with_value); + CHECK(shared.selected_association_id.load() == 41U); + + const auto mismatch = mms_static_boolean_write_oper_contextual( + &binding, make_oper(false, 21U, false), access_for(41U)); + CHECK(!mismatch.success); + CHECK(mismatch.failure_code == 11U); + CHECK(shared.value.load() == 0U); + + const auto operated = mms_static_boolean_write_oper_contextual( + &binding, make_oper(true, 21U, false), access_for(41U)); + CHECK(operated.success); + CHECK(shared.value.load() == 1U); + CHECK(state.pending_termination); + CHECK(shared.selected_association_id.load() == 0U); +} + +void iedscout_sbo_enhanced_accepts_ctl_num_zero_and_fresh_oper_timestamp() { + TestClock clock{100U}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state{}; + auto binding = make_binding( + state, shared, MmsStaticControlModel::sbo_enhanced, 61U, clock); + binding.policy.allow_synchro_check = true; + binding.policy.allow_interlock_check = true; + + // Captured OMICRON IEDScout behavior: + // SBOw uses ctlNum=0 and T1; Oper keeps the selected identity but sends T2. + const auto selected = mms_static_boolean_write_sbow_contextual( + &binding, + make_oper(true, 0U, false, 0xC0U, 2U, true, 1'789'779'196'609LL), + access_for(61U)); + CHECK(selected.success); + CHECK(state.selected_with_value); + CHECK(state.selected_command.control_number == 0U); + const auto selected_timestamp = state.selected_command.timestamp; + + const auto operated = mms_static_boolean_write_oper_contextual( + &binding, + make_oper(true, 0U, false, 0xC0U, 2U, true, 1'789'779'197'488LL), + access_for(61U)); + CHECK(operated.success); + CHECK(shared.value.load() == 1U); + CHECK(state.pending_termination); + CHECK(state.termination_command.control_number == 0U); + CHECK(state.termination_command.timestamp != selected_timestamp); + CHECK(shared.selected_association_id.load() == 0U); + + // The relaxation is only for T and zero ctlNum. Selected value/origin/ + // ctlNum/Check remain immutable. + state.pending_termination = false; + CHECK(mms_static_boolean_write_sbow_contextual( + &binding, + make_oper(false, 0U, false, 0xC0U, 2U, true, 1'789'779'200'000LL), + access_for(61U)).success); + + const auto ctl_num_mismatch = mms_static_boolean_write_oper_contextual( + &binding, + make_oper(false, 1U, false, 0xC0U, 2U, true, 1'789'779'201'000LL), + access_for(61U)); + CHECK(!ctl_num_mismatch.success); + CHECK(ctl_num_mismatch.failure_code == 11U); + + const auto origin_mismatch = mms_static_boolean_write_oper_contextual( + &binding, + make_oper(false, 0U, false, 0xC0U, 3U, true, 1'789'779'201'000LL), + access_for(61U)); + CHECK(!origin_mismatch.success); + CHECK(origin_mismatch.failure_code == 11U); + + const auto check_mismatch = mms_static_boolean_write_oper_contextual( + &binding, + make_oper(false, 0U, false, 0x80U, 2U, true, 1'789'779'201'000LL), + access_for(61U)); + CHECK(!check_mismatch.success); + CHECK(check_mismatch.failure_code == 11U); +} + +void sbo_enhanced_cancel_and_association_close_release_owner() { + TestClock clock{}; + MmsStaticDirectBooleanSharedState shared{}; + MmsStaticDirectBooleanControlState state{}; + auto binding = make_binding( + state, shared, MmsStaticControlModel::sbo_enhanced, 51U, clock); + + CHECK(mms_static_boolean_write_sbow_contextual( + &binding, make_oper(true, 30U, false), access_for(51U)).success); + CHECK(mms_static_boolean_write_cancel_contextual( + &binding, make_cancel(true, 30U, false), access_for(51U)).success); + CHECK(shared.selected_association_id.load() == 0U); + CHECK(shared.value.load() == 0U); + + CHECK(mms_static_boolean_write_sbow_contextual( + &binding, make_oper(true, 31U, false), access_for(51U)).success); + mms_static_control_on_association_closed(binding); + CHECK(shared.selected_association_id.load() == 0U); + CHECK(!state.selected); +} + void read_callbacks_match_mms_types() { MmsStaticDirectBooleanControlState state{}; state.value = 1U; @@ -162,7 +416,16 @@ void read_callbacks_match_mms_types() { read = mms_static_direct_normal_read_ctl_model(nullptr, bytes); CHECK(read.success()); CHECK(read.bytes_written == 3U); - CHECK(bytes[0] == 0x86U && bytes[1] == 0x01U && bytes[2] == 0x01U); + CHECK(bytes[0] == 0x85U && bytes[1] == 0x01U && bytes[2] == 0x01U); + + MmsStaticDirectBooleanControlState configured_state{}; + MmsStaticDirectBooleanControlBinding configured_binding; + configured_binding.state = &configured_state; + configured_binding.model = MmsStaticControlModel::sbo_enhanced; + bytes.fill(0U); + read = mms_static_control_read_ctl_model(&configured_binding, bytes); + CHECK(read.success()); + CHECK(bytes[0] == 0x85U && bytes[1] == 0x01U && bytes[2] == 0x04U); read = mms_static_control_read_unavailable(nullptr, bytes); CHECK(!read.success()); @@ -173,10 +436,16 @@ 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(); backend_failure_does_not_publish_state(); + sbo_normal_enforces_owner_cancel_and_timeout(); + direct_enhanced_queues_one_termination(); + sbo_enhanced_requires_exact_selected_sequence(); + iedscout_sbo_enhanced_accepts_ctl_num_zero_and_fresh_oper_timestamp(); + sbo_enhanced_cancel_and_association_close_release_owner(); read_callbacks_match_mms_types(); std::cout << "MMS static direct-control tests passed.\n"; return 0; diff --git a/tests/test_mms_static_setting_group.cpp b/tests/test_mms_static_setting_group.cpp new file mode 100644 index 000000000..d4d5b8208 --- /dev/null +++ b/tests/test_mms_static_setting_group.cpp @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_setting_group.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850; + +#define CHECK(condition) do { \ + if (!(condition)) throw std::runtime_error(std::string{"CHECK failed: "} + #condition); \ +} while (false) + +[[nodiscard]] bool fixed_utc( + const void*, + const std::span destination) noexcept { + constexpr std::array value{ + 0x65U, 0x53U, 0xF1U, 0x00U, 0x20U, 0x00U, 0x00U, 0x00U}; + std::copy(value.begin(), value.end(), destination.begin()); + return true; +} + +[[nodiscard]] wire::EncodeResult read_base_object( + const void*, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] mms::MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + mms::MmsObjectNameViewKind::domain_specific, + {reinterpret_cast(domain.data()), domain.size()}, + {reinterpret_cast(item.data()), item.size()}}; +} + +void run() { + constexpr std::array root_type{0x83U, 0x00U}; + const std::array base{ + mms::MmsStaticObjectEntry{"LD0", "LLN0", root_type, read_base_object, nullptr}}; + const mms::MmsStaticSettingGroupDefinition definition{"LD0", "LLN0$SP$SGCB"}; + mms::MmsStaticSettingGroupSharedState state{3U, 2U}; + std::array objects{}; + std::array contexts{}; + std::array names{}; + mms::MmsStaticSettingGroupObjectBank bank{ + definition, state, base, objects, contexts, names, fixed_utc, nullptr}; + CHECK(bank.initialize()); + CHECK(bank.valid()); + CHECK(bank.table().valid()); + CHECK(bank.required_object_capacity() == 6U); + + const auto* act = bank.table().find(object_name("LD0", "LLN0$SP$SGCB$ActSG")); + const auto* cnf = bank.table().find(object_name("LD0", "LLN0$SP$SGCB$CnfEdit")); + const auto* edit = bank.table().find(object_name("LD0", "LLN0$SP$SGCB$EditSG")); + const auto* time = bank.table().find(object_name("LD0", "LLN0$SP$SGCB$LActTm")); + const auto* count = bank.table().find(object_name("LD0", "LLN0$SP$SGCB$NumOfSG")); + CHECK(act != nullptr && act->writable()); + CHECK(cnf != nullptr && !cnf->writable()); + CHECK(edit != nullptr && !edit->writable()); + CHECK(time != nullptr && !time->writable()); + CHECK(count != nullptr && !count->writable()); + + std::array encoded{}; + auto read = act->read(act->context, encoded); + CHECK(read.success() && read.bytes_written == 3U); + CHECK(encoded[0] == 0x86U && encoded[1] == 0x01U && encoded[2] == 0x02U); + read = count->read(count->context, encoded); + CHECK(read.success() && encoded[2] == 0x03U); + read = cnf->read(cnf->context, encoded); + CHECK(read.success() && encoded[0] == 0x83U && encoded[2] == 0x00U); + read = edit->read(edit->context, encoded); + CHECK(read.success() && encoded[0] == 0x86U && encoded[2] == 0x00U); + read = time->read(time->context, encoded); + CHECK(read.success() && read.bytes_written == 10U); + CHECK(encoded[0] == 0x91U && encoded[1] == 0x08U); + for (std::size_t index = 2U; index < 10U; ++index) CHECK(encoded[index] == 0U); + + constexpr std::array activate3{0x86U, 0x01U, 0x03U}; + auto written = act->write(act->write_context, activate3); + CHECK(written.success); + CHECK(state.active_setting_group.load() == 3U); + read = time->read(time->context, encoded); + CHECK(read.success()); + constexpr std::array expected_time{ + 0x65U, 0x53U, 0xF1U, 0x00U, 0x20U, 0x00U, 0x00U, 0x00U}; + CHECK(std::equal(expected_time.begin(), expected_time.end(), encoded.begin() + 2)); + + constexpr std::array activate0{0x86U, 0x01U, 0x00U}; + written = act->write(act->write_context, activate0); + CHECK(!written.success && written.failure_code == 11U); + CHECK(state.active_setting_group.load() == 3U); + constexpr std::array activate4{0x86U, 0x01U, 0x04U}; + written = act->write(act->write_context, activate4); + CHECK(!written.success && written.failure_code == 11U); + CHECK(state.active_setting_group.load() == 3U); + constexpr std::array wrong_type{0x83U, 0x01U, 0xFFU}; + written = act->write(act->write_context, wrong_type); + CHECK(!written.success && written.failure_code == 7U); + + CHECK(!state.activation_in_progress.test_and_set()); + written = act->write(act->write_context, activate3); + CHECK(!written.success && written.failure_code == 2U); + state.activation_in_progress.clear(); + + mms::MmsStaticSettingGroupSharedState invalid{0U, 0U}; + mms::MmsStaticSettingGroupObjectBank invalid_bank{ + definition, invalid, base, objects, contexts, names}; + CHECK(!invalid_bank.initialize()); +} +} // namespace + +int main() { + try { + run(); + return 0; + } catch (const std::exception&) { + return 1; + } +} diff --git a/tests/test_reporting.cpp b/tests/test_reporting.cpp index 679d3116f..8bb3a40ad 100644 --- a/tests/test_reporting.cpp +++ b/tests/test_reporting.cpp @@ -55,8 +55,8 @@ MmsInformationReport realistic_report( const std::array time{0x01U, 0x02U, 0x03U, 0x04U, 0x05U, 0x06U}; const std::array entry{0U, 1U, 2U, 3U, 4U, 5U, 6U, 7U}; const std::array inclusion{0xC0U}; - const std::array reason_a{0x80U}; - const std::array reason_b{0x10U}; + const std::array reason_a{0x40U}; + const std::array reason_b{0x08U}; MmsInformationReport report; auto add = [&report](MmsDataValue value) { @@ -75,10 +75,10 @@ MmsInformationReport realistic_report( add(MmsDataValue::boolean(more_segments)); } add(MmsDataValue::bit_string(6U, inclusion)); - add(MmsDataValue::boolean(true)); - add(MmsDataValue::boolean(false)); add(MmsDataValue::visible_string("LD0/GGIO1.Ind1.stVal")); add(MmsDataValue::visible_string("LD0/GGIO1.Ind2.stVal")); + add(MmsDataValue::boolean(true)); + add(MmsDataValue::boolean(false)); add(MmsDataValue::bit_string(2U, reason_a)); add(MmsDataValue::bit_string(2U, reason_b)); return report; @@ -171,6 +171,67 @@ void exact_report_mapping_decodes_optional_fields() { require(frame.decoder_mode == "opt-fields-exact", "Unexpected report decoder mode."); } +void standard_reason_and_trigger_bit_numbering_is_exact() { + struct Case final { + std::uint8_t mask; + const char* name; + }; + constexpr std::array cases{{ + {0x40U, "data-change"}, + {0x20U, "quality-change"}, + {0x10U, "data-update"}, + {0x08U, "integrity"}, + {0x04U, "general-interrogation"}, + }}; + + for (const auto& test : cases) { + auto report = realistic_report(); + const std::array reason{test.mask}; + report.items[13U].value = MmsDataValue::bit_string(2U, reason); + const auto frame = MmsReportFrameMapper::map(report, {}); + require(frame.values[0].reason_for_inclusion.has(test.name), + std::string{"ReasonForInclusion mask did not map to "} + test.name + '.'); + + MmsReportControlCandidate candidate; + candidate.domain = "LD0"; + candidate.logical_node = "LLN0"; + candidate.functional_constraint = "RP"; + candidate.name = "urcb01"; + const std::array attrs{"TrgOps"}; + MmsReadResponse response; + response.invoke_id = 33U; + response.results = {{MmsDataValue::bit_string(2U, reason), std::nullopt}}; + const auto state = MmsReportControlStateMapper::map_read_response( + candidate, attrs, response, nullptr); + require(state.trigger_options.has(test.name), + std::string{"TrgOps mask did not map to "} + test.name + '.'); + } + + const std::array reserved{0x80U}; + auto report = realistic_report(); + report.items[13U].value = MmsDataValue::bit_string(2U, reserved); + const auto frame = MmsReportFrameMapper::map(report, {}); + require(frame.values[0].reason_for_inclusion.names.empty(), + "Reserved ReasonForInclusion bit was assigned a semantic reason."); + require(frame.values[0].reason_for_inclusion.set_bit_indexes == + std::vector{0U}, + "Reserved ReasonForInclusion bit was not preserved as raw evidence."); + + MmsReportControlCandidate candidate; + candidate.domain = "LD0"; + candidate.logical_node = "LLN0"; + candidate.functional_constraint = "RP"; + candidate.name = "urcb01"; + const std::array attrs{"TrgOps"}; + MmsReadResponse response; + response.invoke_id = 34U; + response.results = {{MmsDataValue::bit_string(2U, reserved), std::nullopt}}; + const auto state = MmsReportControlStateMapper::map_read_response( + candidate, attrs, response, nullptr); + require(state.trigger_options.names.empty() && + state.trigger_options.set_bit_indexes == std::vector{0U}, + "Reserved TrgOps bit was assigned a standard semantic trigger."); +} void csharp_exact_report_shape_maps_byte_semantics() { const std::array options{0x7BU, 0x80U}; @@ -201,13 +262,81 @@ void csharp_exact_report_shape_maps_byte_semantics() { "C# exact OptFlds incorrectly enabled data-reference."); require(frame.header.data_set_reference == "LD0/LLN0$DataSet", "C# exact DatSet changed."); require(frame.values.size() == 2U, "C# exact included value count mismatch."); - require(frame.values[0].reason_for_inclusion.has("application-trigger"), - "C# exact reason bit mapping failed."); + require(frame.values[0].reason_for_inclusion.has("general-interrogation"), + "C# exact GI reason bit mapping failed."); +} + +void canonical_report_payload_order_matrix() { + struct Case final { + bool data_reference; + bool reason; + std::uint8_t option_mask; + }; + constexpr std::array cases{{ + {false, false, 0x00U}, + {true, false, 0x04U}, + {false, true, 0x10U}, + {true, true, 0x14U}, + }}; + const std::array inclusion{0xC0U}; + const std::array reason_a{0x40U}; + const std::array reason_b{0x08U}; + const auto directory = MmsDataSetDirectoryCodec::decode_response( + MmsDataSetDirectoryCodec::encode_response_pdu(directory_fixture()), 9U); + + for (const auto& test : cases) { + const std::array options{test.option_mask, 0x00U}; + MmsInformationReport report; + auto add = [&report](MmsDataValue value) { + report.items.push_back({report.items.size(), std::move(value), std::nullopt}); + }; + add(MmsDataValue::visible_string("LD0/LLN0.RP.matrix")); + add(MmsDataValue::bit_string(6U, options)); + add(MmsDataValue::bit_string(6U, inclusion)); + if (test.data_reference) { + add(MmsDataValue::visible_string("LD0/GGIO1.Ind1.stVal")); + add(MmsDataValue::visible_string("LD0/GGIO1.Ind2.stVal")); + } + add(MmsDataValue::boolean(true)); + add(MmsDataValue::boolean(false)); + if (test.reason) { + add(MmsDataValue::bit_string(2U, reason_a)); + add(MmsDataValue::bit_string(2U, reason_b)); + } + + const auto frame = MmsReportFrameMapper::map(report, directory.members); + require(frame.values.size() == 2U, "Canonical report matrix value count mismatch."); + require(frame.values[0].value && frame.values[1].value && + frame.values[0].value->kind() == MmsDataKind::boolean && + frame.values[1].value->kind() == MmsDataKind::boolean && + std::get(frame.values[0].value->value()) && + !std::get(frame.values[1].value->value()), + "Canonical report matrix consumed metadata as a process value."); + require(test.data_reference + ? frame.values[0].data_reference == "LD0/GGIO1.Ind1.stVal" && + frame.values[1].data_reference == "LD0/GGIO1.Ind2.stVal" + : frame.values[0].data_reference.empty() && + frame.values[1].data_reference.empty(), + "Canonical report matrix DataRef projection mismatch."); + require(test.reason + ? frame.values[0].reason_for_inclusion.has("data-change") && + frame.values[1].reason_for_inclusion.has("integrity") + : frame.values[0].reason_for_inclusion.names.empty() && + frame.values[1].reason_for_inclusion.names.empty(), + "Canonical report matrix ReasonForInclusion projection mismatch."); + } + + // Reject the legacy false-green layout where a process value precedes DataRef. + auto legacy = realistic_report(); + std::swap(legacy.items[9U], legacy.items[11U]); + require_throws([&legacy, &directory] { + static_cast(MmsReportFrameMapper::map(legacy, directory.members)); + }, "Legacy Value-before-DataRef report layout was silently accepted."); } void report_failure_result_is_preserved() { auto report = realistic_report(); - report.items[9U] = {9U, std::nullopt, 3U}; + report.items[11U] = {11U, std::nullopt, 3U}; const auto directory = MmsDataSetDirectoryCodec::decode_response( MmsDataSetDirectoryCodec::encode_response_pdu(directory_fixture()), 9U); const auto frame = MmsReportFrameMapper::map(report, directory.members); @@ -316,6 +445,8 @@ int main() { {"InformationReport", information_report_round_trip}, {"InformationReport variableListName", information_report_variable_list_name_is_supported}, {"exact report mapping", exact_report_mapping_decodes_optional_fields}, + {"canonical report payload order", canonical_report_payload_order_matrix}, + {"report reason bit numbering", standard_reason_and_trigger_bit_numbering_is_exact}, {"C# exact report shape", csharp_exact_report_shape_maps_byte_semantics}, {"report failure result", report_failure_result_is_preserved}, {"RCB state mapping", rcb_read_mapping_classifies_availability}, diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index 770ac8346..ce83efd42 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -4,8 +4,10 @@ #include "ariec61850/sampled_values/rational_schedule.hpp" #include "ariec61850/scl/dataset_reference.hpp" #include "ariec61850/scl/parser.hpp" +#include "ariec61850/simulation/ied_simulator_profile.hpp" #include +#include #include #include #include @@ -14,6 +16,7 @@ #include #include #include +#include #include #include @@ -100,6 +103,7 @@ void parser_extracts_minimal_station_semantics() { CHECK(report.control_block_reference == "MU01LD0/LLN0$RP$URCB01"); CHECK(!report.buffered); CHECK(report.indexed); + CHECK(report.max_clients == 1U); CHECK(report.data_set_reference == "MU01LD0/LLN0$dsGO"); CHECK(report.data_set_binding_status == SclDataSetBindingStatus::resolved); CHECK(report.entries.size() == 3U); @@ -150,6 +154,14 @@ void parser_compiles_structured_4800_sv_profile_without_drift() { CHECK(document.data_sets.size() == 1U); CHECK(document.warnings.empty()); + const auto& configured_data_set = document.data_sets.front(); + CHECK(configured_data_set.entries.size() == 8U); + CHECK(configured_data_set.expanded_entries.size() == 16U); + CHECK(std::all_of( + configured_data_set.entries.begin(), + configured_data_set.entries.end(), + [](const SclDataSetEntry& entry) { return entry.da_name.empty(); })); + const auto& stream = document.sampled_values_streams.front(); CHECK(stream.address.destination_mac_text == "01:0C:CD:04:00:00"); CHECK(stream.address.app_id == std::optional{ @@ -268,6 +280,159 @@ void dataset_reference_resolver_accepts_canonical_and_local_forms() { CHECK(not_specified.status == SclDataSetBindingStatus::not_specified); } +void parser_preserves_configured_control_model_value() { + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("minimal-station-brcb.scd")); + const std::array expected{ + std::pair{"SPCSO1", "direct-with-normal-security"}, + std::pair{"SPCSO2", "sbo-with-normal-security"}, + std::pair{"SPCSO3", "direct-with-enhanced-security"}, + std::pair{"SPCSO4", "sbo-with-enhanced-security"}, + }; + for (const auto& [data_object, configured_value] : expected) { + const auto configured = std::find_if( + document.model_entries.begin(), + document.model_entries.end(), + [&](const SclDataSetEntry& entry) { + return entry.ln_class == "GGIO" && entry.ln_inst == "1" && + entry.do_name == data_object && entry.da_name == "ctlModel"; + }); + CHECK(configured != document.model_entries.end()); + CHECK(configured->functional_constraint == "CF"); + CHECK(configured->cdc == "SPC"); + CHECK(configured->basic_type == "Enum"); + CHECK(configured->configured_value == configured_value); + } +} + +void simulator_compiles_semantic_defaults_and_indexed_report_instances() { + using namespace ar::iec61850::scl; + using namespace ar::iec61850::simulation; + + constexpr std::string_view xml = R"xml( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + reservednormal + +)xml"; + + const auto document = SclParser{}.parse(xml, "semantic-defaults.scd"); + CHECK(document.report_controls.size() == 1U); + CHECK(document.report_controls.front().indexed); + CHECK(document.report_controls.front().max_clients == 2U); + + IedSimulatorProfileFromSclOptions options; + options.ied_name = "IED1"; + options.simulation_start_unix_ms = 1'700'000'000'123ULL; + const auto compiled = IedSimulatorProfileBuilder::build(document, options); + CHECK(compiled.report_control_definition_count == 1U); + CHECK(compiled.report_control_instance_count == 2U); + CHECK(compiled.profile.report_control_blocks.size() == 2U); + CHECK(compiled.profile.report_control_blocks[0].mms_item == "LLN0$RP$UR01"); + CHECK(compiled.profile.report_control_blocks[1].mms_item == "LLN0$RP$UR02"); + CHECK(compiled.profile.report_control_blocks[0].reference == "IED1LD0/LLN0$RP$UR01"); + CHECK(compiled.profile.report_control_blocks[1].reference == "IED1LD0/LLN0$RP$UR02"); + CHECK(compiled.profile.report_control_blocks[0].report_id == "RID"); + CHECK(compiled.profile.report_control_blocks[1].report_id == "RID"); + + const auto point = [&](const std::string_view item) -> const IedSimulatorPoint& { + for (const auto& device : compiled.profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + const auto found = std::find_if( + node.points.begin(), node.points.end(), + [item](const IedSimulatorPoint& candidate) { + return candidate.mms_item == item; + }); + if (found != node.points.end()) return *found; + } + } + throw std::runtime_error("missing simulator point: " + std::string{item}); + }; + + CHECK(point("LLN0$ST$Mod$stVal").initial_value == "1"); + CHECK(point("LLN0$ST$Beh$stVal").initial_value == "1"); + CHECK(point("LLN0$ST$Health$stVal").initial_value == "1"); + CHECK(point("LLN0$ST$Mod$q").initial_value == "good"); + CHECK(point("LLN0$ST$Mod$t").initial_value == "unix-ms:1700000000123"); + CHECK(point("LLN0$DC$NamPlt$vendor").initial_value == "SIEMENS"); + CHECK(point("LLN0$DC$NamPlt$configRev").initial_value == "CFG-7"); + + // Semantic defaults are object-aware: an unrelated Enum still receives the + // conservative zero fallback rather than a global "normal=1" mutation. + CHECK(point("GGIO1$ST$Other$stVal").initial_value == "0"); +} + +void parser_preserves_setting_control_and_invalid_state() { + using namespace ar::iec61850::scl; + + constexpr std::string_view valid_xml = R"xml( + + + + +)xml"; + const auto valid = SclParser{}.parse(valid_xml, "setting-valid.scd"); + CHECK(valid.setting_controls.size() == 1U); + const auto& control = valid.setting_controls.front(); + CHECK(control.ied_name == "IED1"); + CHECK(control.ld_inst == "LD0"); + CHECK(control.logical_node_path == "LLN0"); + CHECK(control.control_block_reference == "IED1LD0/LLN0$SP$SGCB"); + CHECK(control.number_of_setting_groups == std::optional{3U}); + CHECK(control.active_setting_group == std::optional{2U}); + CHECK(control.valid()); + CHECK(std::none_of(valid.warnings.begin(), valid.warnings.end(), [](const std::string& warning) { + return warning.find("SettingControl") != std::string::npos; + })); + + constexpr std::string_view invalid_xml = R"xml( + + + + +)xml"; + const auto invalid = SclParser{}.parse(invalid_xml, "setting-invalid.scd"); + CHECK(invalid.setting_controls.size() == 1U); + CHECK(!invalid.setting_controls.front().valid()); + CHECK(invalid.setting_controls.front().number_of_setting_groups == + std::optional{0U}); + CHECK(!invalid.setting_controls.front().active_setting_group.has_value()); + CHECK(std::any_of(invalid.warnings.begin(), invalid.warnings.end(), [](const std::string& warning) { + return warning.find("SettingControl 'IED1LD0/LLN0$SP$SGCB'") != std::string::npos && + warning.find("invalid or missing") != std::string::npos; + })); +} + void parser_detects_duplicate_ieds_and_missing_dataset_references() { using namespace ar::iec61850::scl; @@ -361,6 +526,9 @@ int main() { {"SCL multi-stream", parser_extracts_multiple_sampled_values_streams_and_conflicts}, {"SCL structured 4800 SV profile", parser_compiles_structured_4800_sv_profile_without_drift}, {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms}, + {"SCL configured control model", parser_preserves_configured_control_model_value}, + {"Simulator semantic defaults and indexed RCBs", simulator_compiles_semantic_defaults_and_indexed_report_instances}, + {"SCL SettingControl", parser_preserves_setting_control_and_invalid_state}, {"SCL conflicts and warnings", parser_detects_duplicate_ieds_and_missing_dataset_references}, {"SCL edition detection", parser_detects_editions_from_root_metadata}, {"SCL prefixed namespace", parser_supports_prefixed_namespaces_and_predefined_entities}, diff --git a/tests/test_scl_assisted_connect.cpp b/tests/test_scl_assisted_connect.cpp new file mode 100644 index 000000000..bec5cfe60 --- /dev/null +++ b/tests/test_scl_assisted_connect.cpp @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association.hpp" +#include "ariec61850/mms/scl_assisted_connect.hpp" +#include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/tpkt.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using ByteVector = std::vector; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +class ScriptedTransport final : public mms::MmsByteTransport { +public: + void connect( + const mms::MmsEndpoint& endpoint, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted connect cancelled"); + } + endpoint_ = endpoint; + connected_ = true; + } + + void send( + const std::span bytes, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted send cancelled"); + } + if (!connected_) throw std::runtime_error("scripted transport disconnected"); + sent_.emplace_back(bytes.begin(), bytes.end()); + } + + [[nodiscard]] ByteVector receive( + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted receive cancelled"); + } + if (!connected_) throw std::runtime_error("scripted transport disconnected"); + if (receive_queue_.empty()) { + throw mms::MmsTransportTimeoutError("scripted receive timeout"); + } + auto value = std::move(receive_queue_.front()); + receive_queue_.pop_front(); + return value; + } + + void close() noexcept override { connected_ = false; } + [[nodiscard]] bool connected() const noexcept override { return connected_; } + + void push_receive(ByteVector bytes) { receive_queue_.push_back(std::move(bytes)); } + + [[nodiscard]] const std::vector& sent() const noexcept { return sent_; } + +private: + mms::MmsEndpoint endpoint_; + bool connected_{}; + std::deque receive_queue_; + std::vector sent_; +}; + +[[nodiscard]] ByteVector wrap_application( + const std::span application) { + return osi::TpktFrameCodec::encode(osi::CotpFrameCodec::encode_data(application)); +} + +void queue_handshake(ScriptedTransport& transport) { + const auto confirm = osi::CotpFrameCodec::encode_connection_confirm( + 0x0001U, 0x2345U, 0x0AU); + transport.push_receive(osi::TpktFrameCodec::encode(confirm)); + + const auto request_bytes = acse::AcseAssociationCodec::build_default_association_request(); + const auto request = acse::AcseAssociationCodec::decode_association_request(request_bytes); + const auto response = acse::AcseAssociationCodec::build_accept_response(request); + transport.push_receive(wrap_application(response.payload)); +} + +[[nodiscard]] scl::SclDataSetEntry leaf( + const std::string& ln_class, + const std::string& ln_inst, + const std::string& functional_constraint, + const std::string& do_name, + const std::string& da_name, + const std::size_t index) { + scl::SclDataSetEntry entry; + entry.index = index; + entry.signal_reference = + "IED1LD0/" + ln_class + ln_inst + "." + do_name + "." + da_name; + entry.ied_name = "IED1"; + entry.ld_inst = "LD0"; + entry.ln_class = ln_class; + entry.ln_inst = ln_inst; + entry.do_name = do_name; + entry.da_name = da_name; + entry.functional_constraint = functional_constraint; + entry.basic_type = "BOOLEAN"; + return entry; +} + +[[nodiscard]] scl::SclDocument make_document() { + scl::SclDocument document; + document.ieds.push_back(scl::SclIed{"IED1", "ARStack", "TEST", "1"}); + document.logical_nodes.push_back( + scl::SclLogicalNode{"IED1", "LD0", "", "LLN0", "", "LLN0"}); + document.logical_nodes.push_back( + scl::SclLogicalNode{"IED1", "LD0", "", "XCBR", "1", "XCBR1"}); + + const std::vector lln0_fc{ + "BR", "CF", "CO", "DC", "EX", "OR", "RP", "SE", "SG", "SP", "ST"}; + std::size_t entry_index = 0U; + for (const auto& fc : lln0_fc) { + document.model_entries.push_back( + leaf("LLN0", "", fc, "Do" + fc, "stVal", entry_index++)); + } + + // Two expected leaves under one FC root. The scripted response intentionally + // returns one DA child only under Pos, proving mapping fails closed with no + // partial leaf update. + document.model_entries.push_back( + leaf("XCBR", "1", "ST", "Pos", "stVal", entry_index++)); + document.model_entries.push_back( + leaf("XCBR", "1", "ST", "Pos", "q", entry_index++)); + return document; +} + +[[nodiscard]] mms::MmsReadAccessResult successful_root(const bool value) { + // FC root -> DataObject structure -> DataAttribute scalar. + return { + mms::MmsDataValue::structure({ + mms::MmsDataValue::structure({mms::MmsDataValue::boolean(value)})}), + std::nullopt}; +} + +void queue_scl_assisted_responses(ScriptedTransport& transport) { + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {"IED1LD0", "EXTRA_LD"}, false}))); + + mms::MmsReadResponse first; + first.invoke_id = 2U; + for (std::size_t index = 0U; index < 10U; ++index) { + first.results.push_back(successful_root(index % 2U == 0U)); + } + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(first))); + + mms::MmsReadResponse second; + second.invoke_id = 3U; + second.results.push_back(successful_root(true)); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(second))); + + mms::MmsReadResponse third; + third.invoke_id = 4U; + // XCBR1$ST expects Pos.{stVal,q}; this contains only Pos.stVal. + third.results.push_back(successful_root(true)); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(third))); +} + +[[nodiscard]] std::vector confirmed_requests( + const ScriptedTransport& transport) { + std::vector requests; + // send[0] = COTP CR, send[1] = association request; confirmed MMS starts at send[2]. + for (std::size_t index = 2U; index < transport.sent().size(); ++index) { + const auto tpkt = osi::TpktFrameCodec::decode(transport.sent()[index]); + const auto cotp = osi::CotpFrameCodec::decode(tpkt.payload); + if (cotp.kind != osi::CotpTpduKind::data) continue; + const auto envelope = mms::MmsPduCodec::decode_envelope(cotp.user_data); + if (envelope.kind == mms::MmsPduKind::confirmed_request) { + requests.push_back(envelope); + } + } + return requests; +} + +void planner_keeps_ln_batches_bounded_and_deterministic() { + const auto document = make_document(); + const auto plan = mms::InitialFcReadPlanner::build(document, "IED1"); + CHECK(plan.ied_name == "IED1"); + CHECK(plan.expected_domains.size() == 1U); + CHECK(plan.expected_domains.front() == "IED1LD0"); + CHECK(plan.reference_count() == 12U); + CHECK(plan.batches.size() == 3U); + CHECK(plan.batches[0].logical_node == "LLN0"); + CHECK(plan.batches[0].references.size() == 10U); + CHECK(plan.batches[1].logical_node == "LLN0"); + CHECK(plan.batches[1].references.size() == 1U); + CHECK(plan.batches[2].logical_node == "XCBR1"); + CHECK(plan.batches[2].references.size() == 1U); + + const std::vector expected_first{ + "BR", "CF", "CO", "DC", "EX", "OR", "RP", "SE", "SG", "SP"}; + for (std::size_t index = 0U; index < expected_first.size(); ++index) { + CHECK(plan.batches[0].references[index].functional_constraint == expected_first[index]); + } + CHECK(plan.batches[1].references.front().functional_constraint == "ST"); + CHECK(plan.batches[2].references.front().functional_constraint == "ST"); +} + +void domain_validator_never_silently_merges_identity() { + const std::vector expected{"IED1LD0", "IED1LD1"}; + const std::vector online{"IED1LD0", "EXTRA_LD"}; + const auto validation = mms::MmsSclDomainValidator::compare(expected, online); + CHECK(validation.missing.size() == 1U); + CHECK(validation.missing.front() == "IED1LD1"); + CHECK(validation.extra.size() == 1U); + CHECK(validation.extra.front() == "EXTRA_LD"); + CHECK(validation.expected == expected); + CHECK(validation.online == online); +} + +void scl_assisted_runtime_matches_reference_wire_shape() { + ScriptedTransport transport; + queue_handshake(transport); + queue_scl_assisted_responses(transport); + + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + CHECK(association.associated()); + + const auto document = make_document(); + mms::MmsSclAssistedConnectClient client{association}; + const auto result = client.synchronize(document, "IED1"); + + CHECK(result.domain_request_count == 1U); + CHECK(result.read_request_count == 3U); + CHECK(result.confirmed_request_count() == 4U); + CHECK(result.plan.reference_count() == 12U); + CHECK(result.domains.missing.empty()); + CHECK(result.domains.extra.size() == 1U); + CHECK(result.domains.extra.front() == "EXTRA_LD"); + CHECK(result.batches.size() == 3U); + CHECK(result.batches[0].roots.size() == 10U); + CHECK(result.batches[1].roots.size() == 1U); + CHECK(result.batches[2].roots.size() == 1U); + CHECK(result.successful_root_count == 11U); + CHECK(result.failed_root_count == 1U); + CHECK(result.mapped_leaf_count == 11U); + CHECK(result.batches[2].roots.front().read_success()); + CHECK(!result.batches[2].roots.front().mapping_success()); + CHECK(result.batches[2].roots.front().mapped_leaves.empty()); + CHECK(!result.batches[2].roots.front().mapping_error.empty()); + CHECK(result.associated_after_snapshot); + CHECK(association.associated()); + + const auto requests = confirmed_requests(transport); + CHECK(requests.size() == 4U); + CHECK(requests[0].service_tag.has_value()); + CHECK(*requests[0].service_tag == + static_cast(mms::MmsConfirmedService::get_name_list)); + for (std::size_t index = 1U; index < requests.size(); ++index) { + CHECK(requests[index].service_tag.has_value()); + CHECK(*requests[index].service_tag == + static_cast(mms::MmsConfirmedService::read)); + } + + // Strong negative assertion for the minimum trusted-SCL initial path. + CHECK(std::none_of( + requests.begin(), requests.end(), [](const auto& request) { + return request.service_tag == static_cast( + mms::MmsConfirmedService::get_variable_access_attributes) || + request.service_tag == static_cast( + mms::MmsConfirmedService::get_named_variable_list_attributes) || + request.service_tag == static_cast( + mms::MmsConfirmedService::write); + })); +} + +} // namespace + +int main() { + try { + planner_keeps_ln_batches_bounded_and_deterministic(); + domain_validator_never_silently_merges_identity(); + scl_assisted_runtime_matches_reference_wire_shape(); + std::cout + << "SCL_ASSISTED_CONNECT_PASS domain_queries=1 reads=3 " + "max_refs=10 outstanding=1 no_gvaa=true no_dataset_discovery=true " + "mapping_mismatch=fail_closed association_kept=true\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/test_scl_association_addressing.cpp b/tests/test_scl_association_addressing.cpp new file mode 100644 index 000000000..2d54b51da --- /dev/null +++ b/tests/test_scl_association_addressing.cpp @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association.hpp" +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/scl_association.hpp" +#include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/tpkt.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using ByteVector = std::vector; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +class ScriptedTransport final : public mms::MmsByteTransport { +public: + void connect( + const mms::MmsEndpoint& endpoint, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted connect cancelled"); + } + endpoint_ = endpoint; + connected_ = true; + } + + void send( + const std::span bytes, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted send cancelled"); + } + if (!connected_) throw std::runtime_error("scripted transport disconnected"); + sent_.emplace_back(bytes.begin(), bytes.end()); + } + + [[nodiscard]] ByteVector receive( + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted receive cancelled"); + } + if (!connected_) throw std::runtime_error("scripted transport disconnected"); + if (receive_queue_.empty()) { + throw mms::MmsTransportTimeoutError("scripted receive timeout"); + } + auto value = std::move(receive_queue_.front()); + receive_queue_.pop_front(); + return value; + } + + void close() noexcept override { connected_ = false; } + [[nodiscard]] bool connected() const noexcept override { return connected_; } + + void push_receive(ByteVector bytes) { receive_queue_.push_back(std::move(bytes)); } + [[nodiscard]] const std::vector& sent() const noexcept { return sent_; } + +private: + mms::MmsEndpoint endpoint_; + bool connected_{}; + std::deque receive_queue_; + std::vector sent_; +}; + +[[nodiscard]] ByteVector wrap_application( + const std::span application) { + return osi::TpktFrameCodec::encode(osi::CotpFrameCodec::encode_data(application)); +} + +void queue_compatible_handshake(ScriptedTransport& transport) { + const auto confirm = osi::CotpFrameCodec::encode_connection_confirm( + 0x0001U, 0x2345U, 0x0AU); + transport.push_receive(osi::TpktFrameCodec::encode(confirm)); + + // The runtime only needs a valid accepted association response. The exact + // request values under test are decoded from the bytes that runtime sends. + const auto default_request = + acse::AcseAssociationCodec::decode_association_request( + acse::AcseAssociationCodec::build_default_association_request()); + const auto response = + acse::AcseAssociationCodec::build_accept_response(default_request); + transport.push_receive(wrap_application(response.payload)); +} + +[[nodiscard]] bool bytes_equal( + const std::span actual, + const std::initializer_list expected) { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +void parser_and_resolver_preserve_connected_ap_context() { + constexpr std::string_view xml = R"xml( + + + + +
+

192.0.2.10

+

1,3,9999,23

+

23

+

00 00 00 23

+

00 23

+

00 23

+
+
+
+
+ + + +
)xml"; + + const auto document = scl::SclParser{}.parse(xml, "p4-association.scd"); + CHECK(document.mms_access_points.size() == 1U); + const auto& ap = document.mms_access_points.front(); + CHECK(ap.ied_name == "IED1"); + CHECK(ap.access_point_name == "P1"); + CHECK(ap.ip_address == "192.0.2.10"); + CHECK(ap.tcp_port == 102U); + CHECK(ap.association_parameters_present); + CHECK(ap.association_parameters_valid); + CHECK(ap.ap_title == std::vector({1U, 3U, 9999U, 23U})); + CHECK(ap.ae_qualifier == std::optional{23U}); + CHECK(ap.p_selector == ByteVector({0x00U, 0x00U, 0x00U, 0x23U})); + CHECK(ap.s_selector == ByteVector({0x00U, 0x23U})); + CHECK(ap.t_selector == ByteVector({0x00U, 0x23U})); + + const auto context = + mms::resolve_scl_association_context(document, "192.0.2.10"); + CHECK(context.selected()); + CHECK(context.ied_name == "IED1"); + CHECK(context.access_point_name == "P1"); + CHECK(context.scl_host == "192.0.2.10"); + CHECK(context.scl_port == 102U); + CHECK(context.uses_engineering_addressing()); + CHECK(context.addressing->called_ap_title == ap.ap_title); + CHECK(context.addressing->called_ae_qualifier == ap.ae_qualifier); + CHECK(context.addressing->called_p_selector == ap.p_selector); + CHECK(context.addressing->called_s_selector == ap.s_selector); + CHECK(context.addressing->called_t_selector == ap.t_selector); +} + +void explicit_engineering_addressing_reaches_wire_without_fallback() { + ScriptedTransport transport; + queue_compatible_handshake(transport); + + mms::MmsAssociationAddressing addressing; + addressing.called_ap_title = {1U, 3U, 9999U, 23U}; + addressing.called_ae_qualifier = 23U; + addressing.called_p_selector = {0x00U, 0x00U, 0x00U, 0x23U}; + addressing.called_s_selector = {0x00U, 0x23U}; + addressing.called_t_selector = {0x00U, 0x23U}; + + mms::MmsAssociationOptions options; + options.addressing = addressing; + mms::MmsAssociationRuntime association{transport, options}; + association.connect({"192.0.2.10", 102U}); + + CHECK(association.associated()); + CHECK(association.active_association_profile() == "SclEngineering"); + CHECK(association.association_attempts().size() == 1U); + CHECK(association.association_attempts().front().accepted); + CHECK(transport.sent().size() == 2U); + + const auto cotp_frame = osi::TpktFrameCodec::decode(transport.sent()[0]); + const auto cotp_request = osi::CotpFrameCodec::decode(cotp_frame.payload); + CHECK(cotp_request.kind == osi::CotpTpduKind::connection_request); + const auto source_tsap = cotp_request.parameter(osi::CotpFrameCodec::source_tsap_parameter); + const auto destination_tsap = + cotp_request.parameter(osi::CotpFrameCodec::destination_tsap_parameter); + CHECK(source_tsap.has_value()); + CHECK(destination_tsap.has_value()); + CHECK(bytes_equal(*source_tsap, {0x00U, 0x01U})); + CHECK(bytes_equal(*destination_tsap, {0x00U, 0x23U})); + + const auto association_frame = osi::TpktFrameCodec::decode(transport.sent()[1]); + const auto association_cotp = osi::CotpFrameCodec::decode(association_frame.payload); + CHECK(association_cotp.kind == osi::CotpTpduKind::data); + const auto request = acse::AcseAssociationCodec::decode_association_request( + association_cotp.user_data); + + const auto called_session_selector = request.session.parameter(0x34U); + const auto calling_session_selector = request.session.parameter(0x33U); + CHECK(called_session_selector.has_value()); + CHECK(calling_session_selector.has_value()); + CHECK(bytes_equal(*called_session_selector, {0x00U, 0x23U})); + CHECK(bytes_equal(*calling_session_selector, {0x00U, 0x01U})); + + CHECK(bytes_equal( + request.presentation.called_selector, + {0x00U, 0x00U, 0x00U, 0x23U})); + CHECK(bytes_equal( + request.presentation.calling_selector, + {0x00U, 0x00U, 0x00U, 0x01U})); + + // 1.3.9999.23 => 2B CE 0F 17 in BER OBJECT IDENTIFIER value form. + CHECK(bytes_equal(request.aarq.called_ap_title, {0x2BU, 0xCEU, 0x0FU, 0x17U})); + CHECK(request.aarq.called_ae_qualifier == std::optional{23U}); + + // Calling identity is deliberately not borrowed from the server SCL. + CHECK(request.aarq.calling_ap_title == + acse::AcseAssociationCodec::balanced_calling_ap_title()); + CHECK(request.aarq.calling_ae_qualifier == std::optional{12U}); +} + +void malformed_explicit_scl_addressing_fails_closed() { + constexpr std::string_view xml = R"xml( + + +

192.0.2.10

zzzz

+
+ +
)xml"; + const auto document = scl::SclParser{}.parse(xml, "invalid-p4.scd"); + CHECK(document.mms_access_points.size() == 1U); + CHECK(!document.mms_access_points.front().association_parameters_valid); + + bool threw{}; + try { + static_cast( + mms::resolve_scl_association_context(document, "192.0.2.10")); + } catch (const std::runtime_error&) { + threw = true; + } + CHECK(threw); +} + +} // namespace + +int main() { + try { + parser_and_resolver_preserve_connected_ap_context(); + explicit_engineering_addressing_reaches_wire_without_fallback(); + malformed_explicit_scl_addressing_fails_closed(); + std::cout + << "SCL_ASSOCIATION_ADDRESSING_PASS parser=pass resolver=pass " + "tsel=exact ssel=exact psel=exact ap_title=exact ae_qualifier=exact " + "calling_identity=default attempts=1 invalid=fail_closed\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/test_scl_projection_health.cpp b/tests/test_scl_projection_health.cpp new file mode 100644 index 000000000..8af2e0745 --- /dev/null +++ b/tests/test_scl_projection_health.cpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "../apps/ied_simulator/src/MmsSclClientProjection.hpp" + +#include +#include +#include + +namespace { + +namespace mms = ar::iec61850::mms; +namespace scl = ar::iec61850::scl; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +[[nodiscard]] scl::SclDocument document() { + scl::SclDocument value; + value.ieds.push_back(scl::SclIed{"IED1", "ARStack", "TEST", "1"}); + value.logical_nodes.push_back( + scl::SclLogicalNode{"IED1", "LD0", "", "LLN0", "", "LLN0"}); + return value; +} + +[[nodiscard]] mms::MmsSclAssistedConnectResult matched_snapshot() { + mms::MmsSclAssistedConnectResult snapshot; + snapshot.endpoint = {"192.0.2.10", 102U}; + snapshot.ied_name = "IED1"; + snapshot.domains.expected = {"IED1LD0"}; + snapshot.domains.online = {"IED1LD0"}; + snapshot.plan.ied_name = "IED1"; + snapshot.plan.expected_domains = {"IED1LD0"}; + snapshot.associated_after_snapshot = true; + return snapshot; +} + +void matched_projection_remains_exact() { + const auto model = arstack::iedsim::build_scl_live_model( + document(), matched_snapshot()); + CHECK(model.identity.ied_name == "IED1"); + CHECK(model.identity.confidence == mms::MmsLiveModelConfidence::exact); + CHECK(model.summary.find("Trusted SCL [matched]") != std::string::npos); + CHECK(model.warnings.empty()); +} + +void degraded_projection_is_explicit_and_preserves_scl() { + auto snapshot = matched_snapshot(); + snapshot.domains.online.push_back("EXTRA_LD"); + snapshot.domains.extra.push_back("EXTRA_LD"); + + const auto model = arstack::iedsim::build_scl_live_model(document(), snapshot); + CHECK(model.identity.ied_name == "IED1"); + CHECK(model.identity.confidence == mms::MmsLiveModelConfidence::high); + CHECK(model.logical_devices.size() == 1U); + CHECK(model.logical_devices.front().mms_domain == "IED1LD0"); + CHECK(model.summary.find("Trusted SCL [degraded]") != std::string::npos); + CHECK(model.summary.find("extraDomains=1") != std::string::npos); + CHECK(!model.warnings.empty()); + CHECK(model.warnings.front().code == "SclOnlineIdentityDegraded"); +} + +void incompatible_projection_fails_closed() { + auto snapshot = matched_snapshot(); + snapshot.domains.online = {"OTHERLD0"}; + snapshot.domains.missing = {"IED1LD0"}; + snapshot.domains.extra = {"OTHERLD0"}; + + bool rejected{}; + try { + static_cast( + arstack::iedsim::build_scl_live_model(document(), snapshot)); + } catch (const mms::MmsSclAssistedConnectError& exception) { + rejected = std::string{exception.what()}.find("incompatible") != std::string::npos; + } + CHECK(rejected); +} + +} // namespace + +int main() { + try { + matched_projection_remains_exact(); + degraded_projection_is_explicit_and_preserves_scl(); + incompatible_projection_fails_closed(); + std::cout + << "SCL_PROJECTION_HEALTH_PASS matched=exact degraded=high " + "unknown_domain_not_merged=true incompatible=fail_closed\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/test_scl_sync_health.cpp b/tests/test_scl_sync_health.cpp new file mode 100644 index 000000000..9ce55f4fd --- /dev/null +++ b/tests/test_scl_sync_health.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/scl_sync_health.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +[[nodiscard]] mms::MmsInitialFcReadReference reference( + const std::string& domain, + const std::string& item) { + mms::MmsInitialFcReadReference value; + value.variable = mms::MmsObjectName::domain_specific(domain, item); + value.ied_name = "IED1"; + value.logical_device = domain; + value.logical_node = "LLN0"; + value.functional_constraint = "ST"; + return value; +} + +[[nodiscard]] mms::MmsSclAssistedConnectResult base_result( + const std::size_t root_count = 1U) { + mms::MmsSclAssistedConnectResult result; + result.ied_name = "IED1"; + result.domains.expected = {"IED1LD0"}; + result.domains.online = {"IED1LD0"}; + result.plan.ied_name = "IED1"; + result.plan.expected_domains = {"IED1LD0"}; + + mms::MmsInitialFcReadBatch plan_batch; + plan_batch.domain = "IED1LD0"; + plan_batch.logical_node = "LLN0"; + for (std::size_t index = 0U; index < root_count; ++index) { + plan_batch.references.push_back(reference( + "IED1LD0", "LLN0$ST$Do" + std::to_string(index))); + } + result.plan.batches.push_back(std::move(plan_batch)); + + mms::MmsInitialFcBatchResult batch; + batch.domain = "IED1LD0"; + batch.logical_node = "LLN0"; + for (std::size_t index = 0U; index < root_count; ++index) { + mms::MmsInitialFcRootResult root; + root.reference = result.plan.batches.front().references[index]; + root.root_value = mms::MmsDataValue::boolean(true); + batch.roots.push_back(std::move(root)); + } + result.batches.push_back(std::move(batch)); + result.successful_root_count = root_count; + result.associated_after_snapshot = true; + return result; +} + +void exact_evidence_is_matched() { + const auto health = mms::MmsSclSynchronizationHealthClassifier::evaluate( + base_result(2U)); + CHECK(health.matched()); + CHECK(!health.degraded()); + CHECK(!health.incompatible()); + CHECK(health.expected_domain_count == 1U); + CHECK(health.online_expected_domain_count == 1U); + CHECK(health.missing_domain_count == 0U); + CHECK(health.extra_domain_count == 0U); + CHECK(health.planned_root_count == 2U); + CHECK(health.mapped_root_count == 2U); +} + +void partial_domain_evidence_is_degraded() { + auto result = base_result(1U); + result.domains.expected = {"IED1LD0", "IED1LD1"}; + result.domains.online = {"IED1LD0", "EXTRA_LD"}; + result.domains.missing = {"IED1LD1"}; + result.domains.extra = {"EXTRA_LD"}; + result.plan.expected_domains = result.domains.expected; + + mms::MmsInitialFcReadBatch skipped_plan; + skipped_plan.domain = "IED1LD1"; + skipped_plan.logical_node = "LLN0"; + skipped_plan.references.push_back(reference("IED1LD1", "LLN0$ST$Missing")); + result.plan.batches.push_back(skipped_plan); + + mms::MmsInitialFcBatchResult skipped; + skipped.domain = "IED1LD1"; + skipped.logical_node = "LLN0"; + skipped.skipped_missing_domain = true; + mms::MmsInitialFcRootResult root; + root.reference = skipped_plan.references.front(); + root.mapping_error = "MMS domain is unavailable online; Read was not issued."; + skipped.roots.push_back(std::move(root)); + result.batches.push_back(std::move(skipped)); + + const auto health = mms::MmsSclSynchronizationHealthClassifier::evaluate(result); + CHECK(health.degraded()); + CHECK(!health.incompatible()); + CHECK(health.online_expected_domain_count == 1U); + CHECK(health.missing_domain_count == 1U); + CHECK(health.extra_domain_count == 1U); + CHECK(health.skipped_root_count == 1U); + CHECK(health.mapped_root_count == 1U); +} + +void access_denied_only_is_degraded_not_incompatible() { + auto result = base_result(2U); + result.successful_root_count = 0U; + result.failed_root_count = 2U; + for (auto& root : result.batches.front().roots) { + root.root_value.reset(); + root.access_failure_code = 3U; + } + + const auto health = mms::MmsSclSynchronizationHealthClassifier::evaluate(result); + CHECK(health.degraded()); + CHECK(!health.incompatible()); + CHECK(health.access_failure_count == 2U); + CHECK(health.structural_mapping_failure_count == 0U); + CHECK(health.mapped_root_count == 0U); +} + +void mixed_structural_mapping_is_degraded() { + auto result = base_result(2U); + result.successful_root_count = 1U; + result.failed_root_count = 1U; + result.batches.front().roots[1].mapping_error = + "MMS Data structure child count does not match the SCL type tree."; + + const auto health = mms::MmsSclSynchronizationHealthClassifier::evaluate(result); + CHECK(health.degraded()); + CHECK(!health.incompatible()); + CHECK(health.structural_mapping_failure_count == 1U); + CHECK(health.mapped_root_count == 1U); +} + +void zero_expected_domains_online_is_incompatible() { + auto result = base_result(1U); + result.domains.online = {"OTHERLD0"}; + result.domains.missing = {"IED1LD0"}; + result.domains.extra = {"OTHERLD0"}; + result.successful_root_count = 0U; + result.failed_root_count = 1U; + result.batches.front().skipped_missing_domain = true; + result.batches.front().roots.front().root_value.reset(); + result.batches.front().roots.front().mapping_error = + "MMS domain is unavailable online; Read was not issued."; + + const auto health = mms::MmsSclSynchronizationHealthClassifier::evaluate(result); + CHECK(health.incompatible()); + CHECK(health.online_expected_domain_count == 0U); + + bool rejected{}; + try { + mms::MmsSclSynchronizationHealthClassifier::require_compatible(health); + } catch (const mms::MmsSclAssistedConnectError&) { + rejected = true; + } + CHECK(rejected); +} + +void all_readable_roots_structurally_mismatched_is_incompatible() { + auto result = base_result(2U); + result.successful_root_count = 0U; + result.failed_root_count = 2U; + for (auto& root : result.batches.front().roots) { + root.mapping_error = + "FC-root MMS Data child count does not match the SCL type tree."; + } + + const auto health = mms::MmsSclSynchronizationHealthClassifier::evaluate(result); + CHECK(health.incompatible()); + CHECK(health.access_failure_count == 0U); + CHECK(health.structural_mapping_failure_count == 2U); + CHECK(health.mapped_root_count == 0U); +} + +} // namespace + +int main() { + try { + exact_evidence_is_matched(); + partial_domain_evidence_is_degraded(); + access_denied_only_is_degraded_not_incompatible(); + mixed_structural_mapping_is_degraded(); + zero_expected_domains_online_is_incompatible(); + all_readable_roots_structurally_mismatched_is_incompatible(); + std::cout + << "SCL_SYNC_HEALTH_PASS matched=pass degraded=pass " + "access_denied_not_incompatible=true zero_domains=incompatible " + "all_structural_mismatch=incompatible silent_fallback=false\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/test_simulation.cpp b/tests/test_simulation.cpp new file mode 100644 index 000000000..4cd7a23b0 --- /dev/null +++ b/tests/test_simulation.cpp @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/scl/parser.hpp" +#include "ariec61850/simulation/ied_simulator_engine.hpp" +#include "ariec61850/simulation/ied_simulator_profile.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +std::filesystem::path fixture(const std::string& name) { + return std::filesystem::path{ARIEC61850_SOURCE_DIR} / "tests" / "fixtures" / "scl" / name; +} + +const ar::iec61850::simulation::IedSimulatorPoint* find_point( + const ar::iec61850::simulation::IedSimulatorProfile& profile, + const std::string& mms_item) { + for (const auto& device : profile.logical_devices) { + for (const auto& node : device.logical_nodes) { + const auto found = std::find_if( + node.points.begin(), node.points.end(), + [&mms_item](const auto& point) { return point.mms_item == mms_item; }); + if (found != node.points.end()) return &*found; + } + } + return nullptr; +} + +void profile_builder_preserves_full_structural_model() { + using namespace ar::iec61850; + const auto document = scl::SclParser{}.load(fixture("minimal-station-brcb.scd")); + const auto built = simulation::IedSimulatorProfileBuilder::build(document); + + CHECK(built.source_ied_name == "MU01"); + CHECK(built.selected_ied_name == "MU01"); + CHECK(built.profile.name == "MU01"); + CHECK(built.profile.vendor == "AR"); + CHECK(built.profile.logical_devices.size() == 1U); + CHECK(built.profile.logical_node_count() == 4U); + CHECK(built.profile.point_count() >= 23U); + CHECK(built.structural_data_attribute_count == built.profile.point_count()); + CHECK(built.profile.data_sets.size() == 2U); + CHECK(built.profile.report_control_blocks.size() == 2U); + CHECK(built.profile.data_set_member_count() == 5U); + + const auto* structural_only = find_point( + built.profile, "TCTR1$MX$AmpUnmapped$instMag$i"); + CHECK(structural_only != nullptr); + CHECK(structural_only->mms_domain == "MU01LD0"); + CHECK(structural_only->basic_type == "INT32"); + CHECK(structural_only->display_type == "Number"); + CHECK(structural_only->initial_value == "0"); + + const auto* position = find_point(built.profile, "XCBR1$ST$Pos$stVal"); + CHECK(position != nullptr); + CHECK(position->display_type == "Boolean"); + CHECK(position->initial_value == "false"); + + const auto* control_model = find_point(built.profile, "GGIO1$CF$SPCSO4$ctlModel"); + CHECK(control_model != nullptr); + CHECK(control_model->display_type == "Enumeration"); + CHECK(control_model->initial_value == "4"); + + const auto urcb = std::find_if( + built.profile.report_control_blocks.begin(), + built.profile.report_control_blocks.end(), + [](const auto& report) { return !report.buffered; }); + const auto brcb = std::find_if( + built.profile.report_control_blocks.begin(), + built.profile.report_control_blocks.end(), + [](const auto& report) { return report.buffered; }); + CHECK(urcb != built.profile.report_control_blocks.end()); + CHECK(brcb != built.profile.report_control_blocks.end()); + CHECK(urcb->mode() == "URCB"); + CHECK(brcb->mode() == "BRCB"); + CHECK(urcb->mms_item == "LLN0$RP$URCB01"); + CHECK(brcb->mms_item == "LLN0$BR$BRCB01"); +} + +void profile_builder_remaps_runtime_identity_and_filters_qt() { + using namespace ar::iec61850; + const auto document = scl::SclParser{}.load(fixture("minimal-station-brcb.scd")); + simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = "MU01"; + options.runtime_ied_name = "SIM01"; + options.include_quality_and_timestamp_points = false; + const auto built = simulation::IedSimulatorProfileBuilder::build(document, options); + + CHECK(built.source_ied_name == "MU01"); + CHECK(built.selected_ied_name == "SIM01"); + CHECK(built.profile.name == "SIM01"); + CHECK(!built.profile.logical_devices.empty()); + CHECK(built.profile.logical_devices.front().name == "SIM01LD0"); + CHECK(built.skipped_member_count == 3U); + CHECK(built.profile.data_set_member_count() == 2U); + CHECK(find_point(built.profile, "XCBR1$ST$Pos$q") == nullptr); + CHECK(find_point(built.profile, "XCBR1$ST$Pos$t") == nullptr); + CHECK(!built.profile.report_control_blocks.empty()); + CHECK(built.profile.report_control_blocks.front().mms_domain == "SIM01LD0"); +} + +void profile_builder_seeds_iec61850_semantic_initial_values() { + using namespace ar::iec61850; + + scl::SclDocument document; + document.source_name = "semantic-initial-values.scd"; + + scl::SclIed ied; + ied.name = "IEDSEM"; + ied.manufacturer = "AR"; + document.ieds.push_back(std::move(ied)); + + scl::SclLogicalNode xcbr; + xcbr.ied_name = "IEDSEM"; + xcbr.ld_inst = "LD0"; + xcbr.ln_class = "XCBR"; + xcbr.ln_inst = "1"; + xcbr.name = "XCBR1"; + document.logical_nodes.push_back(std::move(xcbr)); + + scl::SclLogicalNode ggio; + ggio.ied_name = "IEDSEM"; + ggio.ld_inst = "LD0"; + ggio.ln_class = "GGIO"; + ggio.ln_inst = "1"; + ggio.name = "GGIO1"; + document.logical_nodes.push_back(std::move(ggio)); + + const auto append = [&](std::string ln_class, + std::string ln_inst, + std::string do_name, + std::string da_name, + std::string fc, + std::string cdc, + std::string basic_type, + const bool quality = false, + const bool timestamp = false) { + scl::SclDataSetEntry entry; + entry.ied_name = "IEDSEM"; + entry.ld_inst = "LD0"; + entry.ln_class = std::move(ln_class); + entry.ln_inst = std::move(ln_inst); + entry.do_name = std::move(do_name); + entry.da_name = std::move(da_name); + entry.functional_constraint = std::move(fc); + entry.cdc = std::move(cdc); + entry.basic_type = std::move(basic_type); + entry.is_quality = quality; + entry.is_timestamp = timestamp; + document.model_entries.push_back(std::move(entry)); + }; + + append("XCBR", "1", "Pos", "stVal", "ST", "DPC", "Dbpos"); + append("XCBR", "1", "Pos", "q", "ST", "DPC", "Quality", true, false); + append("XCBR", "1", "Pos", "t", "ST", "DPC", "Timestamp", false, true); + append("GGIO", "1", "Ind1", "stVal", "ST", "SPS", "BOOLEAN"); + + simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = "IEDSEM"; + options.simulation_start_unix_ms = 1'720'000'000'000ULL; + const auto built = simulation::IedSimulatorProfileBuilder::build(document, options); + + const auto* pos = find_point(built.profile, "XCBR1$ST$Pos$stVal"); + CHECK(pos != nullptr); + CHECK(pos->basic_type == "Dbpos"); + CHECK(pos->display_type == "Enumeration"); + CHECK(pos->initial_value == "intermediate-state"); + + const auto* quality = find_point(built.profile, "XCBR1$ST$Pos$q"); + CHECK(quality != nullptr); + CHECK(quality->display_type == "Quality"); + CHECK(quality->initial_value == "good"); + + const auto* timestamp = find_point(built.profile, "XCBR1$ST$Pos$t"); + CHECK(timestamp != nullptr); + CHECK(timestamp->display_type == "Timestamp"); + CHECK(timestamp->initial_value == "unix-ms:1720000000000"); + + const auto* sps = find_point(built.profile, "GGIO1$ST$Ind1$stVal"); + CHECK(sps != nullptr); + CHECK(sps->display_type == "Boolean"); + CHECK(sps->initial_value == "false"); +} + +void profile_builder_does_not_suffix_match_timestamp_or_quality() { + using namespace ar::iec61850; + + scl::SclDocument document; + document.source_name = "iedscout-type-regression.scd"; + scl::SclIed ied; + ied.name = "IEDTYPE"; + document.ieds.push_back(std::move(ied)); + + scl::SclLogicalNode cswi; + cswi.ied_name = "IEDTYPE"; + cswi.ld_inst = "LD0"; + cswi.ln_class = "CSWI"; + cswi.ln_inst = "1"; + cswi.name = "CSWI1"; + document.logical_nodes.push_back(std::move(cswi)); + + const auto append = [&](std::string do_name, + std::string da_name, + std::string fc, + std::string cdc, + std::string basic_type, + const bool quality = false, + const bool timestamp = false) { + scl::SclDataSetEntry entry; + entry.ied_name = "IEDTYPE"; + entry.ld_inst = "LD0"; + entry.ln_class = "CSWI"; + entry.ln_inst = "1"; + entry.do_name = std::move(do_name); + entry.da_name = std::move(da_name); + entry.functional_constraint = std::move(fc); + entry.cdc = std::move(cdc); + entry.basic_type = std::move(basic_type); + entry.is_quality = quality; + entry.is_timestamp = timestamp; + document.model_entries.push_back(std::move(entry)); + }; + + append("Pos", "origin.orCat", "ST", "DPC", "INT8"); + append("Pos", "origin.orIdent", "ST", "DPC", "Octet64"); + append("Pos", "sboTimeout", "CF", "DPC", "INT32U"); + append("Pos", "operTimeout", "CF", "DPC", "INT32U"); + append("LocSta", "Oper.Test", "CO", "SPC", "BOOLEAN"); + append("Pos", "q", "ST", "DPC", "Quality", true, false); + append("Pos", "t", "ST", "DPC", "Timestamp", false, true); + + simulation::IedSimulatorProfileFromSclOptions options; + options.ied_name = "IEDTYPE"; + const auto built = simulation::IedSimulatorProfileBuilder::build(document, options); + + const auto* or_cat = find_point(built.profile, "CSWI1$ST$Pos$origin$orCat"); + const auto* or_ident = find_point(built.profile, "CSWI1$ST$Pos$origin$orIdent"); + const auto* sbo_timeout = find_point(built.profile, "CSWI1$CF$Pos$sboTimeout"); + const auto* oper_timeout = find_point(built.profile, "CSWI1$CF$Pos$operTimeout"); + const auto* test = find_point(built.profile, "CSWI1$CO$LocSta$Oper$Test"); + const auto* q = find_point(built.profile, "CSWI1$ST$Pos$q"); + const auto* t = find_point(built.profile, "CSWI1$ST$Pos$t"); + + CHECK(or_cat != nullptr && or_cat->display_type == "Number"); + CHECK(or_ident != nullptr && or_ident->display_type == "Octet64"); + CHECK(sbo_timeout != nullptr && sbo_timeout->display_type == "Number"); + CHECK(oper_timeout != nullptr && oper_timeout->display_type == "Number"); + CHECK(test != nullptr && test->display_type == "Boolean"); + CHECK(q != nullptr && q->display_type == "Quality"); + CHECK(t != nullptr && t->display_type == "Timestamp"); +} + +void engine_supports_case_insensitive_manual_state_and_deterministic_steps() { + using namespace ar::iec61850::simulation; + auto profile = IedSimulatorProfile::create_default_feeder_profile(); + IedSimulatorEngine engine{std::move(profile)}; + + const auto* initial = engine.find_point_state("ied1ld0/xcbr1.pos.stval"); + CHECK(initial != nullptr); + CHECK(initial->value == "closed"); + CHECK(initial->timestamp_unix_milliseconds == 0U); + + const auto mutation = engine.set_point_value( + "IED1LD0/XCBR1$ST$Pos$stVal", + "open", + "questionable", + "Manual", + 1700000000123ULL); + CHECK(mutation.has_value()); + CHECK(mutation->previous_value == "closed"); + CHECK(mutation->new_value == "open"); + CHECK(mutation->reason == "data-change"); + + const auto* updated = engine.find_point_state("XCBR1.Pos.stVal"); + CHECK(updated != nullptr); + CHECK(updated->value == "open"); + CHECK(updated->quality == "questionable"); + CHECK(updated->origin == "Manual"); + CHECK(updated->timestamp_unix_milliseconds == 1700000000123ULL); + + engine.reset(1000U); + engine.start(); + CHECK(engine.running()); + const auto first_events = engine.step(1100U); + CHECK(!first_events.empty()); + const auto* phase_a = engine.find_point_state("IED1LD0/MMXU1.PhV.phsA.cVal.mag.f"); + CHECK(phase_a != nullptr); + CHECK(phase_a->value != "230000"); + CHECK(phase_a->timestamp_unix_milliseconds == 1100U); + CHECK(engine.step_index() == 1U); + + const auto snapshot = engine.snapshot(1200U); + CHECK(snapshot.profile_name == "AR Demo Feeder IED"); + CHECK(snapshot.logical_device_count == 1U); + CHECK(snapshot.logical_node_count == 2U); + CHECK(snapshot.point_count == 4U); + CHECK(snapshot.points.size() == 4U); + + engine.stop(); + CHECK(!engine.running()); +} + +} // namespace + +int main() { + try { + profile_builder_preserves_full_structural_model(); + std::cout << "[PASS] simulator full structural profile\n"; + profile_builder_remaps_runtime_identity_and_filters_qt(); + std::cout << "[PASS] simulator runtime identity/filtering\n"; + profile_builder_seeds_iec61850_semantic_initial_values(); + std::cout << "[PASS] simulator IEC 61850 semantic initial values\n"; + profile_builder_does_not_suffix_match_timestamp_or_quality(); + std::cout << "[PASS] simulator exact q/t attribute classification\n"; + engine_supports_case_insensitive_manual_state_and_deterministic_steps(); + std::cout << "[PASS] simulator deterministic engine\n"; + std::cout << "Passed 5/5 simulator runtime tests.\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "[FAIL] " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt index 86772d31e..309680575 100644 --- a/tools/control_interop_probe/CMakeLists.txt +++ b/tools/control_interop_probe/CMakeLists.txt @@ -12,10 +12,12 @@ 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(NOT TARGET ariec61850_control_interop_probe) + 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) +endif() add_executable(ariec61850_control_inventory_probe ${ARIEC61850_ROOT}/tools/control_inventory_probe.cpp) diff --git a/tools/file_transfer.cpp b/tools/file_transfer.cpp index 638a4e7e7..93d966ed0 100644 --- a/tools/file_transfer.cpp +++ b/tools/file_transfer.cpp @@ -19,7 +19,7 @@ namespace { using namespace ar::iec61850; -enum class Command : std::uint8_t { list, download }; +enum class Command : std::uint8_t { list, download, remove }; struct Options final { mms::MmsEndpoint endpoint; @@ -107,12 +107,13 @@ void usage() { std::cout << "Usage:\n" << " ariec61850_file_transfer [port] [list] [options]\n" - << " ariec61850_file_transfer [port] download --remote PATH --output FILE [options]\n\n" + << " ariec61850_file_transfer [port] download --remote PATH --output FILE [options]\n" + << " ariec61850_file_transfer [port] delete --remote PATH [options]\n\n" << "List options:\n" << " --directory PATH Root is used by default.\n" << " --max-pages N Default 64.\n" << " --max-entries N Default 65536.\n\n" - << "Download options:\n" + << "Download/delete options:\n" << " --remote PATH Explicit remote file to read.\n" << " --output FILE Explicit local destination.\n" << " --max-bytes N Default 536870912.\n" @@ -123,8 +124,8 @@ void usage() { << " --json Print machine-readable evidence.\n" << " --json-output FILE Also write JSON evidence to a local file.\n" << " --help Show this help.\n\n" - << "The default command is read-only FileDirectory. Download must be explicit;\n" - << "this tool never uploads, deletes, renames, or mutates remote files.\n"; + << "The default command is read-only FileDirectory. Download and FileDelete\n" + << "must be explicit; this tool never uploads or renames remote files.\n"; } [[nodiscard]] Options parse_options(const int argc, char** argv) { @@ -137,6 +138,7 @@ void usage() { int index = 2; if (index < argc && std::string_view{argv[index]} != "list" && std::string_view{argv[index]} != "download" && + std::string_view{argv[index]} != "delete" && std::string_view{argv[index]}.rfind("--", 0U) != 0U) { options.endpoint.port = parse_port(argv[index++]); } @@ -145,6 +147,9 @@ void usage() { } else if (index < argc && std::string_view{argv[index]} == "download") { options.command = Command::download; ++index; + } else if (index < argc && std::string_view{argv[index]} == "delete") { + options.command = Command::remove; + ++index; } while (index < argc) { @@ -181,6 +186,9 @@ void usage() { throw std::invalid_argument( "download requires both --remote PATH and --output FILE."); } + if (options.command == Command::remove && options.remote_path.empty()) { + throw std::invalid_argument("delete requires --remote PATH."); + } return options; } @@ -312,6 +320,17 @@ class ConsoleProgress final : public mms::MmsFileProgressSink { return output.str(); } +[[nodiscard]] std::string delete_json( + const mms::MmsFileDeleteResult& result) { + std::ostringstream output; + output << "{\"schemaVersion\":\"ariec61850-mms-file-delete-v1\"," + << "\"destructive\":true,\"success\":" + << (result.success ? "true" : "false") << ',' + << "\"remotePath\":\"" << json_escape(result.remote_path) << "\"," + << "\"message\":\"" << json_escape(result.message) << "\"}"; + return output.str(); +} + void emit_json(const std::string& json, const Options& options) { if (options.json) { std::cout << json << '\n'; @@ -378,7 +397,7 @@ int main(int argc, char** argv) { } } if (!result.success) result_code = exit_code(result.failure_kind); - } else { + } else if (options.command == Command::download) { FileSink sink{options.output_path}; ConsoleProgress progress; mms::MmsFileTransferOptions transfer_options; @@ -392,6 +411,11 @@ int main(int argc, char** argv) { emit_json(json, options); if (!options.json) std::cout << result.message << '\n'; if (!result.success) result_code = exit_code(result.failure_kind); + } else { + const auto result = runtime.remove(options.remote_path); + emit_json(delete_json(result), options); + if (!options.json) std::cout << result.message << '\n'; + if (!result.success) result_code = exit_code(result.failure_kind); } association.disconnect(); return result_code; diff --git a/tools/interop_control_wire_probe.cpp b/tools/interop_control_wire_probe.cpp new file mode 100644 index 000000000..57b6ce239 --- /dev/null +++ b/tools/interop_control_wire_probe.cpp @@ -0,0 +1,129 @@ +// 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 + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +[[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]] MmsDataValue command_value( + const bool value, + const std::int64_t timestamp_ms) { + constexpr std::array origin{ + 0x13U, 0xD5U, 0xC0U, 0x07U}; + constexpr std::array check{0xC0U}; + + return MmsDataValue::structure({ + MmsDataValue::boolean(value), + MmsDataValue::structure({ + MmsDataValue::integer(2), + MmsDataValue::octet_string(origin), + }), + MmsDataValue::unsigned_integer(0U), + MmsDataValue::utc_time(Iec61850UtcTime{ + std::chrono::system_clock::time_point{ + std::chrono::milliseconds{timestamp_ms}}, + 0U}), + MmsDataValue::boolean(false), + MmsDataValue::bit_string(6U, std::span{check}), + }); +} + +void require_write( + MmsAssociationControlTransport& transport, + const MmsObjectName& object, + MmsDataValue value, + const std::string_view label) { + const auto result = transport.write(object, std::move(value)); + if (!result.success) { + throw std::runtime_error( + std::string{label} + " Write rejected" + + (result.failure_code + ? " DataAccessError=" + std::to_string(*result.failure_code) + : "")); + } +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 4) { + std::cerr + << "Usage: ariec61850_interop_control_wire_probe " + " \n"; + return 2; + } + + MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = parse_port(argv[2]); + const std::string domain = argv[3]; + + MmsAssociationOptions options; + options.connect_timeout = std::chrono::milliseconds{5'000}; + options.request_timeout = std::chrono::milliseconds{5'000}; + + MmsTcpLiveDiscoverySession session{{}, options}; + session.connect(endpoint); + MmsAssociationControlTransport transport{session.association()}; + + const auto sbow = MmsObjectName::domain_specific( + domain, "CSWI1$CO$Pos$SBOw"); + const auto oper = MmsObjectName::domain_specific( + domain, "CSWI1$CO$Pos$Oper"); + + // Mirrors the real external vendor capture: ctlNum remains zero while Oper gets + // a fresh T rather than replaying the selection timestamp. + require_write( + transport, + sbow, + command_value(true, 1'789'779'196'609LL), + "SBOw"); + require_write( + transport, + oper, + command_value(true, 1'789'779'197'488LL), + "Oper"); + + MmsInformationReport termination; + if (!transport.wait_information_report( + std::chrono::milliseconds{2'000}, termination)) { + throw std::runtime_error( + "positive CommandTermination was not observed after Oper"); + } + + std::cout + << "INTEROP_ZERO_CTLNUM_CONTROL_PASS " + "sequence=SBOw,Oper,CommandTermination " + "ctlNum=0 freshOperT=true check=sync+interlock\n"; + session.disconnect(); + return 0; + } catch (const std::exception& exception) { + std::cerr << "external IEC 61850 client control wire probe failed: " + << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/interop_reporting_wire_probe.cpp b/tools/interop_reporting_wire_probe.cpp new file mode 100644 index 000000000..195978b48 --- /dev/null +++ b/tools/interop_reporting_wire_probe.cpp @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace mms = ar::iec61850::mms; + +enum class Mode : std::uint8_t { urcb, brcb }; + +struct Options final { + mms::MmsEndpoint endpoint; + Mode mode{Mode::urcb}; + std::string domain; + std::string rcb; + std::string expected_rptid; + std::string expected_dataset; + std::size_t expected_members{}; + std::uint32_t expected_conf_rev{}; + std::uint8_t expected_opt_first{}; + std::uint8_t expected_opt_second{}; + std::chrono::milliseconds timeout{5'000}; +}; + +[[nodiscard]] std::size_t parse_size( + const std::string& option, + const std::string& text, + const std::size_t maximum) { + std::size_t consumed{}; + const auto value = std::stoull(text, &consumed, 0); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument(option + " is outside the supported range."); + } + return static_cast(value); +} + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) { + return exchange.presentation_payload.empty() + ? exchange.envelope.mms_payload + : std::span{exchange.presentation_payload}; +} + +void require_write( + mms::MmsAssociationRuntime& association, + std::vector variables, + std::vector values, + const std::string_view label) { + const auto invoke_id = association.next_invoke_id(); + mms::MmsWriteRequest request; + request.invoke_id = invoke_id; + request.variables = std::move(variables); + request.values = std::move(values); + 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); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error( + std::string{label} + " did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_write_response( + response_payload(exchange), invoke_id); + if (response.results.size() != request.variables.size() || + !response.all_success()) { + std::string detail; + for (std::size_t index = 0U; index < response.results.size(); ++index) { + if (response.results[index].success) continue; + detail += " result[" + std::to_string(index) + "]=" + + (response.results[index].failure_code + ? std::to_string(*response.results[index].failure_code) + : std::string{"unknown"}); + } + throw std::runtime_error( + std::string{label} + " was rejected." + detail); + } +} + +void require_boolean_write( + mms::MmsAssociationRuntime& association, + const std::string& domain, + const std::string& item, + const bool value, + const std::string_view label) { + require_write( + association, + {mms::MmsObjectName::domain_specific(domain, item)}, + {mms::MmsDataValue::boolean(value)}, + label); +} + +[[nodiscard]] std::vector wait_report( + mms::MmsAssociationRuntime& association, + const std::chrono::milliseconds timeout) { + std::vector payload; + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (association.try_pop_information_report(payload)) return payload; + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + if (remaining <= std::chrono::milliseconds::zero()) break; + mms::MmsPduEnvelope envelope; + static_cast(association.try_poll_once_for( + std::min(remaining, std::chrono::milliseconds{250}), envelope)); + } + if (association.try_pop_information_report(payload)) return payload; + throw std::runtime_error("RCB GI did not produce InformationReport."); +} + +[[nodiscard]] bool exact_optflds( + const mms::MmsReportBitField& field, + const std::uint8_t first, + const std::uint8_t second) { + return field.raw == std::vector{6U, first, second}; +} + +[[nodiscard]] bool all_gi(const mms::MmsReportFrame& frame) { + return !frame.values.empty() && + std::all_of( + frame.values.begin(), frame.values.end(), + [](const mms::MmsReportValue& value) { + return value.reason_for_inclusion.has("general-interrogation"); + }); +} + +[[nodiscard]] bool full_inclusion( + const std::vector& indexes, + const std::size_t count) { + if (indexes.size() != count) return false; + for (std::size_t index = 0U; index < count; ++index) { + if (indexes[index] != index) return false; + } + return true; +} + +[[nodiscard]] bool entry_id_one( + const std::vector& entry_id) { + if (entry_id.size() != 8U) return false; + for (std::size_t index = 0U; index + 1U < entry_id.size(); ++index) { + if (entry_id[index] != 0U) return false; + } + return entry_id.back() == 1U; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_interop_reporting_wire_probe [port] [options]\n" + << " --mode urcb|brcb\n" + << " --domain DOMAIN\n" + << " --rcb ITEM\n" + << " --expected-rptid TEXT\n" + << " --expected-dataset DOMAIN/ITEM\n" + << " --expected-members N\n" + << " --expected-confrev N\n" + << " --expected-opt-first N\n" + << " --expected-opt-second N\n" + << " --timeout-ms N\n\n" + << "Replays the static external vendor external IEC 61850 client commissioning order: URCB Resv,\n" + << "grouped TrgOps+RptEna, GI; BRCB grouped TrgOps+RptEna, GI.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + if (argc < 2) throw std::invalid_argument("host is required."); + Options options; + options.endpoint.host = argv[1]; + options.endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.rfind("--", 0U) != 0U) { + options.endpoint.port = static_cast( + parse_size("port", argv[argument++], 65'535U)); + } + while (argument < argc) { + const std::string key = argv[argument++]; + if (key == "--help" || key == "-h") { + print_usage(); + std::exit(0); + } + if (argument >= argc) { + throw std::invalid_argument(key + " requires a value."); + } + const std::string value = argv[argument++]; + if (key == "--mode") { + if (value == "urcb") options.mode = Mode::urcb; + else if (value == "brcb") options.mode = Mode::brcb; + else throw std::invalid_argument("--mode requires urcb or brcb."); + } else if (key == "--domain") { + options.domain = value; + } else if (key == "--rcb") { + options.rcb = value; + } else if (key == "--expected-rptid") { + options.expected_rptid = value; + } else if (key == "--expected-dataset") { + options.expected_dataset = value; + } else if (key == "--expected-members") { + options.expected_members = parse_size(key, value, 4096U); + } else if (key == "--expected-confrev") { + options.expected_conf_rev = static_cast( + parse_size(key, value, 0xFFFFFFFFU)); + } else if (key == "--expected-opt-first") { + options.expected_opt_first = static_cast( + parse_size(key, value, 0xFFU)); + } else if (key == "--expected-opt-second") { + options.expected_opt_second = static_cast( + parse_size(key, value, 0xFFU)); + } else if (key == "--timeout-ms") { + options.timeout = std::chrono::milliseconds{ + static_cast(parse_size(key, value, 120'000U))}; + } else { + throw std::invalid_argument("Unknown option: " + key); + } + } + if (options.domain.empty() || options.rcb.empty() || + options.expected_rptid.empty() || options.expected_dataset.empty() || + options.expected_members == 0U) { + throw std::invalid_argument("required reporting probe options are missing."); + } + return options; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc > 1 && (std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h")) { + print_usage(); + return 0; + } + const auto options = parse_options(argc, argv); + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = options.timeout; + association_options.request_timeout = options.timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(options.endpoint); + + const auto root = options.rcb + "$"; + if (options.mode == Mode::urcb) { + require_boolean_write( + session.association(), options.domain, root + "Resv", true, + "URCB Resv=true"); + } + + const std::array trigger_payload{0x7CU}; + require_write( + session.association(), + { + mms::MmsObjectName::domain_specific( + options.domain, root + "TrgOps"), + mms::MmsObjectName::domain_specific( + options.domain, root + "RptEna"), + }, + { + mms::MmsDataValue::bit_string(2U, trigger_payload), + mms::MmsDataValue::boolean(true), + }, + "grouped TrgOps+RptEna"); + + require_boolean_write( + session.association(), options.domain, root + "GI", true, "GI=true"); + + const auto payload = wait_report(session.association(), options.timeout); + mms::MmsInformationReport report; + std::string error; + if (!mms::MmsInformationReportCodec::try_decode(payload, report, &error)) { + throw std::runtime_error( + "InformationReport decode failed: " + error); + } + const auto frame = mms::MmsReportFrameMapper::map(report, {}); + if (frame.header.report_id != options.expected_rptid || + !exact_optflds( + frame.header.optional_fields, + options.expected_opt_first, + options.expected_opt_second) || + frame.header.sequence_number != 1U || + !frame.header.time_of_entry || + frame.header.data_set_reference != options.expected_dataset || + frame.header.configuration_revision != options.expected_conf_rev || + !full_inclusion( + frame.included_data_set_indexes, options.expected_members) || + frame.values.size() != options.expected_members || + !all_gi(frame)) { + throw std::runtime_error( + "InformationReport semantic golden mismatch."); + } + if (options.mode == Mode::urcb) { + if (!frame.header.entry_id.empty()) { + throw std::runtime_error("URCB GI unexpectedly exposed EntryID."); + } + } else if (!entry_id_one(frame.header.entry_id)) { + throw std::runtime_error("BRCB GI EntryID is not 1."); + } + + require_boolean_write( + session.association(), options.domain, root + "RptEna", false, + "RptEna=false"); + if (options.mode == Mode::urcb) { + require_boolean_write( + session.association(), options.domain, root + "Resv", false, + "URCB Resv=false"); + } + session.disconnect(); + + std::cout + << "INTEROP_REPORTING_WIRE_PASS mode=" + << (options.mode == Mode::urcb ? "urcb" : "brcb") + << " rcb=" << options.domain << '/' << options.rcb + << " members=" << frame.values.size() + << " sqNum=1 confRev=" << *frame.header.configuration_revision + << " optFlds=" << std::hex + << static_cast(options.expected_opt_first) + << static_cast(options.expected_opt_second) + << std::dec + << " reason=general-interrogation" + << " groupedWrite=TrgOps,RptEna" + << " reservation=" + << (options.mode == Mode::urcb ? "urcb-only" : "none") + << '\n'; + return 0; + } catch (const std::exception& exception) { + std::cerr << "external IEC 61850 client reporting wire probe failed: " + << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/mms_brcb_event_probe.cpp b/tools/mms_brcb_event_probe.cpp new file mode 100644 index 000000000..093de17b8 --- /dev/null +++ b/tools/mms_brcb_event_probe.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace mms = ar::iec61850::mms; + +[[nodiscard]] std::size_t parse_size( + const std::string& option, + const std::string& text, + const std::size_t maximum) { + std::size_t consumed{}; + const auto value = std::stoull(text, &consumed, 10); + if (consumed != text.size() || value == 0U || value > maximum) { + throw std::invalid_argument(option + " is outside the supported range."); + } + return static_cast(value); +} + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) { + return exchange.presentation_payload.empty() + ? exchange.envelope.mms_payload + : std::span{exchange.presentation_payload}; +} + +void require_boolean_write( + mms::MmsAssociationRuntime& association, + const std::string& domain, + const std::string& item, + const bool value) { + const auto invoke_id = association.next_invoke_id(); + mms::MmsWriteRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(mms::MmsObjectName::domain_specific(domain, item)); + request.values.push_back(mms::MmsDataValue::boolean(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); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("BRCB Boolean write did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_write_response( + response_payload(exchange), invoke_id); + if (response.results.size() != 1U || !response.results.front().success) { + const std::optional failure = response.results.size() == 1U + ? response.results.front().failure_code + : std::nullopt; + throw std::runtime_error( + "BRCB Boolean write was rejected" + + (failure ? " (DataAccessError=" + std::to_string(*failure) + ")." : ".")); + } +} + +void print_usage() { + std::cout + << "Usage: ariec61850_mms_brcb_event_probe [port] --domain NAME --rcb ITEM [options]\n\n" + << "Options:\n" + << " --timeout-ms N Connect/request/report timeout (default 7000).\n" + << " -h, --help Show this help.\n\n" + << "The probe enables one BRCB, announces readiness, then requires an\n" + << "unsolicited buffered InformationReport with a non-empty EntryID.\n"; +} + +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + print_usage(); + return argc < 2 ? 2 : 0; + } + + mms::MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.rfind("--", 0U) != 0U) { + endpoint.port = static_cast(parse_size( + "port", argv[argument++], 65'535U)); + } + + std::string domain; + std::string rcb; + std::chrono::milliseconds timeout{7'000}; + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") { + print_usage(); + return 0; + } + if (argument >= argc) { + throw std::invalid_argument(option + " requires a value."); + } + const std::string value = argv[argument++]; + if (option == "--domain") { + domain = value; + } else if (option == "--rcb") { + rcb = value; + } else if (option == "--timeout-ms") { + timeout = std::chrono::milliseconds{static_cast( + parse_size(option, value, 120'000U))}; + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + if (domain.empty() || rcb.empty()) { + throw std::invalid_argument("--domain and --rcb are required."); + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = timeout; + association_options.request_timeout = timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + + const auto rpt_ena = rcb + "$RptEna"; + require_boolean_write(session.association(), domain, rpt_ena, true); + std::cout << "MMS_BRCB_EVENT_READY reference=" << domain << '/' << rcb << '\n'; + std::cout.flush(); + + std::vector report_payload; + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline && report_payload.empty()) { + if (session.association().try_pop_information_report(report_payload)) break; + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + if (remaining <= std::chrono::milliseconds::zero()) break; + mms::MmsPduEnvelope envelope; + static_cast(session.association().try_poll_once_for( + std::min(remaining, std::chrono::milliseconds{500}), envelope)); + static_cast(session.association().try_pop_information_report(report_payload)); + } + if (report_payload.empty()) { + throw std::runtime_error("BRCB did not produce an MMS InformationReport."); + } + + mms::MmsInformationReport report; + std::string decode_error; + if (!mms::MmsInformationReportCodec::try_decode( + report_payload, report, &decode_error)) { + throw std::runtime_error( + "BRCB InformationReport could not be decoded: " + decode_error); + } + const auto header = mms::MmsReportFrameMapper::decode_header(report); + if (header.report_id.empty()) { + throw std::runtime_error("BRCB InformationReport has no ReportID."); + } + if (header.entry_id.empty()) { + throw std::runtime_error("BRCB InformationReport has no EntryID."); + } + if (!header.optional_fields.has("entry-id")) { + throw std::runtime_error("BRCB InformationReport OptFlds does not select EntryID."); + } + + require_boolean_write(session.association(), domain, rpt_ena, false); + session.disconnect(); + std::cout << "MMS_BRCB_EVENT_PASS reference=" << domain << '/' << rcb + << " rptid=" << header.report_id + << " entry_id_bytes=" << header.entry_id.size() + << " access_results=" << report.items.size() << '\n'; + return 0; + } catch (const std::exception& exception) { + std::cerr << "MMS BRCB event probe failed: " << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/mms_direct_control_server/CMakeLists.txt b/tools/mms_direct_control_server/CMakeLists.txt index a44feb106..513898a38 100644 --- a/tools/mms_direct_control_server/CMakeLists.txt +++ b/tools/mms_direct_control_server/CMakeLists.txt @@ -38,6 +38,7 @@ add_library(ariec61850_mms_r12_server_core STATIC "${ARSTACK_ROOT}/src/mms/write_span.cpp" "${ARSTACK_ROOT}/src/mms/data_set_span.cpp" "${ARSTACK_ROOT}/src/mms/information_report_span.cpp" + "${ARSTACK_ROOT}/src/mms/buffered_selective_information_report_span.cpp" "${ARSTACK_ROOT}/src/mms/static_object_table.cpp" "${ARSTACK_ROOT}/src/mms/static_write.cpp" "${ARSTACK_ROOT}/src/mms/static_data_set_table.cpp" diff --git a/tools/mms_read_probe.cpp b/tools/mms_read_probe.cpp index bbdf42fc4..bc5d4b105 100644 --- a/tools/mms_read_probe.cpp +++ b/tools/mms_read_probe.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include namespace { namespace mms = ar::iec61850::mms; @@ -37,12 +39,125 @@ namespace mms = ar::iec61850::mms; : std::span{exchange.presentation_payload}; } +[[nodiscard]] std::string_view data_kind_name(const mms::MmsDataKind kind) noexcept { + switch (kind) { + case mms::MmsDataKind::array: return "array"; + case mms::MmsDataKind::structure: return "structure"; + case mms::MmsDataKind::boolean: return "boolean"; + case mms::MmsDataKind::bit_string: return "bit-string"; + case mms::MmsDataKind::integer: return "integer"; + case mms::MmsDataKind::unsigned_integer: return "unsigned"; + case mms::MmsDataKind::floating_point: return "floating-point"; + case mms::MmsDataKind::octet_string: return "octet-string"; + case mms::MmsDataKind::visible_string: return "visible-string"; + case mms::MmsDataKind::binary_time: return "binary-time"; + case mms::MmsDataKind::bcd: return "bcd"; + case mms::MmsDataKind::boolean_array: return "boolean-array"; + case mms::MmsDataKind::object_id: return "object-id"; + case mms::MmsDataKind::mms_string: return "mms-string"; + case mms::MmsDataKind::utc_time: return "utc-time"; + case mms::MmsDataKind::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::string_view type_kind_name(const mms::MmsTypeKind kind) noexcept { + switch (kind) { + case mms::MmsTypeKind::array: return "array"; + case mms::MmsTypeKind::structure: return "structure"; + case mms::MmsTypeKind::boolean: return "boolean"; + case mms::MmsTypeKind::bit_string: return "bit-string"; + case mms::MmsTypeKind::integer: return "integer"; + case mms::MmsTypeKind::unsigned_integer: return "unsigned"; + case mms::MmsTypeKind::floating_point: return "floating-point"; + case mms::MmsTypeKind::octet_string: return "octet-string"; + case mms::MmsTypeKind::visible_string: return "visible-string"; + case mms::MmsTypeKind::binary_time: return "binary-time"; + case mms::MmsTypeKind::bcd: return "bcd"; + case mms::MmsTypeKind::boolean_array: return "boolean-array"; + case mms::MmsTypeKind::object_id: return "object-id"; + case mms::MmsTypeKind::mms_string: return "mms-string"; + case mms::MmsTypeKind::utc_time: return "utc-time"; + case mms::MmsTypeKind::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::string type_shape(const mms::MmsTypeSpecification& type) { + std::string result{type_kind_name(type.kind)}; + if (type.kind != mms::MmsTypeKind::array && + type.kind != mms::MmsTypeKind::structure) { + return result; + } + result.push_back('('); + for (std::size_t index = 0U; index < type.children.size(); ++index) { + if (index != 0U) result.push_back(','); + result += type_shape(type.children[index]); + } + result.push_back(')'); + return result; +} + +void collect_type_bit_widths( + const mms::MmsTypeSpecification& type, + std::vector& widths) { + if (type.kind == mms::MmsTypeKind::bit_string && type.size.has_value()) { + widths.push_back(*type.size); + } + for (const auto& child : type.children) { + collect_type_bit_widths(child, widths); + } +} + +void collect_data_bit_widths( + const mms::MmsDataValue& value, + std::vector& widths) { + if (value.kind() == mms::MmsDataKind::bit_string) { + const auto& raw = value.raw_value(); + if (!raw.empty()) { + const auto storage_bits = + static_cast((raw.size() - 1U) * 8U); + const auto unused = static_cast(raw.front()); + if (unused <= storage_bits) widths.push_back(storage_bits - unused); + } + } + for (const auto& child : value.children()) { + collect_data_bit_widths(child, widths); + } +} + +[[nodiscard]] std::string width_list(const std::vector& widths) { + std::string result; + for (std::size_t index = 0U; index < widths.size(); ++index) { + if (index != 0U) result.push_back(','); + result += std::to_string(widths[index]); + } + return result; +} + +[[nodiscard]] std::string data_shape(const mms::MmsDataValue& value) { + std::string result{data_kind_name(value.kind())}; + if (value.kind() != mms::MmsDataKind::array && + value.kind() != mms::MmsDataKind::structure) { + return result; + } + result.push_back('('); + for (std::size_t index = 0U; index < value.children().size(); ++index) { + if (index != 0U) result.push_back(','); + result += data_shape(value.children()[index]); + } + result.push_back(')'); + return result; +} + void print_usage() { std::cout << "Usage: ariec61850_mms_read_probe [port] --domain NAME --item NAME [options]\n\n" << "Options:\n" << " --count N Read repeatedly on one MMS association (default 1).\n" + << " --with-type Read GetVariableAccessAttributes and print type shape.\n" << " --delay-ms N Delay between reads (default 500).\n" + << " --signal-after-first PATH Create PATH after Read #1 completes.\n" << " --timeout-ms N Connect/request timeout (default 5000).\n" << " -h, --help Show this help.\n"; } @@ -67,14 +182,20 @@ int main(const int argc, char** argv) { std::string domain; std::string item; std::size_t count{1U}; + bool with_type{}; std::chrono::milliseconds delay{500}; std::chrono::milliseconds timeout{5'000}; + std::string signal_after_first; while (argument < argc) { const std::string option = argv[argument++]; if (option == "--help" || option == "-h") { print_usage(); return 0; } + if (option == "--with-type") { + with_type = true; + continue; + } if (argument >= argc) throw std::invalid_argument(option + " requires a value."); const std::string value = argv[argument++]; if (option == "--domain") { @@ -86,6 +207,11 @@ int main(const int argc, char** argv) { } else if (option == "--delay-ms") { delay = std::chrono::milliseconds{static_cast( parse_size(option, value, 60'000U))}; + } else if (option == "--signal-after-first") { + signal_after_first = value; + if (signal_after_first.empty()) { + throw std::invalid_argument("--signal-after-first requires a non-empty path."); + } } else if (option == "--timeout-ms") { timeout = std::chrono::milliseconds{static_cast( parse_size(option, value, 120'000U))}; @@ -102,6 +228,31 @@ int main(const int argc, char** argv) { association_options.request_timeout = timeout; mms::MmsTcpLiveDiscoverySession session{{}, association_options}; session.connect(endpoint); + + std::string type_shape_text; + std::string type_bit_widths_text; + if (with_type) { + const auto invoke_id = session.association().next_invoke_id(); + mms::MmsVariableAccessAttributesRequest request; + request.invoke_id = invoke_id; + request.name = mms::MmsObjectName::domain_specific(domain, item); + const auto encoded = + mms::MmsServiceCodec::encode_variable_access_attributes_request_p_data( + request, session.association().negotiated().presentation_context_id); + const auto exchange = session.association().exchange_confirmed(encoded, invoke_id); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error( + "GetVariableAccessAttributes did not return Confirmed-Response."); + } + const auto response = + mms::MmsServiceCodec::decode_variable_access_attributes_response( + response_payload(exchange), invoke_id); + type_shape_text = type_shape(response.type); + std::vector widths; + collect_type_bit_widths(response.type, widths); + type_bit_widths_text = width_list(widths); + } + for (std::size_t index = 0U; index < count; ++index) { const auto invoke_id = session.association().next_invoke_id(); mms::MmsReadRequest request; @@ -118,12 +269,35 @@ int main(const int argc, char** argv) { if (response.results.size() != 1U || !response.results[0].success()) { throw std::runtime_error("Read returned a failed AccessResult."); } + const auto& value = *response.results[0].value; std::cout << "MMS_READ index=" << (index + 1U) - << " reference=" << domain << '/' << item - << " value=" << mms::MmsDataCodec::to_display_string( - *response.results[0].value) - << '\n'; + << " reference=" << domain << '/' << item; + if (with_type) { + std::cout << " type=" << type_shape_text; + if (!type_bit_widths_text.empty()) { + std::cout << " type_bit_widths=" << type_bit_widths_text; + } + } + std::vector data_widths; + collect_data_bit_widths(value, data_widths); + std::cout << " shape=" << data_shape(value); + if (!data_widths.empty()) { + std::cout << " data_bit_widths=" << width_list(data_widths); + } + std::cout << " value=" << mms::MmsDataCodec::to_display_string(value) + << '\n' << std::flush; std::cout.flush(); + if (index == 0U && !signal_after_first.empty()) { + std::ofstream signal{signal_after_first, std::ios::binary | std::ios::trunc}; + if (!signal) { + throw std::runtime_error("Unable to create --signal-after-first file."); + } + signal << "read-1\n"; + signal.flush(); + if (!signal) { + throw std::runtime_error("Unable to flush --signal-after-first file."); + } + } if (index + 1U < count) std::this_thread::sleep_for(delay); } session.disconnect(); diff --git a/tools/mms_sgcb_probe.cpp b/tools/mms_sgcb_probe.cpp new file mode 100644 index 000000000..5fe0b4a96 --- /dev/null +++ b/tools/mms_sgcb_probe.cpp @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace mms = ar::iec61850::mms; + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) { + return exchange.presentation_payload.empty() + ? exchange.envelope.mms_payload + : std::span{exchange.presentation_payload}; +} + +[[nodiscard]] std::uint64_t parse_u64( + const std::string& option, + const std::string& text, + const std::uint64_t maximum) { + std::size_t consumed{}; + const auto value = std::stoull(text, &consumed, 10); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument(option + " is outside the supported range."); + } + return value; +} + +[[nodiscard]] mms::MmsDataValue read_one( + mms::MmsAssociationRuntime& association, + const std::string& domain, + const std::string& item) { + mms::MmsReadRequest request; + request.invoke_id = association.next_invoke_id(); + request.variables.push_back(mms::MmsObjectName::domain_specific(domain, item)); + const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, request.invoke_id); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("SGCB Read did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_read_response( + response_payload(exchange), request.invoke_id); + if (response.results.size() != 1U || !response.results.front().success()) { + throw std::runtime_error("SGCB Read returned failed AccessResult for " + item + '.'); + } + return *response.results.front().value; +} + +[[nodiscard]] std::uint64_t as_unsigned( + const mms::MmsDataValue& value, + const std::string_view label) { + if (value.kind() == mms::MmsDataKind::unsigned_integer) { + return std::get(value.value()); + } + if (value.kind() == mms::MmsDataKind::integer) { + const auto parsed = std::get(value.value()); + if (parsed >= 0) return static_cast(parsed); + } + throw std::runtime_error(std::string{label} + " is not an unsigned/integer scalar."); +} + +[[nodiscard]] bool as_bool(const mms::MmsDataValue& value, const std::string_view label) { + if (value.kind() != mms::MmsDataKind::boolean) { + throw std::runtime_error(std::string{label} + " is not Boolean."); + } + return std::get(value.value()); +} + +[[nodiscard]] bool write_actsg( + mms::MmsAssociationRuntime& association, + const std::string& domain, + const std::string& item, + const std::uint64_t group) { + mms::MmsWriteRequest request; + request.invoke_id = association.next_invoke_id(); + request.variables.push_back(mms::MmsObjectName::domain_specific(domain, item)); + request.values.push_back(mms::MmsDataValue::unsigned_integer(group)); + const auto encoded = mms::MmsServiceCodec::encode_write_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, request.invoke_id); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("ActSG Write did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_write_response( + response_payload(exchange), request.invoke_id); + if (response.results.size() != 1U) { + throw std::runtime_error("ActSG Write response cardinality mismatch."); + } + return response.all_success(); +} + +void usage() { + std::cout + << "Usage: ariec61850_mms_sgcb_probe [port] --domain D --root ITEM [options]\n" + << " --expect-num N Require NumOfSG.\n" + << " --expect-act N Require initial ActSG.\n" + << " --activate N Write ActSG=N and verify it.\n" + << " --reject N Require ActSG=N to be rejected and state unchanged.\n" + << " --timeout-ms N Connect/request timeout, default 5000.\n"; +} +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + usage(); + return argc < 2 ? 2 : 0; + } + mms::MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.rfind("--", 0U) != 0U) { + endpoint.port = static_cast( + parse_u64("port", argv[argument++], 65'535U)); + } + std::string domain; + std::string root; + std::optional expected_num; + std::optional expected_act; + std::optional activate; + std::optional reject; + std::chrono::milliseconds timeout{5'000}; + 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 == "--domain") domain = value; + else if (option == "--root") root = value; + else if (option == "--expect-num") expected_num = parse_u64(option, value, 255U); + else if (option == "--expect-act") expected_act = parse_u64(option, value, 255U); + else if (option == "--activate") activate = parse_u64(option, value, 255U); + else if (option == "--reject") reject = parse_u64(option, value, 255U); + else if (option == "--timeout-ms") { + timeout = std::chrono::milliseconds{ + static_cast(parse_u64(option, value, 120'000U))}; + } else throw std::invalid_argument("Unknown option: " + option); + } + if (domain.empty() || root.empty()) { + throw std::invalid_argument("--domain and --root are required."); + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = timeout; + association_options.request_timeout = timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + + mms::MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = false; + discovery_options.probe_report_controls = false; + const auto discovered = session.discover(discovery_options); + const auto found_domain = discovered.names.domain_variables.find(domain); + if (found_domain == discovered.names.domain_variables.end()) { + throw std::runtime_error("SGCB domain missing from GetNameList discovery."); + } + const std::vector suffixes{ + "ActSG", "CnfEdit", "EditSG", "LActTm", "NumOfSG"}; + for (const auto& suffix : suffixes) { + const auto exact = root + '$' + suffix; + if (std::find(found_domain->second.begin(), found_domain->second.end(), exact) == + found_domain->second.end()) { + throw std::runtime_error("GetNameList missing exact SGCB attribute: " + exact); + } + } + + const auto act_item = root + "$ActSG"; + const auto act = as_unsigned(read_one(session.association(), domain, act_item), "ActSG"); + const auto count = as_unsigned( + read_one(session.association(), domain, root + "$NumOfSG"), "NumOfSG"); + const auto edit = as_unsigned( + read_one(session.association(), domain, root + "$EditSG"), "EditSG"); + const auto confirm = as_bool( + read_one(session.association(), domain, root + "$CnfEdit"), "CnfEdit"); + const auto last = read_one(session.association(), domain, root + "$LActTm"); + if (last.kind() != mms::MmsDataKind::utc_time) { + throw std::runtime_error("LActTm is not IEC 61850 UTC time."); + } + if (edit != 0U || confirm) { + throw std::runtime_error("Simulator SGCB unexpectedly advertises active edit transaction."); + } + if (expected_num && count != *expected_num) { + throw std::runtime_error("NumOfSG verification mismatch."); + } + if (expected_act && act != *expected_act) { + throw std::runtime_error("Initial ActSG verification mismatch."); + } + + std::uint64_t final_act = act; + if (activate) { + if (!write_actsg(session.association(), domain, act_item, *activate)) { + throw std::runtime_error("Expected ActSG activation was rejected."); + } + final_act = as_unsigned( + read_one(session.association(), domain, act_item), "ActSG verification"); + if (final_act != *activate) { + throw std::runtime_error("ActSG verification Read did not observe requested group."); + } + } + if (reject) { + const auto before = as_unsigned( + read_one(session.association(), domain, act_item), "ActSG before reject"); + if (write_actsg(session.association(), domain, act_item, *reject)) { + throw std::runtime_error("Expected invalid ActSG Write to be rejected."); + } + final_act = as_unsigned( + read_one(session.association(), domain, act_item), "ActSG after reject"); + if (final_act != before) { + throw std::runtime_error("Rejected ActSG Write changed canonical state."); + } + } + + std::cout << "SGCB_PROBE_PASS discovery=5/5 num=" << count + << " initial=" << act << " final=" << final_act + << " edit=0 cnf=false" + << (activate ? " activation=verified" : "") + << (reject ? " rejection=verified" : "") << '\n'; + session.disconnect(); + return 0; + } catch (const std::exception& error) { + std::cerr << "SGCB_PROBE_FAIL " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/mms_type_probe.cpp b/tools/mms_type_probe.cpp new file mode 100644 index 000000000..36ffa62f0 --- /dev/null +++ b/tools/mms_type_probe.cpp @@ -0,0 +1,131 @@ +// 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 + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +[[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); +} + +void json_string(const std::string_view value) { + std::cout << '"'; + for (const auto ch : value) { + if (ch == '"' || ch == '\\') { + std::cout << '\\' << ch; + } else if (ch == '\n') { + std::cout << "\\n"; + } else if (ch == '\r') { + std::cout << "\\r"; + } else if (ch == '\t') { + std::cout << "\\t"; + } else { + std::cout << ch; + } + } + std::cout << '"'; +} + +void write_type(const MmsTypeSpecification& type) { + std::cout << "{\"name\":"; + json_string(type.name); + std::cout << ",\"kind\":"; + json_string(type.mms_type_name()); + std::cout << ",\"size\":"; + if (type.size) std::cout << *type.size; + else std::cout << "null"; + std::cout << ",\"variableLength\":" + << (type.variable_length ? "true" : "false") + << ",\"children\":["; + for (std::size_t index = 0U; index < type.children.size(); ++index) { + if (index != 0U) std::cout << ','; + write_type(type.children[index]); + } + std::cout << "]}"; +} + +void usage() { + std::cout + << "Usage: ariec61850_mms_type_probe [port] " + "--domain DOMAIN --item ITEM\n" + << "Read-only: issues one GetVariableAccessAttributes request and prints " + "the ordered TypeSpecification as JSON.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + 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 domain; + std::string item; + 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 == "--domain") domain = value; + else if (option == "--item") item = value; + else throw std::invalid_argument("Unknown option: " + option); + } + if (domain.empty() || item.empty()) { + throw std::invalid_argument("--domain and --item are required."); + } + + MmsAssociationOptions association_options; + association_options.connect_timeout = std::chrono::milliseconds{5'000}; + association_options.request_timeout = std::chrono::milliseconds{5'000}; + MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + MmsAssociationControlTransport transport{session.association()}; + + const auto specification = transport.variable_specification( + MmsObjectName::domain_specific(domain, item)); + if (!specification) { + throw std::runtime_error("GetVariableAccessAttributes returned no TypeSpecification."); + } + + std::cout << "{\"domain\":"; + json_string(domain); + std::cout << ",\"item\":"; + json_string(item); + std::cout << ",\"type\":"; + write_type(*specification); + std::cout << "}\n"; + session.disconnect(); + return 0; + } catch (const std::exception& exception) { + std::cerr << "MMS type probe failed: " << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/mms_urcb_gi_probe.cpp b/tools/mms_urcb_gi_probe.cpp new file mode 100644 index 000000000..45659ce17 --- /dev/null +++ b/tools/mms_urcb_gi_probe.cpp @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace mms = ar::iec61850::mms; + +[[nodiscard]] std::size_t parse_size( + const std::string& option, + const std::string& text, + const std::size_t maximum) { + std::size_t consumed{}; + const auto value = std::stoull(text, &consumed, 10); + if (consumed != text.size() || value == 0U || value > maximum) { + throw std::invalid_argument(option + " is outside the supported range."); + } + return static_cast(value); +} + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) { + return exchange.presentation_payload.empty() + ? exchange.envelope.mms_payload + : std::span{exchange.presentation_payload}; +} + +[[nodiscard]] std::string_view data_kind_name(const mms::MmsDataKind kind) noexcept { + switch (kind) { + case mms::MmsDataKind::array: return "array"; + case mms::MmsDataKind::structure: return "structure"; + case mms::MmsDataKind::boolean: return "boolean"; + case mms::MmsDataKind::bit_string: return "bit-string"; + case mms::MmsDataKind::integer: return "integer"; + case mms::MmsDataKind::unsigned_integer: return "unsigned"; + case mms::MmsDataKind::floating_point: return "floating-point"; + case mms::MmsDataKind::octet_string: return "octet-string"; + case mms::MmsDataKind::visible_string: return "visible-string"; + case mms::MmsDataKind::binary_time: return "binary-time"; + case mms::MmsDataKind::bcd: return "bcd"; + case mms::MmsDataKind::boolean_array: return "boolean-array"; + case mms::MmsDataKind::object_id: return "object-id"; + case mms::MmsDataKind::mms_string: return "mms-string"; + case mms::MmsDataKind::utc_time: return "utc-time"; + case mms::MmsDataKind::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::string data_shape(const mms::MmsDataValue& value) { + std::string result{data_kind_name(value.kind())}; + if (value.kind() != mms::MmsDataKind::array && + value.kind() != mms::MmsDataKind::structure) { + return result; + } + result.push_back('('); + for (std::size_t index = 0U; index < value.children().size(); ++index) { + if (index != 0U) result.push_back(','); + result += data_shape(value.children()[index]); + } + result.push_back(')'); + return result; +} + +void require_boolean_write( + mms::MmsAssociationRuntime& association, + const std::string& domain, + const std::string& item, + const bool value) { + const auto invoke_id = association.next_invoke_id(); + mms::MmsWriteRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(mms::MmsObjectName::domain_specific(domain, item)); + request.values.push_back(mms::MmsDataValue::boolean(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); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("RCB Boolean write did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_write_response( + response_payload(exchange), invoke_id); + if (response.results.size() != 1U || !response.results.front().success) { + const auto failure = response.results.size() == 1U + ? response.results.front().failure_code + : std::nullopt; + throw std::runtime_error( + "RCB Boolean write was rejected" + + (failure ? " (DataAccessError=" + std::to_string(*failure) + ")." : ".")); + } +} + +void print_usage() { + std::cout + << "Usage: ariec61850_mms_urcb_gi_probe [port] --domain NAME --rcb ITEM [options]\n\n" + << "Options:\n" + << " --timeout-ms N Connect/request/report timeout (default 5000).\n" + << " -h, --help Show this help.\n\n" + << "The probe enables one URCB, requests GI on the same association, and\n" + << "requires an unsolicited MMS InformationReport before disabling it.\n"; +} + +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + print_usage(); + return argc < 2 ? 2 : 0; + } + + mms::MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.rfind("--", 0U) != 0U) { + endpoint.port = static_cast(parse_size( + "port", argv[argument++], 65'535U)); + } + + std::string domain; + std::string rcb; + std::chrono::milliseconds timeout{5'000}; + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") { + print_usage(); + return 0; + } + if (argument >= argc) { + throw std::invalid_argument(option + " requires a value."); + } + const std::string value = argv[argument++]; + if (option == "--domain") { + domain = value; + } else if (option == "--rcb") { + rcb = value; + } else if (option == "--timeout-ms") { + timeout = std::chrono::milliseconds{static_cast( + parse_size(option, value, 120'000U))}; + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + if (domain.empty() || rcb.empty()) { + throw std::invalid_argument("--domain and --rcb are required."); + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = timeout; + association_options.request_timeout = timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + + const auto rpt_ena = rcb + "$RptEna"; + const auto gi = rcb + "$GI"; + require_boolean_write(session.association(), domain, rpt_ena, true); + require_boolean_write(session.association(), domain, gi, true); + + std::vector report_payload; + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline && report_payload.empty()) { + if (session.association().try_pop_information_report(report_payload)) break; + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + if (remaining <= std::chrono::milliseconds::zero()) break; + mms::MmsPduEnvelope envelope; + static_cast(session.association().try_poll_once_for( + std::min(remaining, std::chrono::milliseconds{500}), envelope)); + static_cast(session.association().try_pop_information_report(report_payload)); + } + if (report_payload.empty()) { + throw std::runtime_error("GI did not produce an MMS InformationReport."); + } + + mms::MmsInformationReport report; + std::string decode_error; + if (!mms::MmsInformationReportCodec::try_decode( + report_payload, report, &decode_error)) { + throw std::runtime_error( + "GI InformationReport could not be decoded: " + decode_error); + } + if (report.items.empty()) { + throw std::runtime_error("GI InformationReport contains no AccessResults."); + } + const auto frame = mms::MmsReportFrameMapper::map(report, {}); + if (frame.header.report_id.empty()) { + throw std::runtime_error("GI InformationReport has no ReportID."); + } + if (frame.values.empty() || !frame.values.front().value) { + throw std::runtime_error("GI InformationReport contains no process values."); + } + const auto all_values_present = std::all_of( + frame.values.begin(), frame.values.end(), + [](const mms::MmsReportValue& value) { + return value.value.has_value(); + }); + if (!all_values_present) { + throw std::runtime_error( + "GI InformationReport contains a failed process AccessResult."); + } + const auto first_value_shape = data_shape(*frame.values.front().value); + const auto first_value_display = + mms::MmsDataCodec::to_display_string(*frame.values.front().value); + const auto uniform_value_shape = std::all_of( + frame.values.begin(), frame.values.end(), + [&first_value_shape](const mms::MmsReportValue& value) { + return data_shape(*value.value) == first_value_shape; + }); + + require_boolean_write(session.association(), domain, rpt_ena, false); + session.disconnect(); + std::cout << "MMS_URCB_GI_PASS reference=" << domain << '/' << rcb + << " rptid=" << frame.header.report_id + << " access_results=" << report.items.size() + << " report_values=" << frame.values.size() + << " first_value_shape=" << first_value_shape + << " first_value=" << first_value_display + << " all_values_present=true" + << " uniform_value_shape=" + << (uniform_value_shape ? "true" : "false") << '\n'; + return 0; + } catch (const std::exception& exception) { + std::cerr << "MMS URCB GI probe failed: " << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/mms_urcb_server.cpp b/tools/mms_urcb_server.cpp index 54889957b..1443e5512 100644 --- a/tools/mms_urcb_server.cpp +++ b/tools/mms_urcb_server.cpp @@ -371,32 +371,6 @@ struct EncodedValue final { std::span bytes; }; })); } -[[nodiscard]] std::vector build_lln0_type() { - return mms::MmsServiceCodec::encode_type_specification(structure("", { - structure("RP", { - structure("Outputs01", { - scalar(mms::MmsTypeKind::visible_string, "RptID", 255U), - scalar(mms::MmsTypeKind::boolean, "RptEna"), - scalar(mms::MmsTypeKind::boolean, "Resv"), - scalar(mms::MmsTypeKind::visible_string, "DatSet", 255U), - scalar(mms::MmsTypeKind::unsigned_integer, "ConfRev", 32U), - scalar(mms::MmsTypeKind::bit_string, "OptFlds", 10U), - scalar(mms::MmsTypeKind::unsigned_integer, "BufTm", 32U), - scalar(mms::MmsTypeKind::bit_string, "TrgOps", 6U), - scalar(mms::MmsTypeKind::unsigned_integer, "IntgPd", 32U), - scalar(mms::MmsTypeKind::boolean, "GI"), - scalar(mms::MmsTypeKind::unsigned_integer, "SqNum", 32U), - }), - }), - })); -} - -[[nodiscard]] wire::EncodeResult read_unavailable( - const void*, - const std::span) noexcept { - return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; -} - [[nodiscard]] bool serve_client( SocketHandle client, const mms::MmsStaticApplicationDispatcher& dispatcher, @@ -463,19 +437,17 @@ int main(int argc, char** argv) { constexpr std::string_view domain{"ESP32S3IOLD0"}; constexpr std::array boolean_type{0x83U, 0x00U}; - const auto lln0_type = build_lln0_type(); const auto ggio_type = build_ggio_type(); const EncodedValue ggio_root{ggio_type}; std::array values{}; std::array status_items{}; - std::array base_objects{}; - base_objects[0] = {domain, "LLN0", lln0_type, read_unavailable, nullptr}; - base_objects[1] = {domain, "GGIO1", ggio_type, read_encoded, &ggio_root}; + std::array base_objects{}; + base_objects[0] = {domain, "GGIO1", ggio_type, read_encoded, &ggio_root}; for (std::size_t index = 0U; index < 8U; ++index) { values[index] = static_cast((options.output_mask >> index) & 0x01U); status_items[index] = "GGIO1$ST$SPCSO" + std::to_string(index + 1U) + "$stVal"; - base_objects[index + 2U] = { + base_objects[index + 1U] = { domain, status_items[index], boolean_type, read_boolean, &values[index] }; } @@ -502,7 +474,7 @@ int main(int argc, char** argv) { if (!urcbs.initialize()) return 4; std::array object_storage{}; - std::array context_storage{}; + std::array context_storage{}; std::array name_storage{}; mms::MmsStaticUrcbObjectBank object_bank{ urcbs, base_objects, object_storage, context_storage, name_storage, diff --git a/tools/p0_2_runtime_fixture_patch.py b/tools/p0_2_runtime_fixture_patch.py new file mode 100644 index 000000000..f2378b1c0 --- /dev/null +++ b/tools/p0_2_runtime_fixture_patch.py @@ -0,0 +1,18 @@ +from pathlib import Path + +path = Path("tests/test_mms_runtime.cpp") +text = path.read_text() +old = ''' add(mms::MmsDataValue::bit_string(7U, inclusion)); + add(mms::MmsDataValue::boolean(true)); + add(mms::MmsDataValue::visible_string("LD0/PTOC1.Str.stVal")); + add(mms::MmsDataValue::bit_string(2U, reason)); +''' +new = ''' add(mms::MmsDataValue::bit_string(7U, inclusion)); + add(mms::MmsDataValue::visible_string("LD0/PTOC1.Str.stVal")); + add(mms::MmsDataValue::boolean(true)); + add(mms::MmsDataValue::bit_string(2U, reason)); +''' +count = text.count(old) +if count != 1: + raise SystemExit(f"expected exactly one legacy report fixture layout, found {count}") +path.write_text(text.replace(old, new, 1)) diff --git a/tools/static_file_service_backend.hpp b/tools/static_file_service_backend.hpp new file mode 100644 index 000000000..9952e8465 --- /dev/null +++ b/tools/static_file_service_backend.hpp @@ -0,0 +1,567 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/pdu.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::host { + +class StaticFileServiceRoot final { +public: + StaticFileServiceRoot(std::filesystem::path root, const bool allow_delete) + : allow_delete_{allow_delete} { + std::error_code error; + root_ = std::filesystem::weakly_canonical(std::move(root), error); + if (error || root_.empty() || !std::filesystem::is_directory(root_, error) || error) { + throw std::invalid_argument("MMS file root must resolve to an existing directory."); + } + } + + [[nodiscard]] const std::filesystem::path& root() const noexcept { return root_; } + [[nodiscard]] bool allow_delete() const noexcept { return allow_delete_; } + +private: + friend class StaticFileServiceSession; + std::filesystem::path root_; + bool allow_delete_{}; + std::mutex mutex_; + std::unordered_map open_counts_; +}; + +class StaticFileServiceSession final { +public: + static constexpr std::size_t maximum_path_bytes = 512U; + static constexpr std::size_t maximum_directory_entries = 4'096U; + static constexpr std::size_t directory_page_entries = 64U; + static constexpr std::size_t maximum_open_handles = 16U; + static constexpr std::size_t read_block_bytes = 24U * 1024U; + static constexpr std::uint64_t maximum_file_bytes = 512ULL * 1024ULL * 1024ULL; + static constexpr std::size_t replay_capacity = 16U; + static constexpr std::int32_t file_error_class = 11; + static constexpr std::uint32_t file_error_other = 0U; + static constexpr std::uint32_t file_error_busy = 2U; + static constexpr std::uint32_t file_error_filename_syntax = 3U; + static constexpr std::uint32_t file_error_position_invalid = 5U; + static constexpr std::uint32_t file_error_access_denied = 6U; + static constexpr std::uint32_t file_error_nonexistent = 7U; + + explicit StaticFileServiceSession(StaticFileServiceRoot& root) noexcept : root_{root} {} + + StaticFileServiceSession(const StaticFileServiceSession&) = delete; + StaticFileServiceSession& operator=(const StaticFileServiceSession&) = delete; + + ~StaticFileServiceSession() { close_all(); } + + [[nodiscard]] static mms::MmsStaticConfirmedServiceExtensionResult callback( + void* context, + const mms::MmsWireConfirmedService service, + const std::uint32_t invoke_id, + const bool service_constructed, + const std::span service_value, + const std::span response) noexcept { + if (context == nullptr) return {}; + return static_cast(context)->dispatch( + service, invoke_id, service_constructed, service_value, response); + } + + [[nodiscard]] mms::MmsStaticConfirmedServiceExtensionResult dispatch( + const mms::MmsWireConfirmedService service, + const std::uint32_t invoke_id, + const bool service_constructed, + const std::span service_value, + const std::span response) noexcept { + if (!is_file_service(service)) return {}; + try { + if (const auto* replay = find_replay( + service, invoke_id, service_constructed, service_value)) { + return copy_response(replay->response, response); + } + + std::vector encoded; + switch (service) { + case mms::MmsWireConfirmedService::file_directory: + encoded = directory(invoke_id, service_constructed, service_value); + break; + case mms::MmsWireConfirmedService::file_open: + encoded = open(invoke_id, service_constructed, service_value); + break; + case mms::MmsWireConfirmedService::file_read: + encoded = read(invoke_id, service_constructed, service_value); + break; + case mms::MmsWireConfirmedService::file_close: + encoded = close(invoke_id, service_constructed, service_value); + break; + case mms::MmsWireConfirmedService::file_delete: + encoded = remove(invoke_id, service_constructed, service_value); + break; + default: + return {}; + } + remember(service, invoke_id, service_constructed, service_value, encoded); + return copy_response(encoded, response); + } catch (const FileFault& fault) { + return remember_error_and_copy( + service, invoke_id, service_constructed, service_value, + fault.code, response); + } catch (...) { + return remember_error_and_copy( + service, invoke_id, service_constructed, service_value, + file_error_other, response); + } + } + +private: + struct FileFault final : std::runtime_error { + FileFault(const std::uint32_t value, std::string message) + : std::runtime_error(std::move(message)), code{value} {} + std::uint32_t code{}; + }; + + struct Handle final { + std::int32_t id{}; + std::filesystem::path canonical_path; + std::ifstream stream; + std::uint64_t size{}; + std::uint64_t offset{}; + }; + + struct Replay final { + mms::MmsWireConfirmedService service{mms::MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + bool constructed{}; + std::vector request_value; + std::vector response; + }; + + [[nodiscard]] static bool is_file_service(const mms::MmsWireConfirmedService service) noexcept { + return service == mms::MmsWireConfirmedService::file_directory || + service == mms::MmsWireConfirmedService::file_open || + service == mms::MmsWireConfirmedService::file_read || + service == mms::MmsWireConfirmedService::file_close || + service == mms::MmsWireConfirmedService::file_delete; + } + + [[nodiscard]] static mms::MmsStaticConfirmedServiceExtensionResult copy_response( + const std::span encoded, + const std::span destination) noexcept { + if (destination.size() < encoded.size()) { + return {true, {wire::EncodeStatus::buffer_too_small, 0U, encoded.size()}}; + } + std::copy(encoded.begin(), encoded.end(), destination.begin()); + return {true, {wire::EncodeStatus::ok, encoded.size(), encoded.size()}}; + } + + [[nodiscard]] mms::MmsStaticConfirmedServiceExtensionResult remember_error_and_copy( + const mms::MmsWireConfirmedService service, + const std::uint32_t invoke_id, + const bool constructed, + const std::span request_value, + const std::uint32_t error_value, + const std::span response) noexcept { + try { + auto encoded = mms::MmsPduCodec::encode_confirmed_error( + {invoke_id, file_error_class, error_value}); + remember(service, invoke_id, constructed, request_value, encoded); + return copy_response(encoded, response); + } catch (...) { + return {true, {wire::EncodeStatus::value_out_of_range, 0U, 0U}}; + } + } + + [[nodiscard]] const Replay* find_replay( + const mms::MmsWireConfirmedService service, + const std::uint32_t invoke_id, + const bool constructed, + const std::span request_value) const noexcept { + const auto found = std::find_if(replays_.rbegin(), replays_.rend(), + [&](const Replay& replay) { + return replay.service == service && replay.invoke_id == invoke_id && + replay.constructed == constructed && replay.request_value.size() == request_value.size() && + std::equal(replay.request_value.begin(), replay.request_value.end(), request_value.begin()); + }); + return found == replays_.rend() ? nullptr : &*found; + } + + void remember( + const mms::MmsWireConfirmedService service, + const std::uint32_t invoke_id, + const bool constructed, + const std::span request_value, + const std::span response) { + if (replays_.size() >= replay_capacity) replays_.pop_front(); + Replay replay; + replay.service = service; + replay.invoke_id = invoke_id; + replay.constructed = constructed; + replay.request_value.assign(request_value.begin(), request_value.end()); + replay.response.assign(response.begin(), response.end()); + replays_.push_back(std::move(replay)); + } + + [[nodiscard]] static std::string decode_graphic_name( + const std::span bytes, + const bool allow_empty) { + std::vector parts; + collect_graphic(bytes, parts, 0U); + if (parts.empty()) { + if (allow_empty) return {}; + throw FileFault{file_error_filename_syntax, "FileName is missing."}; + } + std::string joined; + for (auto& part : parts) { + if (!joined.empty() && !part.empty()) joined.push_back('/'); + joined += part; + } + return normalize_remote(joined, allow_empty); + } + + static void collect_graphic( + const std::span bytes, + std::vector& parts, + const std::size_t depth) { + if (depth > 8U) throw FileFault{file_error_filename_syntax, "FileName nesting is too deep."}; + for (const auto& child : asn1::BerReader::read_children(bytes)) { + if (child.encoded_tag == 0x19U || child.encoded_tag == 0x1AU || child.encoded_tag == 0x16U) { + parts.push_back(asn1::BerReader::read_ascii_string(child)); + } else if (child.constructed) { + collect_graphic(child.value, parts, depth + 1U); + } + } + } + + [[nodiscard]] static std::string normalize_remote(std::string value, const bool allow_empty) { + if (value.size() > maximum_path_bytes) { + throw FileFault{file_error_filename_syntax, "FileName exceeds the server bound."}; + } + std::replace(value.begin(), value.end(), '\\', '/'); + while (!value.empty() && value.front() == '/') value.erase(value.begin()); + while (!value.empty() && value.back() == '/') value.pop_back(); + if (value.empty()) { + if (allow_empty) return {}; + throw FileFault{file_error_filename_syntax, "FileName is empty."}; + } + std::string normalized; + std::size_t begin{}; + while (begin <= value.size()) { + const auto end = value.find('/', begin); + const auto segment = value.substr( + begin, end == std::string::npos ? std::string::npos : end - begin); + if (segment.empty() || segment == "." || segment == ".." || + segment.find(':') != std::string::npos) { + throw FileFault{file_error_filename_syntax, "Unsafe FileName segment."}; + } + for (const char character : segment) { + if (static_cast(character) < 0x20U) { + throw FileFault{file_error_filename_syntax, "Control character in FileName."}; + } + } + if (!normalized.empty()) normalized.push_back('/'); + normalized += segment; + if (end == std::string::npos) break; + begin = end + 1U; + } + return normalized; + } + + [[nodiscard]] static bool within_root( + const std::filesystem::path& root, + const std::filesystem::path& candidate) noexcept { + auto root_it = root.begin(); + auto candidate_it = candidate.begin(); + for (; root_it != root.end(); ++root_it, ++candidate_it) { + if (candidate_it == candidate.end() || *root_it != *candidate_it) return false; + } + return true; + } + + [[nodiscard]] std::filesystem::path resolve( + const std::string& remote, + const bool allow_root) const { + const auto normalized = normalize_remote(remote, allow_root); + std::error_code error; + const auto joined = normalized.empty() + ? root_.root_ + : root_.root_ / std::filesystem::path{normalized}; + const auto canonical = std::filesystem::weakly_canonical(joined, error); + if (error || !within_root(root_.root_, canonical)) { + throw FileFault{file_error_access_denied, "FileName escapes the configured root."}; + } + return canonical; + } + + [[nodiscard]] static std::optional signed_integer_content( + const std::span bytes) noexcept { + if (bytes.empty() || bytes.size() > sizeof(std::int32_t)) return std::nullopt; + std::int64_t value = (bytes.front() & 0x80U) != 0U ? -1LL : 0LL; + for (const auto byte : bytes) value = (value << 8U) | static_cast(byte); + if (value < std::numeric_limits::min() || + value > std::numeric_limits::max()) return std::nullopt; + return static_cast(value); + } + + [[nodiscard]] static std::vector context_tlv( + const bool constructed, + const std::int32_t tag, + const std::span value) { + return asn1::BerWriter::encode_tlv(asn1::BerClass::context_specific, constructed, tag, value); + } + + [[nodiscard]] static std::vector graphic(const std::string& value) { + return asn1::BerWriter::encode_tlv(0x19U, asn1::BerWriter::encode_ascii(value)); + } + + [[nodiscard]] static std::vector concat( + const std::initializer_list> parts) { + std::vector result; + std::size_t total{}; + for (const auto part : parts) total += part.size(); + result.reserve(total); + for (const auto part : parts) result.insert(result.end(), part.begin(), part.end()); + return result; + } + + [[nodiscard]] static std::vector success( + const std::uint32_t invoke_id, + const mms::MmsWireConfirmedService service, + const bool constructed, + std::vector value) { + return mms::MmsPduCodec::encode_confirmed_response({ + invoke_id, static_cast(service), constructed, std::move(value)}); + } + + [[nodiscard]] std::vector directory( + const std::uint32_t invoke_id, + const bool constructed, + const std::span value) { + if (!constructed) throw FileFault{file_error_filename_syntax, "FileDirectory must be constructed."}; + std::string directory_name; + std::string continue_after; + for (const auto& field : asn1::BerReader::read_children(value)) { + if (field.tag_class != asn1::BerClass::context_specific || !field.constructed) continue; + if (field.tag_number == 0) directory_name = decode_graphic_name(field.value, true); + else if (field.tag_number == 1) continue_after = decode_graphic_name(field.value, true); + } + + const auto path = resolve(directory_name, true); + std::vector> entries; + { + std::scoped_lock lock{root_.mutex_}; + std::error_code error; + if (!std::filesystem::is_directory(path, error) || error) { + throw FileFault{file_error_nonexistent, "Directory does not exist."}; + } + for (const auto& entry : std::filesystem::directory_iterator(path, error)) { + if (error) throw FileFault{file_error_access_denied, "Directory iteration failed."}; + if (entries.size() >= maximum_directory_entries) { + throw FileFault{file_error_other, "Directory exceeds the server entry bound."}; + } + std::error_code type_error; + if (entry.is_symlink(type_error) || type_error) continue; + const auto name = entry.path().filename().generic_string(); + if (name.empty()) continue; + if (entry.is_directory(type_error) && !type_error) { + entries.emplace_back(name, 0U); + } else if (entry.is_regular_file(type_error) && !type_error) { + const auto size = entry.file_size(type_error); + if (!type_error && size <= maximum_file_bytes) entries.emplace_back(name, size); + } + } + } + std::sort(entries.begin(), entries.end(), [](const auto& left, const auto& right) { + return left.first < right.first; + }); + std::size_t start{}; + if (!continue_after.empty()) { + start = static_cast(std::distance( + entries.begin(), + std::upper_bound(entries.begin(), entries.end(), continue_after, + [](const std::string& key, const auto& entry) { return key < entry.first; }))); + } + const auto end = std::min(entries.size(), start + directory_page_entries); + std::vector entry_bytes; + for (std::size_t index = start; index < end; ++index) { + const auto name = graphic(entries[index].first); + const auto file_name = context_tlv(true, 0, name); + const auto encoded_size = asn1::BerWriter::encode_unsigned_integer(entries[index].second); + const auto size = context_tlv(false, 0, encoded_size); + const auto attrs = context_tlv(true, 1, size); + const auto item = asn1::BerWriter::encode_tlv(0x30U, concat({file_name, attrs})); + entry_bytes.insert(entry_bytes.end(), item.begin(), item.end()); + } + const auto sequence = asn1::BerWriter::encode_tlv(0x30U, entry_bytes); + const auto list = context_tlv(true, 0, sequence); + const auto more = context_tlv(false, 1, asn1::BerWriter::encode_boolean(end < entries.size())); + return success(invoke_id, mms::MmsWireConfirmedService::file_directory, true, concat({list, more})); + } + + [[nodiscard]] std::vector open( + const std::uint32_t invoke_id, + const bool constructed, + const std::span value) { + if (!constructed) throw FileFault{file_error_filename_syntax, "FileOpen must be constructed."}; + std::string remote; + std::uint64_t initial_position{}; + bool have_name{}; + for (const auto& field : asn1::BerReader::read_children(value)) { + if (field.tag_class != asn1::BerClass::context_specific) continue; + if (field.tag_number == 0 && field.constructed) { + remote = decode_graphic_name(field.value, false); + have_name = true; + } else if (field.tag_number == 1 && !field.constructed) { + const auto parsed = asn1::BerReader::read_unsigned_integer(field); + if (!parsed) throw FileFault{file_error_position_invalid, "Invalid FileOpen initial position."}; + initial_position = *parsed; + } + } + if (!have_name) throw FileFault{file_error_filename_syntax, "FileOpen has no FileName."}; + if (handles_.size() >= maximum_open_handles) throw FileFault{file_error_busy, "Too many open files."}; + const auto path = resolve(remote, false); + std::error_code error; + if (!std::filesystem::is_regular_file(path, error) || error) { + throw FileFault{file_error_nonexistent, "File does not exist."}; + } + const auto size = std::filesystem::file_size(path, error); + if (error || size > maximum_file_bytes) throw FileFault{file_error_access_denied, "File size is unavailable or too large."}; + if (initial_position > size) throw FileFault{file_error_position_invalid, "Initial position is past end of file."}; + + Handle handle; + handle.id = allocate_frsm(); + handle.canonical_path = path; + handle.size = size; + handle.offset = initial_position; + handle.stream.open(path, std::ios::binary); + if (!handle.stream) throw FileFault{file_error_access_denied, "File cannot be opened."}; + handle.stream.seekg(static_cast(initial_position), std::ios::beg); + if (!handle.stream) throw FileFault{file_error_position_invalid, "Initial seek failed."}; + { + std::scoped_lock lock{root_.mutex_}; + ++root_.open_counts_[path.generic_string()]; + } + const auto id = handle.id; + handles_.emplace(id, std::move(handle)); + + const auto frsm = context_tlv(false, 0, asn1::BerWriter::encode_signed_integer(id)); + const auto encoded_size = context_tlv(false, 0, asn1::BerWriter::encode_unsigned_integer(size)); + const auto attrs = context_tlv(true, 1, encoded_size); + return success(invoke_id, mms::MmsWireConfirmedService::file_open, true, concat({frsm, attrs})); + } + + [[nodiscard]] std::vector read( + const std::uint32_t invoke_id, + const bool constructed, + const std::span value) { + if (constructed) throw FileFault{file_error_filename_syntax, "FileRead must be primitive."}; + const auto id = signed_integer_content(value); + if (!id) throw FileFault{file_error_filename_syntax, "Invalid FRSM identifier."}; + const auto found = handles_.find(*id); + if (found == handles_.end()) throw FileFault{file_error_nonexistent, "Unknown FRSM identifier."}; + auto& handle = found->second; + const auto remaining = handle.size - std::min(handle.offset, handle.size); + const auto wanted = static_cast(std::min(remaining, read_block_bytes)); + std::vector bytes(wanted); + if (wanted != 0U) { + handle.stream.read(reinterpret_cast(bytes.data()), static_cast(wanted)); + const auto count = handle.stream.gcount(); + if (count < 0) throw FileFault{file_error_access_denied, "FileRead failed."}; + bytes.resize(static_cast(count)); + handle.offset += static_cast(count); + if (static_cast(count) != wanted && handle.offset < handle.size) { + throw FileFault{file_error_access_denied, "FileRead ended before declared size."}; + } + } + const auto block = context_tlv(false, 0, bytes); + const auto more = context_tlv(false, 1, asn1::BerWriter::encode_boolean(handle.offset < handle.size)); + return success(invoke_id, mms::MmsWireConfirmedService::file_read, true, concat({block, more})); + } + + [[nodiscard]] std::vector close( + const std::uint32_t invoke_id, + const bool constructed, + const std::span value) { + if (constructed) throw FileFault{file_error_filename_syntax, "FileClose must be primitive."}; + const auto id = signed_integer_content(value); + if (!id) throw FileFault{file_error_filename_syntax, "Invalid FRSM identifier."}; + const auto found = handles_.find(*id); + if (found == handles_.end()) throw FileFault{file_error_nonexistent, "Unknown FRSM identifier."}; + release_open_count(found->second.canonical_path); + handles_.erase(found); + return success(invoke_id, mms::MmsWireConfirmedService::file_close, false, {}); + } + + [[nodiscard]] std::vector remove( + const std::uint32_t invoke_id, + const bool constructed, + const std::span value) { + if (!constructed) throw FileFault{file_error_filename_syntax, "FileDelete must be constructed."}; + if (!root_.allow_delete_) throw FileFault{file_error_access_denied, "FileDelete is disabled."}; + const auto remote = decode_graphic_name(value, false); + const auto path = resolve(remote, false); + std::scoped_lock lock{root_.mutex_}; + const auto opened = root_.open_counts_.find(path.generic_string()); + if (opened != root_.open_counts_.end() && opened->second != 0U) { + throw FileFault{file_error_busy, "File is currently open."}; + } + std::error_code error; + if (!std::filesystem::is_regular_file(path, error) || error) { + throw FileFault{file_error_nonexistent, "File does not exist."}; + } + if (!std::filesystem::remove(path, error) || error) { + throw FileFault{file_error_access_denied, "FileDelete failed."}; + } + return success(invoke_id, mms::MmsWireConfirmedService::file_delete, false, {}); + } + + [[nodiscard]] std::int32_t allocate_frsm() { + for (std::size_t attempt{}; attempt < maximum_open_handles + 1U; ++attempt) { + if (next_frsm_ <= 0) next_frsm_ = 1; + const auto candidate = next_frsm_++; + if (handles_.find(candidate) == handles_.end()) return candidate; + } + throw FileFault{file_error_busy, "No FRSM identifier is available."}; + } + + void release_open_count(const std::filesystem::path& path) noexcept { + try { + std::scoped_lock lock{root_.mutex_}; + const auto found = root_.open_counts_.find(path.generic_string()); + if (found == root_.open_counts_.end()) return; + if (found->second > 1U) --found->second; + else root_.open_counts_.erase(found); + } catch (...) { + } + } + + void close_all() noexcept { + for (const auto& [id, handle] : handles_) { + static_cast(id); + release_open_count(handle.canonical_path); + } + handles_.clear(); + } + + StaticFileServiceRoot& root_; + std::map handles_; + std::deque replays_; + std::int32_t next_frsm_{1}; +}; + +} // namespace ar::iec61850::host diff --git a/tools/static_file_service_backend_test.cpp b/tools/static_file_service_backend_test.cpp new file mode 100644 index 000000000..13d60c69e --- /dev/null +++ b/tools/static_file_service_backend_test.cpp @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "static_file_service_backend.hpp" +#include "ariec61850/mms/file_service.hpp" +#include "ariec61850/mms/pdu.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mms = ar::iec61850::mms; +namespace host = ar::iec61850::host; +namespace wire = ar::iec61850::wire; + +namespace { + +struct TemporaryDirectory final { + TemporaryDirectory() { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + path = std::filesystem::temp_directory_path() / + ("arstack-file-service-" + std::to_string(stamp)); + std::filesystem::create_directories(path / "nested"); + } + ~TemporaryDirectory() { + std::error_code error; + std::filesystem::remove_all(path, error); + } + std::filesystem::path path; +}; + +[[nodiscard]] bool expect(const bool condition, const char* message) { + if (!condition) std::cerr << "FAIL: " << message << '\n'; + return condition; +} + +[[nodiscard]] std::vector perform_exchange( + host::StaticFileServiceSession& session, + const std::vector& request, + const std::size_t capacity = 128U * 1024U) { + const auto decoded = mms::MmsPduCodec::decode_confirmed_request(request); + std::vector response(capacity); + const auto result = session.dispatch( + static_cast(decoded.service_tag), + decoded.invoke_id, + decoded.service_constructed, + decoded.service_value, + response); + if (!result.handled || !result.encoded.success()) return {}; + response.resize(result.encoded.bytes_written); + return response; +} + +[[nodiscard]] bool confirmed_file_error( + const std::vector& response, + const std::uint32_t invoke_id, + const std::uint32_t value) { + if (response.empty()) return false; + const auto envelope = mms::MmsPduCodec::decode_envelope(response); + if (envelope.kind != mms::MmsPduKind::confirmed_error) return false; + const auto error = mms::MmsPduCodec::decode_confirmed_error(envelope.mms_payload); + return error.invoke_id == invoke_id && + error.error_class_tag == host::StaticFileServiceSession::file_error_class && + error.error_value == value; +} + +} // namespace + +int main() { + try { + TemporaryDirectory temporary; + std::vector source(60'000U); + for (std::size_t index = 0U; index < source.size(); ++index) { + source[index] = static_cast((index * 17U + 3U) & 0xFFU); + } + { + std::ofstream output{temporary.path / "FRA00028.dat", std::ios::binary}; + output.write(reinterpret_cast(source.data()), + static_cast(source.size())); + } + { + std::ofstream output{temporary.path / "FRA00028.cfg", std::ios::binary}; + output << "CFG\n"; + } + + host::StaticFileServiceRoot shared{temporary.path, true}; + host::StaticFileServiceSession session{shared}; + + const auto directory_request = mms::MmsFileServiceCodec::encode_file_directory_request_pdu( + {1U, {}, {}}); + const auto directory_wire = perform_exchange(session, directory_request); + const auto directory = mms::MmsFileServiceCodec::decode_file_directory_response( + directory_wire, 1U, {}); + if (!expect(directory.entries.size() == 3U, "root directory must expose three entries") || + !expect(directory.entries[0].name == "FRA00028.cfg", "directory order must be deterministic") || + !expect(directory.entries[1].name == "FRA00028.dat", "data file must be present") || + !expect(directory.entries[1].size_bytes == source.size(), "directory size must match file") || + !expect(!directory.more_follows, "small directory must fit one page")) return 1; + + const auto open_request = mms::MmsFileServiceCodec::encode_file_open_request_pdu( + {2U, "FRA00028.dat", 0U, true}); + const auto open_wire = perform_exchange(session, open_request); + const auto opened = mms::MmsFileServiceCodec::decode_file_open_response(open_wire, 2U); + if (!expect(opened.file_size_bytes == source.size(), "FileOpen must report exact size") || + !expect(opened.frsm_id > 0, "FileOpen must allocate positive FRSM")) return 1; + + host::StaticFileServiceSession other_session{shared}; + const auto foreign_read_request = mms::MmsFileServiceCodec::encode_file_read_request_pdu( + {3U, opened.frsm_id}); + const auto foreign_read = perform_exchange(other_session, foreign_read_request); + if (!expect(confirmed_file_error( + foreign_read, 3U, host::StaticFileServiceSession::file_error_nonexistent), + "FRSM must be isolated per association")) return 1; + + std::vector reconstructed; + std::uint32_t invoke = 10U; + bool more = true; + while (more) { + const auto read_request = mms::MmsFileServiceCodec::encode_file_read_request_pdu( + {invoke, opened.frsm_id}); + const auto decoded_request = mms::MmsPduCodec::decode_confirmed_request(read_request); + if (invoke == 10U) { + std::array tiny{}; + const auto first = session.dispatch( + mms::MmsWireConfirmedService::file_read, + decoded_request.invoke_id, + decoded_request.service_constructed, + decoded_request.service_value, + tiny); + if (!expect(first.handled && + first.encoded.status == wire::EncodeStatus::buffer_too_small, + "FileRead capacity retry must report required size")) return 1; + } + const auto read_wire = perform_exchange(session, read_request); + const auto block = mms::MmsFileServiceCodec::decode_file_read_response(read_wire, invoke); + reconstructed.insert(reconstructed.end(), block.data.begin(), block.data.end()); + more = block.more_follows; + ++invoke; + } + if (!expect(reconstructed == source, + "capacity retry must not advance FileRead cursor twice")) return 1; + + const auto close_request = mms::MmsFileServiceCodec::encode_file_close_request_pdu( + {invoke++, opened.frsm_id}); + const auto close_wire = perform_exchange(session, close_request); + static_cast(mms::MmsFileServiceCodec::decode_file_close_response( + close_wire, invoke - 1U)); + + host::StaticFileServiceRoot read_only{temporary.path, false}; + host::StaticFileServiceSession read_only_session{read_only}; + const auto denied_request = mms::MmsFileServiceCodec::encode_file_delete_request_pdu( + {50U, "FRA00028.cfg", true}); + const auto denied = perform_exchange(read_only_session, denied_request); + if (!expect(confirmed_file_error( + denied, 50U, host::StaticFileServiceSession::file_error_access_denied), + "FileDelete must require explicit server opt-in") || + !expect(std::filesystem::exists(temporary.path / "FRA00028.cfg"), + "denied delete must not mutate storage")) return 1; + + const auto delete_request = mms::MmsFileServiceCodec::encode_file_delete_request_pdu( + {51U, "FRA00028.cfg", true}); + const auto decoded_delete = mms::MmsPduCodec::decode_confirmed_request(delete_request); + const auto capacity = session.dispatch( + mms::MmsWireConfirmedService::file_delete, + decoded_delete.invoke_id, + decoded_delete.service_constructed, + decoded_delete.service_value, + {}); + if (!expect(capacity.handled && + capacity.encoded.status == wire::EncodeStatus::buffer_too_small, + "FileDelete retry must cache successful destructive response") || + !expect(!std::filesystem::exists(temporary.path / "FRA00028.cfg"), + "first FileDelete execution must remove the file")) return 1; + const auto delete_wire = perform_exchange(session, delete_request); + static_cast(mms::MmsFileServiceCodec::decode_file_delete_response(delete_wire, 51U)); + + const auto refreshed_wire = perform_exchange(session, + mms::MmsFileServiceCodec::encode_file_directory_request_pdu({52U, {}, {}})); + const auto refreshed = mms::MmsFileServiceCodec::decode_file_directory_response( + refreshed_wire, 52U, {}); + if (!expect(refreshed.entries.size() == 2U, + "FileDirectory refresh must reflect successful delete")) return 1; + + std::cout << "HOST_FILE_SERVICE_PASS directory=pass open=pass read=pass close=pass " + "delete=pass retry_idempotent=pass frsm_isolation=pass sandbox=pass\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << "FAIL: exception: " << exception.what() << '\n'; + return 2; + } +} diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp index acab06897..8d1d42cbc 100644 --- a/tools/static_ied_server.cpp +++ b/tools/static_ied_server.cpp @@ -2,6 +2,21 @@ #include "ariec61850/mms/static_server_session.hpp" #include "ariec61850/mms/data_codec.hpp" #include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/simulator_manifest_codec.hpp" +#include "ariec61850/mms/static_direct_control.hpp" +#include "ariec61850/mms/static_setting_group.hpp" +#include "ariec61850/mms/utc_time.hpp" +#include "ariec61850/mms/static_brcb_connection.hpp" +#include "ariec61850/mms/static_brcb_control.hpp" +#include "ariec61850/mms/static_brcb_objects.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" +#include "ariec61850/mms/static_report_connection.hpp" +#include "ariec61850/mms/static_urcb_objects.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" +#include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/tpkt.hpp" +#include "static_file_service_backend.hpp" #include #include @@ -9,15 +24,17 @@ #include #include #include -#include #include +#include #include #include #include #include -#include #include #include +#include +#include +#include #include #include #include @@ -25,8 +42,10 @@ #include #include #include +#include #include #include +#include #include #if defined(_WIN32) @@ -35,10 +54,12 @@ #endif #include #include +#include #else #include #include #include +#include #include #include #include @@ -48,6 +69,7 @@ namespace { namespace embedded = ar::iec61850::embedded; namespace mms = ar::iec61850::mms; namespace wire = ar::iec61850::wire; +namespace filehost = ar::iec61850::host; std::atomic_bool g_stop{false}; @@ -85,9 +107,7 @@ enum class SocketWaitStatus : std::uint8_t { }; void close_socket(const NativeSocket socket) noexcept { - if (socket == kInvalidSocket) { - return; - } + if (socket == kInvalidSocket) return; #if defined(_WIN32) static_cast(::closesocket(socket)); #else @@ -111,6 +131,42 @@ void close_socket(const NativeSocket socket) noexcept { #endif } +[[nodiscard]] bool configure_low_latency_socket(const NativeSocket socket) noexcept { + int enabled = 1; +#if defined(_WIN32) + if (::setsockopt( + socket, + IPPROTO_TCP, + TCP_NODELAY, + reinterpret_cast(&enabled), + static_cast(sizeof(enabled))) != 0) { + return false; + } + int observed = 0; + int observed_size = static_cast(sizeof(observed)); + if (::getsockopt( + socket, + IPPROTO_TCP, + TCP_NODELAY, + reinterpret_cast(&observed), + &observed_size) != 0) { + return false; + } +#else + if (::setsockopt( + socket, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof(enabled)) != 0) { + return false; + } + int observed = 0; + socklen_t observed_size = static_cast(sizeof(observed)); + if (::getsockopt( + socket, IPPROTO_TCP, TCP_NODELAY, &observed, &observed_size) != 0) { + return false; + } +#endif + return observed != 0; +} + [[nodiscard]] SocketWaitStatus wait_socket( const NativeSocket socket, const bool for_read, @@ -119,11 +175,8 @@ void close_socket(const NativeSocket socket) noexcept { fd_set write_set; FD_ZERO(&read_set); FD_ZERO(&write_set); - if (for_read) { - FD_SET(socket, &read_set); - } else { - FD_SET(socket, &write_set); - } + if (for_read) FD_SET(socket, &read_set); + else FD_SET(socket, &write_set); timeval timeout{}; timeout.tv_sec = static_cast(timeout_ms / 1'000U); @@ -143,12 +196,8 @@ void close_socket(const NativeSocket socket) noexcept { nullptr, &timeout); #endif - if (result > 0) { - return SocketWaitStatus::ready; - } - if (result == 0) { - return SocketWaitStatus::timeout; - } + if (result > 0) return SocketWaitStatus::ready; + if (result == 0) return SocketWaitStatus::timeout; return socket_interrupted() ? SocketWaitStatus::interrupted : SocketWaitStatus::error; @@ -201,7 +250,7 @@ void close_socket(const NativeSocket socket) noexcept { close_socket(listener); throw std::runtime_error("bind() failed: " + error); } - if (::listen(listener, 4) != 0) { + if (::listen(listener, 16) != 0) { const auto error = socket_error_text(); close_socket(listener); throw std::runtime_error("listen() failed: " + error); @@ -246,30 +295,22 @@ struct SocketStreamContext final { if (count > 0) { return {embedded::IoStatus::ok, static_cast(count)}; } - if (count == 0) { - return {embedded::IoStatus::closed, 0U}; - } + if (count == 0) return {embedded::IoStatus::closed, 0U}; const auto error = ::WSAGetLastError(); if (error == WSAEWOULDBLOCK || error == WSAEINTR) { return {embedded::IoStatus::would_block, 0U}; } - if (error == WSAETIMEDOUT) { - return {embedded::IoStatus::timeout, 0U}; - } + if (error == WSAETIMEDOUT) return {embedded::IoStatus::timeout, 0U}; #else const auto count = ::recv(stream.socket, destination.data(), destination.size(), 0); if (count > 0) { return {embedded::IoStatus::ok, static_cast(count)}; } - if (count == 0) { - return {embedded::IoStatus::closed, 0U}; - } + if (count == 0) return {embedded::IoStatus::closed, 0U}; if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { return {embedded::IoStatus::would_block, 0U}; } - if (errno == ETIMEDOUT) { - return {embedded::IoStatus::timeout, 0U}; - } + if (errno == ETIMEDOUT) return {embedded::IoStatus::timeout, 0U}; #endif return {embedded::IoStatus::io_error, 0U}; } @@ -307,40 +348,99 @@ struct SocketStreamContext final { if (count > 0) { return {embedded::IoStatus::ok, static_cast(count)}; } - if (count == 0) { - return {embedded::IoStatus::closed, 0U}; - } + if (count == 0) return {embedded::IoStatus::closed, 0U}; const auto error = ::WSAGetLastError(); if (error == WSAEWOULDBLOCK || error == WSAEINTR) { return {embedded::IoStatus::would_block, 0U}; } - if (error == WSAETIMEDOUT) { - return {embedded::IoStatus::timeout, 0U}; - } + if (error == WSAETIMEDOUT) return {embedded::IoStatus::timeout, 0U}; #else const auto count = ::send(stream.socket, bytes.data(), bytes.size(), 0); if (count > 0) { return {embedded::IoStatus::ok, static_cast(count)}; } - if (count == 0) { - return {embedded::IoStatus::closed, 0U}; - } + if (count == 0) return {embedded::IoStatus::closed, 0U}; if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { return {embedded::IoStatus::would_block, 0U}; } - if (errno == ETIMEDOUT) { - return {embedded::IoStatus::timeout, 0U}; - } + if (errno == ETIMEDOUT) return {embedded::IoStatus::timeout, 0U}; #endif return {embedded::IoStatus::io_error, 0U}; } +[[nodiscard]] bool send_all( + const NativeSocket socket, + const std::span bytes) noexcept { + SocketStreamContext context{socket}; + std::size_t offset{}; + std::size_t consecutive_waits{}; + while (offset < bytes.size() && !g_stop.load(std::memory_order_relaxed)) { + const auto result = socket_send(&context, bytes.subspan(offset)); + if (result.status == embedded::IoStatus::ok && result.transferred != 0U) { + offset += result.transferred; + consecutive_waits = 0U; + continue; + } + if (result.status == embedded::IoStatus::timeout || + result.status == embedded::IoStatus::would_block) { + if (++consecutive_waits <= 50U) continue; + } + return false; + } + return offset == bytes.size(); +} + +[[nodiscard]] std::uint64_t monotonic_ms() noexcept { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + +[[nodiscard]] std::uint64_t report_now_ms(const void*) noexcept { + return monotonic_ms(); +} + +[[nodiscard]] bool setting_group_utc_now( + const void*, + const std::span destination) noexcept { + return mms::Iec61850UtcTime{std::chrono::system_clock::now(), 0U} + .try_write_bytes(destination); +} + +[[nodiscard]] std::array report_binary_time() noexcept { + constexpr std::int64_t milliseconds_per_day = 86'400'000LL; + constexpr std::int64_t unix_days_to_1984 = 5'113LL; + auto unix_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + std::int64_t unix_days = unix_ms / milliseconds_per_day; + std::int64_t day_ms = unix_ms % milliseconds_per_day; + if (day_ms < 0) { + day_ms += milliseconds_per_day; + --unix_days; + } + const auto days_since_1984 = std::clamp( + unix_days - unix_days_to_1984, 0LL, 65'535LL); + const auto millis = static_cast(day_ms); + const auto days = static_cast(days_since_1984); + return { + static_cast((millis >> 24U) & 0xFFU), + static_cast((millis >> 16U) & 0xFFU), + static_cast((millis >> 8U) & 0xFFU), + static_cast(millis & 0xFFU), + static_cast((days >> 8U) & 0xFFU), + static_cast(days & 0xFFU)}; +} + struct CliOptions final { std::string bind_address{"0.0.0.0"}; std::string model_manifest; + std::string file_root; std::uint16_t port{102U}; std::uint8_t digital_input_mask{}; std::size_t maximum_connections{}; + std::size_t maximum_active_connections{8U}; + std::uint64_t live_generation{}; + bool live_stdin{}; + bool allow_file_delete{}; }; [[nodiscard]] std::uint32_t parse_u32( @@ -362,8 +462,13 @@ void print_usage() { << " --host IPv4 IPv4 listen address (default 0.0.0.0).\n" << " --port N TCP listen port (default 102).\n" << " --model-manifest PATH Host model manifest emitted by the Qt simulator.\n" + << " --file-root PATH Sandboxed MMS FileDirectory/Open/Read/Close root.\n" + << " --allow-file-delete Explicitly enable MMS FileDelete below --file-root.\n" + << " --live-stdin Accept bounded ARSTACK_LIVE updates on stdin.\n" + << " --live-generation N Runtime generation required by live updates.\n" << " --digital-input-mask N GGIO1 Ind1..Ind8 bit mask (default 0).\n" << " --max-connections N Exit after N accepted TCP connections (default unlimited).\n" + << " --max-active N Maximum concurrent associations (default 8, max 64).\n" << " -h, --help Show this help.\n\n" << "Portable bounded static IEC 61850 MMS server for lab/interoperability work.\n" << "The tool exposes a fixed static object model; it does not claim IEC 61850 conformance.\n"; @@ -377,9 +482,18 @@ void print_usage() { print_usage(); std::exit(0); } + if (option == "--live-stdin") { + options.live_stdin = true; + continue; + } + if (option == "--allow-file-delete") { + options.allow_file_delete = true; + continue; + } if (option == "--host" || option == "--model-manifest" || - option == "--port" || option == "--digital-input-mask" || - option == "--max-connections") { + option == "--file-root" || option == "--port" || option == "--digital-input-mask" || + option == "--max-connections" || option == "--max-active" || + option == "--live-generation") { if (++index >= argc) { throw std::invalid_argument(option + " requires a value."); } @@ -388,6 +502,8 @@ void print_usage() { options.bind_address = value; } else if (option == "--model-manifest") { options.model_manifest = value; + } else if (option == "--file-root") { + options.file_root = value; } else if (option == "--port") { const auto parsed = parse_u32(option, value, 65'535U); if (parsed == 0U) { @@ -397,6 +513,18 @@ void print_usage() { } else if (option == "--digital-input-mask") { options.digital_input_mask = static_cast(parse_u32(option, value, 0xFFU)); + } else if (option == "--max-active") { + const auto parsed = parse_u32(option, value, 64U); + if (parsed == 0U) { + throw std::invalid_argument("--max-active must be 1..64."); + } + options.maximum_active_connections = static_cast(parsed); + } else if (option == "--live-generation") { + std::size_t consumed{}; + options.live_generation = std::stoull(value, &consumed, 10); + if (consumed != value.size() || options.live_generation == 0U) { + throw std::invalid_argument("--live-generation must be a positive integer."); + } } else { options.maximum_connections = static_cast( parse_u32( @@ -408,6 +536,12 @@ void print_usage() { } throw std::invalid_argument("Unknown option: " + option); } + if (options.live_stdin && options.live_generation == 0U) { + throw std::invalid_argument("--live-stdin requires --live-generation."); + } + if (options.allow_file_delete && options.file_root.empty()) { + throw std::invalid_argument("--allow-file-delete requires --file-root."); + } return options; } @@ -452,11 +586,27 @@ struct EncodedValue final { return {wire::EncodeStatus::ok, required, required}; } +[[nodiscard]] wire::EncodeResult read_atomic_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = static_cast*>(context)->load( + std::memory_order_relaxed) != 0U; + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + [[nodiscard]] std::vector encode_ber_length( const std::size_t length) { - if (length < 0x80U) { - return {static_cast(length)}; - } + if (length < 0x80U) return {static_cast(length)}; if (length <= 0xFFU) { return {0x81U, static_cast(length)}; } @@ -481,9 +631,7 @@ struct EncodedValue final { [[nodiscard]] std::vector concat( const std::initializer_list> parts) { std::size_t total = 0U; - for (const auto part : parts) { - total += part.size(); - } + for (const auto part : parts) total += part.size(); std::vector result; result.reserve(total); for (const auto part : parts) { @@ -518,8 +666,6 @@ struct EncodedValue final { for (std::size_t index = 1U; index <= 8U; ++index) { const auto do_name = std::string{"Ind"} + std::to_string(index); const auto encoded = build_single_status_ln_type(do_name); - // A single-status LN is A2/A1/SEQUENCE. Reuse the complete named DO - // component while building the GGIO structure component list. const auto structure_fields = std::span{encoded}.subspan(2U); const auto component_list = structure_fields.subspan(2U); do_entries.insert(do_entries.end(), component_list.begin(), component_list.end()); @@ -527,12 +673,78 @@ struct EncodedValue final { return make_tlv(0xA2U, make_tlv(0xA1U, do_entries)); } +[[nodiscard]] mms::MmsTypeSpecification control_scalar( + const mms::MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt, + const bool variable_length = false) { + mms::MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + result.variable_length = variable_length; + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification control_structure( + std::string name, + std::vector children) { + mms::MmsTypeSpecification result; + result.kind = mms::MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +[[nodiscard]] std::vector boolean_control_type_specification( + std::string name, + const bool include_check) { + std::vector fields; + 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::integer, "orCat", 8U), + control_scalar(mms::MmsTypeKind::octet_string, "orIdent", 64U, true), + })); + fields.push_back(control_scalar(mms::MmsTypeKind::unsigned_integer, "ctlNum", 8U)); + fields.push_back(control_scalar(mms::MmsTypeKind::utc_time, "T")); + fields.push_back(control_scalar(mms::MmsTypeKind::boolean, "Test")); + if (include_check) { + fields.push_back(control_scalar(mms::MmsTypeKind::bit_string, "Check", 2U)); + } + return mms::MmsServiceCodec::encode_type_specification( + control_structure(std::move(name), std::move(fields))); +} + +[[nodiscard]] std::vector direct_boolean_oper_type_specification() { + return boolean_control_type_specification("Oper", true); +} + +[[nodiscard]] std::vector direct_boolean_sbow_type_specification() { + return boolean_control_type_specification("SBOw", true); +} + +[[nodiscard]] std::vector direct_boolean_cancel_type_specification() { + return boolean_control_type_specification("Cancel", false); +} + +[[nodiscard]] std::vector sbo_reference_type_specification() { + return mms::MmsServiceCodec::encode_type_specification( + control_scalar(mms::MmsTypeKind::visible_string, "SBO", 129U, true)); +} + struct ConnectionBuffers final { std::array receive{}; std::array response{}; - std::array workspace{}; + // Host file responses are wrapped as one MMS PDU before COTP segmentation. + // Keep workspace large enough for the negotiated host-side P-DATA frame. + std::array workspace{}; + std::array report_frame{}; + std::array report_workspace{}; }; +struct ManifestTypeNode; + struct ManifestValue final { std::string domain; std::string item; @@ -543,11 +755,18 @@ struct ManifestValue final { std::optional data; std::vector type_specification; std::vector encoded; + const ManifestTypeNode* structured_node{}; + std::size_t source_order{std::numeric_limits::max()}; bool root{}; }; struct ManifestTypeNode final { + // Lookup remains ordered/stable for existing pointer semantics, while + // child_order records the first-seen SCL/manifest declaration order. MMS + // Structure values are positional; alphabetically iterating this map would + // corrupt IEC 61850 CDC layouts such as SPS {stVal, q, t}. std::map children; + std::vector child_order; std::optional value_index; }; @@ -558,6 +777,54 @@ struct ManifestDataSetStorage final { std::vector members; }; +struct ManifestReportControlStorage final { + std::string domain; + std::string item; + std::string report_id; + std::string data_set_domain; + std::string data_set_item; + std::uint32_t conf_revision{1U}; + std::array optional_fields{}; + std::uint32_t buffer_time_ms{}; + std::uint8_t trigger_options{}; + std::uint32_t integrity_period_ms{}; +}; + +struct ManifestSettingGroupStorage final { + std::string domain; + std::string item; + std::uint32_t number_of_groups{}; + std::uint32_t active_group{}; + std::shared_ptr shared_state; +}; + +struct ManifestDirectControlStorage final { + std::string domain; + std::string logical_node; + std::string data_object; + std::string cdc; + std::uint8_t control_model{}; + std::string status_item; + std::string ctl_model_item; + std::string sbo_item; + std::string sbow_item; + std::string oper_item; + std::string cancel_item; + std::string selection_reference; + std::vector sbo_type_specification; + std::vector sbow_type_specification; + std::vector oper_type_specification; + std::vector cancel_type_specification; + std::shared_ptr shared_state; + std::size_t service_object_count{}; + std::size_t service_order{std::numeric_limits::max()}; +}; + +constexpr std::size_t kMaximumSimulatorDirectControls = 64U; +constexpr std::size_t kMaximumSimulatorBrcbs = 64U; +constexpr std::size_t kBrcbRetainedEntries = 4U; +constexpr std::size_t kBrcbSlotBytes = 32U * 1024U; + struct ManifestModel final { std::string path; std::uint64_t revision{}; @@ -566,11 +833,137 @@ struct ManifestModel final { std::vector root_trees; std::vector root_value_indices; std::unordered_map value_indices; + std::vector direct_control_storage; + std::size_t omitted_direct_controls{}; + std::vector setting_group_storage; std::vector data_set_storage; std::vector data_sets; + std::vector report_control_storage; + std::vector urcb_definitions; + std::vector brcb_control_storage; + std::vector brcb_definitions; + std::size_t buffered_report_controls{}; + std::size_t omitted_urcbs{}; + std::size_t omitted_brcbs{}; std::size_t declared_entries{}; }; +struct LiveUpdate final { + std::uint64_t sequence{}; + std::uint64_t generation{}; + std::uint64_t revision{}; + std::string domain; + std::string item; + std::string value; +}; + +struct LiveUpdateCollection final { + std::vector updates; + std::uint64_t sequence{}; + bool resync{}; +}; + +class LiveUpdateBus final { +public: + explicit LiveUpdateBus(const std::uint64_t generation) : generation_{generation} {} + + [[nodiscard]] bool publish(LiveUpdate update, std::uint64_t* const sequence) { + std::scoped_lock lock{mutex_}; + if (update.generation != generation_ || update.revision <= last_revision_) return false; + update.sequence = next_sequence_++; + last_revision_ = update.revision; + const auto key = update.domain + '\x1f' + update.item; + latest_[key] = update; + history_.push_back(update); + while (history_.size() > kHistoryLimit) history_.pop_front(); + if (sequence != nullptr) *sequence = update.sequence; + return true; + } + + [[nodiscard]] LiveUpdateCollection latestSnapshot() const { + std::scoped_lock lock{mutex_}; + LiveUpdateCollection result; + result.sequence = next_sequence_ > 0U ? next_sequence_ - 1U : 0U; + result.resync = true; + result.updates.reserve(latest_.size()); + for (const auto& [key, update] : latest_) { + static_cast(key); + result.updates.push_back(update); + } + std::sort(result.updates.begin(), result.updates.end(), + [](const LiveUpdate& left, const LiveUpdate& right) { + return left.sequence < right.sequence; + }); + return result; + } + + [[nodiscard]] LiveUpdateCollection collectSince(const std::uint64_t sequence) const { + std::scoped_lock lock{mutex_}; + LiveUpdateCollection result; + result.sequence = next_sequence_ > 0U ? next_sequence_ - 1U : 0U; + if (sequence >= result.sequence || history_.empty()) return result; + if (sequence + 1U < history_.front().sequence) { + result.resync = true; + result.updates.reserve(latest_.size()); + for (const auto& [key, update] : latest_) { + static_cast(key); + result.updates.push_back(update); + } + std::sort(result.updates.begin(), result.updates.end(), + [](const LiveUpdate& left, const LiveUpdate& right) { + return left.sequence < right.sequence; + }); + return result; + } + result.updates.reserve(history_.size()); + for (const auto& update : history_) { + if (update.sequence > sequence) result.updates.push_back(update); + } + return result; + } + +private: + static constexpr std::size_t kHistoryLimit = 1024U; + std::uint64_t generation_{}; + mutable std::mutex mutex_; + std::deque history_; + std::unordered_map latest_; + std::uint64_t next_sequence_{1U}; + std::uint64_t last_revision_{}; +}; + +struct LiveInputState final { + std::string buffer; + std::uint64_t last_revision{}; + bool overflow_reported{}; + bool eof{}; +}; + +struct SettingGroupAssociationRuntime final { + mms::MmsStaticSettingGroupDefinition definition; + std::vector object_storage; + std::array context_storage{}; + std::vector name_storage; + std::unique_ptr bank; +}; + +struct BrcbAssociationRuntime final { + const mms::MmsStaticBrcbDefinition* definition{}; + const mms::MmsStaticDataSetEntry* data_set{}; + mms::MmsStaticBrcbPendingState pending{}; + std::array, kBrcbRetainedEntries> + slot_storage{}; + std::array slots{}; + std::unique_ptr reports; + std::unique_ptr control; + std::vector object_storage; + std::array context_storage{}; + std::vector name_storage; + std::unique_ptr bank; +}; + [[nodiscard]] std::vector split_fields( const std::string_view text, const char delimiter) { @@ -587,105 +980,11 @@ struct ManifestModel final { return result; } -[[nodiscard]] std::string upper_copy(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { - return static_cast(std::toupper(ch)); - }); - return value; -} - -[[nodiscard]] bool text_boolean(const std::string_view text) noexcept { - return text == "1" || text == "true" || text == "TRUE" || - text == "on" || text == "ON" || text == "closed" || text == "CLOSED"; -} - -[[nodiscard]] std::int64_t text_integer(const std::string& text) noexcept { - const auto upper = upper_copy(text); - if (upper == "INTERMEDIATE-STATE") return 0; - if (upper == "OFF" || upper == "OPEN") return 1; - if (upper == "ON" || upper == "CLOSED") return 2; - if (upper == "BAD-STATE") return 3; - try { - return std::stoll(text); - } catch (...) { - return 0; - } -} - -[[nodiscard]] mms::MmsTypeSpecification manifest_type( - const std::string& raw_type, - const std::string& normalized_type, - std::string name = {}) { - const auto raw = upper_copy(raw_type); - const auto normalized = upper_copy(normalized_type); - mms::MmsTypeSpecification result; - result.name = std::move(name); - if (normalized == "ENUMERATION" || raw.find("ENUM") != std::string::npos) { - result.kind = mms::MmsTypeKind::integer; - result.size = 32U; - } else if (normalized == "BOOLEAN" || raw.find("BOOL") != std::string::npos) { - result.kind = mms::MmsTypeKind::boolean; - } else if (normalized == "QUALITY") { - result.kind = mms::MmsTypeKind::bit_string; - result.size = 13U; - } else if (normalized == "TIMESTAMP") { - result.kind = mms::MmsTypeKind::utc_time; - } else if (raw.find("FLOAT64") != std::string::npos) { - result.kind = mms::MmsTypeKind::floating_point; - result.size = 64U; - result.exponent_width = 11U; - } else if (raw.find("FLOAT") != std::string::npos) { - result.kind = mms::MmsTypeKind::floating_point; - result.size = 32U; - result.exponent_width = 8U; - } else if (raw.find("INT") != std::string::npos && - (raw.ends_with('U') || raw.find("UINT") != std::string::npos)) { - result.kind = mms::MmsTypeKind::unsigned_integer; - result.size = 32U; - } else if (normalized == "NUMBER" || raw.find("INT") != std::string::npos) { - result.kind = mms::MmsTypeKind::integer; - result.size = 32U; - } else { - result.kind = mms::MmsTypeKind::visible_string; - result.size = 255U; - } - return result; -} - -[[nodiscard]] mms::MmsDataValue manifest_data( - const mms::MmsTypeSpecification& type, - const std::string& text) { - switch (type.kind) { - case mms::MmsTypeKind::boolean: - return mms::MmsDataValue::boolean(text_boolean(text)); - case mms::MmsTypeKind::bit_string: { - constexpr std::array good_quality{}; - return mms::MmsDataValue::bit_string(3U, good_quality); - } - case mms::MmsTypeKind::integer: - return mms::MmsDataValue::integer(text_integer(text)); - case mms::MmsTypeKind::unsigned_integer: - return mms::MmsDataValue::unsigned_integer( - static_cast(std::max(0, text_integer(text)))); - case mms::MmsTypeKind::floating_point: - try { - return type.size.value_or(32U) == 64U - ? mms::MmsDataValue::floating_point(std::stod(text)) - : mms::MmsDataValue::floating_point(std::stof(text)); - } catch (...) { - return mms::MmsDataValue::floating_point(0.0F); - } - case mms::MmsTypeKind::utc_time: - return mms::MmsDataValue::utc_time( - mms::Iec61850UtcTime{std::chrono::system_clock::now(), 0U}); - default: - return mms::MmsDataValue::visible_string(text == "---" ? std::string{} : text); - } -} - void encode_manifest_value(ManifestValue& value) { - value.type = manifest_type(value.raw_type, value.normalized_type); - value.data = manifest_data(value.type, value.text); + value.type = mms::MmsSimulatorManifestCodec::type( + value.raw_type, value.normalized_type); + value.data = mms::MmsSimulatorManifestCodec::data( + value.type, value.raw_type, value.normalized_type, value.text); value.type_specification = mms::MmsServiceCodec::encode_type_specification(value.type); value.encoded = mms::MmsDataCodec::encode(*value.data); } @@ -707,6 +1006,16 @@ void encode_manifest_value(ManifestValue& value) { return {wire::EncodeStatus::ok, value.encoded.size(), value.encoded.size()}; } +[[nodiscard]] const ManifestTypeNode& ordered_child( + const ManifestTypeNode& node, + const std::string& child_name) { + const auto child = node.children.find(child_name); + if (child == node.children.end()) { + throw std::runtime_error("Manifest structure order references a missing child."); + } + return child->second; +} + [[nodiscard]] mms::MmsTypeSpecification node_type( const ManifestTypeNode& node, const ManifestModel& model, @@ -719,9 +1028,10 @@ void encode_manifest_value(ManifestValue& value) { mms::MmsTypeSpecification result; result.kind = mms::MmsTypeKind::structure; result.name = std::move(name); - result.children.reserve(node.children.size()); - for (const auto& [child_name, child] : node.children) { - result.children.push_back(node_type(child, model, child_name)); + result.children.reserve(node.child_order.size()); + for (const auto& child_name : node.child_order) { + result.children.push_back(node_type( + ordered_child(node, child_name), model, child_name)); } return result; } @@ -733,20 +1043,35 @@ void encode_manifest_value(ManifestValue& value) { return *model.values[*node.value_index].data; } std::vector children; - children.reserve(node.children.size()); - for (const auto& [name, child] : node.children) { - static_cast(name); - children.push_back(node_data(child, model)); + children.reserve(node.child_order.size()); + for (const auto& child_name : node.child_order) { + children.push_back(node_data(ordered_child(node, child_name), model)); } return mms::MmsDataValue::structure(std::move(children)); } +[[nodiscard]] std::size_t node_source_order( + const ManifestTypeNode& node, + const ManifestModel& model) noexcept { + auto order = std::numeric_limits::max(); + if (node.value_index.has_value() && *node.value_index < model.values.size()) { + order = model.values[*node.value_index].source_order; + } + for (const auto& child_name : node.child_order) { + order = std::min( + order, + node_source_order(ordered_child(node, child_name), model)); + } + return order; +} + void rebuild_manifest_roots(ManifestModel& model) { for (std::size_t index = 0U; index < model.root_trees.size(); ++index) { const auto value_index = model.root_value_indices[index]; const auto& tree = model.root_trees[index]; if (tree.children.empty()) continue; auto& root = model.values[value_index]; + root.source_order = node_source_order(tree, model); root.type = node_type(tree, model, {}); root.data = node_data(tree, model); root.type_specification = mms::MmsServiceCodec::encode_type_specification(root.type); @@ -754,6 +1079,28 @@ void rebuild_manifest_roots(ManifestModel& model) { } } +void rebuild_manifest_root_values(ManifestModel& model) { + // Runtime edits change only leaf values. Keep the structural MMS type and + // its encoded type-specification storage stable for the lifetime of every + // composed URCB/BRCB object table. Those tables intentionally copy object + // entries (including spans into type-specification storage), while their + // read callbacks retain pointers to ManifestValue and therefore observe + // freshly encoded data without rebuilding the table hierarchy. + for (std::size_t index = 0U; index < model.root_trees.size(); ++index) { + const auto value_index = model.root_value_indices[index]; + const auto& tree = model.root_trees[index]; + if (tree.children.empty()) continue; + auto& root = model.values[value_index]; + root.data = node_data(tree, model); + root.encoded = mms::MmsDataCodec::encode(*root.data); + } + for (auto& value : model.values) { + if (value.structured_node == nullptr) continue; + value.data = node_data(*value.structured_node, model); + value.encoded = mms::MmsDataCodec::encode(*value.data); + } +} + [[nodiscard]] std::uint64_t manifest_revision(const std::string& header) noexcept { const auto fields = split_fields(header, '\t'); if (fields.size() < 3U || fields[0] != "ARSTACK_IED_MODEL") return 0U; @@ -775,6 +1122,26 @@ void rebuild_manifest_roots(ManifestModel& model) { return result; } +[[nodiscard]] const ManifestTypeNode* find_manifest_node( + const ManifestModel& model, + const std::unordered_map& root_indices, + const std::string_view domain, + const std::string_view item) { + const auto parts = split_fields(item, '$'); + if (parts.size() < 2U) return nullptr; + const auto root = root_indices.find(object_key(domain, parts.front())); + if (root == root_indices.end() || root->second >= model.root_trees.size()) { + return nullptr; + } + const auto* node = &model.root_trees[root->second]; + for (std::size_t part = 1U; part < parts.size(); ++part) { + const auto child = node->children.find(parts[part]); + if (child == node->children.end()) return nullptr; + node = &child->second; + } + return node; +} + [[nodiscard]] ManifestModel load_manifest_model( const std::string& path, const std::span fallback_type_specification, @@ -793,15 +1160,44 @@ void rebuild_manifest_roots(ManifestModel& model) { std::string normalized_type; std::string text; }; + struct ParsedControl final { + std::string domain; + std::string logical_node; + std::string data_object; + std::string cdc; + std::uint8_t control_model{}; + }; struct ParsedDataSetMember final { std::string domain; std::string item; std::string member_domain; std::string member_item; }; + struct ParsedSettingGroup final { + std::string domain; + std::string item; + std::uint32_t number_of_groups{}; + std::uint32_t active_group{}; + }; + struct ParsedReportControl final { + std::string domain; + std::string item; + bool buffered{}; + std::string report_id; + std::string data_set_domain; + std::string data_set_item; + std::uint32_t conf_revision{}; + std::uint32_t buffer_time_ms{}; + std::uint32_t integrity_period_ms{}; + std::uint8_t trigger_options{}; + std::array optional_fields{}; + }; std::vector> roots; std::vector parsed_objects; + std::vector parsed_controls; std::vector parsed_members; + std::vector parsed_setting_groups; + std::vector parsed_reports; std::set> unique_roots; std::set> unique_objects; std::string line; @@ -820,8 +1216,62 @@ void rebuild_manifest_roots(ManifestModel& model) { unique_objects.emplace(fields[1], fields[2]).second) { parsed_objects.push_back({fields[1], fields[2], fields[3], fields[4], fields[5]}); } + } else if (fields.size() >= 6U && fields[0] == "CTL") { + ++model.declared_entries; + if (fields[1].empty() || fields[2].empty() || fields[3].empty() || fields[4].empty()) { + throw std::runtime_error("Model manifest contains a malformed CTL entry."); + } + parsed_controls.push_back({ + fields[1], + fields[2], + fields[3], + fields[4], + static_cast(parse_u32("CTL ctlModel", fields[5], 4U))}); } else if (fields.size() >= 5U && fields[0] == "DS") { parsed_members.push_back({fields[1], fields[2], fields[3], fields[4]}); + } else if (fields.size() >= 13U && fields[0] == "RCB") { + const bool partial_data_set_reference = + fields[5].empty() != fields[6].empty(); + if (fields[1].empty() || fields[2].empty() || fields[4].empty() || + partial_data_set_reference || + (fields[3] != "0" && fields[3] != "1")) { + throw std::runtime_error("Model manifest contains a malformed RCB entry."); + } + ParsedReportControl report; + report.domain = fields[1]; + report.item = fields[2]; + report.buffered = fields[3] == "1"; + report.report_id = fields[4]; + report.data_set_domain = fields[5]; + report.data_set_item = fields[6]; + report.conf_revision = parse_u32("RCB ConfRev", fields[7], + std::numeric_limits::max()); + report.buffer_time_ms = parse_u32("RCB BufTm", fields[8], + std::numeric_limits::max()); + report.integrity_period_ms = parse_u32("RCB IntgPd", fields[9], + std::numeric_limits::max()); + report.trigger_options = static_cast( + parse_u32("RCB TrgOps", fields[10], 0xFFU)); + report.optional_fields[0] = static_cast( + parse_u32("RCB OptFlds[0]", fields[11], 0xFFU)); + report.optional_fields[1] = static_cast( + parse_u32("RCB OptFlds[1]", fields[12], 0xFFU)); + parsed_reports.push_back(std::move(report)); + } else if (fields.size() >= 5U && fields[0] == "SGCB") { + if (fields[1].empty() || fields[2].empty()) { + throw std::runtime_error("Model manifest contains a malformed SGCB entry."); + } + ParsedSettingGroup setting; + setting.domain = fields[1]; + setting.item = fields[2]; + setting.number_of_groups = parse_u32("SGCB NumOfSG", fields[3], 0xFFU); + setting.active_group = parse_u32("SGCB ActSG", fields[4], 0xFFU); + if (setting.number_of_groups == 0U || setting.active_group == 0U || + setting.active_group > setting.number_of_groups) { + throw std::runtime_error( + "Model manifest SGCB requires 1 <= ActSG <= NumOfSG <= 255."); + } + parsed_setting_groups.push_back(std::move(setting)); } } if (roots.empty()) { @@ -848,7 +1298,8 @@ void rebuild_manifest_roots(ManifestModel& model) { model.root_trees.emplace_back(); } - for (const auto& parsed : parsed_objects) { + for (std::size_t parsed_index = 0U; parsed_index < parsed_objects.size(); ++parsed_index) { + const auto& parsed = parsed_objects[parsed_index]; if (model.values.size() >= mms::MmsStaticObjectTable::maximum_objects) break; const auto key = object_key(parsed.domain, parsed.item); if (model.value_indices.contains(key)) continue; @@ -858,6 +1309,7 @@ void rebuild_manifest_roots(ManifestModel& model) { value.raw_type = parsed.raw_type; value.normalized_type = parsed.normalized_type; value.text = parsed.text; + value.source_order = parsed_index; encode_manifest_value(value); const auto value_index = model.values.size(); model.values.push_back(std::move(value)); @@ -869,7 +1321,10 @@ void rebuild_manifest_roots(ManifestModel& model) { if (found_root == root_indices.end()) continue; auto* node = &model.root_trees[found_root->second]; for (std::size_t part = 1U; part < parts.size(); ++part) { - node = &node->children[parts[part]]; + const auto& child_name = parts[part]; + const auto [child, inserted] = node->children.try_emplace(child_name); + if (inserted) node->child_order.push_back(child_name); + node = &child->second; } node->value_index = value_index; } @@ -877,20 +1332,155 @@ void rebuild_manifest_roots(ManifestModel& model) { model.objects.reserve(model.values.size()); for (auto& value : model.values) { - model.objects.push_back(mms::MmsStaticObjectEntry{ + auto object = mms::MmsStaticObjectEntry{ value.domain, value.item, value.type_specification, read_manifest_value, - &value}); - } + &value}; + object.declaration_order = value.source_order; + model.objects.push_back(object); + } + + // 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 external IEC 61850 client 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) { + 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; + } + if (model.direct_control_storage.size() >= kMaximumSimulatorDirectControls) { + ++model.omitted_direct_controls; + continue; + } + const auto status_item = parsed.logical_node + "$ST$" + parsed.data_object + "$stVal"; + const auto ctl_model_item = parsed.logical_node + "$CF$" + parsed.data_object + "$ctlModel"; + const auto sbo_item = parsed.logical_node + "$CO$" + parsed.data_object + "$SBO"; + const auto sbow_item = parsed.logical_node + "$CO$" + parsed.data_object + "$SBOw"; + const auto oper_item = parsed.logical_node + "$CO$" + parsed.data_object + "$Oper"; + const auto cancel_item = parsed.logical_node + "$CO$" + parsed.data_object + "$Cancel"; + 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.omitted_direct_controls; + continue; + } + 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; + control.data_object = parsed.data_object; + control.cdc = parsed.cdc; + control.control_model = parsed.control_model; + control.status_item = status_item; + control.ctl_model_item = ctl_model_item; + control.sbo_item = sbo_item; + control.sbow_item = sbow_item; + control.oper_item = oper_item; + control.cancel_item = cancel_item; + control.selection_reference = parsed.domain + "/" + parsed.logical_node + "." + parsed.data_object; + std::replace( + control.selection_reference.begin(), + control.selection_reference.end(), + '$', + '.'); + control.sbo_type_specification = sbo_type; + control.sbow_type_specification = sbow_type; + control.oper_type_specification = oper_type; + control.cancel_type_specification = cancel_type; + control.shared_state = std::make_shared(); + control.shared_state->value.store(initial ? 1U : 0U, std::memory_order_relaxed); + control.service_object_count = + (parsed.control_model == 2U || parsed.control_model == 4U) ? 3U : 1U; + const auto ctl_model_order = model.values[ctl_model->second].source_order; + const auto status_order = status_value.source_order; + control.service_order = + ctl_model_order != std::numeric_limits::max() && ctl_model_order > 0U + ? ctl_model_order - 1U + : status_order; + model.direct_control_storage.push_back(std::move(control)); + } + + std::set> unique_setting_groups; + model.setting_group_storage.reserve(parsed_setting_groups.size()); + for (const auto& parsed : parsed_setting_groups) { + if (!unique_setting_groups.emplace(parsed.domain, parsed.item).second) { + throw std::runtime_error("Model manifest contains a duplicate SGCB entry."); + } + ManifestSettingGroupStorage setting; + setting.domain = parsed.domain; + setting.item = parsed.item; + setting.number_of_groups = parsed.number_of_groups; + setting.active_group = parsed.active_group; + setting.shared_state = std::make_shared( + parsed.number_of_groups, parsed.active_group); + model.setting_group_storage.push_back(std::move(setting)); + } + + const auto ensure_data_set_member_object = [&](const ParsedDataSetMember& member) { + const auto key = object_key(member.member_domain, member.member_item); + if (model.value_indices.contains(key)) return true; + if (model.values.size() >= mms::MmsStaticObjectTable::maximum_objects) return false; + + const auto* node = find_manifest_node( + model, root_indices, member.member_domain, member.member_item); + if (node == nullptr || node->children.empty()) return false; + + const auto parts = split_fields(member.member_item, '$'); + ManifestValue value; + value.domain = member.member_domain; + value.item = member.member_item; + value.structured_node = node; + value.source_order = node_source_order(*node, model); + value.type = node_type(*node, model, parts.empty() ? std::string{} : parts.back()); + value.data = node_data(*node, model); + value.type_specification = mms::MmsServiceCodec::encode_type_specification(value.type); + value.encoded = mms::MmsDataCodec::encode(*value.data); + + const auto value_index = model.values.size(); + model.values.push_back(std::move(value)); + model.value_indices.emplace(key, value_index); + auto& stored = model.values.back(); + auto object = mms::MmsStaticObjectEntry{ + stored.domain, + stored.item, + stored.type_specification, + read_manifest_value, + &stored}; + object.declaration_order = stored.source_order; + model.objects.push_back(object); + return true; + }; std::map, std::vector>> grouped_members; for (const auto& member : parsed_members) { - if (!model.value_indices.contains(object_key(member.member_domain, member.member_item))) { - continue; - } + if (!ensure_data_set_member_object(member)) continue; grouped_members[{member.domain, member.item}].emplace_back( member.member_domain, member.member_item); } @@ -914,10 +1504,129 @@ void rebuild_manifest_roots(ManifestModel& model) { model.data_sets.push_back({ storage.domain, storage.item, storage.members, false}); } + + std::set> available_data_sets; + for (const auto& data_set : model.data_sets) { + available_data_sets.emplace(data_set.domain, data_set.item); + } + + std::size_t control_service_objects{}; + for (const auto& control : model.direct_control_storage) { + if (control.service_object_count > + mms::MmsStaticObjectTable::maximum_objects - control_service_objects) { + throw std::runtime_error("Configured controls exceed MMS object capacity."); + } + control_service_objects += control.service_object_count; + } + const auto setting_group_count = model.setting_group_storage.size(); + if (setting_group_count > + mms::MmsStaticObjectTable::maximum_objects / + mms::MmsStaticSettingGroupObjectBank::attributes_per_control_block) { + throw std::runtime_error("Configured SGCBs exceed MMS object capacity."); + } + const auto setting_group_service_objects = setting_group_count * + mms::MmsStaticSettingGroupObjectBank::attributes_per_control_block; + if (control_service_objects > + mms::MmsStaticObjectTable::maximum_objects - setting_group_service_objects) { + throw std::runtime_error("Configured controls and SGCBs exceed MMS object capacity."); + } + const auto service_objects = control_service_objects + setting_group_service_objects; + if (model.objects.size() > mms::MmsStaticObjectTable::maximum_objects - service_objects) { + throw std::runtime_error("Configured controls and SGCBs exceed MMS object capacity."); + } + auto remaining_object_slots = mms::MmsStaticObjectTable::maximum_objects - + model.objects.size() - service_objects; + const auto available_urcb_slots = std::min( + mms::MmsStaticUrcbRuntime::maximum_control_blocks, + remaining_object_slots / + mms::MmsStaticUrcbObjectBank::attributes_per_control_block); + model.report_control_storage.reserve(available_urcb_slots); + for (auto& report : parsed_reports) { + if (report.buffered) continue; + const bool has_data_set = !report.data_set_domain.empty(); + if ((has_data_set && + !available_data_sets.contains({report.data_set_domain, report.data_set_item})) || + model.report_control_storage.size() >= available_urcb_slots) { + ++model.omitted_urcbs; + continue; + } + ManifestReportControlStorage storage; + storage.domain = report.domain; + storage.item = report.item; + storage.report_id = report.report_id; + storage.data_set_domain = report.data_set_domain; + storage.data_set_item = report.data_set_item; + storage.conf_revision = report.conf_revision; + storage.optional_fields = report.optional_fields; + storage.buffer_time_ms = report.buffer_time_ms; + storage.trigger_options = report.trigger_options; + storage.integrity_period_ms = report.integrity_period_ms; + model.report_control_storage.push_back(std::move(storage)); + } + model.urcb_definitions.reserve(model.report_control_storage.size()); + for (const auto& storage : model.report_control_storage) { + model.urcb_definitions.push_back(mms::MmsStaticUrcbDefinition{ + storage.domain, + storage.item, + storage.report_id, + storage.data_set_domain, + storage.data_set_item, + storage.conf_revision, + storage.optional_fields, + storage.buffer_time_ms, + storage.trigger_options, + storage.integrity_period_ms}); + } + remaining_object_slots -= model.urcb_definitions.size() * + mms::MmsStaticUrcbObjectBank::attributes_per_control_block; + + const auto available_brcb_slots = std::min( + kMaximumSimulatorBrcbs, + remaining_object_slots / + mms::MmsStaticBrcbObjectBank::attributes_per_control_block); + model.brcb_control_storage.reserve(available_brcb_slots); + for (auto& report : parsed_reports) { + if (!report.buffered) continue; + ++model.buffered_report_controls; + if (!available_data_sets.contains({report.data_set_domain, report.data_set_item}) || + model.brcb_control_storage.size() >= available_brcb_slots) { + ++model.omitted_brcbs; + continue; + } + ManifestReportControlStorage storage; + storage.domain = report.domain; + storage.item = report.item; + storage.report_id = report.report_id; + storage.data_set_domain = report.data_set_domain; + storage.data_set_item = report.data_set_item; + storage.conf_revision = report.conf_revision; + storage.optional_fields = report.optional_fields; + storage.buffer_time_ms = report.buffer_time_ms; + storage.trigger_options = report.trigger_options; + storage.integrity_period_ms = report.integrity_period_ms; + model.brcb_control_storage.push_back(std::move(storage)); + } + model.brcb_definitions.reserve(model.brcb_control_storage.size()); + for (const auto& storage : model.brcb_control_storage) { + model.brcb_definitions.push_back(mms::MmsStaticBrcbDefinition{ + storage.domain, + storage.item, + storage.report_id, + storage.data_set_domain, + storage.data_set_item, + storage.conf_revision, + storage.optional_fields, + storage.buffer_time_ms, + storage.trigger_options, + storage.integrity_period_ms}); + } return model; } -[[nodiscard]] std::size_t refresh_manifest_values(ManifestModel& model) { +[[nodiscard]] std::size_t refresh_manifest_values( + ManifestModel& model, + std::vector* const changed_value_indices = nullptr) { + if (changed_value_indices != nullptr) changed_value_indices->clear(); if (model.path.empty()) return 0U; std::ifstream input{model.path}; if (!input) return 0U; @@ -935,24 +1644,315 @@ void rebuild_manifest_roots(ManifestModel& model) { auto& value = model.values[found->second]; if (value.text == fields[5]) continue; value.text = fields[5]; - value.data = manifest_data(value.type, value.text); + value.data = mms::MmsSimulatorManifestCodec::data( + value.type, value.raw_type, value.normalized_type, value.text); value.encoded = mms::MmsDataCodec::encode(*value.data); + if (changed_value_indices != nullptr) { + changed_value_indices->push_back(found->second); + } ++changed; } model.revision = revision; if (changed != 0U) { - rebuild_manifest_roots(model); - // Re-encoding a root may reallocate its byte vector. Refresh the - // object-table spans so later attribute/root reads never retain - // storage from the previous manifest revision. - for (const auto value_index : model.root_value_indices) { - model.objects[value_index].type_specification = - model.values[value_index].type_specification; - } + // Value refresh is deliberately data-only. Re-encoding structural type + // specifications here can reallocate their backing vectors and leave + // the spans copied into composed control/URCB/BRCB tables dangling. + rebuild_manifest_root_values(model); } return changed; } +[[nodiscard]] std::optional decode_hex(const std::string_view text) { + if ((text.size() & 1U) != 0U || text.size() > 8U * 1024U) return std::nullopt; + const auto nibble = [](const char value) -> int { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; + }; + std::string decoded; + decoded.resize(text.size() / 2U); + for (std::size_t index = 0U; index < decoded.size(); ++index) { + const auto high = nibble(text[index * 2U]); + const auto low = nibble(text[index * 2U + 1U]); + if (high < 0 || low < 0) return std::nullopt; + decoded[index] = static_cast((high << 4) | low); + } + return decoded; +} + +[[nodiscard]] bool apply_live_updates( + ManifestModel& model, + const std::span updates, + std::vector* const changed_value_indices) { + bool changed{}; + for (const auto& update : updates) { + const auto found = model.value_indices.find(object_key(update.domain, update.item)); + if (found == model.value_indices.end()) continue; + auto& value = model.values[found->second]; + if (value.text == update.value) continue; + const auto data = mms::MmsSimulatorManifestCodec::data( + value.type, value.raw_type, value.normalized_type, update.value); + value.text = update.value; + value.data = data; + value.encoded = mms::MmsDataCodec::encode(*value.data); + if (changed_value_indices != nullptr) changed_value_indices->push_back(found->second); + changed = true; + } + if (changed) rebuild_manifest_root_values(model); + return changed; +} + +void update_live_control_state(ManifestModel& model, const LiveUpdate& update) { + for (auto& control : model.direct_control_storage) { + if (control.shared_state == nullptr || control.domain != update.domain || + control.status_item != update.item) { + continue; + } + const auto normalized = update.value == "true" || update.value == "1" || + update.value == "on" || update.value == "TRUE"; + control.shared_state->value.store(normalized ? 1U : 0U, std::memory_order_relaxed); + } +} + +[[nodiscard]] int read_live_stdin(std::array& bytes) noexcept { +#if defined(_WIN32) + const auto handle = ::GetStdHandle(STD_INPUT_HANDLE); + if (handle == nullptr || handle == INVALID_HANDLE_VALUE) return -1; + DWORD available{}; + if (!::PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr)) return -1; + if (available == 0U) return 0; + DWORD read{}; + const auto wanted = static_cast(std::min(bytes.size(), available)); + if (!::ReadFile(handle, bytes.data(), wanted, &read, nullptr)) return -1; + return static_cast(read); +#else + fd_set read_set; + FD_ZERO(&read_set); + FD_SET(STDIN_FILENO, &read_set); + timeval timeout{}; + const auto ready = ::select(STDIN_FILENO + 1, &read_set, nullptr, nullptr, &timeout); + if (ready == 0) return 0; + if (ready < 0) return errno == EINTR ? 0 : -1; + const auto count = ::read(STDIN_FILENO, bytes.data(), bytes.size()); + if (count <= 0) return -1; + return static_cast(count); +#endif +} + +void reject_live_update( + const std::uint64_t generation, + const std::uint64_t revision, + const std::string_view reason) { + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=live_update_ack generation=" << generation + << " revision=" << revision << " accepted=false reason=" << reason << '\n'; +} + +void drain_live_stdin( + LiveInputState& input, + const CliOptions& options, + ManifestModel& model, + LiveUpdateBus& bus) { + if (input.eof) return; + std::array bytes{}; + const auto count = read_live_stdin(bytes); + if (count < 0) { + input.eof = true; + return; + } + if (count > 0) { + input.buffer.append(bytes.data(), static_cast(count)); + if (input.buffer.size() > 64U * 1024U) { + input.buffer.clear(); + if (!input.overflow_reported) { + input.overflow_reported = true; + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=live_input_overflow limit=65536\n"; + } + return; + } + } + + std::size_t drained{}; + while (drained < 64U) { + const auto newline = input.buffer.find('\n'); + if (newline == std::string::npos) break; + auto line = input.buffer.substr(0U, newline); + input.buffer.erase(0U, newline + 1U); + ++drained; + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.size() > 12U * 1024U) { + reject_live_update(options.live_generation, 0U, "line-too-large"); + continue; + } + const auto fields = split_fields(line, '\t'); + if (fields.size() != 7U || fields[0] != "ARSTACK_LIVE" || fields[1] != "1") { + reject_live_update(options.live_generation, 0U, "malformed"); + continue; + } + std::size_t generation_consumed{}; + std::size_t revision_consumed{}; + std::uint64_t generation{}; + std::uint64_t revision{}; + try { + generation = std::stoull(fields[2], &generation_consumed, 10); + revision = std::stoull(fields[3], &revision_consumed, 10); + } catch (...) { + reject_live_update(options.live_generation, 0U, "bad-revision"); + continue; + } + if (generation_consumed != fields[2].size() || revision_consumed != fields[3].size() || + generation != options.live_generation || revision == 0U || revision <= input.last_revision) { + reject_live_update(generation, revision, "stale-generation-or-revision"); + continue; + } + const auto domain = decode_hex(fields[4]); + const auto item = decode_hex(fields[5]); + const auto value = decode_hex(fields[6]); + if (!domain.has_value() || !item.has_value() || !value.has_value() || + domain->empty() || item->empty() || value->size() > 4U * 1024U) { + reject_live_update(generation, revision, "bad-payload"); + continue; + } + const auto found = model.value_indices.find(object_key(*domain, *item)); + if (found == model.value_indices.end()) { + reject_live_update(generation, revision, "unknown-object"); + continue; + } + auto& target = model.values[found->second]; + const auto parsed = mms::MmsSimulatorManifestCodec::data( + target.type, target.raw_type, target.normalized_type, *value); + + LiveUpdate update; + update.generation = generation; + update.revision = revision; + update.domain = *domain; + update.item = *item; + update.value = *value; + std::uint64_t sequence{}; + if (!bus.publish(update, &sequence)) { + reject_live_update(generation, revision, "stale-generation-or-revision"); + continue; + } + target.text = update.value; + target.data = parsed; + target.encoded = mms::MmsDataCodec::encode(*target.data); + rebuild_manifest_root_values(model); + update_live_control_state(model, update); + input.last_revision = revision; + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=live_update_ack generation=" << generation + << " revision=" << revision << " accepted=true sequence=" << sequence << '\n'; + } +} + +[[nodiscard]] const mms::MmsStaticDataSetEntry* find_data_set( + const mms::MmsStaticDataSetTable& data_sets, + const std::string_view domain, + const std::string_view item) noexcept { + for (const auto& data_set : data_sets.data_sets()) { + if (data_set.domain == domain && data_set.item == item) return &data_set; + } + return nullptr; +} + + +[[nodiscard]] mms::MmsStaticUrcbEventReason urcb_event_reason( + const ManifestValue& value) noexcept { + return value.normalized_type == "Quality" || value.item.ends_with("$q") + ? mms::MmsStaticUrcbEventReason::quality_change + : mms::MmsStaticUrcbEventReason::data_change; +} + +void notify_urcb_changes( + const ManifestModel& model, + const std::span changed_value_indices, + mms::MmsStaticUrcbRuntime& urcbs, + const mms::MmsStaticDataSetTable& data_sets, + const std::uint64_t now_ms) { + for (const auto value_index : changed_value_indices) { + if (value_index >= model.values.size()) continue; + const auto& value = model.values[value_index]; + const auto reason = urcb_event_reason(value); + for (std::size_t rcb_index = 0U; rcb_index < urcbs.size(); ++rcb_index) { + const auto* definition = urcbs.definition(rcb_index); + if (definition == nullptr) continue; + const auto* data_set = find_data_set( + data_sets, definition->data_set_domain, definition->data_set_item); + if (data_set == nullptr) continue; + for (std::size_t member_index = 0U; + member_index < data_set->members.size(); + ++member_index) { + const auto& member = data_set->members[member_index]; + const auto member_matches_value = + member.domain == value.domain && + (member.item == value.item || + (value.item.size() > member.item.size() && + value.item.compare(0U, member.item.size(), member.item) == 0 && + value.item[member.item.size()] == '$')); + if (!member_matches_value) continue; + const auto status = urcbs.notify( + rcb_index, member_index, reason, now_ms); + if (status != mms::MmsStaticUrcbStatus::ok && + status != mms::MmsStaticUrcbStatus::temporarily_unavailable) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=urcb_notify_error rcb=" + << definition->item + << " status=" << static_cast(status) << '\n'; + } + break; + } + } + } +} + +[[nodiscard]] mms::MmsStaticBrcbEventReason brcb_event_reason( + const ManifestValue& value) noexcept { + return value.normalized_type == "Quality" || value.item.ends_with("$q") + ? mms::MmsStaticBrcbEventReason::quality_change + : mms::MmsStaticBrcbEventReason::data_change; +} + +void notify_brcb_changes( + const ManifestModel& model, + const std::span changed_value_indices, + const std::span> brcbs, + const std::uint64_t now_ms) { + for (const auto value_index : changed_value_indices) { + if (value_index >= model.values.size()) continue; + const auto& value = model.values[value_index]; + const auto reason = brcb_event_reason(value); + for (const auto& brcb : brcbs) { + if (brcb == nullptr || brcb->reports == nullptr || brcb->data_set == nullptr) continue; + for (std::size_t member_index = 0U; + member_index < brcb->data_set->members.size(); + ++member_index) { + const auto& member = brcb->data_set->members[member_index]; + const auto member_matches_value = + member.domain == value.domain && + (member.item == value.item || + (value.item.size() > member.item.size() && + value.item.compare(0U, member.item.size(), member.item) == 0 && + value.item[member.item.size()] == '$')); + if (!member_matches_value) continue; + const auto status = brcb->reports->notify(member_index, reason, now_ms); + if (status != mms::MmsStaticBrcbStatus::ok && + status != mms::MmsStaticBrcbStatus::trigger_not_selected && + status != mms::MmsStaticBrcbStatus::temporarily_unavailable) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=brcb_notify_error rcb=" + << (brcb->definition == nullptr + ? std::string_view{"unknown"} + : brcb->definition->item) + << " status=" << static_cast(status) << '\n'; + } + break; + } + } + } +} + [[nodiscard]] std::string_view connection_state_text( const mms::MmsStaticConnectionState state) noexcept { switch (state) { @@ -976,6 +1976,10 @@ void rebuild_manifest_roots(ManifestModel& model) { return "GetVariableAccessAttributes"; case mms::MmsWireConfirmedService::get_named_variable_list_attributes: return "GetNamedVariableListAttributes"; + case mms::MmsWireConfirmedService::file_open: return "FileOpen"; + case mms::MmsWireConfirmedService::file_read: return "FileRead"; + case mms::MmsWireConfirmedService::file_close: return "FileClose"; + case mms::MmsWireConfirmedService::file_delete: return "FileDelete"; case mms::MmsWireConfirmedService::file_directory: return "FileDirectory"; case mms::MmsWireConfirmedService::unknown: return "Unknown"; } @@ -990,16 +1994,404 @@ void rebuild_manifest_roots(ManifestModel& model) { return std::string{text.data()} + ':' + std::to_string(ntohs(peer.sin_port)); } +[[nodiscard]] std::vector build_directory_index( + const mms::MmsStaticObjectTable& objects) { + std::vector ordered; + ordered.reserve(objects.objects().size()); + for (const auto& object : objects.objects()) ordered.push_back(&object); + std::stable_sort( + ordered.begin(), ordered.end(), + [](const mms::MmsStaticObjectEntry* left, + const mms::MmsStaticObjectEntry* right) noexcept { + if (left->domain != right->domain) return left->domain < right->domain; + if (left->declaration_order != right->declaration_order) { + return left->declaration_order < right->declaration_order; + } + return left->item < right->item; + }); + + std::vector directory; + std::size_t estimated{}; + for (const auto* object : ordered) { + estimated += 1U + static_cast( + std::count(object->item.begin(), object->item.end(), '$')); + } + directory.reserve(estimated); + std::set> seen; + for (const auto* object : ordered) { + std::size_t prefix_end = object->item.find('$'); + while (true) { + const auto prefix = object->item.substr( + 0U, + prefix_end == std::string_view::npos ? object->item.size() : prefix_end); + if (!prefix.empty() && seen.emplace(object->domain, prefix).second) { + directory.push_back({object->domain, prefix}); + } + if (prefix_end == std::string_view::npos || + prefix_end + 1U >= object->item.size()) { + break; + } + prefix_end = object->item.find('$', prefix_end + 1U); + } + } + return directory; +} + void serve_connection( const NativeSocket socket, const mms::MmsStaticObjectTable& object_table, const mms::MmsStaticDataSetTable& data_sets, ManifestModel* const manifest_model, + LiveUpdateBus* const live_updates, + std::uint64_t live_sequence, + filehost::StaticFileServiceRoot* const file_root, const std::uint64_t association_id, const std::string_view remote) { + std::vector urcb_states; + std::vector urcb_object_storage; + std::vector urcb_context_storage; + std::vector urcb_name_storage; + std::unique_ptr urcb_runtime; + std::unique_ptr urcb_bank; + 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; + std::unique_ptr file_session; + mms::MmsStaticDispatchPolicy dispatch_policy; + // external IEC 61850 client can configure an RCB and re-enable it in one MMS Write. + // Keep the host transaction bounded to one complete BRCB attribute set. + dispatch_policy.maximum_write_variables = 16U; + const mms::MmsStaticObjectTable* dispatch_objects = &object_table; + 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 integer8_type{0x85U, 0x01U, 0x08U}; + + for (std::size_t index = 0U; index < manifest_model->direct_control_storage.size(); ++index) { + auto& control = manifest_model->direct_control_storage[index]; + auto& state = direct_control_states[index]; + auto& binding = direct_control_bindings[index]; + state.value = control.shared_state->value.load(std::memory_order_relaxed); + binding.state = &state; + binding.shared_state = control.shared_state.get(); + binding.model = static_cast(control.control_model); + binding.association_id = association_id; + binding.selection_reference = control.selection_reference; + binding.sbo_timeout_ms = 10'000U; + binding.now_ms = report_now_ms; + binding.now_context = nullptr; + // Golden external IEC 61850 client 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) { + 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{integer8_type}; + object.read = mms::mms_static_control_read_ctl_model; + object.context = &binding; + object.write = nullptr; + object.write_context = nullptr; + object.contextual_write = nullptr; + ctl_model_found = true; + } + } + if (!status_found || !ctl_model_found) { + throw std::runtime_error("Configured command control is missing ST/CF backing objects."); + } + + const bool select_before_operate = + control.control_model == 2U || control.control_model == 4U; + const auto service_order = [&](const std::size_t offset) noexcept { + constexpr auto unspecified = std::numeric_limits::max(); + if (control.service_order == unspecified || + offset > unspecified - control.service_order) { + return unspecified; + } + return control.service_order + offset; + }; + + if (control.control_model == 2U) { + auto service = mms::MmsStaticObjectEntry{ + control.domain, + control.sbo_item, + control.sbo_type_specification, + mms::mms_static_sbo_normal_read, + &binding}; + service.declaration_order = service_order(0U); + direct_control_objects.push_back(service); + } else if (control.control_model == 4U) { + auto service = mms::MmsStaticObjectEntry{ + control.domain, + control.sbow_item, + control.sbow_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + nullptr, + &binding, + mms::mms_static_boolean_write_sbow_contextual}; + service.declaration_order = service_order(0U); + direct_control_objects.push_back(service); + } + + auto operate_service = mms::MmsStaticObjectEntry{ + control.domain, + control.oper_item, + control.oper_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + nullptr, + &binding, + mms::mms_static_boolean_write_oper_contextual}; + operate_service.declaration_order = service_order(select_before_operate ? 1U : 0U); + direct_control_objects.push_back(operate_service); + + if (control.control_model == 2U || control.control_model == 4U) { + auto service = mms::MmsStaticObjectEntry{ + control.domain, + control.cancel_item, + control.cancel_type_specification, + mms::mms_static_control_read_unavailable, + nullptr, + false, + nullptr, + &binding, + mms::mms_static_boolean_write_cancel_contextual}; + service.declaration_order = service_order(2U); + direct_control_objects.push_back(service); + } + } + // MmsStaticObjectTable requires strict (domain,item) ordering. The + // structural model is already sorted, but control-service aliases are + // appended above and can sort before ST/CF leaves (for example $CO$ + // sorts before $ST$). Re-sort the composed per-association table before + // validating or exposing it to GetNameList/GVAA/Read/Write. + std::stable_sort( + direct_control_objects.begin(), + direct_control_objects.end(), + [](const mms::MmsStaticObjectEntry& left, + const mms::MmsStaticObjectEntry& right) noexcept { + if (left.domain != right.domain) return left.domain < right.domain; + return left.item < right.item; + }); + direct_control_table = std::make_unique( + std::span{direct_control_objects}); + if (!direct_control_table->valid()) { + throw std::runtime_error("Configured command-control MMS object table is invalid."); + } + dispatch_objects = direct_control_table.get(); + dispatch_policy.advertise_flattened_child_aliases = true; + } + if (manifest_model != nullptr && !manifest_model->setting_group_storage.empty()) { + setting_group_runtimes.reserve(manifest_model->setting_group_storage.size()); + for (auto& setting : manifest_model->setting_group_storage) { + if (setting.shared_state == nullptr || !setting.shared_state->valid()) { + throw std::runtime_error("Configured SGCB has no valid canonical shared state."); + } + auto runtime = std::make_unique(); + runtime->definition = mms::MmsStaticSettingGroupDefinition{ + setting.domain, setting.item}; + mms::MmsStaticSettingGroupObjectBank sizing_bank{ + runtime->definition, + *setting.shared_state, + dispatch_objects->objects(), + std::span{}, + std::span{}, + std::span{}, + setting_group_utc_now, + nullptr}; + const auto required_objects = sizing_bank.required_object_capacity(); + const auto required_contexts = sizing_bank.required_context_capacity(); + const auto required_names = sizing_bank.required_name_bytes(); + if (required_objects == std::numeric_limits::max() || + required_contexts == std::numeric_limits::max() || + required_names == std::numeric_limits::max()) { + throw std::runtime_error("SGCB object-bank capacity calculation failed."); + } + runtime->object_storage.resize(required_objects); + runtime->name_storage.resize(required_names); + runtime->bank = std::make_unique( + runtime->definition, + *setting.shared_state, + dispatch_objects->objects(), + std::span{runtime->object_storage}, + std::span{runtime->context_storage}, + std::span{runtime->name_storage}, + setting_group_utc_now, + nullptr); + if (!runtime->bank->initialize()) { + throw std::runtime_error("Could not expose SGCB MMS attribute objects."); + } + dispatch_objects = &runtime->bank->table(); + setting_group_runtimes.push_back(std::move(runtime)); + } + dispatch_policy.advertise_flattened_child_aliases = true; + } + + const auto* process_objects = dispatch_objects; + if (manifest_model != nullptr && !manifest_model->urcb_definitions.empty()) { + urcb_states.resize(manifest_model->urcb_definitions.size()); + urcb_runtime = std::make_unique( + std::span{manifest_model->urcb_definitions}, + std::span{urcb_states}, + *process_objects, + data_sets); + if (!urcb_runtime->initialize()) { + throw std::runtime_error("Could not initialize per-association URCB runtime."); + } + + // Size the URCB object bank from the model that will actually be + // wrapped. Configured controls may already have extended the base + // object table with SBO/SBOw/Oper/Cancel objects. Sizing from the + // original table under-allocates the per-association bank and makes + // initialize() fail before the MMS association can be accepted. + mms::MmsStaticUrcbObjectBank sizing_bank{ + *urcb_runtime, + process_objects->objects(), + std::span{}, + std::span{}, + std::span{}, + report_now_ms, + nullptr}; + const auto required_objects = sizing_bank.required_object_capacity(); + const auto required_contexts = sizing_bank.required_context_capacity(); + const auto required_names = sizing_bank.required_name_bytes(); + if (required_objects == std::numeric_limits::max() || + required_contexts == std::numeric_limits::max() || + required_names == std::numeric_limits::max()) { + throw std::runtime_error("URCB object-bank capacity calculation failed."); + } + urcb_object_storage.resize(required_objects); + urcb_context_storage.resize(required_contexts); + urcb_name_storage.resize(required_names); + urcb_bank = std::make_unique( + *urcb_runtime, + process_objects->objects(), + std::span{urcb_object_storage}, + std::span{urcb_context_storage}, + std::span{urcb_name_storage}, + report_now_ms, + nullptr); + if (!urcb_bank->initialize()) { + throw std::runtime_error("Could not expose URCB MMS attribute objects."); + } + dispatch_objects = &urcb_bank->table(); + dispatch_policy.advertise_flattened_child_aliases = true; + } + + if (manifest_model != nullptr && !manifest_model->brcb_definitions.empty()) { + brcb_runtimes.reserve(manifest_model->brcb_definitions.size()); + for (const auto& definition : manifest_model->brcb_definitions) { + auto brcb = std::make_unique(); + brcb->definition = &definition; + brcb->data_set = find_data_set( + data_sets, definition.data_set_domain, definition.data_set_item); + if (brcb->data_set == nullptr) { + throw std::runtime_error("BRCB references an unavailable DataSet."); + } + for (std::size_t slot = 0U; slot < brcb->slots.size(); ++slot) { + brcb->slots[slot] = mms::MmsStaticBrcbSlot{brcb->slot_storage[slot]}; + } + brcb->reports = std::make_unique( + definition, + brcb->pending, + std::span{brcb->slots}, + *process_objects, + data_sets); + if (!brcb->reports->initialize()) { + std::ostringstream detail; + detail << "Could not initialize per-association BRCB runtime" + << " objectsValid=" << (process_objects->valid() ? "true" : "false") + << " dataSetsValid=" << (data_sets.valid() ? "true" : "false") + << " dataSetsAgainstObjects=" + << (data_sets.valid_against(*process_objects) ? "true" : "false") + << " domain='" << definition.domain << "'" + << " item='" << definition.item << "'" + << " rptId='" << definition.report_id << "'" + << " dataSet='" << definition.data_set_domain << "/" + << definition.data_set_item << "'" + << " optFlds=" + << static_cast(definition.optional_fields[0]) << ',' + << static_cast(definition.optional_fields[1]) + << " trgOps=" << static_cast(definition.trigger_options) + << " slots=" << brcb->slots.size() + << " slot0Bytes=" + << (brcb->slots.empty() ? 0U : brcb->slots.front().storage.size()) + << " dsMembers=" << brcb->data_set->members.size(); + throw std::runtime_error(detail.str()); + } + brcb->control = std::make_unique(*brcb->reports); + + mms::MmsStaticBrcbObjectBank sizing_bank{ + definition, + *brcb->reports, + *brcb->control, + dispatch_objects->objects(), + std::span{}, + std::span{}, + std::span{}, + report_now_ms, + nullptr}; + const auto required_objects = sizing_bank.required_object_capacity(); + const auto required_names = sizing_bank.required_name_bytes(); + if (required_objects == std::numeric_limits::max() || + required_names == std::numeric_limits::max()) { + throw std::runtime_error("BRCB object-bank capacity calculation failed."); + } + brcb->object_storage.resize(required_objects); + brcb->name_storage.resize(required_names); + brcb->bank = std::make_unique( + definition, + *brcb->reports, + *brcb->control, + dispatch_objects->objects(), + std::span{brcb->object_storage}, + std::span{brcb->context_storage}, + std::span{brcb->name_storage}, + report_now_ms, + nullptr); + if (!brcb->bank->initialize()) { + throw std::runtime_error("Could not expose BRCB MMS attribute objects."); + } + dispatch_objects = &brcb->bank->table(); + brcb_runtimes.push_back(std::move(brcb)); + } + dispatch_policy.advertise_flattened_child_aliases = true; + } + + std::vector directory_index; + if (dispatch_policy.advertise_flattened_child_aliases) { + directory_index = build_directory_index(*dispatch_objects); + } const mms::MmsStaticApplicationDispatcher dispatcher{ - object_table, data_sets, dispatch_policy}; + *dispatch_objects, + data_sets, + std::span{directory_index}, + dispatch_policy}; mms::MmsStaticConnectionPolicy policy; policy.association_id = association_id; @@ -1009,6 +2401,11 @@ void serve_connection( policy.owner[index] = static_cast( (association_id >> shift) & 0xFFU); } + if (file_root != nullptr) { + file_session = std::make_unique(*file_root); + policy.confirmed_service = &filehost::StaticFileServiceSession::callback; + policy.confirmed_service_context = file_session.get(); + } mms::MmsStaticConnectionRuntime runtime{dispatcher, policy}; SocketStreamContext socket_context{socket}; @@ -1022,6 +2419,22 @@ void serve_connection( stream, {buffers.receive, buffers.response, buffers.workspace}}; + const auto close_brcbs = [&] { + const auto now_ms = monotonic_ms(); + for (auto& brcb : brcb_runtimes) { + if (brcb != nullptr && brcb->control != nullptr) { + brcb->control->on_association_closed(association_id, now_ms); + } + } + for (auto& binding : direct_control_bindings) { + mms::mms_static_control_on_association_closed(binding); + } + }; + + std::vector changed_value_indices; + if (manifest_model != nullptr) { + changed_value_indices.reserve(manifest_model->values.size()); + } auto previous_state = runtime.state(); auto next_model_refresh = std::chrono::steady_clock::now(); std::size_t total_received = 0U; @@ -1031,56 +2444,384 @@ void serve_connection( if (manifest_model != nullptr && now >= next_model_refresh) { next_model_refresh = now + std::chrono::milliseconds{25}; try { - const auto changed = refresh_manifest_values(*manifest_model); - if (changed != 0U) { - std::cout << "IEDSIM_EVENT kind=value_sync association=" - << association_id << " changed=" << changed - << " revision=" << manifest_model->revision << '\n'; - std::cout.flush(); + changed_value_indices.clear(); + const auto manifest_changed = refresh_manifest_values( + *manifest_model, &changed_value_indices); + bool live_changed{}; + bool resync{}; + if (live_updates != nullptr) { + LiveUpdateCollection collection; + if (manifest_changed != 0U) { + // A legacy manifest refresh may contain stale values for + // objects already changed through the hot channel. Reapply + // the bounded latest-state overlay before notifying reports. + collection = live_updates->latestSnapshot(); + } else { + collection = live_updates->collectSince(live_sequence); + } + if (!collection.updates.empty()) { + live_changed = apply_live_updates( + *manifest_model, collection.updates, &changed_value_indices); + } + live_sequence = collection.sequence; + resync = collection.resync; + } + if (!changed_value_indices.empty()) { + std::sort(changed_value_indices.begin(), changed_value_indices.end()); + changed_value_indices.erase( + std::unique(changed_value_indices.begin(), changed_value_indices.end()), + changed_value_indices.end()); + const auto report_change_ms = monotonic_ms(); + if (urcb_runtime != nullptr) { + notify_urcb_changes( + *manifest_model, + changed_value_indices, + *urcb_runtime, + data_sets, + report_change_ms); + } + notify_brcb_changes( + *manifest_model, + changed_value_indices, + brcb_runtimes, + report_change_ms); + } + if (manifest_changed != 0U) { + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=value_sync association=" + << association_id << " changed=" << manifest_changed + << " revision=" << manifest_model->revision << '\n'; + } + if (live_changed) { + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=live_value_sync association=" + << association_id << " changed=" << changed_value_indices.size() + << " sequence=" << live_sequence + << " resync=" << (resync ? "true" : "false") << '\n'; } } catch (const std::exception& exception) { - std::cerr << "IEDSIM_EVENT kind=value_sync_error association=" - << association_id << " message=" << exception.what() << '\n'; + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=value_sync_error association=" + << association_id << " message=" << exception.what() << '\n'; } } + const auto result = session.poll_once(); total_received += result.bytes_received; total_sent += result.bytes_sent; const auto current_state = runtime.state(); if (current_state != previous_state) { - std::cout << "IEDSIM_EVENT kind=protocol_stage association=" - << association_id << " remote=" << remote - << " stage=" << connection_state_text(current_state) << '\n'; - std::cout.flush(); + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=protocol_stage association=" + << association_id << " remote=" << remote + << " stage=" << connection_state_text(current_state) << '\n'; previous_state = current_state; } if (result.application_service != mms::MmsWireConfirmedService::unknown) { - std::cout << "IEDSIM_EVENT kind=mms_service association=" - << association_id << " remote=" << remote - << " service=" << service_text(result.application_service) - << " invoke=" << result.invoke_id - << " accepted=" - << (result.status == mms::MmsStaticServerSessionStatus::application_rejected - ? "false" : "true") - << '\n'; - std::cout.flush(); + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=mms_service association=" + << association_id << " remote=" << remote + << " service=" << service_text(result.application_service) + << " invoke=" << result.invoke_id + << " accepted=" + << (result.status == mms::MmsStaticServerSessionStatus::application_rejected + ? "false" : "true") + << " rx_bytes=" << result.bytes_received + << " tx_bytes=" << result.bytes_sent + << " pending_tx=" << session.pending_output_bytes() + << '\n'; } if (result.terminal()) { - std::cout << "IEDSIM_EVENT kind=client_closed association=" - << association_id << " remote=" << remote - << " rx=" << total_received << " tx=" << total_sent - << " state=" << connection_state_text(runtime.state()) << '\n'; - std::cout.flush(); + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=client_closed association=" + << association_id << " remote=" << remote + << " rx=" << total_received << " tx=" << total_sent + << " state=" << connection_state_text(runtime.state()) << '\n'; + close_brcbs(); return; } + + 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) { + auto& binding = direct_control_bindings[index]; + if (binding.state == nullptr || !binding.state->pending_termination) continue; + if (index >= manifest_model->direct_control_storage.size()) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=command_termination_error association=" + << association_id << " message=control-index-mismatch\n"; + close_brcbs(); + return; + } + const auto& control = manifest_model->direct_control_storage[index]; + const auto command = binding.state->termination_command; + try { + const auto origin = std::span{ + command.origin_identifier.data(), command.origin_identifier_size}; + 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::integer(command.origin_category), + mms::MmsDataValue::octet_string(origin), + }), + mms::MmsDataValue::unsigned_integer(command.control_number), + 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(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); + const auto frame = ar::iec61850::osi::TpktFrameCodec::encode(cotp); + if (!send_all(socket, frame)) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=command_termination_send_error association=" + << association_id << " object=" << control.selection_reference << '\n'; + close_brcbs(); + return; + } + total_sent += frame.size(); + binding.state->pending_termination = false; + binding.state->termination_command = {}; + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=command_termination association=" + << association_id << " object=" << control.selection_reference + << " ctlNum=" << static_cast(command.control_number) + << " positive=true bytes=" << frame.size() << '\n'; + } catch (const std::exception& exception) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=command_termination_error association=" + << association_id << " object=" << control.selection_reference + << " message=" << exception.what() << '\n'; + close_brcbs(); + return; + } + break; + } + } + // Match the external IEC 61850 client 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) { + if (brcb == nullptr || brcb->reports == nullptr) continue; + mms::MmsStaticBrcbCapturePlan plan; + if (!brcb->reports->next_due(now_ms, plan)) continue; + const auto capture = brcb->reports->capture( + plan, + binary_time, + buffers.report_frame, + buffers.report_workspace); + if (!capture.success()) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=brcb_capture_error association=" + << association_id << " rcb=" + << (brcb->definition == nullptr + ? std::string_view{"unknown"} + : brcb->definition->item) + << " status=" << static_cast(capture.status) + << " required=" << capture.required_bytes << '\n'; + } + } + } + + if (urcb_runtime != nullptr && session.pending_output_bytes() == 0U) { + const auto binary_time = report_binary_time(); + const auto report = mms::MmsStaticReportConnection::poll( + runtime, + *urcb_runtime, + now_ms, + binary_time, + buffers.report_frame, + buffers.report_workspace); + if (report.response_ready()) { + if (!send_all( + socket, + std::span{buffers.report_frame}.first( + report.bytes_written))) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=report_send_error association=" + << association_id << " remote=" << remote << '\n'; + close_brcbs(); + return; + } + total_sent += report.bytes_written; + const auto* definition = urcb_runtime->definition(report.control_block_index); + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=report_sent association=" + << association_id << " buffered=false rcb=" + << (definition == nullptr ? std::string_view{"unknown"} : definition->item) + << " sqnum=" << static_cast(report.sequence_number) + << " reason=" << static_cast(report.reason) + << " bytes=" << report.bytes_written << '\n'; + } else if ( + report.status == mms::MmsStaticReportConnectionStatus::response_buffer_too_small || + report.status == mms::MmsStaticReportConnectionStatus::workspace_too_small || + report.status == mms::MmsStaticReportConnectionStatus::report_encode_failed) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=report_error association=" + << association_id + << " status=" << static_cast(report.status) + << " urcb_status=" << static_cast(report.urcb_status) + << '\n'; + } + } + + if (!brcb_runtimes.empty() && session.pending_output_bytes() == 0U) { + for (auto& brcb : brcb_runtimes) { + if (brcb == nullptr || brcb->reports == nullptr || brcb->control == nullptr) { + continue; + } + const auto staged = mms::MmsStaticBrcbConnection::poll( + runtime, + *brcb->control, + *brcb->reports, + now_ms, + buffers.report_frame, + buffers.report_workspace); + if (!staged.response_ready()) continue; + if (!send_all( + socket, + std::span{buffers.report_frame}.first( + staged.bytes_written))) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=brcb_send_error association=" + << association_id << " remote=" << remote << '\n'; + close_brcbs(); + return; + } + if (!mms::MmsStaticBrcbConnection::commit_sent( + runtime, + *brcb->control, + *brcb->reports, + now_ms, + staged)) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=brcb_commit_error association=" + << association_id << " rcb=" + << (brcb->definition == nullptr + ? std::string_view{"unknown"} + : brcb->definition->item) + << '\n'; + close_brcbs(); + return; + } + total_sent += staged.bytes_written; + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=report_sent association=" + << association_id << " buffered=true rcb=" + << (brcb->definition == nullptr + ? std::string_view{"unknown"} + : brcb->definition->item) + << " sqnum=" << static_cast(staged.sequence_number) + << " bytes=" << staged.bytes_written << '\n'; + break; + } + } + if (result.status == mms::MmsStaticServerSessionStatus::would_block || result.status == mms::MmsStaticServerSessionStatus::timed_out) { std::this_thread::sleep_for(std::chrono::milliseconds{1}); } } + close_brcbs(); runtime.close_transport(); } +struct WorkerSlot final { + std::jthread thread; + std::shared_ptr done; +}; + +[[nodiscard]] WorkerSlot* available_worker(std::vector& workers) { + // Prefer never-used/reaped slots before joining a just-completed worker. + // On Windows a worker can have emitted client_closed while its thread is + // still finishing transport/model teardown; joining that slot directly in + // the accept loop can transiently stop new associations even though other + // worker capacity is available. + for (auto& worker : workers) { + if (!worker.thread.joinable()) return &worker; + } + for (auto& worker : workers) { + if (worker.done != nullptr && worker.done->load(std::memory_order_acquire)) { + worker.thread.join(); + worker.done.reset(); + return &worker; + } + } + return nullptr; +} + } // namespace int main(int argc, char** argv) { @@ -1132,35 +2873,15 @@ int main(int argc, char** argv) { std::array objects{}; objects[0] = mms::MmsStaticObjectEntry{ - "ESP32S3IOLD0", - "LLN0", - lln0_type, - read_encoded, - &root_values[0]}; + "ESP32S3IOLD0", "LLN0", lln0_type, read_encoded, &root_values[0]}; objects[1] = mms::MmsStaticObjectEntry{ - "ESP32S3IOLD0", - "LPHD1", - lphd1_type, - read_encoded, - &root_values[1]}; + "ESP32S3IOLD0", "LPHD1", lphd1_type, read_encoded, &root_values[1]}; objects[2] = mms::MmsStaticObjectEntry{ - "ESP32S3IOLD0", - "GGIO1", - ggio1_type, - read_encoded, - &root_values[2]}; + "ESP32S3IOLD0", "GGIO1", ggio1_type, read_encoded, &root_values[2]}; objects[3] = mms::MmsStaticObjectEntry{ - "ESP32S3IOLD0", - "LLN0$ST$Mod$stVal", - boolean_type, - read_boolean, - &values[0]}; + "ESP32S3IOLD0", "LLN0$ST$Mod$stVal", boolean_type, read_boolean, &values[0]}; objects[4] = mms::MmsStaticObjectEntry{ - "ESP32S3IOLD0", - "LPHD1$ST$PhyHealth$stVal", - boolean_type, - read_boolean, - &values[1]}; + "ESP32S3IOLD0", "LPHD1$ST$PhyHealth$stVal", boolean_type, read_boolean, &values[1]}; constexpr std::array leaf_items{ "GGIO1$ST$Ind1$stVal", "GGIO1$ST$Ind2$stVal", @@ -1205,26 +2926,61 @@ int main(int argc, char** argv) { throw std::runtime_error("Static MMS server model is invalid."); } + std::unique_ptr file_root; + if (!options.file_root.empty()) { + file_root = std::make_unique( + std::filesystem::path{options.file_root}, options.allow_file_delete); + } + const auto listener = create_listener(options.bind_address, options.port); std::set domain_names; for (const auto& object : object_span) domain_names.insert(object.domain); const auto truncated = manifest_model.declared_entries > object_span.size() ? manifest_model.declared_entries - object_span.size() : 0U; - std::cout << "IEDSIM_EVENT kind=server_ready bind=" - << options.bind_address << " port=" << options.port - << " objects=" << object_span.size() - << " domains=" << domain_names.size() - << " datasets=" << data_set_span.size() - << " truncated=" << truncated - << " profile=interop" << '\n'; - std::cout.flush(); - + const auto exposed_objects = object_span.size() + + manifest_model.setting_group_storage.size() * + mms::MmsStaticSettingGroupObjectBank::attributes_per_control_block + + manifest_model.urcb_definitions.size() * + mms::MmsStaticUrcbObjectBank::attributes_per_control_block + + manifest_model.brcb_definitions.size() * + mms::MmsStaticBrcbObjectBank::attributes_per_control_block; + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=server_ready bind=" + << options.bind_address << " port=" << options.port + << " objects=" << exposed_objects + << " domains=" << domain_names.size() + << " datasets=" << data_set_span.size() + << " sgcbs=" << manifest_model.setting_group_storage.size() + << " urcbs=" << manifest_model.urcb_definitions.size() + << " brcbs=" << manifest_model.brcb_definitions.size() + << " declared_brcbs=" << manifest_model.buffered_report_controls + << " omitted_urcbs=" << manifest_model.omitted_urcbs + << " omitted_brcbs=" << manifest_model.omitted_brcbs + << " truncated=" << truncated + << " max_active=" << options.maximum_active_connections + << " files=" << (file_root ? "enabled" : "disabled") + << " file_delete=" << (options.allow_file_delete ? "enabled" : "disabled") + << " profile=interop" << '\n'; + + LiveUpdateBus live_updates{options.live_generation}; + LiveInputState live_input; + std::vector workers(options.maximum_active_connections); std::size_t connection_count = 0U; while (!g_stop.load(std::memory_order_relaxed) && (options.maximum_connections == 0U || connection_count < options.maximum_connections)) { - const auto readiness = wait_socket(listener, true, 200U); + if (options.live_stdin) { + drain_live_stdin(live_input, options, manifest_model, live_updates); + } + auto* worker = available_worker(workers); + if (worker == nullptr) { + std::this_thread::sleep_for(std::chrono::milliseconds{2}); + continue; + } + + const auto readiness = wait_socket( + listener, true, options.live_stdin ? 25U : 200U); if (readiness == SocketWaitStatus::timeout || readiness == SocketWaitStatus::interrupted) { continue; @@ -1245,33 +3001,155 @@ int main(int argc, char** argv) { reinterpret_cast(&peer), &peer_size); if (client == kInvalidSocket) { - if (g_stop.load(std::memory_order_relaxed) || socket_interrupted()) { - continue; - } - std::cerr << "accept() failed: " << socket_error_text() << '\n'; + if (g_stop.load(std::memory_order_relaxed) || socket_interrupted()) continue; + std::osyncstream{std::cerr} + << "accept() failed: " << socket_error_text() << '\n'; continue; } + + // IEC 61850 MMS uses small request/response APDUs where Nagle can + // add avoidable latency when paired with delayed ACK behavior. This + // is a host-socket concern only; protocol timeouts/retries remain + // unchanged. Verify the option after setting it so integration tests + // prove the accepted connection actually runs in low-latency mode. + const bool tcp_nodelay = configure_low_latency_socket(client); + if (!tcp_nodelay) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=socket_option_warning option=tcp_nodelay" + << " error=" << socket_error_text() << '\n'; + } + ++connection_count; + const auto association_id = static_cast(connection_count); const auto remote = peer_address(peer); - std::cout << "IEDSIM_EVENT kind=client_connected association=" - << connection_count << " remote=" << remote << '\n'; - std::cout.flush(); - serve_connection( + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=client_connected association=" + << association_id << " remote=" << remote + << " tcp_nodelay=" << (tcp_nodelay ? "true" : "false") << '\n'; + + worker->done = std::make_shared(false); + const auto done = worker->done; + worker->thread = std::jthread([ + &options, + &manifest_type, + &manifest_value, + &manifest_model, + &live_updates, + &object_table, + &data_sets, + &file_root, client, - object_table, - data_sets, - manifest_model.objects.empty() ? nullptr : &manifest_model, - static_cast(connection_count), - remote); - close_socket(client); + association_id, + remote, + done] { + try { + if (!options.model_manifest.empty()) { + auto local_model = load_manifest_model( + options.model_manifest, manifest_type, manifest_value); + + // Process state belongs to the simulated IED, not to an MMS + // association. Per-association model copies isolate mutable + // report/control bookkeeping, but every configured control + // must point at the canonical server-level process/selection + // state so an Oper performed by one client is immediately + // visible to a second client and SBO ownership is global. + for (auto& local_control : local_model.direct_control_storage) { + const auto shared = std::find_if( + manifest_model.direct_control_storage.begin(), + manifest_model.direct_control_storage.end(), + [&](const auto& candidate) { + return candidate.domain == local_control.domain && + candidate.status_item == local_control.status_item && + candidate.control_model == local_control.control_model; + }); + if (shared == manifest_model.direct_control_storage.end() || + shared->shared_state == nullptr) { + throw std::runtime_error( + "Per-association control has no canonical shared state."); + } + local_control.shared_state = shared->shared_state; + } + for (auto& local_setting : local_model.setting_group_storage) { + const auto shared = std::find_if( + manifest_model.setting_group_storage.begin(), + manifest_model.setting_group_storage.end(), + [&](const auto& candidate) { + return candidate.domain == local_setting.domain && + candidate.item == local_setting.item; + }); + if (shared == manifest_model.setting_group_storage.end() || + shared->shared_state == nullptr) { + throw std::runtime_error( + "Per-association SGCB has no canonical shared state."); + } + local_setting.shared_state = shared->shared_state; + } + + std::uint64_t live_sequence{}; + if (options.live_stdin) { + const auto overlay = live_updates.latestSnapshot(); + std::vector overlay_indices; + overlay_indices.reserve(overlay.updates.size()); + static_cast(apply_live_updates( + local_model, overlay.updates, &overlay_indices)); + live_sequence = overlay.sequence; + } + + const auto local_object_span = + std::span{local_model.objects}; + const auto local_data_set_span = + std::span{local_model.data_sets}; + const mms::MmsStaticObjectTable local_object_table{local_object_span}; + const mms::MmsStaticDataSetTable local_data_sets{local_data_set_span}; + if (!local_object_table.valid() || + !local_data_sets.valid() || + !local_data_sets.valid_against(local_object_table)) { + throw std::runtime_error( + "Per-association MMS manifest model is invalid."); + } + serve_connection( + client, + local_object_table, + local_data_sets, + &local_model, + options.live_stdin ? &live_updates : nullptr, + live_sequence, + file_root.get(), + association_id, + remote); + } else { + serve_connection( + client, + object_table, + data_sets, + nullptr, + nullptr, + 0U, + file_root.get(), + association_id, + remote); + } + } catch (const std::exception& exception) { + std::osyncstream{std::cerr} + << "IEDSIM_EVENT kind=client_error association=" + << association_id << " remote=" << remote + << " message=" << exception.what() << '\n'; + } + close_socket(client); + done->store(true, std::memory_order_release); + }); } close_socket(listener); - std::cout << "IEDSIM_EVENT kind=server_stopped connections=" - << connection_count << '\n'; + for (auto& worker : workers) { + if (worker.thread.joinable()) worker.thread.join(); + } + std::osyncstream{std::cout} + << "IEDSIM_EVENT kind=server_stopped connections=" + << connection_count << '\n'; return 0; } catch (const std::exception& exception) { std::cerr << "Static IED server failed: " << exception.what() << '\n'; return 2; } -} +} \ No newline at end of file diff --git a/tools/static_ied_server_iedsim/CMakeLists.txt b/tools/static_ied_server_iedsim/CMakeLists.txt index 7eefa2752..d0feda6f1 100644 --- a/tools/static_ied_server_iedsim/CMakeLists.txt +++ b/tools/static_ied_server_iedsim/CMakeLists.txt @@ -37,12 +37,23 @@ add_library(ariec61850_interop_parity_server_core STATIC "${ARSTACK_ROOT}/src/mms/write_span.cpp" "${ARSTACK_ROOT}/src/mms/data_set_span.cpp" "${ARSTACK_ROOT}/src/mms/information_report_span.cpp" + "${ARSTACK_ROOT}/src/mms/buffered_selective_information_report_span.cpp" "${ARSTACK_ROOT}/src/mms/static_object_table.cpp" "${ARSTACK_ROOT}/src/mms/static_write.cpp" + "${ARSTACK_ROOT}/src/mms/static_direct_control.cpp" "${ARSTACK_ROOT}/src/mms/static_data_set_table.cpp" + "${ARSTACK_ROOT}/src/mms/static_setting_group.cpp" "${ARSTACK_ROOT}/src/mms/static_dispatcher.cpp" "${ARSTACK_ROOT}/src/mms/connection_runtime_iedsim.cpp" - "${ARSTACK_ROOT}/src/mms/static_server_session.cpp") + "${ARSTACK_ROOT}/src/mms/static_server_session.cpp" + "${ARSTACK_ROOT}/src/mms/static_information_report.cpp" + "${ARSTACK_ROOT}/src/mms/static_report_connection.cpp" + "${ARSTACK_ROOT}/src/mms/static_urcb_objects.cpp" + "${ARSTACK_ROOT}/src/mms/static_urcb_runtime.cpp" + "${ARSTACK_ROOT}/src/mms/static_brcb_runtime.cpp" + "${ARSTACK_ROOT}/src/mms/static_brcb_control.cpp" + "${ARSTACK_ROOT}/src/mms/static_brcb_objects.cpp" + "${ARSTACK_ROOT}/src/mms/static_brcb_connection.cpp") target_include_directories(ariec61850_interop_parity_server_core PUBLIC "${ARSTACK_ROOT}/include") target_compile_features(ariec61850_interop_parity_server_core PUBLIC cxx_std_20) @@ -74,6 +85,49 @@ target_link_libraries(ariec61850_interop_parity_read_probe PRIVATE target_compile_features(ariec61850_interop_parity_read_probe PRIVATE cxx_std_20) parity_strict_warnings(ariec61850_interop_parity_read_probe) +add_executable(ariec61850_interop_parity_type_probe + "${ARSTACK_ROOT}/tools/mms_type_probe.cpp") +target_link_libraries(ariec61850_interop_parity_type_probe PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_interop_parity_type_probe PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_parity_type_probe) + +add_executable(ariec61850_interop_parity_urcb_gi_probe + "${ARSTACK_ROOT}/tools/mms_urcb_gi_probe.cpp") +target_link_libraries(ariec61850_interop_parity_urcb_gi_probe PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_interop_parity_urcb_gi_probe PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_parity_urcb_gi_probe) + +add_executable(ariec61850_interop_parity_control_probe + "${ARSTACK_ROOT}/tools/control_interop_probe.cpp") +target_link_libraries(ariec61850_interop_parity_control_probe PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_interop_parity_control_probe PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_parity_control_probe) + +add_executable(ariec61850_interop_control_wire_probe + "${ARSTACK_ROOT}/tools/interop_control_wire_probe.cpp") +target_link_libraries(ariec61850_interop_control_wire_probe PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_interop_control_wire_probe PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_control_wire_probe) + +add_executable(ariec61850_interop_reporting_golden_test + "${CMAKE_CURRENT_LIST_DIR}/test_reporting_golden.cpp") +target_link_libraries(ariec61850_interop_reporting_golden_test PRIVATE + ariec61850_interop_parity_server_core + ARIEC61850::core) +target_compile_features(ariec61850_interop_reporting_golden_test PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_reporting_golden_test) + +add_executable(ariec61850_interop_reporting_wire_probe + "${ARSTACK_ROOT}/tools/interop_reporting_wire_probe.cpp") +target_link_libraries(ariec61850_interop_reporting_wire_probe PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_interop_reporting_wire_probe PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_reporting_wire_probe) + enable_testing() add_test(NAME interop_parity_server_help COMMAND ariec61850_static_ied_server_iedsim --help) @@ -94,4 +148,85 @@ add_test( "${CMAKE_CURRENT_LIST_DIR}/test_runtime_model.py" --server $ --discovery $ - --read-probe $) + --read-probe $ + --urcb-probe $) + +add_test( + NAME interop_parity_full_discovery_large_model + COMMAND + ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_LIST_DIR}/test_interop_full_discovery.py" + --server $ + --discovery $) +set_tests_properties( + interop_parity_full_discovery_large_model + PROPERTIES TIMEOUT 20) + +add_test( + NAME interop_parity_cswi1_type_golden + COMMAND + ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_LIST_DIR}/test_cswi1_type_golden.py" + --server $ + --type-probe $) +set_tests_properties( + interop_parity_cswi1_type_golden + PROPERTIES TIMEOUT 20) + +add_test( + NAME interop_parity_dpc_control + COMMAND + ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_LIST_DIR}/test_control_golden.py" + --server $ + --control-probe $) + +add_test( + NAME interop_parity_real_control_wire + COMMAND + ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_LIST_DIR}/test_interop_control_wire.py" + --server $ + --wire-probe $) +set_tests_properties( + interop_parity_real_control_wire + PROPERTIES TIMEOUT 20) + +add_test( + NAME interop_parity_reporting_golden + COMMAND ariec61850_interop_reporting_golden_test) +set_tests_properties( + interop_parity_reporting_golden + PROPERTIES TIMEOUT 20) + +add_test( + NAME interop_parity_reporting_wire + COMMAND + ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_LIST_DIR}/test_reporting_wire.py" + --server $ + --wire-probe $) +set_tests_properties( + interop_parity_reporting_wire + PROPERTIES TIMEOUT 30) +add_executable(ariec61850_interop_parity_file_transfer + "${ARSTACK_ROOT}/tools/file_transfer.cpp") +target_link_libraries(ariec61850_interop_parity_file_transfer PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_interop_parity_file_transfer PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_parity_file_transfer) + +add_executable(ariec61850_interop_host_file_service_test + "${ARSTACK_ROOT}/tools/static_file_service_backend_test.cpp") +target_link_libraries(ariec61850_interop_host_file_service_test PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_interop_host_file_service_test PRIVATE cxx_std_20) +parity_strict_warnings(ariec61850_interop_host_file_service_test) + +add_test(NAME interop_host_file_service + COMMAND ariec61850_interop_host_file_service_test) +add_test( + NAME interop_file_service_loopback + COMMAND + ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_LIST_DIR}/test_file_service.py" + --server $ + --client $) \ No newline at end of file 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()) diff --git a/tools/static_ied_server_iedsim/test_cswi1_type_golden.py b/tools/static_ied_server_iedsim/test_cswi1_type_golden.py new file mode 100644 index 000000000..44283a584 --- /dev/null +++ b/tools/static_ied_server_iedsim/test_cswi1_type_golden.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Exact OMICRON IEDScout CSWI1 TypeSpecification golden regression.""" + +from __future__ import annotations + +import argparse +import json +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 listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def creation_flags() -> int: + return getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + + +def manifest() -> str: + d = "AA1E1F06R4ESQZ1" + lines = [ + "ARSTACK_IED_MODEL\t2\t1", + f"LN\t{d}\tCSWI1", + + # ST — exact leaf order/type shape captured from OMICRON IEDScout. + f"OBJ\t{d}\tCSWI1$ST$Mod$stVal\tENUM\tEnumeration\t1", + f"OBJ\t{d}\tCSWI1$ST$Mod$q\tQUALITY\tQuality\tgood", + f"OBJ\t{d}\tCSWI1$ST$Mod$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$ST$Beh$stVal\tENUM\tEnumeration\t1", + f"OBJ\t{d}\tCSWI1$ST$Beh$q\tQUALITY\tQuality\tgood", + f"OBJ\t{d}\tCSWI1$ST$Beh$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$ST$Health$stVal\tENUM\tEnumeration\t1", + f"OBJ\t{d}\tCSWI1$ST$Health$q\tQUALITY\tQuality\tgood", + f"OBJ\t{d}\tCSWI1$ST$Health$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$ST$Loc$stVal\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$ST$Loc$q\tQUALITY\tQuality\tgood", + f"OBJ\t{d}\tCSWI1$ST$Loc$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$ST$Pos$origin$orCat\tINT8\tNumber\t0", + f"OBJ\t{d}\tCSWI1$ST$Pos$origin$orIdent\tOctet64\tOctet64\thex:4152", + f"OBJ\t{d}\tCSWI1$ST$Pos$ctlNum\tINT8U\tNumber\t1", + f"OBJ\t{d}\tCSWI1$ST$Pos$stVal\tDBPOS\tEnumeration\toff", + f"OBJ\t{d}\tCSWI1$ST$Pos$q\tQUALITY\tQuality\tgood", + f"OBJ\t{d}\tCSWI1$ST$Pos$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$ST$Pos$stSeld\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$ST$LocKey$stVal\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$ST$LocKey$q\tQUALITY\tQuality\tgood", + f"OBJ\t{d}\tCSWI1$ST$LocKey$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$ST$LocSta$origin$orCat\tINT8\tNumber\t0", + f"OBJ\t{d}\tCSWI1$ST$LocSta$origin$orIdent\tOctet64\tOctet64\thex:4152", + f"OBJ\t{d}\tCSWI1$ST$LocSta$ctlNum\tINT8U\tNumber\t1", + f"OBJ\t{d}\tCSWI1$ST$LocSta$stVal\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$ST$LocSta$q\tQUALITY\tQuality\tgood", + f"OBJ\t{d}\tCSWI1$ST$LocSta$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + + # Keep engineering declaration order deliberately as the failed ARStack + # capture (CF/DC/EX/OR before CO). Dispatcher parity must still project + # the IEDScout root order ST,CO,CF,DC,EX,OR. + f"OBJ\t{d}\tCSWI1$CF$Mod$ctlModel\tENUM\tEnumeration\t0", + f"OBJ\t{d}\tCSWI1$CF$Pos$ctlModel\tENUM\tEnumeration\t4", + f"OBJ\t{d}\tCSWI1$CF$Pos$sboTimeout\tINT32U\tNumber\t10000", + f"OBJ\t{d}\tCSWI1$CF$Pos$operTimeout\tINT32U\tNumber\t10000", + f"OBJ\t{d}\tCSWI1$CF$LocSta$ctlModel\tENUM\tEnumeration\t1", + f"OBJ\t{d}\tCSWI1$DC$NamPlt$vendor\tVisString255\tText\tSiemens", + f"OBJ\t{d}\tCSWI1$DC$NamPlt$swRev\tVisString255\tText\t1", + f"OBJ\t{d}\tCSWI1$DC$NamPlt$d\tVisString255\tText\tCSWI", + f"OBJ\t{d}\tCSWI1$DC$NamPlt$configRev\tVisString255\tText\t1", + f"OBJ\t{d}\tCSWI1$EX$NamPlt$lnNs\tVisString255\tText\tIEC 61850-7-4:2007", + f"OBJ\t{d}\tCSWI1$OR$Pos$opRcvd\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$OR$Pos$opOk\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$OR$Pos$tOpOk\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$OR$LocSta$opRcvd\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$OR$LocSta$opOk\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$OR$LocSta$tOpOk\tTimestamp\tTimestamp\tunix-ms:1720000000000", + + # CO.Pos SBO is an engineering object; SBOw/Oper/Cancel are compiled + # virtual service objects from CTL. + f"OBJ\t{d}\tCSWI1$CO$Pos$SBO\tObjRef\tText\t{d}/CSWI1.Pos", + f"OBJ\t{d}\tCSWI1$CO$LocSta$Oper$ctlVal\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$CO$LocSta$Oper$origin$orCat\tINT8\tNumber\t0", + f"OBJ\t{d}\tCSWI1$CO$LocSta$Oper$origin$orIdent\tOctet64\tOctet64\thex:4152", + f"OBJ\t{d}\tCSWI1$CO$LocSta$Oper$ctlNum\tINT8U\tNumber\t1", + f"OBJ\t{d}\tCSWI1$CO$LocSta$Oper$T\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{d}\tCSWI1$CO$LocSta$Oper$Test\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{d}\tCSWI1$CO$LocSta$Oper$Check\tCHECK\tCheck\t0", + f"CTL\t{d}\tCSWI1\tPos\tDPC\t4", + "", + ] + return "\n".join(lines) + + +EXPECTED = [ + ("ST.Mod.stVal", "integer", 8), + ("ST.Mod.q", "bit-string", 13), + ("ST.Mod.t", "utc-time", None), + ("ST.Beh.stVal", "integer", 8), + ("ST.Beh.q", "bit-string", 13), + ("ST.Beh.t", "utc-time", None), + ("ST.Health.stVal", "integer", 8), + ("ST.Health.q", "bit-string", 13), + ("ST.Health.t", "utc-time", None), + ("ST.Loc.stVal", "boolean", None), + ("ST.Loc.q", "bit-string", 13), + ("ST.Loc.t", "utc-time", None), + ("ST.Pos.origin.orCat", "integer", 8), + ("ST.Pos.origin.orIdent", "octet-string", -64), + ("ST.Pos.ctlNum", "unsigned", 8), + ("ST.Pos.stVal", "bit-string", 2), + ("ST.Pos.q", "bit-string", 13), + ("ST.Pos.t", "utc-time", None), + ("ST.Pos.stSeld", "boolean", None), + ("ST.LocKey.stVal", "boolean", None), + ("ST.LocKey.q", "bit-string", 13), + ("ST.LocKey.t", "utc-time", None), + ("ST.LocSta.origin.orCat", "integer", 8), + ("ST.LocSta.origin.orIdent", "octet-string", -64), + ("ST.LocSta.ctlNum", "unsigned", 8), + ("ST.LocSta.stVal", "boolean", None), + ("ST.LocSta.q", "bit-string", 13), + ("ST.LocSta.t", "utc-time", None), + ("CO.Pos.SBO", "visible-string", -129), + ("CO.Pos.SBOw.ctlVal", "boolean", None), + ("CO.Pos.SBOw.origin.orCat", "integer", 8), + ("CO.Pos.SBOw.origin.orIdent", "octet-string", -64), + ("CO.Pos.SBOw.ctlNum", "unsigned", 8), + ("CO.Pos.SBOw.T", "utc-time", None), + ("CO.Pos.SBOw.Test", "boolean", None), + ("CO.Pos.SBOw.Check", "bit-string", 2), + ("CO.Pos.Oper.ctlVal", "boolean", None), + ("CO.Pos.Oper.origin.orCat", "integer", 8), + ("CO.Pos.Oper.origin.orIdent", "octet-string", -64), + ("CO.Pos.Oper.ctlNum", "unsigned", 8), + ("CO.Pos.Oper.T", "utc-time", None), + ("CO.Pos.Oper.Test", "boolean", None), + ("CO.Pos.Oper.Check", "bit-string", 2), + ("CO.Pos.Cancel.ctlVal", "boolean", None), + ("CO.Pos.Cancel.origin.orCat", "integer", 8), + ("CO.Pos.Cancel.origin.orIdent", "octet-string", -64), + ("CO.Pos.Cancel.ctlNum", "unsigned", 8), + ("CO.Pos.Cancel.T", "utc-time", None), + ("CO.Pos.Cancel.Test", "boolean", None), + ("CO.LocSta.Oper.ctlVal", "boolean", None), + ("CO.LocSta.Oper.origin.orCat", "integer", 8), + ("CO.LocSta.Oper.origin.orIdent", "octet-string", -64), + ("CO.LocSta.Oper.ctlNum", "unsigned", 8), + ("CO.LocSta.Oper.T", "utc-time", None), + ("CO.LocSta.Oper.Test", "boolean", None), + ("CO.LocSta.Oper.Check", "bit-string", 2), + ("CF.Mod.ctlModel", "integer", 8), + ("CF.Pos.ctlModel", "integer", 8), + ("CF.Pos.sboTimeout", "unsigned", 32), + ("CF.Pos.operTimeout", "unsigned", 32), + ("CF.LocSta.ctlModel", "integer", 8), + ("DC.NamPlt.vendor", "visible-string", -255), + ("DC.NamPlt.swRev", "visible-string", -255), + ("DC.NamPlt.d", "visible-string", -255), + ("DC.NamPlt.configRev", "visible-string", -255), + ("EX.NamPlt.lnNs", "visible-string", -255), + ("OR.Pos.opRcvd", "boolean", None), + ("OR.Pos.opOk", "boolean", None), + ("OR.Pos.tOpOk", "utc-time", None), + ("OR.LocSta.opRcvd", "boolean", None), + ("OR.LocSta.opOk", "boolean", None), + ("OR.LocSta.tOpOk", "utc-time", None), +] + + +def signed_size(node: dict) -> int | None: + value = node.get("size") + if value is None: + return None + size = int(value) + return -size if node.get("variableLength") else size + + +def flatten(node: dict, prefix: str = "") -> list[tuple[str, str, int | None]]: + name = str(node.get("name", "")) + path = ".".join(part for part in (prefix, name) if part) + if node.get("kind") == "structure": + out: list[tuple[str, str, int | None]] = [] + for child in node.get("children", []): + out.extend(flatten(child, path)) + return out + return [(path, str(node.get("kind")), signed_size(node))] + + +def child_names(node: dict) -> list[str]: + return [str(child.get("name", "")) for child in node.get("children", [])] + + +def child(node: dict, name: str) -> dict: + for item in node.get("children", []): + if item.get("name") == name: + return item + raise RuntimeError(f"missing child {name!r}") + + +def run_probe(probe: str, port: int) -> dict: + command = [ + probe, "127.0.0.1", str(port), + "--domain", "AA1E1F06R4ESQZ1", + "--item", "CSWI1", + ] + deadline = time.monotonic() + 6.0 + last = "" + while time.monotonic() < deadline: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=8, + check=False, + creationflags=creation_flags(), + ) + last = result.stdout + result.stderr + if result.returncode == 0: + return json.loads(result.stdout) + time.sleep(0.10) + raise RuntimeError(f"GVAA type probe did not succeed:\n{last}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--type-probe", required=True) + args = parser.parse_args() + + port = free_port() + with tempfile.TemporaryDirectory(prefix="arstack-cswi1-golden-") as directory: + root = Path(directory) + model = root / "cswi1.model" + model.write_text(manifest(), encoding="utf-8", newline="\n") + stdout_path = root / "server.stdout.log" + stderr_path = root / "server.stderr.log" + + with stdout_path.open("w+", encoding="utf-8") as out, \ + stderr_path.open("w+", encoding="utf-8") as err: + server = subprocess.Popen( + [ + args.server, + "--host", "127.0.0.1", + "--port", str(port), + "--model-manifest", str(model), + "--max-connections", "3", + "--max-active", "3", + ], + stdout=out, + stderr=err, + text=True, + creationflags=creation_flags(), + ) + try: + document = run_probe(args.type_probe, port) + finally: + server.kill() + server.wait(timeout=8) + + root_type = document.get("type") + if not isinstance(root_type, dict): + raise RuntimeError(f"type probe returned no root TypeSpecification: {document}") + + root_order = child_names(root_type) + if root_order != ["ST", "CO", "CF", "DC", "EX", "OR"]: + raise RuntimeError(f"CSWI1 FC order mismatch: {root_order}") + + co = child(root_type, "CO") + pos = child(co, "Pos") + pos_order = child_names(pos) + if pos_order != ["SBO", "SBOw", "Oper", "Cancel"]: + raise RuntimeError(f"CO.Pos service order mismatch: {pos_order}") + + actual = flatten(root_type) + if actual != EXPECTED: + limit = max(len(actual), len(EXPECTED)) + differences = [] + for index in range(limit): + observed = actual[index] if index < len(actual) else None + expected = EXPECTED[index] if index < len(EXPECTED) else None + if observed != expected: + differences.append( + f"{index}: observed={observed!r} expected={expected!r}" + ) + if len(differences) >= 20: + break + raise RuntimeError( + "CSWI1 golden TypeSpecification mismatch: " + f"observed={len(actual)} expected={len(EXPECTED)}\n" + + "\n".join(differences) + ) + + server_stdout = stdout_path.read_text(encoding="utf-8", errors="replace") + server_stderr = stderr_path.read_text(encoding="utf-8", errors="replace") + if "kind=client_error" in server_stdout or "protocol_error" in server_stdout: + raise RuntimeError( + "server reported protocol error during golden GVAA:\n" + + server_stdout + "\n" + server_stderr + ) + + print( + "IEDSCOUT_CSWI1_TYPE_GOLDEN_PASS " + "leaves=72 root=ST,CO,CF,DC,EX,OR " + "control=SBO,SBOw,Oper,Cancel mismatches=0" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/static_ied_server_iedsim/test_file_service.py b/tools/static_ied_server_iedsim/test_file_service.py new file mode 100644 index 000000000..d4cc13c6e --- /dev/null +++ b/tools/static_ied_server_iedsim/test_file_service.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import socket +import subprocess +import tempfile +import time + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def run_client(client: str, port: int, *args: str) -> dict: + completed = subprocess.run( + [client, "127.0.0.1", str(port), *args, "--json"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=20, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError( + f"client failed rc={completed.returncode} args={args}\n" + f"stdout={completed.stdout}\nstderr={completed.stderr}" + ) + lines = [line for line in completed.stdout.splitlines() if line.strip().startswith("{")] + if not lines: + raise RuntimeError(f"client produced no JSON: {completed.stdout!r}") + return json.loads(lines[-1]) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--client", required=True) + args = parser.parse_args() + + with tempfile.TemporaryDirectory(prefix="arstack-file-server-") as directory: + root = pathlib.Path(directory) + payload = bytes(((index * 29 + 11) & 0xFF) for index in range(70_000)) + (root / "FRA00028.dat").write_bytes(payload) + (root / "FRA00028.cfg").write_text("CFG\n", encoding="ascii") + (root / "nested").mkdir() + download = root / "download.bin" + port = free_port() + + server = subprocess.Popen( + [ + args.server, + "--host", "127.0.0.1", + "--port", str(port), + "--file-root", str(root), + "--allow-file-delete", + "--max-connections", "4", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + time.sleep(0.25) + first = run_client(args.client, port, "list") + names = {entry["name"] for entry in first.get("entries", [])} + if not {"FRA00028.cfg", "FRA00028.dat", "nested"}.issubset(names): + raise RuntimeError(f"initial FileDirectory missing entries: {first}") + + transfer = run_client( + args.client, port, "download", + "--remote", "FRA00028.dat", + "--output", str(download), + ) + if not transfer.get("success") or not transfer.get("remoteFileClosed"): + raise RuntimeError(f"download did not close cleanly: {transfer}") + if download.read_bytes() != payload: + raise RuntimeError("downloaded bytes differ from source") + + deleted = run_client( + args.client, port, "delete", "--remote", "FRA00028.cfg" + ) + if not deleted.get("success"): + raise RuntimeError(f"FileDelete failed: {deleted}") + if (root / "FRA00028.cfg").exists(): + raise RuntimeError("FileDelete returned success but file still exists") + + refreshed = run_client(args.client, port, "list") + refreshed_names = {entry["name"] for entry in refreshed.get("entries", [])} + if "FRA00028.cfg" in refreshed_names or "FRA00028.dat" not in refreshed_names: + raise RuntimeError(f"directory refresh is inconsistent: {refreshed}") + + stdout, stderr = server.communicate(timeout=15) + if server.returncode != 0: + raise RuntimeError( + f"server failed rc={server.returncode}\nstdout={stdout}\nstderr={stderr}" + ) + required = ["service=FileDirectory", "service=FileOpen", "service=FileRead", + "service=FileClose", "service=FileDelete"] + missing = [marker for marker in required if marker not in stdout] + if missing: + raise RuntimeError( + f"server diagnostics missing {missing}\nstdout={stdout}\nstderr={stderr}" + ) + print( + "IEDSCOUT_FILE_SERVER_LOOPBACK_PASS " + "directory=pass open=pass read=pass close=pass delete=pass " + "refresh=pass segmented_payload=70000" + ) + return 0 + finally: + if server.poll() is None: + server.terminate() + try: + server.wait(timeout=3) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=3) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/static_ied_server_iedsim/test_interop_control_wire.py b/tools/static_ied_server_iedsim/test_interop_control_wire.py new file mode 100644 index 000000000..ca688dab9 --- /dev/null +++ b/tools/static_ied_server_iedsim/test_interop_control_wire.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Real external vendor external IEC 61850 client SBO-enhanced wire regression. + +Locks the observed external-client behavior: +- ctlNum = 0 is accepted on SBOw and Oper; +- Oper may use a fresh T relative to SBOw; +- Check requests synchro + interlock; +- positive CommandTermination precedes process feedback. +""" + +from __future__ import annotations + +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 listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.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\tAA1E1F06R4Q0\tCSWI1", + "OBJ\tAA1E1F06R4Q0\tCSWI1$ST$Pos$stVal\tDBPOS\tEnumeration\toff", + "OBJ\tAA1E1F06R4Q0\tCSWI1$ST$Pos$q\tQUALITY\tQuality\tgood", + "OBJ\tAA1E1F06R4Q0\tCSWI1$ST$Pos$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + "OBJ\tAA1E1F06R4Q0\tCSWI1$CF$Pos$ctlModel\tENUM\tEnumeration\t4", + "CTL\tAA1E1F06R4Q0\tCSWI1\tPos\tDPC\t4", + "", + ] + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--wire-probe", required=True) + args = parser.parse_args() + + port = free_port() + with tempfile.TemporaryDirectory(prefix="arstack-interop-control-wire-") as directory: + root = Path(directory) + model = root / "interop-control.model" + model.write_text(manifest(), encoding="utf-8", newline="\n") + stdout_path = root / "server.stdout.log" + stderr_path = root / "server.stderr.log" + + with stdout_path.open("w+", encoding="utf-8") as out, \ + stderr_path.open("w+", encoding="utf-8") as err: + server = subprocess.Popen( + [ + args.server, + "--host", "127.0.0.1", + "--port", str(port), + "--model-manifest", str(model), + "--max-connections", "4", + "--max-active", "4", + ], + stdout=out, + stderr=err, + text=True, + creationflags=creation_flags(), + ) + try: + time.sleep(0.35) + probe = subprocess.run( + [ + args.wire_probe, + "127.0.0.1", + str(port), + "AA1E1F06R4Q0", + ], + capture_output=True, + text=True, + timeout=12, + check=False, + creationflags=creation_flags(), + ) + if probe.returncode != 0: + raise RuntimeError( + f"external IEC 61850 client wire probe failed (exit={probe.returncode}):\n" + f"stdout:\n{probe.stdout}\nstderr:\n{probe.stderr}" + ) + if "INTEROP_ZERO_CTLNUM_CONTROL_PASS" not in probe.stdout: + raise RuntimeError( + "wire probe did not emit the expected pass marker:\n" + + probe.stdout + ) + # Give the server one bounded scheduling turn to emit feedback + # after positive termination. + time.sleep(0.10) + 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 "DataAccessError=11" in server_stdout or "protocol_error" in server_stdout: + raise RuntimeError( + "server rejected the real external IEC 61850 client-compatible command:\n" + + server_stdout + "\n" + server_stderr + ) + + 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 termination_index >= feedback_index + or "positive=true" not in server_stdout + or "value=on" not in server_stdout + ): + raise RuntimeError( + "server did not preserve positive termination before process feedback:\n" + + server_stdout + "\n" + server_stderr + ) + + print( + "INTEROP_REAL_CONTROL_GOLDEN_PASS " + "ctlNum=0 freshOperT=true " + "sequence=SBOw,Oper,CommandTermination,ProcessFeedback" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/static_ied_server_iedsim/test_interop_full_discovery.py b/tools/static_ied_server_iedsim/test_interop_full_discovery.py new file mode 100644 index 000000000..cd9199e0e --- /dev/null +++ b/tools/static_ied_server_iedsim/test_interop_full_discovery.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Large-model external IEC 61850 client discovery parity regression. + +Reproduces the failure shape captured from external vendor external IEC 61850 client: a ~5k-object +reference IEC 61850 model-like inventory where GVAA of AA1E1F06R4ESQZ1/CSWI1 must complete +without stalling the association worker. +""" + +from __future__ import annotations + +import argparse +import json +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 listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def creation_flags() -> int: + return getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + + +def build_manifest() -> str: + lines = ["ARSTACK_IED_MODEL\t2\t1"] + target = "AA1E1F06R4ESQZ1" + # Preserve the same root order seen on the golden external IEC 61850 client server. + lines.extend([ + f"LN\t{target}\tLLN0", + f"LN\t{target}\tCSWI1", + f"LN\t{target}\tCILO1", + f"LN\t{target}\tXSWI1", + f"OBJ\t{target}\tLLN0$ST$Mod$stVal\tENUM\tEnumeration\t1", + f"OBJ\t{target}\tLLN0$ST$Mod$q\tQUALITY\tQuality\tgood", + f"OBJ\t{target}\tLLN0$ST$Mod$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{target}\tCSWI1$ST$Pos$stVal\tDBPOS\tEnumeration\toff", + f"OBJ\t{target}\tCSWI1$ST$Pos$q\tQUALITY\tQuality\tgood", + f"OBJ\t{target}\tCSWI1$ST$Pos$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"OBJ\t{target}\tCSWI1$ST$Pos$stSeld\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{target}\tCSWI1$CF$Pos$ctlModel\tENUM\tEnumeration\t4", + f"OBJ\t{target}\tCSWI1$CF$Pos$sboTimeout\tINT32U\tNumber\t10000", + f"OBJ\t{target}\tCSWI1$CF$Pos$operTimeout\tINT32U\tNumber\t10000", + f"OBJ\t{target}\tCSWI1$DC$NamPlt$vendor\tVisString255\tString\tARStack", + f"OBJ\t{target}\tCSWI1$EX$EEHealth$stVal\tENUM\tEnumeration\t1", + f"OBJ\t{target}\tCSWI1$OR$OpCnt$stVal\tINT32U\tNumber\t0", + f"OBJ\t{target}\tCILO1$ST$EnaOpn$stVal\tBOOLEAN\tBoolean\ttrue", + f"OBJ\t{target}\tXSWI1$ST$Pos$stVal\tDBPOS\tEnumeration\toff", + f"CTL\t{target}\tCSWI1\tPos\tDPC\t4", + ]) + # Make CSWI1 non-trivial while keeping the same functional-constraint shape. + for index in range(1, 81): + lines.extend([ + f"OBJ\t{target}\tCSWI1$ST$Ind{index}$stVal\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{target}\tCSWI1$ST$Ind{index}$q\tQUALITY\tQuality\tgood", + f"OBJ\t{target}\tCSWI1$ST$Ind{index}$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + ]) + + # Inflate the global object table to the production-scale range that exposed + # the pathological repeated full-table scans. + for ld in range(1, 32): + domain = f"AA1E1F06R4LD{ld:02d}" + lines.append(f"LN\t{domain}\tGGIO1") + for index in range(1, 51): + lines.extend([ + f"OBJ\t{domain}\tGGIO1$ST$Ind{index}$stVal\tBOOLEAN\tBoolean\tfalse", + f"OBJ\t{domain}\tGGIO1$ST$Ind{index}$q\tQUALITY\tQuality\tgood", + f"OBJ\t{domain}\tGGIO1$ST$Ind{index}$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + ]) + lines.append("") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--discovery", required=True) + args = parser.parse_args() + + port = free_port() + with tempfile.TemporaryDirectory(prefix="arstack-interop-full-discovery-") as directory: + root = Path(directory) + model = root / "large.model" + model.write_text(build_manifest(), encoding="utf-8", newline="\n") + stdout_path = root / "server.stdout.log" + stderr_path = root / "server.stderr.log" + + with stdout_path.open("w+", encoding="utf-8") as server_out, \ + stderr_path.open("w+", encoding="utf-8") as server_err: + server = subprocess.Popen( + [ + args.server, + "--host", "127.0.0.1", + "--port", str(port), + "--model-manifest", str(model), + "--max-connections", "4", + "--max-active", "4", + ], + stdout=server_out, + stderr=server_err, + text=True, + creationflags=creation_flags(), + ) + try: + time.sleep(0.4) + started = time.monotonic() + discovery = subprocess.run( + [ + args.discovery, + "127.0.0.1", + str(port), + "--model-json", + "--no-datasets", + "--no-rcb", + "--max-types", "128", + "--timeout-ms", "2500", + ], + capture_output=True, + text=True, + timeout=12, + check=False, + creationflags=creation_flags(), + ) + elapsed_ms = (time.monotonic() - started) * 1000.0 + if discovery.returncode != 0: + raise RuntimeError( + "large-model discovery failed: " + f"exit={discovery.returncode} elapsed_ms={elapsed_ms:.1f}\n" + f"stdout:\n{discovery.stdout}\nstderr:\n{discovery.stderr}" + ) + document = json.loads(discovery.stdout) + coverage = document.get("coverage", {}) + if coverage.get("logicalDeviceCount") != 32: + raise RuntimeError(f"logical-device coverage mismatch: {coverage}") + serialized = discovery.stdout + for required in [ + "AA1E1F06R4ESQZ1", + "CSWI1", + "CILO1", + "XSWI1", + ]: + if required not in serialized: + raise RuntimeError( + f"golden discovery identity {required!r} missing" + ) + # Golden external IEC 61850 client answers the captured CSWI1 GVAA in ~61 ms. + # CI is intentionally looser, but a multi-second per-root stall + # must fail this gate. + if elapsed_ms > 8000.0: + raise RuntimeError( + f"large-model discovery exceeded bounded budget: {elapsed_ms:.1f} ms" + ) + 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=server_ready" not in server_stdout or "stage=mms" not in server_stdout: + raise RuntimeError( + "server lifecycle evidence missing:\n" + + server_stdout + "\n" + server_stderr + ) + if "kind=client_error" in server_stdout or "protocol_error" in server_stdout: + raise RuntimeError( + "server reported an error during full discovery:\n" + + server_stdout + "\n" + server_stderr + ) + + print( + "INTEROP_FULL_DISCOVERY_GOLDEN_PASS " + "objects=~4900 logicalDevices=32 target=AA1E1F06R4ESQZ1/CSWI1 " + f"elapsed_ms={elapsed_ms:.1f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/static_ied_server_iedsim/test_loopback.py b/tools/static_ied_server_iedsim/test_loopback.py index 097e993fd..88bb58fe6 100644 --- a/tools/static_ied_server_iedsim/test_loopback.py +++ b/tools/static_ied_server_iedsim/test_loopback.py @@ -74,10 +74,14 @@ def main() -> int: document = json.loads(client.stdout) coverage = document.get("coverage", {}) + # This fixture exposes LLN0.Mod, LPHD1.PhyHealth, and eight GGIO1 + # indications through the composed GVAA hierarchy. Assert the complete + # model so a future root-LN discovery regression cannot silently pass. expected = { "logicalDeviceCount": 1, - "dataObjectCount": 8, - "dataAttributeCount": 8, + "logicalNodeCount": 3, + "dataObjectCount": 10, + "dataAttributeCount": 10, "dataSetCount": 1, } mismatches = { diff --git a/tools/static_ied_server_iedsim/test_reporting_golden.cpp b/tools/static_ied_server_iedsim/test_reporting_golden.cpp new file mode 100644 index 000000000..46f6459bb --- /dev/null +++ b/tools/static_ied_server_iedsim/test_reporting_golden.cpp @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::string_view kDomain{"AA1E1F06R4Application"}; +constexpr std::string_view kAnalogDataSet{"LLN0$Analog"}; +constexpr std::string_view kDigitalDataSet{"LLN0$Digital"}; +constexpr std::string_view kUrcbRptId{ + "AA1E1F06R4/Application/LLN0$RP$Unbuffer"}; +constexpr std::string_view kBrcbRptId{ + "AA1E1F06R4/Application/LLN0$BR$Buffer"}; +constexpr std::uint32_t kConfRev = 100'001U; +constexpr std::array kExpectedUrcbOptFlds{0x78U, 0x80U}; +constexpr std::array kExpectedBrcbOptFlds{0x79U, 0x80U}; +constexpr std::array kReportTime{ + 0x00U, 0xACU, 0x36U, 0x52U, 0x3CU, 0xF2U}; +constexpr std::array kBooleanType{0x83U, 0x00U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = + *static_cast(context) != 0U ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] std::string two_digit(const std::size_t value) { + return value < 10U + ? "0" + std::to_string(value) + : std::to_string(value); +} + +[[nodiscard]] bool exact_optflds( + const mms::MmsReportHeader& header, + const std::array& expected) { + return header.optional_fields.raw == + std::vector{6U, expected[0], expected[1]}; +} + +[[nodiscard]] bool full_indexes( + const std::vector& indexes, + const std::size_t count) { + if (indexes.size() != count) return false; + for (std::size_t index = 0U; index < count; ++index) { + if (indexes[index] != index) return false; + } + return true; +} + +[[nodiscard]] bool all_reason( + const mms::MmsReportFrame& frame, + const std::string& name) { + if (frame.values.empty()) return false; + for (const auto& value : frame.values) { + if (!value.reason_for_inclusion.has(name)) return false; + } + return true; +} + +[[nodiscard]] bool entry_id_is( + const std::vector& entry, + const std::uint64_t expected) { + if (entry.size() != mms::MmsInformationReportSpanCodec::entry_id_bytes) { + return false; + } + for (std::size_t index = 0U; index < entry.size(); ++index) { + const auto shift = static_cast( + (entry.size() - 1U - index) * 8U); + if (entry[index] != + static_cast((expected >> shift) & 0xFFU)) { + return false; + } + } + return true; +} + +} // namespace + +int main() { + constexpr std::size_t digital_count = 36U; + constexpr std::size_t analog_count = 22U; + constexpr std::size_t total_count = digital_count + analog_count; + + std::array values{}; + std::vector names; + names.reserve(total_count); + for (std::size_t index = 0U; index < digital_count; ++index) { + names.push_back( + "GGIO1$ST$Ind" + two_digit(index + 1U) + "$stVal"); + } + for (std::size_t index = 0U; index < analog_count; ++index) { + names.push_back( + "MMXU1$MX$An" + two_digit(index + 1U) + "$mag"); + } + + std::vector objects; + objects.reserve(total_count); + for (std::size_t index = 0U; index < total_count; ++index) { + objects.push_back(mms::MmsStaticObjectEntry{ + kDomain, + names[index], + kBooleanType, + read_boolean, + &values[index], + false}); + } + const mms::MmsStaticObjectTable object_table{objects}; + if (!object_table.valid()) return 1; + + std::array analog_members{}; + for (std::size_t index = 0U; index < analog_count; ++index) { + analog_members[index] = { + kDomain, + names[digital_count + index]}; + } + std::array digital_members{}; + for (std::size_t index = 0U; index < digital_count; ++index) { + digital_members[index] = {kDomain, names[index]}; + } + const std::array data_sets{{ + {kDomain, kAnalogDataSet, analog_members, false}, + {kDomain, kDigitalDataSet, digital_members, false}, + }}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid() || + !data_set_table.valid_against(object_table)) { + return 2; + } + + // Real IEDScout static-URCB acceptance capture: + // IEDScout did not rewrite OptFlds. The configured/effective report profile + // emits OptFlds 78 80, SqNum=1, DataSet Analog, ConfRev=100001, + // 22/22 included members and GI ReasonForInclusion. + const std::array urcb_definitions{{ + { + kDomain, + "LLN0$RP$Unbuffer01", + kUrcbRptId, + kDomain, + kAnalogDataSet, + kConfRev, + kExpectedUrcbOptFlds, + 0U, + 0x7CU, + 0U, + }, + }}; + std::array urcb_states{}; + mms::MmsStaticUrcbRuntime urcb{ + urcb_definitions, urcb_states, object_table, data_set_table}; + if (!urcb.initialize()) return 3; + const auto* urcb_state = urcb.state(0U); + if (urcb_state == nullptr || + urcb_state->optional_fields != kExpectedUrcbOptFlds || + urcb.set_enabled(0U, true, 1'000U) != mms::MmsStaticUrcbStatus::ok || + urcb.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok) { + return 4; + } + mms::MmsStaticUrcbEmissionPlan urcb_plan; + if (!urcb.next_due(1'000U, urcb_plan) || + urcb_plan.sequence_number != 1U || + urcb_plan.reason != + mms::MmsStaticUrcbReportReason::general_interrogation) { + return 5; + } + std::array urcb_pdu{}; + std::array urcb_workspace{}; + const auto urcb_encoded = urcb.encode( + urcb_plan, kReportTime, urcb_pdu, urcb_workspace); + if (!urcb_encoded.success() || + urcb_encoded.member_count != analog_count) { + return 6; + } + const auto urcb_report = mms::MmsInformationReportCodec::decode( + std::span{urcb_pdu}.first( + urcb_encoded.bytes_written)); + const auto urcb_frame = + mms::MmsReportFrameMapper::map(urcb_report, {}); + if (urcb_frame.header.report_id != kUrcbRptId || + !exact_optflds(urcb_frame.header, kExpectedUrcbOptFlds) || + urcb_frame.header.sequence_number != 1U || + !urcb_frame.header.time_of_entry || + urcb_frame.header.data_set_reference != + std::string{kDomain} + "/" + std::string{kAnalogDataSet} || + !urcb_frame.header.entry_id.empty() || + urcb_frame.header.configuration_revision != kConfRev || + !full_indexes( + urcb_frame.included_data_set_indexes, analog_count) || + urcb_frame.values.size() != analog_count || + !all_reason(urcb_frame, "general-interrogation") || + urcb_frame.raw_access_result_count != + 7U + (2U * analog_count)) { + return 7; + } + if (urcb.commit(urcb_plan, 1'000U) != + mms::MmsStaticUrcbStatus::ok) { + return 8; + } + + // Real IEDScout BRCB acceptance capture: + // RptID ...$BR$Buffer, OptFlds 79 80, SqNum=1, + // DataSet Digital, EntryID=1, ConfRev=100001, 36/36 included, GI reason. + const mms::MmsStaticBrcbDefinition brcb_definition{ + kDomain, + "LLN0$BR$Buffer01", + kBrcbRptId, + kDomain, + kDigitalDataSet, + kConfRev, + kExpectedBrcbOptFlds, + 100U, + 0x7CU, + 0U, + }; + std::array, 4U> slot_storage{}; + std::array slots{{ + {slot_storage[0]}, + {slot_storage[1]}, + {slot_storage[2]}, + {slot_storage[3]}, + }}; + mms::MmsStaticBrcbPendingState brcb_pending{}; + mms::MmsStaticBrcbRuntime brcb{ + brcb_definition, + brcb_pending, + slots, + object_table, + data_set_table}; + if (!brcb.initialize() || + brcb.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + brcb.request_general_interrogation() != + mms::MmsStaticBrcbStatus::ok) { + return 9; + } + mms::MmsStaticBrcbCapturePlan brcb_plan; + if (!brcb.next_due(2'000U, brcb_plan) || + brcb_plan.sequence_number != 1U || + brcb_plan.entry_number != 1U || + brcb_plan.reason != + mms::MmsStaticBrcbCaptureReason::general_interrogation) { + return 10; + } + std::array brcb_staging{}; + std::array brcb_workspace{}; + const auto brcb_gi = brcb.capture( + brcb_plan, kReportTime, brcb_staging, brcb_workspace); + if (!brcb_gi.success() || + brcb_gi.included_member_count != digital_count) { + return 11; + } + mms::MmsStaticBrcbEntryView brcb_entry; + if (!brcb.front(brcb_entry)) return 12; + const auto brcb_gi_report = + mms::MmsInformationReportCodec::decode(brcb_entry.mms_pdu); + const auto brcb_gi_frame = + mms::MmsReportFrameMapper::map(brcb_gi_report, {}); + if (brcb_gi_frame.header.report_id != kBrcbRptId || + !exact_optflds(brcb_gi_frame.header, kExpectedBrcbOptFlds) || + brcb_gi_frame.header.sequence_number != 1U || + !brcb_gi_frame.header.time_of_entry || + brcb_gi_frame.header.data_set_reference != + std::string{kDomain} + "/" + std::string{kDigitalDataSet} || + !entry_id_is(brcb_gi_frame.header.entry_id, 1U) || + brcb_gi_frame.header.configuration_revision != kConfRev || + !full_indexes( + brcb_gi_frame.included_data_set_indexes, digital_count) || + brcb_gi_frame.values.size() != digital_count || + !all_reason(brcb_gi_frame, "general-interrogation") || + brcb_gi_frame.raw_access_result_count != + 8U + (2U * digital_count)) { + return 13; + } + if (brcb.commit_delivery(brcb_entry.entry_id) != + mms::MmsStaticBrcbStatus::ok) { + return 14; + } + + // The accepted OMICRON command capture produced a selective BRCB report + // after process feedback. Its 36-bit inclusion field selects member 33 and + // ReasonForInclusion is data-change. Lock SqNum/EntryID progression too. + constexpr std::size_t changed_member = 33U; + values[changed_member] = 1U; + if (brcb.notify( + changed_member, + mms::MmsStaticBrcbEventReason::data_change, + 3'000U) != mms::MmsStaticBrcbStatus::ok || + brcb.next_due(3'099U, brcb_plan) || + !brcb.next_due(3'100U, brcb_plan) || + brcb_plan.sequence_number != 2U || + brcb_plan.entry_number != 2U || + brcb_plan.reason != mms::MmsStaticBrcbCaptureReason::event) { + return 15; + } + const auto brcb_event = brcb.capture( + brcb_plan, kReportTime, brcb_staging, brcb_workspace); + if (!brcb_event.success() || + brcb_event.included_member_count != 1U || + !brcb.front(brcb_entry)) { + return 16; + } + const auto brcb_event_report = + mms::MmsInformationReportCodec::decode(brcb_entry.mms_pdu); + const auto brcb_event_frame = + mms::MmsReportFrameMapper::map(brcb_event_report, {}); + if (brcb_event_frame.header.sequence_number != 2U || + !entry_id_is(brcb_event_frame.header.entry_id, 2U) || + !exact_optflds(brcb_event_frame.header, kExpectedBrcbOptFlds) || + brcb_event_frame.included_data_set_indexes != + std::vector{changed_member} || + brcb_event_frame.values.size() != 1U || + !brcb_event_frame.values[0].reason_for_inclusion.has( + "data-change") || + brcb_event_frame.raw_access_result_count != 10U) { + return 17; + } + if (brcb.commit_delivery(brcb_entry.entry_id) != + mms::MmsStaticBrcbStatus::ok) { + return 18; + } + + // The second accepted command toggles the same process member back and + // produces the next buffered report. The real capture advanced both + // sequence number and EntryID to 3 without resetting the RCB stream. + values[changed_member] = 0U; + if (brcb.notify( + changed_member, + mms::MmsStaticBrcbEventReason::data_change, + 4'000U) != mms::MmsStaticBrcbStatus::ok || + brcb.next_due(4'099U, brcb_plan) || + !brcb.next_due(4'100U, brcb_plan) || + brcb_plan.sequence_number != 3U || + brcb_plan.entry_number != 3U || + brcb_plan.reason != mms::MmsStaticBrcbCaptureReason::event) { + return 19; + } + const auto brcb_event_two = brcb.capture( + brcb_plan, kReportTime, brcb_staging, brcb_workspace); + if (!brcb_event_two.success() || + brcb_event_two.included_member_count != 1U || + !brcb.front(brcb_entry)) { + return 20; + } + const auto brcb_event_two_report = + mms::MmsInformationReportCodec::decode(brcb_entry.mms_pdu); + const auto brcb_event_two_frame = + mms::MmsReportFrameMapper::map(brcb_event_two_report, {}); + if (brcb_event_two_frame.header.sequence_number != 3U || + !entry_id_is(brcb_event_two_frame.header.entry_id, 3U) || + !exact_optflds(brcb_event_two_frame.header, kExpectedBrcbOptFlds) || + brcb_event_two_frame.included_data_set_indexes != + std::vector{changed_member} || + brcb_event_two_frame.values.size() != 1U || + !brcb_event_two_frame.values[0].reason_for_inclusion.has( + "data-change") || + brcb_event_two_frame.raw_access_result_count != 10U) { + return 21; + } + + std::cout + << "IEDSCOUT_REPORTING_GOLDEN_PASS " + << "urcbMembers=22 urcbOptFlds=7880 urcbSqNum=1 " + << "brcbMembers=36 brcbOptFlds=7980 " + << "brcbGiSqNum=1 brcbGiEntryID=1 " + << "eventIndex=33 eventReason=data-change " + << "eventSqNum=2,3 eventEntryID=2,3\n"; + return 0; +} diff --git a/tools/static_ied_server_iedsim/test_reporting_wire.py b/tools/static_ied_server_iedsim/test_reporting_wire.py new file mode 100644 index 000000000..a17faf777 --- /dev/null +++ b/tools/static_ied_server_iedsim/test_reporting_wire.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Server-level static RCB commissioning parity with the accepted external IEC 61850 client capture.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time + + +DOMAIN = "AA1E1F06R4Application" + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def creation_flags() -> int: + return getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + + +def manifest() -> str: + lines = [ + "ARSTACK_IED_MODEL\t2\t1", + f"LN\t{DOMAIN}\tLLN0", + f"LN\t{DOMAIN}\tGGIO1", + f"LN\t{DOMAIN}\tMMXU1", + ] + for index in range(1, 37): + item = f"GGIO1$ST$Ind{index:02d}$stVal" + lines.append( + f"OBJ\t{DOMAIN}\t{item}\tBOOLEAN\tBoolean\tfalse" + ) + lines.append( + f"DS\t{DOMAIN}\tLLN0$Digital\t{DOMAIN}\t{item}" + ) + for index in range(1, 23): + item = f"MMXU1$MX$An{index:02d}$mag" + lines.append( + f"OBJ\t{DOMAIN}\t{item}\tINT32\tNumber\t{index}" + ) + lines.append( + f"DS\t{DOMAIN}\tLLN0$Analog\t{DOMAIN}\t{item}" + ) + + # Static commissioning values reproduce the accepted external IEC 61850 client session. + # No OptFlds rewrite is made by the client probe. + lines.append( + "RCB\t" + DOMAIN + "\tLLN0$RP$Unbuffer01\t0\t" + "AA1E1F06R4/Application/LLN0$RP$Unbuffer\t" + + DOMAIN + "\tLLN0$Analog\t100001\t0\t0\t124\t120\t128" + ) + lines.append( + "RCB\t" + DOMAIN + "\tLLN0$BR$Buffer01\t1\t" + "AA1E1F06R4/Application/LLN0$BR$Buffer\t" + + DOMAIN + "\tLLN0$Digital\t100001\t100\t0\t124\t121\t128" + ) + lines.append("") + return "\n".join(lines) + + +def run_probe(executable: str, port: int, mode: str) -> subprocess.CompletedProcess[str]: + if mode == "urcb": + rcb = "LLN0$RP$Unbuffer01" + rptid = "AA1E1F06R4/Application/LLN0$RP$Unbuffer" + dataset = f"{DOMAIN}/LLN0$Analog" + members = "22" + opt_first = "0x78" + else: + rcb = "LLN0$BR$Buffer01" + rptid = "AA1E1F06R4/Application/LLN0$BR$Buffer" + dataset = f"{DOMAIN}/LLN0$Digital" + members = "36" + opt_first = "0x79" + + return subprocess.run( + [ + executable, + "127.0.0.1", + str(port), + "--mode", + mode, + "--domain", + DOMAIN, + "--rcb", + rcb, + "--expected-rptid", + rptid, + "--expected-dataset", + dataset, + "--expected-members", + members, + "--expected-confrev", + "100001", + "--expected-opt-first", + opt_first, + "--expected-opt-second", + "0x80", + "--timeout-ms", + "5000", + ], + capture_output=True, + text=True, + timeout=12, + check=False, + creationflags=creation_flags(), + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--wire-probe", required=True) + args = parser.parse_args() + + port = free_port() + with tempfile.TemporaryDirectory(prefix="arstack-reporting-wire-") as directory: + root = Path(directory) + model = root / "reporting.model" + model.write_text(manifest(), encoding="utf-8", newline="\n") + stdout_path = root / "server.stdout.log" + stderr_path = root / "server.stderr.log" + + with stdout_path.open("w+", encoding="utf-8") as out, \ + stderr_path.open("w+", encoding="utf-8") as err: + server = subprocess.Popen( + [ + args.server, + "--host", + "127.0.0.1", + "--port", + str(port), + "--model-manifest", + str(model), + "--max-connections", + "4", + "--max-active", + "4", + ], + stdout=out, + stderr=err, + text=True, + creationflags=creation_flags(), + ) + try: + time.sleep(0.35) + urcb = run_probe(args.wire_probe, port, "urcb") + if ( + urcb.returncode != 0 + or "INTEROP_REPORTING_WIRE_PASS mode=urcb" not in urcb.stdout + or "members=22" not in urcb.stdout + or "groupedWrite=TrgOps,RptEna" not in urcb.stdout + or "reservation=urcb-only" not in urcb.stdout + ): + raise RuntimeError( + "URCB interoperability commissioning parity failed: " + f"exit={urcb.returncode} stdout={urcb.stdout!r} " + f"stderr={urcb.stderr!r}" + ) + + brcb = run_probe(args.wire_probe, port, "brcb") + if ( + brcb.returncode != 0 + or "INTEROP_REPORTING_WIRE_PASS mode=brcb" not in brcb.stdout + or "members=36" not in brcb.stdout + or "groupedWrite=TrgOps,RptEna" not in brcb.stdout + or "reservation=none" not in brcb.stdout + ): + raise RuntimeError( + "BRCB interoperability commissioning parity failed: " + f"exit={brcb.returncode} stdout={brcb.stdout!r} " + f"stderr={brcb.stderr!r}" + ) + 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=server_ready" not in server_stdout: + raise RuntimeError( + "server readiness evidence missing:\n" + + server_stdout + "\n" + server_stderr + ) + if "kind=client_error" in server_stdout or "protocol_error" in server_stdout: + raise RuntimeError( + "server reported client/protocol error:\n" + + server_stdout + "\n" + server_stderr + ) + + print( + "INTEROP_REPORTING_COMMISSIONING_PASS " + "urcb=Resv,grouped-TrgOps-RptEna,GI,report " + "brcb=grouped-TrgOps-RptEna,GI,report " + "urcbMembers=22 brcbMembers=36" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/static_ied_server_iedsim/test_runtime_model.py b/tools/static_ied_server_iedsim/test_runtime_model.py index 884a7d9d2..83ba7a17c 100644 --- a/tools/static_ied_server_iedsim/test_runtime_model.py +++ b/tools/static_ied_server_iedsim/test_runtime_model.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Prove SCL-style static DataSets and live values on one MMS association.""" +"""Prove structured SCL-style static DataSets and live values on one MMS association.""" from __future__ import annotations @@ -24,20 +24,66 @@ def creation_flags() -> int: def manifest(revision: int, value: bool) -> str: - text = "true" if value else "false" - return "\n".join( + """Build a vendor-style model with configured whole-DO DataSet members. + + The 36/22 cardinalities mirror the SIPROTEC acceptance case that exposed the + regression. Each configured member owns multiple leaves, so flattening the + Digital DataSet would exceed the 64-member static DataSet limit while the + correct configured membership remains safely at 36. + """ + tracked_value = "true" if value else "false" + lines = [ + f"ARSTACK_IED_MODEL\t2\t{revision}", + "LN\tTESTIEDLD0\tLLN0", + "LN\tTESTIEDLD0\tGGIO1", + "LN\tTESTIEDLD0\tXCBR1", + ] + + for index in range(1, 37): + object_name = f"Digital{index}" + st_val = tracked_value if index == 1 else "false" + lines.extend( + [ + f"OBJ\tTESTIEDLD0\tGGIO1$ST${object_name}$stVal\tBOOLEAN\tBoolean\t{st_val}", + f"OBJ\tTESTIEDLD0\tGGIO1$ST${object_name}$q\tQUALITY\tQuality\tgood", + f"OBJ\tTESTIEDLD0\tGGIO1$ST${object_name}$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"DS\tTESTIEDLD0\tLLN0$Digital\tTESTIEDLD0\tGGIO1$ST${object_name}", + ] + ) + + for index in range(1, 23): + object_name = f"Analog{index}" + lines.extend( + [ + f"OBJ\tTESTIEDLD0\tGGIO1$MX${object_name}$mag$i\tINT32\tInt32\t{index}", + f"OBJ\tTESTIEDLD0\tGGIO1$MX${object_name}$mag$f\tFLOAT32\tFloat32\t{index}.5", + f"OBJ\tTESTIEDLD0\tGGIO1$MX${object_name}$q\tQUALITY\tQuality\tgood", + f"OBJ\tTESTIEDLD0\tGGIO1$MX${object_name}$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", + f"DS\tTESTIEDLD0\tLLN0$Analog\tTESTIEDLD0\tGGIO1$MX${object_name}", + ] + ) + + lines.extend( [ - f"ARSTACK_IED_MODEL\t2\t{revision}", - "LN\tTESTIEDLD0\tLLN0", - "LN\tTESTIEDLD0\tGGIO1", - f"OBJ\tTESTIEDLD0\tGGIO1$ST$Ind1$stVal\tBOOLEAN\tBoolean\t{text}", - "OBJ\tTESTIEDLD0\tGGIO1$ST$Ind2$stVal\tBOOLEAN\tBoolean\tfalse", - "DS\tTESTIEDLD0\tLLN0$Status\tTESTIEDLD0\tGGIO1$ST$Ind1$stVal", - "DS\tTESTIEDLD0\tLLN0$Status\tTESTIEDLD0\tGGIO1$ST$Ind2$stVal", - "", + "OBJ\tTESTIEDLD0\tXCBR1$ST$Pos$stVal\tDBPOS\tEnumeration\tintermediate-state", + "OBJ\tTESTIEDLD0\tXCBR1$ST$Pos$q\tQUALITY\tQuality\tgood", + "OBJ\tTESTIEDLD0\tXCBR1$ST$Pos$t\tTimestamp\tTimestamp\tunix-ms:1720000000000", ] ) + lines.append( + "RCB\tTESTIEDLD0\tLLN0$RP$Structured01\t0\t" + "TESTIEDLD0/LLN0$RP$Structured01\tTESTIEDLD0\tLLN0$Digital\t" + "1\t0\t0\t124\t120\t128" + ) + lines.append( + "RCB\tTESTIEDLD0\tLLN0$RP$StructuredAnalog01\t0\t" + "TESTIEDLD0/LLN0$RP$StructuredAnalog01\tTESTIEDLD0\tLLN0$Analog\t" + "1\t0\t0\t124\t120\t128" + ) + lines.append("") + return "\n".join(lines) + def atomic_write(path: Path, text: str) -> None: replacement = path.with_suffix(".next") @@ -45,17 +91,67 @@ def atomic_write(path: Path, text: str) -> None: os.replace(replacement, path) +def run_read_probe( + executable: str, + port: int, + item: str, + *, + count: int = 1, + delay_ms: int = 0, + with_type: bool = False, +) -> subprocess.CompletedProcess[str]: + command = [ + executable, + "127.0.0.1", + str(port), + "--domain", + "TESTIEDLD0", + "--item", + item, + "--count", + str(count), + "--timeout-ms", + "3000", + ] + if delay_ms: + command.extend(["--delay-ms", str(delay_ms)]) + if with_type: + command.append("--with-type") + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=10, + check=False, + creationflags=creation_flags(), + ) + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--server", required=True) parser.add_argument("--discovery", required=True) parser.add_argument("--read-probe", required=True) + parser.add_argument("--urcb-probe", required=True) args = parser.parse_args() port = free_port() with tempfile.TemporaryDirectory(prefix="arstack-iedsim-runtime-") as directory: model = Path(directory) / "runtime.model" atomic_write(model, manifest(1, False)) + server_stdout_path = Path(directory) / "server.stdout.log" + server_stderr_path = Path(directory) / "server.stderr.log" + server_stdout_handle = server_stdout_path.open("w+", encoding="utf-8") + server_stderr_handle = server_stderr_path.open("w+", encoding="utf-8") + + def collect_server_logs() -> tuple[str, str]: + server_stdout_handle.flush() + server_stderr_handle.flush() + return ( + server_stdout_path.read_text(encoding="utf-8", errors="replace"), + server_stderr_path.read_text(encoding="utf-8", errors="replace"), + ) + server = subprocess.Popen( [ args.server, @@ -66,10 +162,12 @@ def main() -> int: "--model-manifest", str(model), "--max-connections", - "2", + "32", + "--max-active", + "32", ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=server_stdout_handle, + stderr=server_stderr_handle, text=True, creationflags=creation_flags(), ) @@ -93,12 +191,77 @@ def main() -> int: if discovery.returncode != 0: raise RuntimeError(f"discovery failed: {discovery.stderr}\n{discovery.stdout}") document = json.loads(discovery.stdout) - if document.get("coverage", {}).get("dataSetCount") != 1: - raise RuntimeError(f"static DataSet missing: {discovery.stdout}") + coverage = document.get("coverage", {}) + if coverage.get("dataSetCount") != 2: + raise RuntimeError(f"static DataSets missing: {discovery.stdout}") + if ( + coverage.get("reportControlCount") != 2 + or coverage.get("reportControlBindingNotReadCount") != 0 + or coverage.get("reportControlBindingReadFailedCount") != 0 + ): + raise RuntimeError( + "LN-root synthetic discovery lost configured URCB hierarchy: " + f"{coverage}" + ) data_sets = document.get("dataSets", []) - if len(data_sets) != 1 or data_sets[0].get("memberCount") != 2: - raise RuntimeError(f"static DataSet members mismatch: {data_sets}") + member_counts = sorted(data_set.get("memberCount") for data_set in data_sets) + if len(data_sets) != 2 or member_counts != [22, 36]: + raise RuntimeError( + "configured whole-DO DataSet cardinalities changed: " + f"{data_sets}" + ) + # A whole-DO FCDA must be exposed as a readable MMS STRUCTURE. A + # successful external read proves the synthesized object is present + # in the static object table instead of being silently dropped. + structured = run_read_probe( + args.read_probe, + port, + "GGIO1$ST$Digital1", + with_type=True, + ) + digital_signature = "structure(boolean,bit-string,utc-time)" + digital_type = f"type={digital_signature}" + digital_shape = f"shape={digital_signature}" + if ( + structured.returncode != 0 + or digital_type not in structured.stdout + or digital_shape not in structured.stdout + ): + raise RuntimeError( + "whole-DO IEC 61850 SPS type/data ordering mismatch: " + f"expectedType={digital_type!r} expectedData={digital_shape!r} " + f"exit={structured.returncode} stdout={structured.stdout!r} " + f"stderr={structured.stderr!r}" + ) + + # Nested constructed attributes are positional too. Model a typical + # analogue value as mag{i,f}, followed by Quality and Timestamp, and + # prove both the outer and inner declaration order survive MMS Read. + analog = run_read_probe( + args.read_probe, + port, + "GGIO1$MX$Analog1", + with_type=True, + ) + analog_signature = ( + "structure(structure(integer,floating-point),bit-string,utc-time)" + ) + analog_type = f"type={analog_signature}" + analog_shape = f"shape={analog_signature}" + if ( + analog.returncode != 0 + or analog_type not in analog.stdout + or analog_shape not in analog.stdout + ): + raise RuntimeError( + "nested IEC 61850 analogue type/data ordering mismatch: " + f"expectedType={analog_type!r} expectedData={analog_shape!r} " + f"exit={analog.returncode} stdout={analog.stdout!r} " + f"stderr={analog.stderr!r}" + ) + + first_read_signal = Path(directory) / "first-read.signal" probe = subprocess.Popen( [ args.read_probe, @@ -107,46 +270,188 @@ def main() -> int: "--domain", "TESTIEDLD0", "--item", - "GGIO1$ST$Ind1$stVal", + "GGIO1$ST$Digital1$stVal", "--count", - "4", + "3", "--delay-ms", "500", + "--signal-after-first", + str(first_read_signal), "--timeout-ms", - "3000", + "10000", ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, creationflags=creation_flags(), ) - assert probe.stdout is not None - first = probe.stdout.readline().strip() - if "value=false" not in first: - raise RuntimeError(f"initial MMS value mismatch: {first}") + signal_deadline = time.monotonic() + 15.0 + while not first_read_signal.exists() and time.monotonic() < signal_deadline: + if probe.poll() is not None: + break + time.sleep(0.02) + if not first_read_signal.exists(): + probe_stdout, probe_stderr = probe.communicate(timeout=5) + raise RuntimeError( + "first same-association MMS read was not signaled: " + f"exit={probe.returncode} stdout={probe_stdout!r} stderr={probe_stderr!r}" + ) atomic_write(model, manifest(2, True)) - remaining_stdout, probe_stderr = probe.communicate(timeout=10) - reads = [first, *remaining_stdout.splitlines()] - if probe.returncode != 0 or not any("value=true" in line for line in reads[1:]): + probe_stdout, probe_stderr = probe.communicate(timeout=10) + reads = [line for line in probe_stdout.splitlines() if line.startswith("MMS_READ ")] + if ( + probe.returncode != 0 + or len(reads) != 3 + or "value=false" not in reads[0] + or sum("value=true" in line for line in reads[1:]) != 2 + ): raise RuntimeError( - "live MMS value did not refresh on the existing association:\n" + "live MMS value did not remain refreshed on the existing association:\n" + "\n".join(reads) - + f"\nstderr:\n{probe_stderr}" + + f"\nexit={probe.returncode} stderr:\n{probe_stderr}" + ) + + report = subprocess.run( + [ + args.urcb_probe, + "127.0.0.1", + str(port), + "--domain", + "TESTIEDLD0", + "--rcb", + "LLN0$RP$Structured01", + "--timeout-ms", + "5000", + ], + capture_output=True, + text=True, + timeout=10, + check=False, + creationflags=creation_flags(), + ) + digital_report_shape = ( + "access_results=79 report_values=36 " + "first_value_shape=structure(boolean,bit-string,utc-time)" + ) + digital_report_value = "first_value={true," + if ( + report.returncode != 0 + or digital_report_shape not in report.stdout + or digital_report_value not in report.stdout + or "uniform_value_shape=true" not in report.stdout + ): + raise RuntimeError( + "Digital URCB GI structured DataSet projection mismatch: " + f"expectedShape={digital_report_shape!r} " + f"expectedLiveValue={digital_report_value!r} " + f"exit={report.returncode} stdout={report.stdout!r} " + f"stderr={report.stderr!r}" ) - server_stdout, server_stderr = server.communicate(timeout=8) - except BaseException: + + analog_report = subprocess.run( + [ + args.urcb_probe, + "127.0.0.1", + str(port), + "--domain", + "TESTIEDLD0", + "--rcb", + "LLN0$RP$StructuredAnalog01", + "--timeout-ms", + "5000", + ], + capture_output=True, + text=True, + timeout=10, + check=False, + creationflags=creation_flags(), + ) + analog_report_shape = ( + "access_results=51 report_values=22 " + "first_value_shape=" + "structure(structure(integer,floating-point),bit-string,utc-time)" + ) + if ( + analog_report.returncode != 0 + or analog_report_shape not in analog_report.stdout + or "uniform_value_shape=true" not in analog_report.stdout + ): + raise RuntimeError( + "Analog URCB GI structured DataSet projection mismatch: " + f"expectedShape={analog_report_shape!r} " + f"exit={analog_report.returncode} " + f"stdout={analog_report.stdout!r} " + f"stderr={analog_report.stderr!r}" + ) + # Keep the DPC whole-object probe last. Windows can retain the + # just-closed disposable client association longer than Linux, but + # the DPC semantic proof itself is independent of a subsequent + # reconnect. Earlier discovery/read/report probes already prove + # multiple sequential associations on this server instance. + dpc = run_read_probe( + args.read_probe, + port, + "XCBR1$ST$Pos", + with_type=True, + ) + dpc_signature = "structure(bit-string,bit-string,utc-time)" + dpc_type = f"type={dpc_signature}" + dpc_shape = f"shape={dpc_signature}" + if ( + dpc.returncode != 0 + or dpc_type not in dpc.stdout + or "type_bit_widths=2,13" not in dpc.stdout + or dpc_shape not in dpc.stdout + or "data_bit_widths=2,13" not in dpc.stdout + ): + raise RuntimeError( + "DPC Pos stVal/q positional width mismatch: " + f"expectedType={dpc_type!r} expectedTypeWidths='2,13' " + f"expectedData={dpc_shape!r} expectedDataWidths='2,13' " + f"exit={dpc.returncode} stdout={dpc.stdout!r} " + f"stderr={dpc.stderr!r}" + ) + + # Bounded/graceful server-loop shutdown is covered by the dedicated + # parity loopback tests. This runtime-model gate stops after semantic + # evidence so platform-specific disposable-client teardown timing is + # not confused with IEC 61850 model/report parity. + server.kill() + server.wait(timeout=8) + server_stdout, server_stderr = collect_server_logs() + except BaseException as error: server.kill() - server_stdout, server_stderr = server.communicate() - raise + server.wait(timeout=8) + server_stdout, server_stderr = collect_server_logs() + server_stdout_handle.close() + server_stderr_handle.close() + raise RuntimeError( + f"{error}\n--- server stdout ---\n{server_stdout}" + f"\n--- server stderr ---\n{server_stderr}" + ) from error + server_stdout_handle.close() + server_stderr_handle.close() - if server.returncode != 0 or "kind=value_sync" not in server_stdout: + if ( + "kind=server_ready" not in server_stdout + or "kind=value_sync" not in server_stdout + ): raise RuntimeError( - f"server live-value evidence missing (exit={server.returncode}):\n" + f"server structured/live-value evidence missing (exit={server.returncode}):\n" f"{server_stdout}\n{server_stderr}" ) print( - "IEDSIM_RUNTIME_MODEL_PASS datasets=1 members=2 " - "valueTransition=false->true associationPreserved=true" + "IEDSIM_RUNTIME_MODEL_PASS datasets=2 reportControls=2 rcbBindingsComplete=true " + "digitalMembers=36 analogMembers=22 " + "digitalTypeDataOrder=structure(boolean,bit-string,utc-time) " + "analogTypeDataOrder=structure(structure(integer,floating-point),bit-string,utc-time) " + "dpcPosOrder=stVal2bit,q13bit,tUtc " + "urcbGiDigital=36 urcbGiAnalog=22 reportBackedTotal=58 " + "productionOptFlds=0x78,0x80 reasonAlignment=true " + "digitalReportShape=structure(boolean,bit-string,utc-time) " + "analogReportShape=structure(structure(integer,floating-point),bit-string,utc-time) " + "digitalReportLiveValue=true valueTransition=false->true " + "associationPreserved=true" ) return 0 diff --git a/tools/static_ied_server_sigpipe.cpp b/tools/static_ied_server_sigpipe.cpp index 432c651f8..28fd5aeb2 100644 --- a/tools/static_ied_server_sigpipe.cpp +++ b/tools/static_ied_server_sigpipe.cpp @@ -1,9 +1,80 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include +#include +#include +#include namespace { +// std::osyncstream makes concurrent event records atomic, but emitting a +// completed record does not necessarily flush the wrapped stdout pipe. The Qt +// host consumes these records as a control channel (not just diagnostic text), +// so a server_ready line must become visible immediately. Keep the buffering +// policy at the desktop adapter boundary rather than leaking flush calls into +// the protocol/session core. +class LineFlushingStreamBuffer final : public std::streambuf { +public: + explicit LineFlushingStreamBuffer(std::streambuf* wrapped) noexcept + : wrapped_{wrapped} {} + +protected: + int_type overflow(const int_type ch) override { + if (traits_type::eq_int_type(ch, traits_type::eof())) { + return traits_type::not_eof(ch); + } + if (wrapped_ == nullptr || + traits_type::eq_int_type( + wrapped_->sputc(traits_type::to_char_type(ch)), + traits_type::eof())) { + return traits_type::eof(); + } + if (traits_type::to_char_type(ch) == '\n' && wrapped_->pubsync() != 0) { + return traits_type::eof(); + } + return ch; + } + + std::streamsize xsputn(const char* const data, const std::streamsize count) override { + if (wrapped_ == nullptr || data == nullptr || count <= 0) return 0; + const auto written = wrapped_->sputn(data, count); + if (written > 0 && + std::memchr(data, '\n', static_cast(written)) != nullptr) { + static_cast(wrapped_->pubsync()); + } + return written; + } + + int sync() override { + return wrapped_ == nullptr ? -1 : wrapped_->pubsync(); + } + +private: + std::streambuf* wrapped_{}; +}; + +struct DesktopStreamGuard final { + DesktopStreamGuard() noexcept + : cout_buffer_{std::cout.rdbuf()}, + cerr_buffer_{std::cerr.rdbuf()}, + original_cout_{std::cout.rdbuf(&cout_buffer_)}, + original_cerr_{std::cerr.rdbuf(&cerr_buffer_)} {} + + ~DesktopStreamGuard() { + std::cout.flush(); + std::cerr.flush(); + std::cout.rdbuf(original_cout_); + std::cerr.rdbuf(original_cerr_); + } + + LineFlushingStreamBuffer cout_buffer_; + LineFlushingStreamBuffer cerr_buffer_; + std::streambuf* original_cout_{}; + std::streambuf* original_cerr_{}; +}; + +[[maybe_unused]] DesktopStreamGuard kDesktopStreamGuard{}; + #if !defined(_WIN32) struct PosixSigpipeGuard final { PosixSigpipeGuard() noexcept { @@ -14,4 +85,4 @@ struct PosixSigpipeGuard final { [[maybe_unused]] const PosixSigpipeGuard kPosixSigpipeGuard{}; #endif -} // namespace +} // namespace \ No newline at end of file