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/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/docs/DYNAMIC_RCB_LIVE_TRIAL.md b/docs/DYNAMIC_RCB_LIVE_TRIAL.md new file mode 100644 index 000000000..6289cc3d4 --- /dev/null +++ b/docs/DYNAMIC_RCB_LIVE_TRIAL.md @@ -0,0 +1,100 @@ +# 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. + +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 + +```powershell +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 +.\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_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/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_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 new file mode 100644 index 000000000..72d8a110b --- /dev/null +++ b/tools/dynamic_rcb_trial.cpp @@ -0,0 +1,419 @@ +// 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" +#include "ariec61850/mms/services.hpp" + +#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::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); +} + +[[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) { + 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) + ")." + : ".")); + } +} + +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; + 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 { + 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"; + 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; + } + + 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'; + 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) { + 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'; + 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(); + 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..1131c3a06 --- /dev/null +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -0,0 +1,43 @@ +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_executable(ariec61850_dynamic_rcb_trial + ${CMAKE_CURRENT_LIST_DIR}/../dynamic_rcb_trial.cpp) +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_rcb_trial PRIVATE /W4 /permissive- /WX) +else() + 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) + +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) +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; + } +}