From e2c6316ce407dee667f55ed5d723a0abdabc766b Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 06:17:27 +0700 Subject: [PATCH] feat(reporting): add guarded static RCB sessions --- CMakeLists.txt | 1 + REPORTING_PROFILE.md | 19 + docs/FEATURE_MATRIX.md | 1 + docs/STATIC_RCB_LIVE_TRIAL.md | 80 ++++ .../ariec61850/mms/static_report_session.hpp | 83 +++++ scripts/build_dynamic_rcb_trial.ps1 | 19 + src/mms/static_report_session.cpp | 199 ++++++++++ tests/test_mms_runtime.cpp | 155 ++++++++ tools/dynamic_rcb_trial/CMakeLists.txt | 15 + tools/static_rcb_trial.cpp | 343 ++++++++++++++++++ 10 files changed, 915 insertions(+) create mode 100644 docs/STATIC_RCB_LIVE_TRIAL.md create mode 100644 include/ariec61850/mms/static_report_session.hpp create mode 100644 src/mms/static_report_session.cpp create mode 100644 tools/static_rcb_trial.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 559815fc2..0c9720733 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ add_library(ariec61850_core src/mms/live_discovery.cpp src/mms/rcb_selection.cpp src/mms/rcb_failover.cpp + src/mms/static_report_session.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/quality.cpp diff --git a/REPORTING_PROFILE.md b/REPORTING_PROFILE.md index c52aa3641..6e2027554 100644 --- a/REPORTING_PROFILE.md +++ b/REPORTING_PROFILE.md @@ -21,6 +21,25 @@ may rank another candidate. Once any mutation is attempted, automatic switching is disabled; an uncertain write must be cleaned up and diagnosed on the original RCB instead of risking a second remote mutation. +## Static DataSet report session + +`MmsStaticReportSessionRuntime` composes the static pool selector, bounded +pre-claim contention/failover, populated DataSet-directory evidence, and the +persistent subscription runtime. `prepare()` is read-only and may rerank after +a busy or flapping candidate. `start()` uses the RCB's existing static binding; +the runtime rejects any request to rewrite `DatSet`. + +The guarded `ariec61850_static_rcb_trial` host tool exposes this lifecycle for +authorized lab validation. Its default mode performs discovery and pre-claim +Reads only. Armed mode enables the selected free BRCB/URCB, optionally requests +GI, observes InformationReports, and cleans up only state acquired by the +runtime. See [docs/STATIC_RCB_LIVE_TRIAL.md](docs/STATIC_RCB_LIVE_TRIAL.md). + +The current guarded single-profile run used a populated static URCB: three +pre-claim Reads remained stable, GI produced one decoded report with zero decode +failures, and disable/reservation release completed without deferred cleanup. +This is not yet long-duration or multi-vendor acceptance. + ## Phase 4C.1 live-model integration Read-only discovery includes DataSet directory and RCB state evidence in `live-ied-model-v1`. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index a13b76d2d..728b4fe11 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -38,6 +38,7 @@ Status terms are intentionally conservative: | Reporting | Bound/Unbound runtime semantics | Live-proven | One current snapshot observed 6 Bound and 280 Unbound. This is mutable runtime state, not a permanent device capability claim. | | Reporting | Dynamic RCB candidate planner | Live-proven | Read-only ranking selected an empty dynamic BRCB candidate on current endpoint. | | Reporting | Conservative operational availability | Live-proven | Separates dynamic-slot eligibility from populated/static report availability. | +| Reporting | Static DataSet + RCB session | Live-proven / guarded single profile | Reusable runtime selected a populated static URCB after BRCB-only planning correctly found no eligible candidate. Three pre-claim Reads stayed stable; the armed trial reserved/enabled it, requested GI, preserved the existing binding, decoded 1 report with 0 failures, then disabled/released it with cleanup verified. `DatSet` rewrites are rejected. | | Reporting | Read-only contention pre-claim probe | Live-proven | Three repeated probes on `OCR7SR12CTRL/BI6GGIO1.urcbA01` remained free/stable and returned `StableProceed`; no claim/write was performed. | | Reporting | Bounded smart pre-claim failover | Offline-tested / stable live probe | Immediately before mutation, repeated read-only probes reject busy/flapping candidates, add them to the command-local exclusion set, and rerank the next candidate. A stable live candidate passed three probes; a live contended switch remains pending. Mutation failures never trigger automatic switching. | | Reporting | RCB claim/reservation/enable | Live-proven / guarded lab | Explicitly armed Dynamic RCB trial bound and enabled one BRCB, requested GI, then disabled and unbound it with cleanup verified. Live discovery itself remains read-only. | diff --git a/docs/STATIC_RCB_LIVE_TRIAL.md b/docs/STATIC_RCB_LIVE_TRIAL.md new file mode 100644 index 000000000..2b83eb6b9 --- /dev/null +++ b/docs/STATIC_RCB_LIVE_TRIAL.md @@ -0,0 +1,80 @@ +# Static DataSet / RCB Live Trial + +`ariec61850_static_rcb_trial` validates the client-side static reporting path +without changing the IED's DataSet model. It discovers populated DataSet +directories and their bound RCBs, applies Smart RCB ranking, then performs +bounded repeated pre-claim Reads immediately before any optional activation. + +## Safety boundary + +The default command is read-only. It sends association, discovery, DataSet +directory, RCB-state, and contention-probe Reads only. Armed execution requires +the exact token `IEC61850-LAB-STATIC-RCB`. + +An armed trial may reserve a selected free URCB, write `RptEna=true`, and issue +`GI=true`. Shutdown writes only the inverse state touched by that runtime: +`RptEna=false` and, for a runtime-reserved URCB, `Resv=false`. + +The static trial never: + +- writes the RCB `DatSet` attribute; +- defines or deletes a DataSet; +- disables or takes over an RCB observed as busy; +- switches to another RCB after any mutation begins. + +If a candidate becomes enabled, reserved, or flaps during the pre-claim window, +the command excludes it and reranks another eligible static candidate within a +hard attempt bound. + +## Build + +```powershell +.\scripts\build_dynamic_rcb_trial.ps1 -BuildDir build-static-rcb-trial -Configuration Release +``` + +The shared harness builds both the dynamic and static reporting executables and +runs their help smoke tests plus the dynamic member-planner regression. + +## Read-only planning + +```powershell +.\build-static-rcb-trial\Release\ariec61850_static_rcb_trial.exe --no-urcb-fallback +``` + +Success ends with `READ_ONLY_STATIC_PLAN_OK` and prints the selected RCB, mode, +bound DataSet, member count, and every bounded pre-claim attempt. + +Use `--dataset-ref ` to require a particular static DataSet or +`--preferred-rcb ` to rank one RCB first. A preferred RCB is not an +ownership override: a busy preferred candidate is still skipped. + +## Guarded subscription + +Run only against an authorized lab IED: + +```powershell +.\build-static-rcb-trial\Release\ariec61850_static_rcb_trial.exe --no-urcb-fallback --arm IEC61850-LAB-STATIC-RCB +``` + +The strongest successful result is `SMART_STATIC_RCB_TRIAL_PASS`, which means +the selected static RCB was enabled, its binding remained populated during +bounded confirmation Reads, an InformationReport was decoded, and cleanup +completed without deferred state. + +`STATIC_TRIAL_LIFECYCLE_PASS_REPORT_PENDING` exits with code 4. It means guarded +enable/confirm/disable cleanup passed, but no GI report arrived within the +bounded observation window. That is lifecycle evidence, not report-delivery +acceptance. + +## Portable runtime API + +Applications can embed `MmsStaticReportSessionRuntime` directly: + +1. retain the `MmsLiveDiscoveryResult` for the session lifetime; +2. call `prepare()` for read-only selection and pre-claim failover; +3. inspect `snapshot()` and the selected static binding; +4. call `start()` only when remote activation is authorized; +5. poll or drain reports, then call `stop()` before disconnecting. + +The runtime rejects `write_data_set_reference=true` at construction, so static +and dynamic DataSet ownership cannot be accidentally mixed. diff --git a/include/ariec61850/mms/static_report_session.hpp b/include/ariec61850/mms/static_report_session.hpp new file mode 100644 index 000000000..efb71e444 --- /dev/null +++ b/include/ariec61850/mms/static_report_session.hpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/rcb_contention.hpp" +#include "ariec61850/mms/rcb_failover.hpp" +#include "ariec61850/mms/rcb_selection.hpp" +#include "ariec61850/mms/report_subscription_runtime.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsStaticReportSessionError final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +struct MmsStaticReportSessionOptions final { + MmsRcbStaticSelectionOptions selection; + MmsRcbContentionProbeOptions contention; + std::size_t maximum_candidate_attempts{4U}; + MmsReportSubscriptionOptions subscription; +}; + +struct MmsStaticReportSessionSnapshot final { + bool prepared{}; + bool active{}; + std::string selected_rcb_reference; + std::string data_set_reference; + std::size_t data_set_member_count{}; + MmsRcbPreclaimFailoverSnapshot failover; + std::vector blockers; + std::optional subscription; +}; + +// Client-side static DataSet reporting orchestration. prepare() performs only +// bounded Read operations. start() never rewrites DatSet and delegates guarded +// reservation/enable/report/cleanup behavior to MmsReportSubscriptionRuntime. +class MmsStaticReportSessionRuntime final { +public: + MmsStaticReportSessionRuntime( + MmsAssociationRuntime& association, + const MmsLiveDiscoveryResult& discovery, + MmsStaticReportSessionOptions options = {}); + + void prepare(std::stop_token stop_token = {}); + void start(std::stop_token stop_token = {}); + void stop(std::stop_token stop_token = {}) noexcept; + [[nodiscard]] bool poll_once(std::stop_token stop_token = {}); + [[nodiscard]] std::size_t drain_queued_reports(); + + [[nodiscard]] MmsStaticReportSessionSnapshot snapshot() const; + [[nodiscard]] bool prepared() const noexcept { return prepared_; } + [[nodiscard]] bool active() const noexcept { + return subscription_ != nullptr && subscription_->active(); + } + [[nodiscard]] const MmsReportControlCandidate* selected_candidate() const noexcept { + return selected_candidate_ ? &*selected_candidate_ : nullptr; + } + +private: + void resolve_selected_static_binding(); + + MmsAssociationRuntime& association_; + const MmsLiveDiscoveryResult& discovery_; + MmsStaticReportSessionOptions options_; + MmsRcbPreclaimFailoverTracker failover_; + std::optional selected_candidate_; + std::optional selected_directory_; + std::string selected_data_set_reference_; + std::vector blockers_; + std::unique_ptr subscription_; + bool prepared_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/scripts/build_dynamic_rcb_trial.ps1 b/scripts/build_dynamic_rcb_trial.ps1 index ff67ecc57..1b07792d2 100644 --- a/scripts/build_dynamic_rcb_trial.ps1 +++ b/scripts/build_dynamic_rcb_trial.ps1 @@ -31,3 +31,22 @@ 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" + +$staticCandidates = @( + (Join-Path $build "$Configuration\ariec61850_static_rcb_trial.exe"), + (Join-Path $build "ariec61850_static_rcb_trial.exe") +) +$staticExe = $staticCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $staticExe) { + throw "ariec61850_static_rcb_trial.exe was not found after build." +} + +Write-Host "" +Write-Host "Static RCB trial executable:" +Write-Host " $staticExe" +Write-Host "" +Write-Host "Read-only static plan example:" +Write-Host " `"$staticExe`" 192.168.1.10 --no-urcb-fallback" +Write-Host "" +Write-Host "Armed static subscription example:" +Write-Host " `"$staticExe`" 192.168.1.10 --no-urcb-fallback --arm IEC61850-LAB-STATIC-RCB" diff --git a/src/mms/static_report_session.cpp b/src/mms/static_report_session.cpp new file mode 100644 index 000000000..b11bc104d --- /dev/null +++ b/src/mms/static_report_session.cpp @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_report_session.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool same_object_name( + const std::string& left, + const std::string& right) noexcept { + try { + return MmsDataSetDirectoryCodec::parse_data_set_reference(left) == + MmsDataSetDirectoryCodec::parse_data_set_reference(right); + } catch (...) { + return false; + } +} + +[[nodiscard]] const MmsReportControlEvidence* find_control_evidence( + const MmsLiveDiscoveryResult& discovery, + const std::string& reference) noexcept { + const auto found = std::find_if( + discovery.report_controls.begin(), + discovery.report_controls.end(), + [&reference](const auto& evidence) { + return evidence.candidate.reference == reference; + }); + return found == discovery.report_controls.end() ? nullptr : &*found; +} + +[[nodiscard]] const MmsDataSetDirectoryEvidence* find_directory_evidence( + const MmsLiveDiscoveryResult& discovery, + const std::string& reference) noexcept { + const auto found = std::find_if( + discovery.data_set_directories.begin(), + discovery.data_set_directories.end(), + [&reference](const auto& evidence) { + return same_object_name(evidence.candidate.reference, reference); + }); + return found == discovery.data_set_directories.end() ? nullptr : &*found; +} + +} // namespace + +MmsStaticReportSessionRuntime::MmsStaticReportSessionRuntime( + MmsAssociationRuntime& association, + const MmsLiveDiscoveryResult& discovery, + MmsStaticReportSessionOptions options) + : association_{association}, + discovery_{discovery}, + options_{std::move(options)}, + failover_{options_.maximum_candidate_attempts} { + if (options_.subscription.write_data_set_reference) { + throw std::invalid_argument( + "Static report session cannot rewrite the RCB DatSet binding."); + } +} + +void MmsStaticReportSessionRuntime::prepare( + const std::stop_token stop_token) { + if (prepared_ || selected_candidate_ || subscription_) { + throw MmsStaticReportSessionError( + "Static report session prepare may be called only once."); + } + if (!association_.associated()) { + throw MmsStaticReportSessionError( + "Static report session requires an active MMS association."); + } + + MmsRcbContentionProbeClient probe{association_}; + while (failover_.may_attempt()) { + auto selection_options = options_.selection; + selection_options.excluded_rcb_references = + failover_.excluded_rcb_references(); + const auto selection = MmsRcbPoolSelector::build_static_selection( + discovery_, selection_options); + if (!selection.selected()) { + blockers_ = selection.blockers; + break; + } + const auto* candidate = MmsRcbPoolSelector::select_report_control( + discovery_, selection); + if (candidate == nullptr) { + blockers_.push_back( + "Static RCB selection returned no matching inventory candidate."); + break; + } + + const auto contention = probe.probe( + *candidate, options_.contention, stop_token); + const auto outcome = failover_.observe(contention); + if (outcome == MmsRcbPreclaimOutcome::stable_proceed) { + selected_candidate_ = *candidate; + resolve_selected_static_binding(); + prepared_ = true; + return; + } + } + + if (blockers_.empty()) { + blockers_.push_back( + failover_.snapshot().exhausted + ? "Static report pre-claim candidate attempt limit was exhausted." + : "No stable static RCB candidate remained after pre-claim selection."); + } + throw MmsStaticReportSessionError(blockers_.front()); +} + +void MmsStaticReportSessionRuntime::resolve_selected_static_binding() { + if (!selected_candidate_) { + throw MmsStaticReportSessionError( + "Cannot resolve a static DataSet without a selected RCB."); + } + const auto* control = find_control_evidence( + discovery_, selected_candidate_->reference); + if (control == nullptr || !control->state) { + throw MmsStaticReportSessionError( + "Selected static RCB has no successful runtime-state evidence."); + } + selected_data_set_reference_ = control->state->data_set_reference; + if (selected_data_set_reference_.empty()) { + throw MmsStaticReportSessionError( + "Selected static RCB has no bound DataSet reference."); + } + const auto* directory = find_directory_evidence( + discovery_, selected_data_set_reference_); + if (directory == nullptr || !directory->directory || + directory->directory->members.empty()) { + throw MmsStaticReportSessionError( + "Selected static RCB DataSet directory is missing or empty."); + } + selected_directory_ = *directory->directory; +} + +void MmsStaticReportSessionRuntime::start( + const std::stop_token stop_token) { + if (!prepared_ || !selected_candidate_ || !selected_directory_) { + throw MmsStaticReportSessionError( + "Static report session must be prepared before start."); + } + if (subscription_) { + throw MmsStaticReportSessionError( + "Static report subscription has already been started."); + } + subscription_ = std::make_unique( + association_, *selected_candidate_, *selected_directory_, + options_.subscription); + subscription_->start(stop_token); +} + +void MmsStaticReportSessionRuntime::stop( + const std::stop_token stop_token) noexcept { + if (subscription_) { + subscription_->stop(stop_token); + } +} + +bool MmsStaticReportSessionRuntime::poll_once( + const std::stop_token stop_token) { + if (!subscription_) { + throw MmsStaticReportSessionError( + "Static report session has not been started."); + } + return subscription_->poll_once(stop_token); +} + +std::size_t MmsStaticReportSessionRuntime::drain_queued_reports() { + if (!subscription_) { + throw MmsStaticReportSessionError( + "Static report session has not been started."); + } + return subscription_->drain_queued_reports(); +} + +MmsStaticReportSessionSnapshot MmsStaticReportSessionRuntime::snapshot() const { + MmsStaticReportSessionSnapshot result; + result.prepared = prepared_; + result.active = active(); + result.failover = failover_.snapshot(); + result.blockers = blockers_; + result.selected_rcb_reference = selected_candidate_ + ? selected_candidate_->reference + : std::string{}; + result.data_set_reference = selected_data_set_reference_; + result.data_set_member_count = selected_directory_ + ? selected_directory_->members.size() + : 0U; + if (subscription_) { + result.subscription = subscription_->snapshot(); + } + return result; +} + +} // namespace ar::iec61850::mms diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index 5b1ff2f3b..32edad863 100644 --- a/tests/test_mms_runtime.cpp +++ b/tests/test_mms_runtime.cpp @@ -5,6 +5,7 @@ #include "ariec61850/mms/report_subscription_runtime.hpp" #include "ariec61850/mms/reporting.hpp" #include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_report_session.hpp" #include "ariec61850/osi/cotp.hpp" #include "ariec61850/osi/tpkt.hpp" @@ -387,6 +388,158 @@ void queue_write_success( mms::MmsServiceCodec::encode_write_response_p_data(response))); } +[[nodiscard]] mms::MmsReportControlCandidate make_static_brcb_candidate( + std::string name) { + mms::MmsReportControlCandidate candidate; + candidate.domain = "LD0"; + candidate.logical_node = "LLN0"; + candidate.functional_constraint = "BR"; + candidate.name = std::move(name); + candidate.reference = "LD0/LLN0.BR." + candidate.name; + candidate.buffered = true; + candidate.attributes = { + "RptID", "RptEna", "DatSet", "ConfRev", "GI", "ResvTms"}; + return candidate; +} + +[[nodiscard]] mms::MmsLiveDiscoveryResult make_static_discovery( + const mms::MmsReportControlCandidate& preferred, + const mms::MmsReportControlCandidate& fallback) { + mms::MmsLiveDiscoveryResult discovery; + discovery.report_inventory.report_controls = {preferred, fallback}; + + for (const auto& candidate : discovery.report_inventory.report_controls) { + mms::MmsReportControlEvidence control; + control.candidate = candidate; + control.requested_attributes = { + "DatSet", "RptID", "ConfRev", "RptEna", "ResvTms"}; + mms::MmsReportControlState state; + state.candidate = candidate; + state.data_set_reference = "LD0/LLN0.DataSet"; + state.report_id = "RPT-" + candidate.name; + state.configuration_revision = 1U; + state.report_enabled = false; + state.reservation_time_seconds = 0U; + control.state = std::move(state); + discovery.report_controls.push_back(std::move(control)); + } + + mms::MmsDataSetDirectoryEvidence directory; + directory.candidate.domain = "LD0"; + directory.candidate.logical_node = "LLN0"; + directory.candidate.name = "DataSet"; + directory.candidate.reference = "LD0/LLN0.DataSet"; + directory.directory = make_directory(); + discovery.data_set_directories.push_back(std::move(directory)); + return discovery; +} + +void queue_static_preclaim_response( + ScriptedTransport& transport, + const std::uint32_t invoke_id, + const bool enabled) { + mms::MmsReadResponse response; + response.invoke_id = invoke_id; + response.results = { + {mms::MmsDataValue::visible_string("LD0/LLN0.DataSet"), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(1U), std::nullopt}, + {mms::MmsDataValue::boolean(enabled), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(0U), std::nullopt}, + }; + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(response))); +} + +void queue_static_subscription_probe_response( + ScriptedTransport& transport, + const std::uint32_t invoke_id) { + mms::MmsReadResponse response; + response.invoke_id = invoke_id; + response.results = { + {mms::MmsDataValue::visible_string("RPT-FALLBACK"), std::nullopt}, + {mms::MmsDataValue::boolean(false), std::nullopt}, + {mms::MmsDataValue::visible_string("LD0/LLN0.DataSet"), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(1U), std::nullopt}, + {mms::MmsDataValue::boolean(false), std::nullopt}, + {mms::MmsDataValue::unsigned_integer(0U), std::nullopt}, + }; + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(response))); +} + +void static_session_skips_contended_preferred_and_subscribes_fallback() { + ScriptedTransport transport; + queue_handshake(transport); + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + + const auto preferred = make_static_brcb_candidate("brcbPreferred01"); + const auto fallback = make_static_brcb_candidate("brcbFallback01"); + const auto discovery = make_static_discovery(preferred, fallback); + + queue_static_preclaim_response(transport, 1U, true); + queue_static_preclaim_response(transport, 2U, false); + queue_static_subscription_probe_response(transport, 3U); + queue_write_success(transport, 4U); // RptEna=true + queue_write_success(transport, 5U); // GI=true + + mms::MmsStaticReportSessionOptions options; + options.selection.preferred_rcb_reference = preferred.reference; + options.selection.allow_urcb_fallback = false; + options.contention.probe_count = 1U; + options.contention.probe_delay = std::chrono::milliseconds{0}; + options.maximum_candidate_attempts = 2U; + + mms::MmsStaticReportSessionRuntime session{ + association, discovery, options}; + session.prepare(); + const auto prepared = session.snapshot(); + CHECK(prepared.prepared); + CHECK(!prepared.active); + CHECK(prepared.selected_rcb_reference == fallback.reference); + CHECK(prepared.data_set_reference == "LD0/LLN0.DataSet"); + CHECK(prepared.data_set_member_count == 1U); + CHECK(prepared.failover.attempts.size() == 2U); + CHECK(prepared.failover.excluded_rcb_references == + std::vector{preferred.reference}); + + session.start(); + CHECK(session.active()); + auto active = session.snapshot(); + CHECK(active.subscription.has_value()); + CHECK(active.subscription->enabled_by_runtime); + CHECK(!active.subscription->reservation_touched); + + transport.push_receive(wrap_application( + mms::MmsInformationReportCodec::encode_p_data(make_report()))); + CHECK(session.poll_once()); + CHECK(session.snapshot().subscription->received_reports == 1U); + + queue_write_success(transport, 6U); // RptEna=false + session.stop(); + const auto stopped = session.snapshot(); + CHECK(!stopped.active); + CHECK(stopped.subscription->state == + mms::MmsReportSubscriptionState::stopped); + CHECK(!stopped.subscription->cleanup_required); + CHECK(transport.sent().size() == 8U); // CR, AARQ, 3 reads, 3 writes +} + +void static_session_rejects_data_set_rebinding() { + ScriptedTransport transport; + mms::MmsAssociationRuntime association{transport}; + const auto preferred = make_static_brcb_candidate("brcbPreferred01"); + const auto fallback = make_static_brcb_candidate("brcbFallback01"); + const auto discovery = make_static_discovery(preferred, fallback); + mms::MmsStaticReportSessionOptions options; + options.subscription.write_data_set_reference = true; + check_throws([&] { + mms::MmsStaticReportSessionRuntime session{ + association, discovery, options}; + static_cast(session); + }); +} + void subscription_runtime_reserves_enables_receives_and_cleans_up() { ScriptedTransport transport; queue_handshake(transport); @@ -486,6 +639,8 @@ int main() { {"report subscription lifecycle", subscription_runtime_reserves_enables_receives_and_cleans_up}, {"report cleanup after association loss", subscription_marks_cleanup_required_when_association_is_lost}, {"report takeover protection", subscription_does_not_take_over_an_enabled_rcb}, + {"static report session failover lifecycle", static_session_skips_contended_preferred_and_subscribes_fallback}, + {"static report session forbids DataSet rebinding", static_session_rejects_data_set_rebinding}, }; std::size_t passed = 0U; diff --git a/tools/dynamic_rcb_trial/CMakeLists.txt b/tools/dynamic_rcb_trial/CMakeLists.txt index 1131c3a06..2ad9e0637 100644 --- a/tools/dynamic_rcb_trial/CMakeLists.txt +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -28,6 +28,21 @@ endif() enable_testing() add_test(NAME dynamic_rcb_trial_help COMMAND ariec61850_dynamic_rcb_trial --help) +add_executable(ariec61850_static_rcb_trial + ${CMAKE_CURRENT_LIST_DIR}/../static_rcb_trial.cpp) +target_link_libraries(ariec61850_static_rcb_trial PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_static_rcb_trial PRIVATE cxx_std_20) +if(MSVC) + target_compile_options(ariec61850_static_rcb_trial PRIVATE /W4 /permissive- /WX) +else() + target_compile_options(ariec61850_static_rcb_trial PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() +if(WIN32) + target_link_libraries(ariec61850_static_rcb_trial PRIVATE ws2_32) +endif() +add_test(NAME static_rcb_trial_help COMMAND ariec61850_static_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 diff --git a/tools/static_rcb_trial.cpp b/tools/static_rcb_trial.cpp new file mode 100644 index 000000000..59636abf8 --- /dev/null +++ b/tools/static_rcb_trial.cpp @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/mms/static_report_session.hpp" + +#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-STATIC-RCB"; + +struct CliOptions final { + mms::MmsEndpoint endpoint; + std::chrono::milliseconds timeout{5'000}; + std::string preferred_rcb_reference; + std::string preferred_data_set_reference; + std::size_t maximum_data_set_directories{4'096U}; + std::size_t maximum_rcb_probes{4'096U}; + std::size_t maximum_claim_candidates{4U}; + std::size_t preclaim_probe_count{3U}; + std::chrono::milliseconds preclaim_probe_delay{250}; + int contention_cooldown_seconds{60}; + std::size_t confirmation_cycles{3U}; + std::chrono::milliseconds confirmation_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_static_rcb_trial [port] [options]\n\n" + << "Default mode is READ-ONLY: discover populated static DataSets, rank\n" + << "their free BRCB/URCB bindings, and run bounded pre-claim Read probes.\n" + << "No MMS Write is sent unless the exact --arm token is supplied.\n\n" + << "Options:\n" + << " --preferred-rcb REF Prefer one static RCB reference.\n" + << " --dataset-ref REF Require a specific bound static DataSet.\n" + << " --no-urcb-fallback Use BRCB candidates only.\n" + << " --max-datasets N Bound DataSet directory reads (default 4096).\n" + << " --max-rcb N Bound RCB state probes (default 4096).\n" + << " --max-claim-candidates N Bound pre-claim candidates (default 4).\n" + << " --preclaim-probes N Read-only probes per candidate (default 3).\n" + << " --preclaim-delay-ms N Delay between pre-claim probes (default 250).\n" + << " --contention-cooldown N Recorded skip cooldown seconds (default 60).\n" + << " --probe-cycles N Confirmation reads after enable (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 the selected static RCB, observe reports,\n" + << " then disable/release only state touched here.\n" + << " -h, --help Show this help.\n\n" + << "This harness never rewrites DatSet, creates/deletes a DataSet, disables a\n" + << "busy RCB, or switches candidates after a mutation begins.\n"; +} + +[[nodiscard]] CliOptions parse_cli(const 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 == "--preferred-rcb" || + option == "--dataset-ref" || + option == "--max-datasets" || + option == "--max-rcb" || + option == "--max-claim-candidates" || + option == "--preclaim-probes" || + option == "--preclaim-delay-ms" || + option == "--contention-cooldown" || + 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 == "--preferred-rcb") { + options.preferred_rcb_reference = value; + } else if (option == "--dataset-ref") { + options.preferred_data_set_reference = value; + } else if (option == "--max-datasets") { + options.maximum_data_set_directories = parse_positive(option, value); + } else if (option == "--max-rcb") { + options.maximum_rcb_probes = parse_positive(option, value); + } else if (option == "--max-claim-candidates") { + options.maximum_claim_candidates = parse_positive(option, value); + } else if (option == "--preclaim-probes") { + options.preclaim_probe_count = parse_positive(option, value); + } else if (option == "--preclaim-delay-ms") { + options.preclaim_probe_delay = std::chrono::milliseconds{ + static_cast(parse_positive(option, value))}; + } else if (option == "--contention-cooldown") { + const auto seconds = parse_positive(option, value); + if (seconds > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument( + option + " exceeds the supported integer range."); + } + options.contention_cooldown_seconds = static_cast(seconds); + } else if (option == "--probe-cycles") { + options.confirmation_cycles = parse_positive(option, value); + } else if (option == "--probe-delay-ms") { + options.confirmation_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]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) { + if (!exchange.presentation_payload.empty()) { + return exchange.presentation_payload; + } + return exchange.envelope.mms_payload; +} + +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( + "Static 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( + "Static RCB confirmation read returned incomplete results."); + } + const auto* enabled = std::get_if(&response.results[0].value->value()); + const auto* data_set = std::get_if( + &response.results[1].value->value()); + if (enabled == nullptr || !*enabled || data_set == nullptr || + data_set->empty()) { + throw std::runtime_error( + "Static RCB confirmation did not preserve RptEna=true plus DatSet."); + } +} + +void print_preclaim_evidence( + const mms::MmsStaticReportSessionSnapshot& snapshot) { + for (const auto& attempt : snapshot.failover.attempts) { + std::cout << "PRECLAIM_ATTEMPT attempt=" << attempt.attempt_number + << " rcb=" << attempt.rcb_reference + << " outcome=" + << mms::mms_rcb_preclaim_outcome_name(attempt.outcome) + << " busy=" << (attempt.busy ? "true" : "false") + << " flapping=" << (attempt.flapping ? "true" : "false") + << " cooldownSeconds=" << attempt.cooldown_seconds << '\n'; + } +} + +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc == 2 && (std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h")) { + print_usage(); + return 0; + } + const auto cli = parse_cli(argc, argv); + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = cli.timeout; + association_options.request_timeout = cli.timeout; + mms::MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(cli.endpoint); + + mms::MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = true; + discovery_options.maximum_data_set_directories = + cli.maximum_data_set_directories; + discovery_options.probe_report_controls = true; + discovery_options.maximum_report_control_probes = cli.maximum_rcb_probes; + auto discovery = live_session.discover(discovery_options); + + mms::MmsStaticReportSessionOptions session_options; + session_options.selection.preferred_rcb_reference = + cli.preferred_rcb_reference; + session_options.selection.preferred_data_set_reference = + cli.preferred_data_set_reference; + session_options.selection.allow_urcb_fallback = + cli.allow_urcb_fallback; + session_options.selection.allow_polling_fallback = false; + session_options.contention.probe_count = cli.preclaim_probe_count; + session_options.contention.probe_delay = cli.preclaim_probe_delay; + session_options.contention.cooldown_seconds = + cli.contention_cooldown_seconds; + session_options.maximum_candidate_attempts = + cli.maximum_claim_candidates; + session_options.subscription.trigger_general_interrogation = + cli.trigger_gi; + session_options.subscription.reserve_unbuffered_rcb = true; + session_options.subscription.write_data_set_reference = false; + + mms::MmsStaticReportSessionRuntime report_session{ + live_session.association(), discovery, session_options}; + try { + report_session.prepare(); + auto prepared = report_session.snapshot(); + print_preclaim_evidence(prepared); + const auto* candidate = report_session.selected_candidate(); + if (candidate == nullptr) { + throw std::runtime_error( + "Static session prepared without a selected RCB."); + } + std::cout << "STATIC_PLAN selectedRcb=" + << prepared.selected_rcb_reference + << " mode=" << (candidate->buffered ? "BRCB" : "URCB") + << " dataSet=" << prepared.data_set_reference + << " members=" << prepared.data_set_member_count << '\n'; + + if (!cli.armed) { + live_session.disconnect(); + std::cout << "READ_ONLY_STATIC_PLAN_OK\n"; + return 0; + } + + report_session.start(); + std::cout << "STATIC_RCB_ENABLE_OK gi=" + << (cli.trigger_gi ? "requested" : "not-requested") + << '\n'; + for (std::size_t cycle = 0U; + cycle < cli.confirmation_cycles; ++cycle) { + std::this_thread::sleep_for(cli.confirmation_delay); + confirmation_read(live_session.association(), *candidate); + const auto drained = report_session.drain_queued_reports(); + std::cout << "CONFIRM_READ cycle=" << (cycle + 1U) + << " queuedReportsDrained=" << drained << '\n'; + } + + const auto active = report_session.snapshot(); + if (!active.subscription) { + throw std::runtime_error( + "Static subscription snapshot is unavailable."); + } + std::cout << "REPORT_EVIDENCE received=" + << active.subscription->received_reports + << " decodeFailures=" + << active.subscription->decode_failures + << " streams=" << active.subscription->streams.size() + << '\n'; + + report_session.stop(); + const auto stopped = report_session.snapshot(); + if (!stopped.subscription || stopped.subscription->cleanup_required) { + throw std::runtime_error( + "Static RCB stop left cleanup_required=true."); + } + std::cout << "STATIC_RCB_DISABLE_OK\n"; + live_session.disconnect(); + + if (cli.trigger_gi && active.subscription->received_reports == 0U) { + std::cout + << "STATIC_TRIAL_LIFECYCLE_PASS_REPORT_PENDING: enable/disable " + "and binding preservation passed, but no GI report was " + "observed during bounded confirmation reads.\n"; + return 4; + } + std::cout << "SMART_STATIC_RCB_TRIAL_PASS\n"; + return 0; + } catch (...) { + report_session.stop(); + live_session.disconnect(); + throw; + } + } catch (const std::exception& exception) { + std::cerr << "Static RCB trial failed: " << exception.what() << '\n'; + return 2; + } +}