Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/dynamic-rcb-trial-ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
endif()
100 changes: 100 additions & 0 deletions docs/DYNAMIC_RCB_LIVE_TRIAL.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions include/ariec61850/mms/dynamic_report_planner.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once

#include "ariec61850/mms/live_discovery.hpp"

#include <cstddef>
#include <vector>

namespace ar::iec61850::mms {

struct MmsDynamicReportMemberSelection final {
std::size_t successful_type_probes{};
std::size_t scalar_leaf_candidates{};
std::vector<MmsObjectName> 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
2 changes: 2 additions & 0 deletions include/ariec61850/mms/reporting.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint8_t> encode_request_pdu(
const MmsDataSetDirectoryRequest& request);
Expand Down
33 changes: 33 additions & 0 deletions scripts/build_dynamic_rcb_trial.ps1
Original file line number Diff line number Diff line change
@@ -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"
166 changes: 166 additions & 0 deletions src/mms/dynamic_report_planner.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: GPL-3.0-or-later

#include "ariec61850/mms/dynamic_report_planner.hpp"

#include <algorithm>
#include <cctype>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

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<char>(
std::tolower(static_cast<unsigned char>(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<ItemIdentity> 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<MmsObjectName>& 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> ranked;
std::set<std::string, std::less<>> 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<MmsObjectName> 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<int>(
std::min<std::size_t>(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
Loading
Loading