From 553397eecb76d0075c8aed449a05b9578637372d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 04:02:29 +0700 Subject: [PATCH 1/3] Add guarded smart dynamic RCB live trial harness --- .github/workflows/dynamic-rcb-trial-ci.yml | 37 ++ docs/DYNAMIC_RCB_LIVE_TRIAL.md | 82 ++++ include/ariec61850/mms/dynamic_data_set.hpp | 154 ++++++ scripts/build_dynamic_rcb_trial.ps1 | 33 ++ src/mms/dynamic_data_set.cpp | 517 ++++++++++++++++++++ tools/dynamic_rcb_trial.cpp | 455 +++++++++++++++++ tools/dynamic_rcb_trial/CMakeLists.txt | 41 ++ 7 files changed, 1319 insertions(+) create mode 100644 .github/workflows/dynamic-rcb-trial-ci.yml create mode 100644 docs/DYNAMIC_RCB_LIVE_TRIAL.md create mode 100644 include/ariec61850/mms/dynamic_data_set.hpp create mode 100644 scripts/build_dynamic_rcb_trial.ps1 create mode 100644 src/mms/dynamic_data_set.cpp create mode 100644 tools/dynamic_rcb_trial.cpp create mode 100644 tools/dynamic_rcb_trial/CMakeLists.txt diff --git a/.github/workflows/dynamic-rcb-trial-ci.yml b/.github/workflows/dynamic-rcb-trial-ci.yml new file mode 100644 index 000000000..7e20d3b91 --- /dev/null +++ b/.github/workflows/dynamic-rcb-trial-ci.yml @@ -0,0 +1,37 @@ +name: Dynamic RCB Trial Harness CI + +on: + push: + branches: ["agent/phase-4d-d2-dynamic-rcb-live-trial"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + cc: gcc + cxx: g++ + - os: ubuntu-latest + cc: clang + cxx: clang++ + - os: windows-latest + cc: "" + cxx: "" + runs-on: ${{ matrix.os }} + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S tools/dynamic_rcb_trial -B build-dynamic-rcb-trial -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build-dynamic-rcb-trial --config Release --parallel + - name: Help contract + run: ctest --test-dir build-dynamic-rcb-trial -C Release --output-on-failure diff --git a/docs/DYNAMIC_RCB_LIVE_TRIAL.md b/docs/DYNAMIC_RCB_LIVE_TRIAL.md new file mode 100644 index 000000000..78d0a29ec --- /dev/null +++ b/docs/DYNAMIC_RCB_LIVE_TRIAL.md @@ -0,0 +1,82 @@ +# Dynamic RCB Live Trial + +This harness is for an isolated IEC 61850 laboratory IED/simulator. It intentionally does not share or modify the Smart Control branch. + +## Safety model + +Default execution is read-only. It performs live MMS association/discovery, probes RCB state, ranks only empty dynamic slots, and selects scalar ST/MX members. No mutation service is sent unless the exact arm token is present. + +An armed run performs one bounded lifecycle with no automatic mutation retry: + +1. discover and rank empty dynamic RCB candidates; +2. choose one RCB with empty `DatSet`, `RptEna=false`, and no active reservation; +3. create one uniquely named domain-specific Dynamic DataSet; +4. verify exact member count and order with `GetNamedVariableListAttributes`; +5. bind the selected RCB `DatSet`; +6. reserve URCB when required, enable `RptEna`, and request GI when exposed; +7. use bounded confirmed Read cycles to give asynchronous reports an opportunity to enter the association queue; +8. decode/drain queued reports through the normal reporting runtime; +9. disable the RCB and release only reservations acquired by this runtime; +10. set `DatSet` back to empty; +11. delete only the Dynamic DataSet created by this run. + +Cleanup traffic is allowed after a failed mutation, but the user action itself is never retried on another RCB automatically. + +## Windows build + +```powershell +pwsh -ExecutionPolicy Bypass -File .\scripts\build_dynamic_rcb_trial.ps1 +``` + +## First run: read-only plan + +```powershell +.\build-dynamic-rcb-trial\Release\ariec61850_dynamic_rcb_trial.exe 192.168.1.10 --no-urcb-fallback --auto-members 4 +``` + +Depending on the generator, the executable can also be directly under `build-dynamic-rcb-trial`. + +Expected output includes `RCB selection`, one `Selected` candidate, `SMART_DYNAMIC_RCB_PLAN`, and `READ_ONLY_PLAN_OK`. + +## Armed trial + +After reviewing the selected RCB and member list: + +```powershell +.\build-dynamic-rcb-trial\Release\ariec61850_dynamic_rcb_trial.exe 192.168.1.10 --no-urcb-fallback --auto-members 4 --arm IEC61850-LAB-DYNAMIC-RCB +``` + +Success evidence is: + +- `DEFINE_OK` +- `RCB_ENABLE_OK` +- one or more `CONFIRM_READ` lines +- `REPORT_EVIDENCE` +- `RCB_DISABLE_OK` +- `RCB_UNBIND_OK` +- `DELETE_OK` +- `SMART_DYNAMIC_RCB_TRIAL_PASS` + +If the lifecycle is accepted but no GI report arrives during the bounded confirmation window, the harness returns `TRIAL_LIFECYCLE_PASS_REPORT_PENDING` with exit code 4. That means the mutation/cleanup path worked but report reception still needs a longer or targeted evidence run. + +## Targeting a specific logical device or RCB + +```powershell +... 192.168.1.10 --rcb-ld LD0 --no-urcb-fallback --auto-members 4 +``` + +```powershell +... 192.168.1.10 --preferred-rcb "LD0/LLN0.brcbA01" --no-urcb-fallback --auto-members 4 +``` + +Use the exact reference printed by the read-only planner. Explicit DataSet members can be supplied with repeated `--member LD/LN$FC$DO$DA...` arguments. + +## Capture filter + +For retained physical evidence, capture MMS/TCP traffic to the IED and retain the PCAP/PCAPNG together with terminal output. A useful Wireshark display filter for this target is: + +```text +ip.addr == 192.168.1.10 && tcp.port == 102 +``` + +Do not run the armed mode against an operational substation IED. diff --git a/include/ariec61850/mms/dynamic_data_set.hpp b/include/ariec61850/mms/dynamic_data_set.hpp new file mode 100644 index 000000000..8b0f1abb3 --- /dev/null +++ b/include/ariec61850/mms/dynamic_data_set.hpp @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/reporting.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsDynamicDataSetError final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +struct MmsDefineNamedVariableListRequest final { + std::uint32_t invoke_id{}; + MmsObjectName data_set_name; + std::vector members; + + friend bool operator==(const MmsDefineNamedVariableListRequest&, + const MmsDefineNamedVariableListRequest&) = default; +}; + +struct MmsDefineNamedVariableListResponse final { + std::uint32_t invoke_id{}; + + friend bool operator==(const MmsDefineNamedVariableListResponse&, + const MmsDefineNamedVariableListResponse&) = default; +}; + +struct MmsDeleteNamedVariableListRequest final { + std::uint32_t invoke_id{}; + MmsObjectName data_set_name; + + friend bool operator==(const MmsDeleteNamedVariableListRequest&, + const MmsDeleteNamedVariableListRequest&) = default; +}; + +struct MmsDeleteNamedVariableListResponse final { + std::uint32_t invoke_id{}; + std::optional number_matched; + std::optional number_deleted; + + [[nodiscard]] bool deleted() const noexcept { + return number_deleted.value_or(0U) > 0U; + } + + friend bool operator==(const MmsDeleteNamedVariableListResponse&, + const MmsDeleteNamedVariableListResponse&) = default; +}; + +class MmsNamedVariableListCodec final { +public: + static constexpr std::int32_t define_service_tag = 11; + static constexpr std::int32_t delete_service_tag = 13; + static constexpr std::size_t maximum_members = 65'536U; + + [[nodiscard]] static std::vector encode_define_request_pdu( + const MmsDefineNamedVariableListRequest& request); + [[nodiscard]] static std::vector encode_define_request_p_data( + const MmsDefineNamedVariableListRequest& request, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsDefineNamedVariableListRequest decode_define_request( + std::span presentation_or_mms_payload); + + [[nodiscard]] static std::vector encode_define_response_pdu( + const MmsDefineNamedVariableListResponse& response); + [[nodiscard]] static std::vector encode_define_response_p_data( + const MmsDefineNamedVariableListResponse& response, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsDefineNamedVariableListResponse decode_define_response( + std::span presentation_or_mms_payload, + std::optional expected_invoke_id = std::nullopt); + + [[nodiscard]] static std::vector encode_delete_request_pdu( + const MmsDeleteNamedVariableListRequest& request); + [[nodiscard]] static std::vector encode_delete_request_p_data( + const MmsDeleteNamedVariableListRequest& request, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsDeleteNamedVariableListRequest decode_delete_request( + std::span presentation_or_mms_payload); + + [[nodiscard]] static std::vector encode_delete_response_pdu( + const MmsDeleteNamedVariableListResponse& response); + [[nodiscard]] static std::vector encode_delete_response_p_data( + const MmsDeleteNamedVariableListResponse& response, + std::uint32_t presentation_context_id = 3U); + [[nodiscard]] static MmsDeleteNamedVariableListResponse decode_delete_response( + std::span presentation_or_mms_payload, + std::optional expected_invoke_id = std::nullopt); +}; + +struct MmsDynamicDataSetOptions final { + // Dynamic report DataSets are safest when intentionally small. The wire codec + // supports the full bounded MMS list size, while this operational default can + // be raised explicitly by a caller that has verified the target IED limits. + std::size_t maximum_members{64U}; + bool verify_after_create{true}; +}; + +struct MmsDynamicDataSetCreateResult final { + std::string data_set_reference; + MmsDefineNamedVariableListResponse define_response; + std::optional verified_directory; +}; + +enum class MmsDynamicDataSetDeletePolicy : std::uint8_t { + owned_only, + explicit_override, +}; + +class MmsDynamicDataSetRuntime final { +public: + explicit MmsDynamicDataSetRuntime( + MmsAssociationRuntime& association, + MmsDynamicDataSetOptions options = {}); + + [[nodiscard]] MmsDynamicDataSetCreateResult create( + const std::string& data_set_reference, + std::span members, + std::stop_token stop_token = {}); + + [[nodiscard]] MmsDeleteNamedVariableListResponse remove( + const std::string& data_set_reference, + MmsDynamicDataSetDeletePolicy policy = MmsDynamicDataSetDeletePolicy::owned_only, + std::stop_token stop_token = {}); + + [[nodiscard]] bool owns(const std::string& data_set_reference) const; + [[nodiscard]] std::vector owned_data_sets() const; + +private: + [[nodiscard]] std::string normalize_reference(const std::string& reference) const; + [[nodiscard]] MmsDeleteNamedVariableListResponse remove_impl( + const std::string& normalized_reference, + std::stop_token stop_token); + [[nodiscard]] MmsDataSetDirectoryResponse verify( + const MmsObjectName& data_set_name, + std::stop_token stop_token); + + MmsAssociationRuntime& association_; + MmsDynamicDataSetOptions options_{}; + std::set> owned_data_sets_; +}; + +} // namespace ar::iec61850::mms diff --git a/scripts/build_dynamic_rcb_trial.ps1 b/scripts/build_dynamic_rcb_trial.ps1 new file mode 100644 index 000000000..ff67ecc57 --- /dev/null +++ b/scripts/build_dynamic_rcb_trial.ps1 @@ -0,0 +1,33 @@ +param( + [string]$BuildDir = "build-dynamic-rcb-trial", + [ValidateSet("Release", "Debug", "RelWithDebInfo")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$source = Join-Path $root "tools\dynamic_rcb_trial" +$build = Join-Path $root $BuildDir + +cmake -S $source -B $build -DCMAKE_BUILD_TYPE=$Configuration +cmake --build $build --config $Configuration --parallel +ctest --test-dir $build -C $Configuration --output-on-failure + +$candidates = @( + (Join-Path $build "$Configuration\ariec61850_dynamic_rcb_trial.exe"), + (Join-Path $build "ariec61850_dynamic_rcb_trial.exe") +) +$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $exe) { + throw "ariec61850_dynamic_rcb_trial.exe was not found after build." +} + +Write-Host "" +Write-Host "Dynamic RCB trial executable:" +Write-Host " $exe" +Write-Host "" +Write-Host "Read-only plan example:" +Write-Host " `"$exe`" 192.168.1.10 --no-urcb-fallback --auto-members 4" +Write-Host "" +Write-Host "Armed lab lifecycle example:" +Write-Host " `"$exe`" 192.168.1.10 --no-urcb-fallback --auto-members 4 --arm IEC61850-LAB-DYNAMIC-RCB" diff --git a/src/mms/dynamic_data_set.cpp b/src/mms/dynamic_data_set.cpp new file mode 100644 index 000000000..f3a20d9fd --- /dev/null +++ b/src/mms/dynamic_data_set.cpp @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/dynamic_data_set.hpp" + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/pdu.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +using asn1::BerClass; +using asn1::BerReader; +using asn1::BerTlv; +using asn1::BerWriter; + +[[nodiscard]] std::vector concat( + const std::initializer_list> parts) { + std::size_t total = 0U; + for (const auto part : parts) { + if (part.size() > MmsPduCodec::maximum_pdu_bytes - total) { + throw MmsDynamicDataSetError( + "Dynamic DataSet encoding exceeds the configured MMS PDU limit."); + } + total += part.size(); + } + + std::vector result; + result.reserve(total); + for (const auto part : parts) { + result.insert(result.end(), part.begin(), part.end()); + } + return result; +} + +[[nodiscard]] std::vector positive_integer_content( + const std::uint32_t value) { + auto encoded = BerWriter::encode_unsigned_integer(value); + if (encoded.empty()) { + encoded.push_back(0U); + } + if ((encoded.front() & 0x80U) != 0U) { + encoded.insert(encoded.begin(), 0U); + } + return encoded; +} + +[[nodiscard]] std::uint32_t read_u32( + const BerTlv& tlv, + const char* field_name) { + const auto value = BerReader::read_unsigned_integer(tlv); + if (!value || *value > std::numeric_limits::max()) { + throw MmsDynamicDataSetError( + std::string{"Dynamic DataSet "} + field_name + + " is not an unsigned 32-bit value."); + } + return static_cast(*value); +} + +void validate_data_set_name(const MmsObjectName& name) { + if (name.kind != MmsObjectNameKind::domain_specific || + name.domain.empty() || name.item.empty() || + name.item.find('$') == std::string::npos) { + throw MmsDynamicDataSetError( + "Dynamic DataSet name must be domain-specific IEC 61850 LD/LN.DataSetName."); + } + static_cast(MmsServiceCodec::encode_object_name(name)); +} + +void validate_members(const std::span members) { + if (members.empty() || members.size() > MmsNamedVariableListCodec::maximum_members) { + throw MmsDynamicDataSetError( + "Dynamic DataSet member count is outside the configured MMS limit."); + } + + std::set> unique; + for (const auto& member : members) { + if (member.kind != MmsObjectNameKind::domain_specific || + member.domain.empty() || member.item.empty()) { + throw MmsDynamicDataSetError( + "Dynamic DataSet members must be domain-specific MMS named variables."); + } + static_cast(MmsServiceCodec::encode_object_name(member)); + if (!unique.insert(member.reference()).second) { + throw MmsDynamicDataSetError( + "Dynamic DataSet contains a duplicate MMS member reference."); + } + } +} + +[[nodiscard]] std::vector encode_variable_definition( + const MmsObjectName& name) { + const auto encoded_name = MmsServiceCodec::encode_object_name(name); + const auto variable_specification = BerWriter::encode_tlv(0xA0U, encoded_name); + return BerWriter::encode_tlv(0x30U, variable_specification); +} + +[[nodiscard]] MmsObjectName decode_variable_definition(const BerTlv& definition) { + if (definition.encoded_tag != 0x30U || !definition.constructed) { + throw MmsDynamicDataSetError( + "Dynamic DataSet member is not an MMS VariableList SEQUENCE."); + } + const auto fields = BerReader::read_children(definition.value); + if (fields.size() != 1U || fields[0].encoded_tag != 0xA0U || + !fields[0].constructed) { + throw MmsDynamicDataSetError( + "Dynamic DataSet member has an invalid variableSpecification."); + } + return MmsServiceCodec::decode_object_name(fields[0].value); +} + +[[nodiscard]] std::vector reencode_tlv(const BerTlv& tlv) { + return BerWriter::encode_tlv(tlv.encoded_tag, tlv.value); +} + +[[nodiscard]] std::span response_payload( + const MmsConfirmedExchangeResult& exchange) { + if (!exchange.presentation_payload.empty()) { + return exchange.presentation_payload; + } + return exchange.envelope.mms_payload; +} + +void require_confirmed_response( + const MmsConfirmedExchangeResult& exchange, + const char* operation) { + if (exchange.envelope.kind == MmsPduKind::confirmed_error) { + throw MmsDynamicDataSetError( + std::string{"MMS "} + operation + " returned a Confirmed-Error PDU."); + } + if (exchange.envelope.kind != MmsPduKind::confirmed_response) { + throw MmsDynamicDataSetError( + std::string{"MMS "} + operation + + " did not return a Confirmed-Response PDU."); + } +} + +} // namespace + +std::vector MmsNamedVariableListCodec::encode_define_request_pdu( + const MmsDefineNamedVariableListRequest& request) { + validate_data_set_name(request.data_set_name); + validate_members(request.members); + + const auto list_name = MmsServiceCodec::encode_object_name(request.data_set_name); + std::vector definitions; + for (const auto& member : request.members) { + const auto definition = encode_variable_definition(member); + if (definition.size() > MmsPduCodec::maximum_pdu_bytes - definitions.size()) { + throw MmsDynamicDataSetError( + "Dynamic DataSet member definitions exceed the configured MMS PDU limit."); + } + definitions.insert(definitions.end(), definition.begin(), definition.end()); + } + const auto list_of_variable = BerWriter::encode_tlv(0xA0U, definitions); + const auto body = concat({list_name, list_of_variable}); + return MmsPduCodec::encode_confirmed_request( + {request.invoke_id, define_service_tag, true, body}); +} + +std::vector MmsNamedVariableListCodec::encode_define_request_p_data( + const MmsDefineNamedVariableListRequest& request, + const std::uint32_t presentation_context_id) { + const auto pdu = encode_define_request_pdu(request); + return MmsPduCodec::wrap_p_data(pdu, presentation_context_id); +} + +MmsDefineNamedVariableListRequest MmsNamedVariableListCodec::decode_define_request( + const std::span presentation_or_mms_payload) { + const auto mms = MmsPduCodec::extract_mms_payload(presentation_or_mms_payload); + const auto request = MmsPduCodec::decode_confirmed_request(mms); + if (request.service_tag != define_service_tag || !request.service_constructed) { + throw MmsDynamicDataSetError( + "MMS request is not DefineNamedVariableList."); + } + + const auto fields = BerReader::read_children(request.service_value); + if (fields.size() != 2U || fields[0].encoded_tag != 0xA1U || + !fields[0].constructed || fields[1].encoded_tag != 0xA0U || + !fields[1].constructed) { + throw MmsDynamicDataSetError( + "DefineNamedVariableList request fields are invalid."); + } + + MmsDefineNamedVariableListRequest result; + result.invoke_id = request.invoke_id; + const auto encoded_list_name = reencode_tlv(fields[0]); + result.data_set_name = MmsServiceCodec::decode_object_name(encoded_list_name); + validate_data_set_name(result.data_set_name); + + const auto definitions = BerReader::read_children(fields[1].value); + if (definitions.empty() || definitions.size() > maximum_members) { + throw MmsDynamicDataSetError( + "DefineNamedVariableList member count is outside the configured limit."); + } + result.members.reserve(definitions.size()); + for (const auto& definition : definitions) { + result.members.push_back(decode_variable_definition(definition)); + } + validate_members(result.members); + return result; +} + +std::vector MmsNamedVariableListCodec::encode_define_response_pdu( + const MmsDefineNamedVariableListResponse& response) { + // ISO 9506 DefineNamedVariableList success is the service choice [11] + // with an empty value. The primitive tag (0x8B) matches the C# oracle. + return MmsPduCodec::encode_confirmed_response( + {response.invoke_id, define_service_tag, false, {}}); +} + +std::vector MmsNamedVariableListCodec::encode_define_response_p_data( + const MmsDefineNamedVariableListResponse& response, + const std::uint32_t presentation_context_id) { + const auto pdu = encode_define_response_pdu(response); + return MmsPduCodec::wrap_p_data(pdu, presentation_context_id); +} + +MmsDefineNamedVariableListResponse MmsNamedVariableListCodec::decode_define_response( + const std::span presentation_or_mms_payload, + const std::optional expected_invoke_id) { + const auto mms = MmsPduCodec::extract_mms_payload(presentation_or_mms_payload); + const auto response = MmsPduCodec::decode_confirmed_response(mms); + if (expected_invoke_id && response.invoke_id != *expected_invoke_id) { + throw MmsDynamicDataSetError( + "DefineNamedVariableList response invoke ID mismatch."); + } + if (response.service_tag != define_service_tag || !response.service_value.empty()) { + throw MmsDynamicDataSetError( + "MMS response is not an empty successful DefineNamedVariableList response."); + } + return {response.invoke_id}; +} + +std::vector MmsNamedVariableListCodec::encode_delete_request_pdu( + const MmsDeleteNamedVariableListRequest& request) { + validate_data_set_name(request.data_set_name); + const auto list_name = MmsServiceCodec::encode_object_name(request.data_set_name); + const auto list_of_variable_list_name = BerWriter::encode_tlv(0xA1U, list_name); + return MmsPduCodec::encode_confirmed_request( + {request.invoke_id, delete_service_tag, true, list_of_variable_list_name}); +} + +std::vector MmsNamedVariableListCodec::encode_delete_request_p_data( + const MmsDeleteNamedVariableListRequest& request, + const std::uint32_t presentation_context_id) { + const auto pdu = encode_delete_request_pdu(request); + return MmsPduCodec::wrap_p_data(pdu, presentation_context_id); +} + +MmsDeleteNamedVariableListRequest MmsNamedVariableListCodec::decode_delete_request( + const std::span presentation_or_mms_payload) { + const auto mms = MmsPduCodec::extract_mms_payload(presentation_or_mms_payload); + const auto request = MmsPduCodec::decode_confirmed_request(mms); + if (request.service_tag != delete_service_tag || !request.service_constructed) { + throw MmsDynamicDataSetError( + "MMS request is not DeleteNamedVariableList."); + } + + const auto fields = BerReader::read_children(request.service_value); + if (fields.size() != 1U || fields[0].encoded_tag != 0xA1U || + !fields[0].constructed) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList request listOfVariableListName is invalid."); + } + const auto names = BerReader::read_children(fields[0].value); + if (names.size() != 1U || names[0].encoded_tag != 0xA1U || + !names[0].constructed) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList must contain exactly one domain-specific list name."); + } + + const auto encoded_name = reencode_tlv(names[0]); + auto data_set_name = MmsServiceCodec::decode_object_name(encoded_name); + validate_data_set_name(data_set_name); + return {request.invoke_id, std::move(data_set_name)}; +} + +std::vector MmsNamedVariableListCodec::encode_delete_response_pdu( + const MmsDeleteNamedVariableListResponse& response) { + std::vector body; + if (response.number_matched) { + const auto value = positive_integer_content(*response.number_matched); + const auto field = BerWriter::encode_tlv( + BerClass::context_specific, false, 0, value); + body.insert(body.end(), field.begin(), field.end()); + } + if (response.number_deleted) { + const auto value = positive_integer_content(*response.number_deleted); + const auto field = BerWriter::encode_tlv( + BerClass::context_specific, false, 1, value); + body.insert(body.end(), field.begin(), field.end()); + } + if (body.empty()) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList response requires matched or deleted count evidence."); + } + return MmsPduCodec::encode_confirmed_response( + {response.invoke_id, delete_service_tag, true, body}); +} + +std::vector MmsNamedVariableListCodec::encode_delete_response_p_data( + const MmsDeleteNamedVariableListResponse& response, + const std::uint32_t presentation_context_id) { + const auto pdu = encode_delete_response_pdu(response); + return MmsPduCodec::wrap_p_data(pdu, presentation_context_id); +} + +MmsDeleteNamedVariableListResponse MmsNamedVariableListCodec::decode_delete_response( + const std::span presentation_or_mms_payload, + const std::optional expected_invoke_id) { + const auto mms = MmsPduCodec::extract_mms_payload(presentation_or_mms_payload); + const auto response = MmsPduCodec::decode_confirmed_response(mms); + if (expected_invoke_id && response.invoke_id != *expected_invoke_id) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList response invoke ID mismatch."); + } + if (response.service_tag != delete_service_tag || !response.service_constructed) { + throw MmsDynamicDataSetError( + "MMS response is not DeleteNamedVariableList."); + } + + MmsDeleteNamedVariableListResponse result; + result.invoke_id = response.invoke_id; + const auto fields = BerReader::read_children(response.service_value); + for (const auto& field : fields) { + if (field.tag_class != BerClass::context_specific || field.constructed) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList response contains an invalid count field."); + } + if (field.tag_number == 0) { + if (result.number_matched) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList response repeats numberMatched."); + } + result.number_matched = read_u32(field, "numberMatched"); + } else if (field.tag_number == 1) { + if (result.number_deleted) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList response repeats numberDeleted."); + } + result.number_deleted = read_u32(field, "numberDeleted"); + } else { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList response contains an unknown field."); + } + } + if (!result.number_matched && !result.number_deleted) { + throw MmsDynamicDataSetError( + "DeleteNamedVariableList response contains no count evidence."); + } + return result; +} + +MmsDynamicDataSetRuntime::MmsDynamicDataSetRuntime( + MmsAssociationRuntime& association, + MmsDynamicDataSetOptions options) + : association_{association}, options_{options} { + if (options_.maximum_members == 0U || + options_.maximum_members > MmsNamedVariableListCodec::maximum_members) { + throw std::invalid_argument( + "Dynamic DataSet operational member limit is outside the codec bounds."); + } +} + +std::string MmsDynamicDataSetRuntime::normalize_reference( + const std::string& reference) const { + auto name = MmsDataSetDirectoryCodec::parse_data_set_reference(reference); + if (name.kind != MmsObjectNameKind::domain_specific) { + throw MmsDynamicDataSetError( + "Dynamic DataSet reference must use IEC 61850 LD/LN.DataSetName form."); + } + if (name.item.find('$') == std::string::npos) { + name.item = "LLN0$" + name.item; + } + validate_data_set_name(name); + return MmsDataSetDirectoryCodec::to_iec_reference(name); +} + +MmsDataSetDirectoryResponse MmsDynamicDataSetRuntime::verify( + const MmsObjectName& data_set_name, + const std::stop_token stop_token) { + const auto invoke_id = association_.next_invoke_id(); + const MmsDataSetDirectoryRequest request{invoke_id, data_set_name}; + const auto encoded = MmsDataSetDirectoryCodec::encode_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, invoke_id, stop_token); + require_confirmed_response(exchange, "GetNamedVariableListAttributes verification"); + return MmsDataSetDirectoryCodec::decode_response( + response_payload(exchange), invoke_id); +} + +MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( + const std::string& data_set_reference, + const std::span members, + const std::stop_token stop_token) { + if (!association_.associated()) { + throw MmsDynamicDataSetError( + "Cannot create a dynamic DataSet without an active MMS association."); + } + if (members.empty() || members.size() > options_.maximum_members) { + throw MmsDynamicDataSetError( + "Dynamic DataSet member count exceeds the configured operational limit."); + } + validate_members(members); + + const auto normalized = normalize_reference(data_set_reference); + if (owned_data_sets_.contains(normalized)) { + throw MmsDynamicDataSetError( + "This runtime already owns the requested dynamic DataSet reference."); + } + const auto data_set_name = MmsDataSetDirectoryCodec::parse_data_set_reference(normalized); + + const auto invoke_id = association_.next_invoke_id(); + MmsDefineNamedVariableListRequest request; + request.invoke_id = invoke_id; + request.data_set_name = data_set_name; + request.members.assign(members.begin(), members.end()); + const auto encoded = MmsNamedVariableListCodec::encode_define_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, invoke_id, stop_token); + require_confirmed_response(exchange, "DefineNamedVariableList"); + const auto define_response = MmsNamedVariableListCodec::decode_define_response( + response_payload(exchange), invoke_id); + + MmsDynamicDataSetCreateResult result; + result.data_set_reference = normalized; + result.define_response = define_response; + + if (options_.verify_after_create) { + try { + const auto directory = verify(data_set_name, stop_token); + if (directory.members.size() != members.size()) { + throw MmsDynamicDataSetError( + "Dynamic DataSet verification returned a different member count."); + } + for (std::size_t index = 0U; index < members.size(); ++index) { + if (directory.members[index].object_name != members[index]) { + throw MmsDynamicDataSetError( + "Dynamic DataSet verification returned different member ordering/content."); + } + } + result.verified_directory = directory; + } catch (...) { + try { + static_cast(remove_impl(normalized, stop_token)); + } catch (...) { + // The creation error remains primary; callers can inspect the IED + // if best-effort rollback also failed. + } + throw; + } + } + + owned_data_sets_.insert(normalized); + return result; +} + +MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove_impl( + const std::string& normalized_reference, + const std::stop_token stop_token) { + const auto data_set_name = + MmsDataSetDirectoryCodec::parse_data_set_reference(normalized_reference); + const auto invoke_id = association_.next_invoke_id(); + const MmsDeleteNamedVariableListRequest request{invoke_id, data_set_name}; + const auto encoded = MmsNamedVariableListCodec::encode_delete_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, invoke_id, stop_token); + require_confirmed_response(exchange, "DeleteNamedVariableList"); + return MmsNamedVariableListCodec::decode_delete_response( + response_payload(exchange), invoke_id); +} + +MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove( + const std::string& data_set_reference, + const MmsDynamicDataSetDeletePolicy policy, + const std::stop_token stop_token) { + if (!association_.associated()) { + throw MmsDynamicDataSetError( + "Cannot delete a dynamic DataSet without an active MMS association."); + } + const auto normalized = normalize_reference(data_set_reference); + const auto owned = owned_data_sets_.contains(normalized); + if (!owned && policy != MmsDynamicDataSetDeletePolicy::explicit_override) { + throw MmsDynamicDataSetError( + "Refusing to delete a DataSet not created by this runtime; use explicit_override intentionally."); + } + + const auto response = remove_impl(normalized, stop_token); + if (response.deleted() || response.number_matched.value_or(1U) == 0U) { + owned_data_sets_.erase(normalized); + } + return response; +} + +bool MmsDynamicDataSetRuntime::owns( + const std::string& data_set_reference) const { + try { + return owned_data_sets_.contains(normalize_reference(data_set_reference)); + } catch (...) { + return false; + } +} + +std::vector MmsDynamicDataSetRuntime::owned_data_sets() const { + return {owned_data_sets_.begin(), owned_data_sets_.end()}; +} + +} // namespace ar::iec61850::mms diff --git a/tools/dynamic_rcb_trial.cpp b/tools/dynamic_rcb_trial.cpp new file mode 100644 index 000000000..3f69fb372 --- /dev/null +++ b/tools/dynamic_rcb_trial.cpp @@ -0,0 +1,455 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/dynamic_data_set.hpp" +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/rcb_selection.hpp" +#include "ariec61850/mms/report_subscription_runtime.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::string_view k_arm_token = "IEC61850-LAB-DYNAMIC-RCB"; + +struct CliOptions final { + mms::MmsEndpoint endpoint; + std::chrono::milliseconds timeout{5'000}; + std::string preferred_logical_device; + std::string preferred_rcb_reference; + std::string data_set_reference; + std::vector explicit_members; + std::size_t auto_member_count{4U}; + std::size_t maximum_type_probes{2'048U}; + std::size_t maximum_rcb_probes{4'096U}; + std::size_t probe_cycles{3U}; + std::chrono::milliseconds probe_delay{150}; + bool allow_urcb_fallback{true}; + bool trigger_gi{true}; + bool armed{}; +}; + +[[nodiscard]] std::size_t parse_positive(const std::string& option, const std::string& text) { + std::size_t consumed = 0U; + const auto value = std::stoull(text, &consumed, 10); + if (consumed != text.size() || value == 0ULL || + value > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive integer."); + } + return static_cast(value); +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& text) { + const auto value = parse_positive("port", text); + if (value > 65'535U) throw std::invalid_argument("port must be 1..65535."); + return static_cast(value); +} + +void print_usage() { + std::cout + << "Usage: ariec61850_dynamic_rcb_trial [port] [options]\n\n" + << "Default mode is READ-ONLY: discover the IED, rank empty dynamic RCB slots,\n" + << "and show the member set that would be used. No MMS Write is sent.\n\n" + << "Options:\n" + << " --rcb-ld LD Prefer a logical device.\n" + << " --preferred-rcb REF Prefer one RCB reference.\n" + << " --no-urcb-fallback Use BRCB candidates only.\n" + << " --dataset-ref REF Dynamic DataSet reference (default auto-generated).\n" + << " --member LD/ITEM Exact MMS member; may be repeated.\n" + << " --auto-members N Auto-pick N scalar ST/MX members (default 4).\n" + << " --max-types N Bound GVAA probes used for auto-members (default 2048).\n" + << " --max-rcb N Bound RCB probes (default 4096).\n" + << " --probe-cycles N Confirmed read cycles after GI (default 3).\n" + << " --probe-delay-ms N Delay between confirmation reads (default 150).\n" + << " --timeout-ms N Connect/request timeout (default 5000).\n" + << " --no-gi Do not request GI after enabling.\n" + << " --arm " << k_arm_token << "\n" + << " Enable mutation: Define DataSet -> bind RCB -> enable/GI\n" + << " -> observe -> disable -> unbind -> Delete DataSet.\n" + << " -h, --help Show this help.\n\n" + << "This harness never auto-retries a failed mutation and never disables a busy RCB.\n"; +} + +[[nodiscard]] CliOptions parse_cli(int argc, char** argv) { + if (argc < 2) throw std::invalid_argument("host is required."); + CliOptions options; + options.endpoint.host = argv[1]; + options.endpoint.port = 102U; + int index = 2; + if (index < argc && std::string_view{argv[index]}.rfind("--", 0) != 0U) { + options.endpoint.port = parse_port(argv[index++]); + } + while (index < argc) { + const std::string option = argv[index++]; + if (option == "--no-urcb-fallback") options.allow_urcb_fallback = false; + else if (option == "--no-gi") options.trigger_gi = false; + else if (option == "--rcb-ld" || option == "--preferred-rcb" || + option == "--dataset-ref" || option == "--member" || + option == "--auto-members" || option == "--max-types" || + option == "--max-rcb" || option == "--probe-cycles" || + option == "--probe-delay-ms" || option == "--timeout-ms" || + option == "--arm") { + if (index >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[index++]; + if (option == "--rcb-ld") options.preferred_logical_device = value; + else if (option == "--preferred-rcb") options.preferred_rcb_reference = value; + else if (option == "--dataset-ref") options.data_set_reference = value; + else if (option == "--member") options.explicit_members.push_back(value); + else if (option == "--auto-members") options.auto_member_count = parse_positive(option, value); + else if (option == "--max-types") options.maximum_type_probes = parse_positive(option, value); + else if (option == "--max-rcb") options.maximum_rcb_probes = parse_positive(option, value); + else if (option == "--probe-cycles") options.probe_cycles = parse_positive(option, value); + else if (option == "--probe-delay-ms") { + options.probe_delay = std::chrono::milliseconds{static_cast(parse_positive(option, value))}; + } else if (option == "--timeout-ms") { + options.timeout = std::chrono::milliseconds{static_cast(parse_positive(option, value))}; + } else if (option == "--arm") { + if (value != k_arm_token) throw std::invalid_argument("invalid --arm token."); + options.armed = true; + } + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + return options; +} + +[[nodiscard]] bool contains_attribute(const mms::MmsReportControlCandidate& candidate, std::string_view name) { + return std::find(candidate.attributes.begin(), candidate.attributes.end(), name) != candidate.attributes.end(); +} + +[[nodiscard]] std::string lower_ascii(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](char value) { + return static_cast(std::tolower(static_cast(value))); + }); + return text; +} + +[[nodiscard]] bool same_text(const std::string& left, const std::string& right) { + return lower_ascii(left) == lower_ascii(right); +} + +[[nodiscard]] std::optional> split_domain_item(const std::string& reference) { + const auto slash = reference.find('/'); + if (slash == std::string::npos || slash == 0U || slash + 1U >= reference.size()) return std::nullopt; + return std::pair{reference.substr(0U, slash), reference.substr(slash + 1U)}; +} + +[[nodiscard]] mms::MmsObjectName parse_member(const std::string& reference) { + const auto parts = split_domain_item(reference); + if (!parts) throw std::invalid_argument("--member must use exact MMS form LD/LN$FC$DO$DA..."); + return mms::MmsObjectName::domain_specific(parts->first, parts->second); +} + +struct ItemIdentity final { + std::string logical_node; + std::string functional_constraint; +}; + +[[nodiscard]] std::optional parse_item_identity(const std::string& item) { + const auto first = item.find('$'); + if (first == std::string::npos || first == 0U) return std::nullopt; + const auto second = item.find('$', first + 1U); + if (second == std::string::npos || second == first + 1U) return std::nullopt; + return ItemIdentity{item.substr(0U, first), item.substr(first + 1U, second - first - 1U)}; +} + +[[nodiscard]] bool scalar_type(const mms::MmsTypeSpecification& type) { + return type.kind != mms::MmsTypeKind::array && + type.kind != mms::MmsTypeKind::structure && + type.kind != mms::MmsTypeKind::unknown; +} + +[[nodiscard]] std::vector select_auto_members( + const mms::MmsLiveDiscoveryResult& discovery, + const mms::MmsReportControlCandidate& rcb, + const std::size_t requested) { + struct Ranked final { int score{}; mms::MmsObjectName name; }; + std::vector ranked; + std::set> unique; + for (const auto& evidence : discovery.variable_types) { + if (!evidence.success()) continue; + const auto& variable = evidence.variable; + if (variable.kind != mms::MmsObjectNameKind::domain_specific || + !same_text(variable.domain, rcb.domain) || !scalar_type(evidence.attributes->type)) continue; + const auto identity = parse_item_identity(variable.item); + if (!identity || (identity->functional_constraint != "ST" && identity->functional_constraint != "MX")) continue; + if (!unique.insert(variable.reference()).second) continue; + int score = identity->functional_constraint == "ST" ? 40 : 25; + if (same_text(identity->logical_node, rcb.logical_node)) score += 80; + if (same_text(identity->logical_node, "LLN0")) score += 10; + score -= static_cast(std::min(variable.item.size(), 60U) / 10U); + ranked.push_back({score, variable}); + } + std::sort(ranked.begin(), ranked.end(), [](const auto& left, const auto& right) { + if (left.score != right.score) return left.score > right.score; + return left.name.reference() < right.name.reference(); + }); + std::vector result; + const auto count = std::min(requested, ranked.size()); + result.reserve(count); + for (std::size_t index = 0U; index < count; ++index) result.push_back(ranked[index].name); + return result; +} + +[[nodiscard]] std::string make_trial_data_set_reference(const std::string& domain) { + const auto epoch = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + std::ostringstream name; + name << domain << "/LLN0.ARTRIAL" << std::uppercase << std::hex + << static_cast(epoch & 0xFFFF'FFFFLL); + return name.str(); +} + +[[nodiscard]] std::span response_payload(const mms::MmsConfirmedExchangeResult& exchange) { + if (!exchange.presentation_payload.empty()) return exchange.presentation_payload; + return exchange.envelope.mms_payload; +} + +void require_write_success( + mms::MmsAssociationRuntime& association, + const mms::MmsReportControlCandidate& candidate, + const std::string& attribute, + mms::MmsDataValue value) { + const auto invoke_id = association.next_invoke_id(); + mms::MmsWriteRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(candidate.attribute_object_name(attribute)); + request.values.push_back(std::move(value)); + const auto encoded = mms::MmsServiceCodec::encode_write_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, invoke_id); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("RCB " + attribute + " 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) { + throw std::runtime_error("RCB " + attribute + " write was rejected."); + } +} + +void confirmation_read(mms::MmsAssociationRuntime& association, const mms::MmsReportControlCandidate& candidate) { + const auto invoke_id = association.next_invoke_id(); + mms::MmsReadRequest request; + request.invoke_id = invoke_id; + request.variables.push_back(candidate.attribute_object_name("RptEna")); + request.variables.push_back(candidate.attribute_object_name("DatSet")); + const auto encoded = mms::MmsServiceCodec::encode_read_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, invoke_id); + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("RCB confirmation read did not return Confirmed-Response."); + } + const auto response = mms::MmsServiceCodec::decode_read_response(response_payload(exchange), invoke_id); + if (response.results.size() != 2U || !response.results[0].success() || !response.results[1].success()) { + throw std::runtime_error("RCB confirmation read returned incomplete results."); + } +} + +void print_candidates(const mms::MmsRcbSelectionEvidence& selection) { + std::cout << selection.summary() << '\n'; + const auto count = std::min(12U, selection.candidates.size()); + for (std::size_t index = 0U; index < count; ++index) { + const auto& item = selection.candidates[index]; + std::cout << " [" << mms::mms_rcb_selection_decision_name(item.decision) << "] " + << item.reference << " score=" << item.score + << " availability=" << mms::mms_rcb_availability_kind_name(item.availability) + << " DatSet=" << (item.data_set_reference.empty() ? "" : item.data_set_reference) + << " RptEna=" << (item.report_enabled.empty() ? "?" : item.report_enabled) + << "\n"; + } +} + +void best_effort_cleanup( + mms::MmsAssociationRuntime& association, + const mms::MmsReportControlCandidate* candidate, + mms::MmsDynamicDataSetRuntime* dynamic_data_set, + const std::string& data_set_reference, + const bool data_set_created) noexcept { + if (!association.associated()) return; + if (candidate != nullptr) { + try { require_write_success(association, *candidate, "RptEna", mms::MmsDataValue::boolean(false)); } + catch (const std::exception& exception) { std::cerr << "CLEANUP warning: RptEna=false failed: " << exception.what() << '\n'; } + if (!candidate->buffered && contains_attribute(*candidate, "Resv")) { + try { require_write_success(association, *candidate, "Resv", mms::MmsDataValue::boolean(false)); } + catch (const std::exception& exception) { std::cerr << "CLEANUP warning: Resv=false failed: " << exception.what() << '\n'; } + } + if (contains_attribute(*candidate, "DatSet")) { + try { require_write_success(association, *candidate, "DatSet", mms::MmsDataValue::visible_string("")); } + catch (const std::exception& exception) { std::cerr << "CLEANUP warning: DatSet unbind failed: " << exception.what() << '\n'; } + } + } + if (data_set_created && dynamic_data_set != nullptr) { + try { + const auto response = dynamic_data_set->remove(data_set_reference); + std::cerr << "CLEANUP Dynamic DataSet delete matched=" << response.number_matched.value_or(0U) + << " deleted=" << response.number_deleted.value_or(0U) << '\n'; + } catch (const std::exception& exception) { + std::cerr << "CLEANUP warning: Dynamic DataSet delete failed: " << exception.what() << '\n'; + } + } +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc == 2 && (std::string_view{argv[1]} == "--help" || std::string_view{argv[1]} == "-h")) { + print_usage(); + return 0; + } + auto cli = parse_cli(argc, argv); + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = cli.timeout; + association_options.request_timeout = cli.timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(cli.endpoint); + + mms::MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = true; + discovery_options.maximum_variable_type_probes = cli.maximum_type_probes; + discovery_options.read_data_set_directories = false; + discovery_options.probe_report_controls = true; + discovery_options.maximum_report_control_probes = cli.maximum_rcb_probes; + auto discovery = session.discover(discovery_options); + + mms::MmsRcbDynamicSelectionOptions selection_options; + selection_options.preferred_logical_device = cli.preferred_logical_device; + selection_options.preferred_rcb_reference = cli.preferred_rcb_reference; + selection_options.allow_urcb_fallback = cli.allow_urcb_fallback; + selection_options.allow_polling_fallback = false; + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection(discovery, selection_options); + print_candidates(selection); + const auto* candidate = mms::MmsRcbPoolSelector::select_report_control(discovery, selection); + if (candidate == nullptr) { + session.disconnect(); + std::cerr << "BLOCKED: no safe empty dynamic RCB slot was found. No mutation sent.\n"; + return 3; + } + if (!contains_attribute(*candidate, "DatSet") || !contains_attribute(*candidate, "RptEna")) { + session.disconnect(); + std::cerr << "BLOCKED: selected RCB does not expose DatSet + RptEna. No mutation sent.\n"; + return 3; + } + + std::vector members; + if (!cli.explicit_members.empty()) { + members.reserve(cli.explicit_members.size()); + for (const auto& reference : cli.explicit_members) members.push_back(parse_member(reference)); + } else { + members = select_auto_members(discovery, *candidate, cli.auto_member_count); + } + if (members.empty()) { + session.disconnect(); + std::cerr << "BLOCKED: no safe scalar ST/MX members were selected. Use --member explicitly.\n"; + return 3; + } + + const auto data_set_reference = cli.data_set_reference.empty() + ? make_trial_data_set_reference(candidate->domain) + : cli.data_set_reference; + + std::cout << "SMART_DYNAMIC_RCB_PLAN\n" + << " endpoint=" << cli.endpoint.host << ':' << cli.endpoint.port << '\n' + << " rcb=" << candidate->reference << " mode=" << candidate->mode() << '\n' + << " dataset=" << data_set_reference << '\n' + << " members=" << members.size() << '\n'; + for (const auto& member : members) std::cout << " - " << member.reference() << '\n'; + + if (!cli.armed) { + std::cout << "READ_ONLY_PLAN_OK: no MMS Write/Define/Delete was sent.\n" + << "Re-run with --arm " << k_arm_token << " to execute the lab lifecycle.\n"; + session.disconnect(); + return 0; + } + + mms::MmsDynamicDataSetRuntime dynamic_data_set{session.association()}; + bool data_set_created = false; + std::unique_ptr subscription; + try { + const auto created = dynamic_data_set.create(data_set_reference, members); + data_set_created = true; + if (!created.verified_directory) throw std::runtime_error("Dynamic DataSet create returned no verified directory."); + std::cout << "DEFINE_OK dataset=" << created.data_set_reference + << " verifiedMembers=" << created.verified_directory->members.size() << '\n'; + + mms::MmsReportSubscriptionOptions subscription_options; + subscription_options.write_data_set_reference = true; + subscription_options.data_set_reference = created.data_set_reference; + subscription_options.trigger_general_interrogation = cli.trigger_gi && contains_attribute(*candidate, "GI"); + subscription_options.reserve_unbuffered_rcb = true; + subscription = std::make_unique( + session.association(), *candidate, *created.verified_directory, subscription_options); + subscription->start(); + std::cout << "RCB_ENABLE_OK gi=" + << (subscription_options.trigger_general_interrogation ? "requested" : "not-requested") << '\n'; + + for (std::size_t cycle = 0U; cycle < cli.probe_cycles; ++cycle) { + std::this_thread::sleep_for(cli.probe_delay); + confirmation_read(session.association(), *candidate); + const auto drained = subscription->drain_queued_reports(); + std::cout << "CONFIRM_READ cycle=" << (cycle + 1U) + << " queuedReportsDrained=" << drained << '\n'; + } + + const auto active = subscription->snapshot(); + std::cout << "REPORT_EVIDENCE received=" << active.received_reports + << " decodeFailures=" << active.decode_failures + << " streams=" << active.streams.size() << '\n'; + + subscription->stop(); + const auto stopped = subscription->snapshot(); + if (stopped.cleanup_required) { + throw std::runtime_error("RCB stop left cleanup_required=true; refusing normal DataSet deletion."); + } + std::cout << "RCB_DISABLE_OK\n"; + + require_write_success( + session.association(), *candidate, "DatSet", mms::MmsDataValue::visible_string("")); + std::cout << "RCB_UNBIND_OK\n"; + + const auto deleted = dynamic_data_set.remove(created.data_set_reference); + data_set_created = false; + std::cout << "DELETE_OK matched=" << deleted.number_matched.value_or(0U) + << " deleted=" << deleted.number_deleted.value_or(0U) << '\n'; + session.disconnect(); + + if (subscription_options.trigger_general_interrogation && active.received_reports == 0U) { + std::cout << "TRIAL_LIFECYCLE_PASS_REPORT_PENDING: mutation lifecycle passed, but no GI report was observed during bounded confirmation reads.\n"; + return 4; + } + std::cout << "SMART_DYNAMIC_RCB_TRIAL_PASS\n"; + return 0; + } catch (...) { + if (subscription) subscription->stop(); + best_effort_cleanup( + session.association(), candidate, &dynamic_data_set, + data_set_reference, data_set_created); + session.disconnect(); + throw; + } + } catch (const std::exception& exception) { + std::cerr << "Dynamic RCB trial failed: " << exception.what() << '\n'; + return 2; + } +} diff --git a/tools/dynamic_rcb_trial/CMakeLists.txt b/tools/dynamic_rcb_trial/CMakeLists.txt new file mode 100644 index 000000000..a1849b180 --- /dev/null +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850DynamicRcbTrial 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_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) + +add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. arstack_core) + +add_library(ariec61850_dynamic_dataset_trial_support STATIC + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/dynamic_data_set.cpp) +target_link_libraries(ariec61850_dynamic_dataset_trial_support PUBLIC ARIEC61850::core) +target_compile_features(ariec61850_dynamic_dataset_trial_support PUBLIC cxx_std_20) +target_include_directories(ariec61850_dynamic_dataset_trial_support PUBLIC + ${CMAKE_CURRENT_LIST_DIR}/../../include) + +add_executable(ariec61850_dynamic_rcb_trial + ${CMAKE_CURRENT_LIST_DIR}/../dynamic_rcb_trial.cpp) +target_link_libraries(ariec61850_dynamic_rcb_trial PRIVATE + ARIEC61850::core + ariec61850_dynamic_dataset_trial_support) +target_compile_features(ariec61850_dynamic_rcb_trial PRIVATE cxx_std_20) + +if(MSVC) + target_compile_options(ariec61850_dynamic_dataset_trial_support PRIVATE /W4 /permissive- /WX) + target_compile_options(ariec61850_dynamic_rcb_trial PRIVATE /W4 /permissive- /WX) +else() + target_compile_options(ariec61850_dynamic_dataset_trial_support PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + target_compile_options(ariec61850_dynamic_rcb_trial PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +if(WIN32) + target_link_libraries(ariec61850_dynamic_rcb_trial PRIVATE ws2_32) +endif() + +enable_testing() +add_test(NAME dynamic_rcb_trial_help COMMAND ariec61850_dynamic_rcb_trial --help) From 1d0badb0a220827f839513233921f7a725dceca5 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 04:46:11 +0700 Subject: [PATCH 2/3] Fix live smart dynamic RCB reporting --- docs/DYNAMIC_RCB_LIVE_TRIAL.md | 18 ++ .../ariec61850/mms/dynamic_report_planner.hpp | 28 +++ include/ariec61850/mms/reporting.hpp | 2 + src/mms/dynamic_report_planner.cpp | 166 +++++++++++++++++ src/mms/report_subscription_runtime.cpp | 11 +- src/mms/reporting.cpp | 35 +++- tests/test_reporting.cpp | 34 ++++ tools/dynamic_rcb_trial.cpp | 98 ++++------ tools/dynamic_rcb_trial/CMakeLists.txt | 18 +- .../test_dynamic_report_planner.cpp | 174 ++++++++++++++++++ 10 files changed, 510 insertions(+), 74 deletions(-) create mode 100644 include/ariec61850/mms/dynamic_report_planner.hpp create mode 100644 src/mms/dynamic_report_planner.cpp create mode 100644 tools/dynamic_rcb_trial/test_dynamic_report_planner.cpp diff --git a/docs/DYNAMIC_RCB_LIVE_TRIAL.md b/docs/DYNAMIC_RCB_LIVE_TRIAL.md index 78d0a29ec..6289cc3d4 100644 --- a/docs/DYNAMIC_RCB_LIVE_TRIAL.md +++ b/docs/DYNAMIC_RCB_LIVE_TRIAL.md @@ -20,6 +20,18 @@ An armed run performs one bounded lifecycle with no automatic mutation retry: 10. set `DatSet` back to empty; 11. delete only the Dynamic DataSet created by this run. +Automatic members are projected from the bounded Logical-Node +`GetVariableAccessAttributes` type trees. The planner walks nested FC/DO/DA +structures, keeps only scalar `ST`/`MX` leaves in the selected RCB domain, and +prefers primary values such as `stVal`, `general`, and measurement magnitude +leaves. Arrays, structures, unknown types, and other domains are excluded. +The canonical `LD/LN.DataSet` reference used for directory services is +converted to the IEC 61850 RCB attribute value `LD/LN$DataSet` before the +`DatSet` write. +Incoming reports accept both `listOfVariable [0]` and the +`variableListName [1]` form used by IEDScout, while still decoding only the +trailing `listOfAccessResult` as report data. + Cleanup traffic is allowed after a failed mutation, but the user action itself is never retried on another RCB automatically. ## Windows build @@ -28,6 +40,12 @@ Cleanup traffic is allowed after a failed mutation, but the user action itself i pwsh -ExecutionPolicy Bypass -File .\scripts\build_dynamic_rcb_trial.ps1 ``` +Windows PowerShell 5.1 can run the same script when `pwsh` is not installed: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File .\scripts\build_dynamic_rcb_trial.ps1 +``` + ## First run: read-only plan ```powershell diff --git a/include/ariec61850/mms/dynamic_report_planner.hpp b/include/ariec61850/mms/dynamic_report_planner.hpp new file mode 100644 index 000000000..7b7acb757 --- /dev/null +++ b/include/ariec61850/mms/dynamic_report_planner.hpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include + +namespace ar::iec61850::mms { + +struct MmsDynamicReportMemberSelection final { + std::size_t successful_type_probes{}; + std::size_t scalar_leaf_candidates{}; + std::vector members; +}; + +// Projects scalar ST/MX leaves from live GetVariableAccessAttributes evidence. +// Discovery normally probes one type tree per Logical Node, so this selector +// walks nested FC/DO/DA structures instead of requiring one probe per leaf. +class MmsDynamicReportMemberSelector final { +public: + [[nodiscard]] static MmsDynamicReportMemberSelection select( + const MmsLiveDiscoveryResult& discovery, + const MmsReportControlCandidate& report_control, + std::size_t requested_members); +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/reporting.hpp b/include/ariec61850/mms/reporting.hpp index 792f27c52..7f9c7910f 100644 --- a/include/ariec61850/mms/reporting.hpp +++ b/include/ariec61850/mms/reporting.hpp @@ -108,6 +108,8 @@ class MmsDataSetDirectoryCodec final { [[nodiscard]] static MmsObjectName parse_data_set_reference( const std::string& reference); [[nodiscard]] static std::string to_iec_reference(const MmsObjectName& name); + [[nodiscard]] static std::string to_report_attribute_value( + const std::string& reference); [[nodiscard]] static std::vector encode_request_pdu( const MmsDataSetDirectoryRequest& request); diff --git a/src/mms/dynamic_report_planner.cpp b/src/mms/dynamic_report_planner.cpp new file mode 100644 index 000000000..7802a0d87 --- /dev/null +++ b/src/mms/dynamic_report_planner.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/dynamic_report_planner.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::size_t maximum_type_tree_depth = 64U; + +[[nodiscard]] std::string lower_ascii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char character) { + return static_cast( + std::tolower(static_cast(character))); + }); + return value; +} + +[[nodiscard]] bool same_text( + const std::string_view left, + const std::string_view right) { + return lower_ascii(std::string{left}) == lower_ascii(std::string{right}); +} + +[[nodiscard]] bool scalar_type(const MmsTypeSpecification& type) noexcept { + return type.kind != MmsTypeKind::array && + type.kind != MmsTypeKind::structure && + type.kind != MmsTypeKind::unknown; +} + +struct ItemIdentity final { + std::string logical_node; + std::string functional_constraint; + std::string leaf_name; +}; + +[[nodiscard]] std::optional parse_item_identity( + const std::string& item) { + const auto first = item.find('$'); + if (first == std::string::npos || first == 0U) return std::nullopt; + const auto second = item.find('$', first + 1U); + if (second == std::string::npos || second == first + 1U) { + return std::nullopt; + } + const auto last = item.rfind('$'); + return ItemIdentity{ + item.substr(0U, first), + item.substr(first + 1U, second - first - 1U), + item.substr(last + 1U)}; +} + +void collect_scalar_leaves( + const MmsObjectName& root, + const MmsTypeSpecification& type, + const std::string& suffix, + const std::size_t depth, + std::vector& leaves) { + if (depth > maximum_type_tree_depth) { + throw std::runtime_error( + "MMS variable type tree exceeds the dynamic-report traversal limit."); + } + if (scalar_type(type)) { + auto item = root.item; + if (!suffix.empty()) item += '$' + suffix; + leaves.push_back(MmsObjectName::domain_specific(root.domain, std::move(item))); + return; + } + if (type.kind != MmsTypeKind::structure) return; + + for (const auto& child : type.children) { + if (child.name.empty()) continue; + const auto child_suffix = suffix.empty() + ? child.name + : suffix + '$' + child.name; + collect_scalar_leaves(root, child, child_suffix, depth + 1U, leaves); + } +} + +[[nodiscard]] int semantic_leaf_score(const std::string& leaf_name) { + const auto leaf = lower_ascii(leaf_name); + if (leaf == "stval") return 30; + if (leaf == "general") return 25; + if (leaf == "f" || leaf == "i") return 22; + if (leaf == "mag") return 15; + if (leaf == "q") return -10; + if (leaf == "t") return -20; + return 0; +} + +} // namespace + +MmsDynamicReportMemberSelection MmsDynamicReportMemberSelector::select( + const MmsLiveDiscoveryResult& discovery, + const MmsReportControlCandidate& report_control, + const std::size_t requested_members) { + if (requested_members == 0U) { + throw std::invalid_argument( + "Dynamic report member count must be positive."); + } + + struct Ranked final { + int score{}; + MmsObjectName name; + }; + + MmsDynamicReportMemberSelection result; + std::vector ranked; + std::set> unique; + + for (const auto& evidence : discovery.variable_types) { + if (!evidence.success()) continue; + ++result.successful_type_probes; + const auto& variable = evidence.variable; + if (variable.kind != MmsObjectNameKind::domain_specific || + !same_text(variable.domain, report_control.domain)) { + continue; + } + + std::vector leaves; + collect_scalar_leaves( + variable, evidence.attributes->type, {}, 0U, leaves); + for (auto& leaf : leaves) { + const auto identity = parse_item_identity(leaf.item); + if (!identity || + (!same_text(identity->functional_constraint, "ST") && + !same_text(identity->functional_constraint, "MX"))) { + continue; + } + if (!unique.insert(lower_ascii(leaf.reference())).second) continue; + + int score = same_text(identity->functional_constraint, "ST") ? 40 : 25; + if (same_text(identity->logical_node, report_control.logical_node)) { + score += 80; + } + if (same_text(identity->logical_node, "LLN0")) score += 10; + score += semantic_leaf_score(identity->leaf_name); + score -= static_cast( + std::min(leaf.item.size(), 60U) / 10U); + ranked.push_back({score, std::move(leaf)}); + } + } + + result.scalar_leaf_candidates = ranked.size(); + std::sort(ranked.begin(), ranked.end(), [](const auto& left, const auto& right) { + if (left.score != right.score) return left.score > right.score; + return left.name.reference() < right.name.reference(); + }); + + const auto count = std::min(requested_members, ranked.size()); + result.members.reserve(count); + for (std::size_t index = 0U; index < count; ++index) { + result.members.push_back(std::move(ranked[index].name)); + } + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/report_subscription_runtime.cpp b/src/mms/report_subscription_runtime.cpp index 4dd92ad5d..016b2f5f1 100644 --- a/src/mms/report_subscription_runtime.cpp +++ b/src/mms/report_subscription_runtime.cpp @@ -119,8 +119,13 @@ void MmsReportSubscriptionRuntime::write_attribute( const auto response = 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 MmsReportSubscriptionError( - "RCB attribute write failed: " + attribute + "."); + "RCB attribute write failed: " + attribute + + (failure ? " (DataAccessError=" + std::to_string(*failure) + ")." + : ".")); } add_event(MmsReportSubscriptionEventKind::attribute_written, "RCB attribute written: " + attribute + "."); @@ -201,7 +206,9 @@ void MmsReportSubscriptionRuntime::start( } write_attribute( "DatSet", - MmsDataValue::visible_string(options_.data_set_reference), + MmsDataValue::visible_string( + MmsDataSetDirectoryCodec::to_report_attribute_value( + options_.data_set_reference)), stop_token); } if (options_.write_trigger_options) { diff --git a/src/mms/reporting.cpp b/src/mms/reporting.cpp index bf732c86b..d935c758c 100644 --- a/src/mms/reporting.cpp +++ b/src/mms/reporting.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -215,6 +216,19 @@ MmsObjectName decode_variable_definition(const BerTlv& definition) { return MmsServiceCodec::decode_object_name(fields[0].value); } +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&) { + const auto encoded = BerWriter::encode_tlv( + specification.encoded_tag, specification.value); + return MmsServiceCodec::decode_object_name(encoded); + } +} + std::string continuity_message(const MmsReportContinuityEventKind kind) { switch (kind) { @@ -331,6 +345,12 @@ std::string MmsDataSetDirectoryCodec::to_iec_reference(const MmsObjectName& name return name.domain + "/" + normalize_data_set_item(name.item); } +std::string MmsDataSetDirectoryCodec::to_report_attribute_value( + const std::string& reference) { + if (reference.empty()) return {}; + return parse_data_set_reference(reference).reference(); +} + std::vector MmsDataSetDirectoryCodec::encode_request_pdu(const MmsDataSetDirectoryRequest& request) { const auto object = MmsServiceCodec::encode_object_name(request.data_set_name); return MmsPduCodec::encode_confirmed_request({request.invoke_id, k_get_named_variable_list_attributes, true, object}); @@ -416,12 +436,19 @@ MmsInformationReport MmsInformationReportCodec::decode(const std::span maximum_variable_references) throw MmsReportingFormatError("MMS InformationReport variable reference count exceeds the configured limit."); + for (const auto& definition : definitions) report.variable_references.push_back(decode_variable_definition(definition)); + } else if (fields[0].tag_number == 1) { + report.variable_references.push_back( + decode_variable_list_name(fields[0])); + } else { throw MmsReportingFormatError("MMS InformationReport variable-access specification is invalid."); } - const auto definitions = BerReader::read_children(fields[0].value); - if (definitions.size() > maximum_variable_references) throw MmsReportingFormatError("MMS InformationReport variable reference count exceeds the configured limit."); - for (const auto& definition : definitions) report.variable_references.push_back(decode_variable_definition(definition)); result_index = 1U; } const auto& list = fields[result_index]; diff --git a/tests/test_reporting.cpp b/tests/test_reporting.cpp index a641fd506..679d3116f 100644 --- a/tests/test_reporting.cpp +++ b/tests/test_reporting.cpp @@ -2,6 +2,9 @@ #include "ariec61850/mms/reporting.hpp" +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/data_codec.hpp" + #include #include #include @@ -99,6 +102,9 @@ void inventory_builder_groups_datasets_and_rcbs() { void dataset_directory_round_trip_and_normalization() { const MmsDataSetDirectoryRequest request{7U, MmsDataSetDirectoryCodec::parse_data_set_reference("LD0/LLN0.Events")}; + require(MmsDataSetDirectoryCodec::to_report_attribute_value("LD0/LLN0.Events") == + "LD0/LLN0$Events", + "RCB DatSet attribute value did not use the MMS wire reference."); const auto request_wire = MmsDataSetDirectoryCodec::encode_request_p_data(request); const auto request_decoded = MmsDataSetDirectoryCodec::decode_request(request_wire); require(request_decoded.invoke_id == 7U && request_decoded.data_set_name.item == "LLN0$Events", @@ -123,6 +129,33 @@ void information_report_round_trip() { require(decoded.variable_references.size() == 1U, "InformationReport variable reference was lost."); } +void information_report_variable_list_name_is_supported() { + using ar::iec61850::asn1::BerWriter; + const auto object_name = MmsServiceCodec::encode_object_name( + MmsObjectName::domain_specific("LD0", "LLN0$DynamicSet")); + const auto value = MmsDataCodec::encode( + MmsDataValue::visible_string("LD0/LLN0.BR.brcbA01")); + const auto result_list = BerWriter::encode_tlv(0xA0U, value); + + const std::array specifications{ + BerWriter::encode_tlv(0xA1U, object_name), + object_name}; + for (const auto& specification : specifications) { + auto body = specification; + body.insert(body.end(), result_list.begin(), result_list.end()); + const auto information_report = BerWriter::encode_tlv(0xA0U, body); + const auto pdu = BerWriter::encode_tlv(0xA3U, information_report); + const auto decoded = MmsInformationReportCodec::decode(pdu); + require(decoded.variable_references.size() == 1U, + "InformationReport variableListName was not decoded."); + require(decoded.variable_references.front().reference() == + "LD0/LLN0$DynamicSet", + "InformationReport variableListName changed during decode."); + require(decoded.items.size() == 1U, + "InformationReport access-result list was not preserved."); + } +} + void exact_report_mapping_decodes_optional_fields() { const auto directory = MmsDataSetDirectoryCodec::decode_response( MmsDataSetDirectoryCodec::encode_response_pdu(directory_fixture()), 9U); @@ -281,6 +314,7 @@ int main() { {"inventory builder", inventory_builder_groups_datasets_and_rcbs}, {"DataSet directory", dataset_directory_round_trip_and_normalization}, {"InformationReport", information_report_round_trip}, + {"InformationReport variableListName", information_report_variable_list_name_is_supported}, {"exact report mapping", exact_report_mapping_decodes_optional_fields}, {"C# exact report shape", csharp_exact_report_shape_maps_byte_semantics}, {"report failure result", report_failure_result_is_preserved}, diff --git a/tools/dynamic_rcb_trial.cpp b/tools/dynamic_rcb_trial.cpp index 3f69fb372..72d8a110b 100644 --- a/tools/dynamic_rcb_trial.cpp +++ b/tools/dynamic_rcb_trial.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/mms/dynamic_data_set.hpp" +#include "ariec61850/mms/dynamic_report_planner.hpp" #include "ariec61850/mms/live_discovery.hpp" #include "ariec61850/mms/rcb_selection.hpp" #include "ariec61850/mms/report_subscription_runtime.hpp" @@ -8,7 +9,6 @@ #include #include -#include #include #include #include @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -137,17 +136,6 @@ void print_usage() { return std::find(candidate.attributes.begin(), candidate.attributes.end(), name) != candidate.attributes.end(); } -[[nodiscard]] std::string lower_ascii(std::string text) { - std::transform(text.begin(), text.end(), text.begin(), [](char value) { - return static_cast(std::tolower(static_cast(value))); - }); - return text; -} - -[[nodiscard]] bool same_text(const std::string& left, const std::string& right) { - return lower_ascii(left) == lower_ascii(right); -} - [[nodiscard]] std::optional> split_domain_item(const std::string& reference) { const auto slash = reference.find('/'); if (slash == std::string::npos || slash == 0U || slash + 1U >= reference.size()) return std::nullopt; @@ -160,57 +148,6 @@ void print_usage() { return mms::MmsObjectName::domain_specific(parts->first, parts->second); } -struct ItemIdentity final { - std::string logical_node; - std::string functional_constraint; -}; - -[[nodiscard]] std::optional parse_item_identity(const std::string& item) { - const auto first = item.find('$'); - if (first == std::string::npos || first == 0U) return std::nullopt; - const auto second = item.find('$', first + 1U); - if (second == std::string::npos || second == first + 1U) return std::nullopt; - return ItemIdentity{item.substr(0U, first), item.substr(first + 1U, second - first - 1U)}; -} - -[[nodiscard]] bool scalar_type(const mms::MmsTypeSpecification& type) { - return type.kind != mms::MmsTypeKind::array && - type.kind != mms::MmsTypeKind::structure && - type.kind != mms::MmsTypeKind::unknown; -} - -[[nodiscard]] std::vector select_auto_members( - const mms::MmsLiveDiscoveryResult& discovery, - const mms::MmsReportControlCandidate& rcb, - const std::size_t requested) { - struct Ranked final { int score{}; mms::MmsObjectName name; }; - std::vector ranked; - std::set> unique; - for (const auto& evidence : discovery.variable_types) { - if (!evidence.success()) continue; - const auto& variable = evidence.variable; - if (variable.kind != mms::MmsObjectNameKind::domain_specific || - !same_text(variable.domain, rcb.domain) || !scalar_type(evidence.attributes->type)) continue; - const auto identity = parse_item_identity(variable.item); - if (!identity || (identity->functional_constraint != "ST" && identity->functional_constraint != "MX")) continue; - if (!unique.insert(variable.reference()).second) continue; - int score = identity->functional_constraint == "ST" ? 40 : 25; - if (same_text(identity->logical_node, rcb.logical_node)) score += 80; - if (same_text(identity->logical_node, "LLN0")) score += 10; - score -= static_cast(std::min(variable.item.size(), 60U) / 10U); - ranked.push_back({score, variable}); - } - std::sort(ranked.begin(), ranked.end(), [](const auto& left, const auto& right) { - if (left.score != right.score) return left.score > right.score; - return left.name.reference() < right.name.reference(); - }); - std::vector result; - const auto count = std::min(requested, ranked.size()); - result.reserve(count); - for (std::size_t index = 0U; index < count; ++index) result.push_back(ranked[index].name); - return result; -} - [[nodiscard]] std::string make_trial_data_set_reference(const std::string& domain) { const auto epoch = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); @@ -243,7 +180,13 @@ void require_write_success( } const auto response = mms::MmsServiceCodec::decode_write_response(response_payload(exchange), invoke_id); if (response.results.size() != 1U || !response.results.front().success) { - throw std::runtime_error("RCB " + attribute + " write was rejected."); + const auto failure = response.results.size() == 1U + ? response.results.front().failure_code + : std::nullopt; + throw std::runtime_error( + "RCB " + attribute + " write was rejected" + + (failure ? " (DataAccessError=" + std::to_string(*failure) + ")." + : ".")); } } @@ -353,15 +296,25 @@ int main(int argc, char** argv) { } std::vector members; + std::optional auto_selection; if (!cli.explicit_members.empty()) { members.reserve(cli.explicit_members.size()); for (const auto& reference : cli.explicit_members) members.push_back(parse_member(reference)); } else { - members = select_auto_members(discovery, *candidate, cli.auto_member_count); + auto_selection = mms::MmsDynamicReportMemberSelector::select( + discovery, *candidate, cli.auto_member_count); + members = auto_selection->members; } if (members.empty()) { session.disconnect(); - std::cerr << "BLOCKED: no safe scalar ST/MX members were selected. Use --member explicitly.\n"; + std::cerr << "BLOCKED: no safe scalar ST/MX members were selected"; + if (auto_selection) { + std::cerr << " (successfulTypeProbes=" + << auto_selection->successful_type_probes + << ", scalarLeafCandidates=" + << auto_selection->scalar_leaf_candidates << ')'; + } + std::cerr << ". Use --member explicitly.\n"; return 3; } @@ -374,6 +327,12 @@ int main(int argc, char** argv) { << " rcb=" << candidate->reference << " mode=" << candidate->mode() << '\n' << " dataset=" << data_set_reference << '\n' << " members=" << members.size() << '\n'; + if (auto_selection) { + std::cout << " autoMemberEvidence=typeProbes:" + << auto_selection->successful_type_probes + << ",scalarLeaves:" + << auto_selection->scalar_leaf_candidates << '\n'; + } for (const auto& member : members) std::cout << " - " << member.reference() << '\n'; if (!cli.armed) { @@ -416,6 +375,11 @@ int main(int argc, char** argv) { std::cout << "REPORT_EVIDENCE received=" << active.received_reports << " decodeFailures=" << active.decode_failures << " streams=" << active.streams.size() << '\n'; + for (const auto& event : active.events) { + if (event.kind == mms::MmsReportSubscriptionEventKind::report_decode_failed) { + std::cout << "REPORT_DIAGNOSTIC " << event.message << '\n'; + } + } subscription->stop(); const auto stopped = subscription->snapshot(); diff --git a/tools/dynamic_rcb_trial/CMakeLists.txt b/tools/dynamic_rcb_trial/CMakeLists.txt index a1849b180..bf7b82f1d 100644 --- a/tools/dynamic_rcb_trial/CMakeLists.txt +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -10,7 +10,8 @@ set(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. arstack_core) add_library(ariec61850_dynamic_dataset_trial_support STATIC - ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/dynamic_data_set.cpp) + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/dynamic_data_set.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/dynamic_report_planner.cpp) target_link_libraries(ariec61850_dynamic_dataset_trial_support PUBLIC ARIEC61850::core) target_compile_features(ariec61850_dynamic_dataset_trial_support PUBLIC cxx_std_20) target_include_directories(ariec61850_dynamic_dataset_trial_support PUBLIC @@ -39,3 +40,18 @@ endif() enable_testing() add_test(NAME dynamic_rcb_trial_help COMMAND ariec61850_dynamic_rcb_trial --help) + +add_executable(ariec61850_dynamic_report_planner_tests + ${CMAKE_CURRENT_LIST_DIR}/test_dynamic_report_planner.cpp) +target_link_libraries(ariec61850_dynamic_report_planner_tests PRIVATE + ARIEC61850::core + ariec61850_dynamic_dataset_trial_support) +target_compile_features(ariec61850_dynamic_report_planner_tests PRIVATE cxx_std_20) +if(MSVC) + target_compile_options(ariec61850_dynamic_report_planner_tests PRIVATE /W4 /permissive- /WX) +else() + target_compile_options(ariec61850_dynamic_report_planner_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() +add_test(NAME dynamic_report_planner_tests + COMMAND ariec61850_dynamic_report_planner_tests) diff --git a/tools/dynamic_rcb_trial/test_dynamic_report_planner.cpp b/tools/dynamic_rcb_trial/test_dynamic_report_planner.cpp new file mode 100644 index 000000000..8408f9f48 --- /dev/null +++ b/tools/dynamic_rcb_trial/test_dynamic_report_planner.cpp @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/dynamic_report_planner.hpp" + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/data_codec.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::MmsTypeSpecification scalar( + const mms::MmsTypeKind kind, + std::string name) { + mms::MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification 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]] mms::MmsVariableTypeEvidence evidence( + std::string domain, + std::string item, + mms::MmsTypeSpecification type) { + mms::MmsVariableTypeEvidence result; + result.variable = mms::MmsObjectName::domain_specific( + std::move(domain), std::move(item)); + mms::MmsVariableAccessAttributesResponse attributes; + attributes.type = std::move(type); + result.attributes = std::move(attributes); + return result; +} + +[[nodiscard]] mms::MmsReportControlCandidate report_control() { + mms::MmsReportControlCandidate result; + result.domain = "IEDLD0"; + result.logical_node = "LLN0"; + result.reference = "IEDLD0/LLN0.BR.brcbA01"; + result.buffered = true; + return result; +} + +void logical_node_tree_projects_scalar_st_mx_leaves() { + mms::MmsLiveDiscoveryResult discovery; + discovery.variable_types.push_back(evidence( + "IEDLD0", "LLN0", structure("", { + structure("ST", { + structure("Beh", { + scalar(mms::MmsTypeKind::boolean, "stVal"), + scalar(mms::MmsTypeKind::bit_string, "q")})}), + structure("MX", { + structure("Hz", { + structure("mag", { + scalar(mms::MmsTypeKind::floating_point, "f")})})}), + structure("CF", { + scalar(mms::MmsTypeKind::unsigned_integer, "configRev")})}) + )); + + mms::MmsTypeSpecification array; + array.kind = mms::MmsTypeKind::array; + array.name = "samples"; + array.children = {scalar(mms::MmsTypeKind::integer, "element")}; + discovery.variable_types.push_back(evidence( + "IEDLD0", "GGIO1", structure("", { + structure("ST", { + structure("Ind1", { + scalar(mms::MmsTypeKind::boolean, "stVal"), array})})}) + )); + discovery.variable_types.push_back(evidence( + "OTHERLD", "LLN0$ST$Mod$stVal", + scalar(mms::MmsTypeKind::boolean, ""))); + + const auto selected = mms::MmsDynamicReportMemberSelector::select( + discovery, report_control(), 8U); + CHECK(selected.successful_type_probes == 3U); + CHECK(selected.scalar_leaf_candidates == 4U); + CHECK(selected.members.size() == 4U); + CHECK(selected.members[0].reference() == "IEDLD0/LLN0$ST$Beh$stVal"); + CHECK(selected.members[1].reference() == "IEDLD0/LLN0$MX$Hz$mag$f"); + CHECK(selected.members[2].reference() == "IEDLD0/LLN0$ST$Beh$q"); + CHECK(selected.members[3].reference() == "IEDLD0/GGIO1$ST$Ind1$stVal"); +} + +void direct_scalar_evidence_and_requested_bound_are_preserved() { + mms::MmsLiveDiscoveryResult discovery; + discovery.variable_types.push_back(evidence( + "iedld0", "LLN0$st$Mod$stVal", + scalar(mms::MmsTypeKind::boolean, ""))); + discovery.variable_types.push_back(evidence( + "IEDLD0", "LLN0$ST$Beh$stVal", + scalar(mms::MmsTypeKind::boolean, ""))); + + const auto selected = mms::MmsDynamicReportMemberSelector::select( + discovery, report_control(), 1U); + CHECK(selected.scalar_leaf_candidates == 2U); + CHECK(selected.members.size() == 1U); + CHECK(selected.members.front().reference() == + "IEDLD0/LLN0$ST$Beh$stVal"); +} + +void report_dataset_attribute_uses_mms_wire_reference() { + CHECK(mms::MmsDataSetDirectoryCodec::to_report_attribute_value( + "IEDLD0/LLN0.DynamicSet") == + "IEDLD0/LLN0$DynamicSet"); + CHECK(mms::MmsDataSetDirectoryCodec::to_report_attribute_value( + "IEDLD0/LLN0$DynamicSet") == + "IEDLD0/LLN0$DynamicSet"); + CHECK(mms::MmsDataSetDirectoryCodec::to_report_attribute_value({}).empty()); +} + +void information_report_variable_list_name_is_supported() { + using ar::iec61850::asn1::BerWriter; + const auto object_name = mms::MmsServiceCodec::encode_object_name( + mms::MmsObjectName::domain_specific("IEDLD0", "LLN0$DynamicSet")); + const auto report_value = mms::MmsDataCodec::encode( + mms::MmsDataValue::visible_string("IEDLD0/LLN0.BR.brcbA01")); + const auto result_list = BerWriter::encode_tlv(0xA0U, report_value); + const std::array specifications{ + BerWriter::encode_tlv(0xA1U, object_name), + object_name}; + for (const auto& specification : specifications) { + auto body = specification; + body.insert(body.end(), result_list.begin(), result_list.end()); + const auto information_report = BerWriter::encode_tlv(0xA0U, body); + const auto pdu = BerWriter::encode_tlv(0xA3U, information_report); + const auto decoded = mms::MmsInformationReportCodec::decode(pdu); + CHECK(decoded.variable_references.size() == 1U); + CHECK(decoded.variable_references.front().reference() == + "IEDLD0/LLN0$DynamicSet"); + CHECK(decoded.items.size() == 1U); + } +} + +} // namespace + +int main() { + try { + logical_node_tree_projects_scalar_st_mx_leaves(); + direct_scalar_evidence_and_requested_bound_are_preserved(); + report_dataset_attribute_uses_mms_wire_reference(); + information_report_variable_list_name_is_supported(); + std::cout << "Dynamic report member planner tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} From fdf0691f73fabfc9552dcf533234e943918131e2 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 04:59:01 +0700 Subject: [PATCH 3/3] Integrate dynamic report planner into core --- CMakeLists.txt | 3 ++- tools/dynamic_rcb_trial/CMakeLists.txt | 18 ++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ed152026c..cdebc85c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ add_library(ariec61850_core src/mms/pdu.cpp src/mms/services.cpp src/mms/dynamic_data_set.cpp + src/mms/dynamic_report_planner.cpp src/mms/invoke_router.cpp src/mms/reporting.cpp src/mms/association_runtime.cpp @@ -312,4 +313,4 @@ if(ARIEC61850_BUILD_FUZZERS) ariec61850_add_fuzzer(ariec61850_fuzz_association fuzz/fuzz_association.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_mms_services fuzz/fuzz_mms_services.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_reporting fuzz/fuzz_reporting.cpp) -endif() \ No newline at end of file +endif() diff --git a/tools/dynamic_rcb_trial/CMakeLists.txt b/tools/dynamic_rcb_trial/CMakeLists.txt index bf7b82f1d..1131c3a06 100644 --- a/tools/dynamic_rcb_trial/CMakeLists.txt +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -9,27 +9,14 @@ set(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. arstack_core) -add_library(ariec61850_dynamic_dataset_trial_support STATIC - ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/dynamic_data_set.cpp - ${CMAKE_CURRENT_LIST_DIR}/../../src/mms/dynamic_report_planner.cpp) -target_link_libraries(ariec61850_dynamic_dataset_trial_support PUBLIC ARIEC61850::core) -target_compile_features(ariec61850_dynamic_dataset_trial_support PUBLIC cxx_std_20) -target_include_directories(ariec61850_dynamic_dataset_trial_support PUBLIC - ${CMAKE_CURRENT_LIST_DIR}/../../include) - add_executable(ariec61850_dynamic_rcb_trial ${CMAKE_CURRENT_LIST_DIR}/../dynamic_rcb_trial.cpp) -target_link_libraries(ariec61850_dynamic_rcb_trial PRIVATE - ARIEC61850::core - ariec61850_dynamic_dataset_trial_support) +target_link_libraries(ariec61850_dynamic_rcb_trial PRIVATE ARIEC61850::core) target_compile_features(ariec61850_dynamic_rcb_trial PRIVATE cxx_std_20) if(MSVC) - target_compile_options(ariec61850_dynamic_dataset_trial_support PRIVATE /W4 /permissive- /WX) target_compile_options(ariec61850_dynamic_rcb_trial PRIVATE /W4 /permissive- /WX) else() - target_compile_options(ariec61850_dynamic_dataset_trial_support PRIVATE - -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) target_compile_options(ariec61850_dynamic_rcb_trial PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) endif() @@ -44,8 +31,7 @@ add_test(NAME dynamic_rcb_trial_help COMMAND ariec61850_dynamic_rcb_trial --help add_executable(ariec61850_dynamic_report_planner_tests ${CMAKE_CURRENT_LIST_DIR}/test_dynamic_report_planner.cpp) target_link_libraries(ariec61850_dynamic_report_planner_tests PRIVATE - ARIEC61850::core - ariec61850_dynamic_dataset_trial_support) + ARIEC61850::core) target_compile_features(ariec61850_dynamic_report_planner_tests PRIVATE cxx_std_20) if(MSVC) target_compile_options(ariec61850_dynamic_report_planner_tests PRIVATE /W4 /permissive- /WX)