From 5089a94b9a6a55e4f31169be5dba878ba0b46a75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 04:28:14 +0700 Subject: [PATCH 001/179] Add built-in TCP transport and live discovery --- CMakeLists.txt | 28 +- LIVE_DISCOVERY_PROFILE.md | 62 +++ MIGRATION_CHECKLIST.md | 27 +- PORT_STATUS.md | 45 +- README.md | 38 +- include/ariec61850/mms/live_discovery.hpp | 131 ++++++ include/ariec61850/mms/tcp_transport.hpp | 63 +++ src/mms/live_discovery.cpp | 429 ++++++++++++++++++ src/mms/tcp_transport.cpp | 512 ++++++++++++++++++++++ tests/test_live_discovery.cpp | 261 +++++++++++ tests/test_tcp_transport.cpp | 195 ++++++++ tools/live_discover.cpp | 204 +++++++++ 12 files changed, 1960 insertions(+), 35 deletions(-) create mode 100644 LIVE_DISCOVERY_PROFILE.md create mode 100644 include/ariec61850/mms/live_discovery.hpp create mode 100644 include/ariec61850/mms/tcp_transport.hpp create mode 100644 src/mms/live_discovery.cpp create mode 100644 src/mms/tcp_transport.cpp create mode 100644 tests/test_live_discovery.cpp create mode 100644 tests/test_tcp_transport.cpp create mode 100644 tools/live_discover.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e322bcc1b..36cba1d67 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850Cpp - VERSION 0.13.0 + VERSION 0.14.0 DESCRIPTION "Incremental C++20 port of the ARIEC61850 protocol stack" LANGUAGES CXX) @@ -29,6 +29,8 @@ add_library(ariec61850_core src/mms/reporting.cpp src/mms/association_runtime.cpp src/mms/report_subscription_runtime.cpp + src/mms/tcp_transport.cpp + src/mms/live_discovery.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/quality.cpp @@ -56,6 +58,10 @@ target_include_directories(ariec61850_core $ $) +if(WIN32) + target_link_libraries(ariec61850_core PRIVATE ws2_32) +endif() + function(ariec61850_apply_warnings target) if(MSVC) target_compile_options(${target} PRIVATE /W4 /permissive-) @@ -99,6 +105,12 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_comtrade_inspect PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_comtrade_inspect) ariec61850_apply_sanitizers(ariec61850_comtrade_inspect) + + add_executable(ariec61850_live_discover tools/live_discover.cpp) + target_link_libraries(ariec61850_live_discover PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_live_discover PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_live_discover) + ariec61850_apply_sanitizers(ariec61850_live_discover) endif() if(ARIEC61850_BUILD_TESTS) @@ -190,6 +202,20 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_mms_runtime_tests) add_test(NAME ariec61850_mms_runtime_tests COMMAND ariec61850_mms_runtime_tests) + add_executable(ariec61850_tcp_transport_tests tests/test_tcp_transport.cpp) + target_link_libraries(ariec61850_tcp_transport_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_tcp_transport_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_tcp_transport_tests) + ariec61850_apply_sanitizers(ariec61850_tcp_transport_tests) + add_test(NAME ariec61850_tcp_transport_tests COMMAND ariec61850_tcp_transport_tests) + + add_executable(ariec61850_live_discovery_tests tests/test_live_discovery.cpp) + target_link_libraries(ariec61850_live_discovery_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_live_discovery_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_live_discovery_tests) + ariec61850_apply_sanitizers(ariec61850_live_discovery_tests) + add_test(NAME ariec61850_live_discovery_tests COMMAND ariec61850_live_discovery_tests) + add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE diff --git a/LIVE_DISCOVERY_PROFILE.md b/LIVE_DISCOVERY_PROFILE.md new file mode 100644 index 000000000..58f133f84 --- /dev/null +++ b/LIVE_DISCOVERY_PROFILE.md @@ -0,0 +1,62 @@ +# Built-in TCP and Live Read-Only Discovery Profile + +## Scope + +Phase 4C provides a cross-platform TCP implementation of `MmsByteTransport` and a bounded +live discovery workflow above the Phase 4B association runtime. It makes the C++ stack usable +against an explicitly selected MMS endpoint without requiring an application-specific socket +adapter. + +## TCP transport + +`TcpMmsByteTransport`: + +- resolves IPv4 and IPv6 endpoints with `getaddrinfo`; +- uses non-blocking Winsock or POSIX sockets; +- awaits connect, send, and receive readiness in bounded slices; +- observes the caller's absolute deadline and `std::stop_token` during every wait; +- enables TCP_NODELAY and keepalive by default; +- handles partial sends and arbitrary receive chunking; +- reports timeout, cancellation, peer close, and socket failures as typed runtime errors; and +- owns no worker thread and performs no automatic reconnect. + +The transport is intentionally serialized: one association operation may use it at a time. +`MmsAssociationRuntime` remains responsible for TPKT/COTP framing, association state, invoke +routing, and reconnect policy. + +## Live discovery + +`MmsLiveDiscoveryClient` performs only these confirmed MMS services: + +1. GetNameList for VMD domains; +2. GetNameList for domain-specific named variables; +3. GetNameList for domain-specific named-variable lists; +4. optional GetVariableAccessAttributes probes; +5. optional GetNamedVariableListAttributes DataSet directory reads; and +6. optional multi-variable Read requests for discovered RCB attributes. + +The result contains bounded domain/name evidence, DataSet and BRCB/URCB inventory, variable +TypeSpecification evidence, DataSet members, read-only RCB state, and explicit diagnostics for +optional probes that failed. Pagination must make forward progress and all domain, page, name, +type, DataSet, and RCB counts are caller-bounded. + +`MmsTcpLiveDiscoverySession` combines the built-in TCP transport, Phase 4B association runtime, +and the discovery client. The `ariec61850_live_discover` tool exposes this workflow with human +or JSON summaries. + +## Read-only boundary + +The discovery client never constructs an MMS Write request. It does not enable or reserve an +RCB, issue GI, create/delete a DataSet, operate a control object, or access MMS file services. +Regression tests decode every discovery request and reject service tag 5 (Write). + +`TcpMmsByteTransport` itself is a general byte transport. An application that deliberately +combines it with another runtime surface remains responsible for that surface's authorization +and safety policy. + +## Acceptance boundary + +Automated loopback transport tests and scripted discovery tests do not replace physical IED +interoperability. Controlled laboratory evidence is still required for vendor-specific +association parameters, large model pagination, slow IED timing, disconnect behavior, and +long-running network stability. diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index b70de0286..87b9f6d5d 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -84,7 +84,7 @@ until laboratory interoperability is complete. - [x] Multi-variable Write with per-variable success/failure results. - [x] MMS-services mutation smoke and LLVM libFuzzer corpus. -## Phase 4 — reporting, control, and file service +## Phase 4 — reporting, live discovery, control, and file service ### Phase 4A — offline reporting foundation @@ -109,9 +109,22 @@ until laboratory interoperability is complete. - [x] URCB reservation, RptEna enable/disable, optional GI, and touched-state cleanup. - [x] Persistent report polling and Phase 4A monitor ingestion. - [x] Cleanup-required evidence and retry path after association loss. -- [ ] Built-in TCP socket transport and physical IED association validation. - [ ] BRCB EntryID resume, purge decisioning, and buffer-overflow recovery. +### Phase 4C — built-in TCP and live read-only discovery + +- [~] Non-blocking Windows Winsock and POSIX TCP `MmsByteTransport` implementation. +- [~] IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial-send, and peer-close handling. +- [~] Deadline and cancellation-aware connect/send/receive readiness waits. +- [~] Bounded live GetNameList pagination for domains, variables, and DataSets. +- [~] Optional GetVariableAccessAttributes type probes. +- [~] Optional GetNamedVariableListAttributes DataSet directory reads. +- [~] Optional read-only RCB attribute inventory/state probes. +- [~] Read-only `ariec61850_live_discover` human/JSON CLI. +- [~] Discovery service allowlist regression proving no MMS Write request is emitted. +- [ ] Controlled physical IED association/discovery validation. +- [ ] Multi-vendor pagination, timeout, reconnect, and soak evidence. + ### Later Phase 4 work - [ ] Direct and SBO control state machines. @@ -136,12 +149,14 @@ until laboratory interoperability is complete. - [x] PR #6 through PR #10 squash-merged sequentially to `main`. - [x] Full main-based branch GCC/Clang/MSVC and security workflows through Phase 4A. - [x] Full stacked-branch GCC/Clang/MSVC and security workflows through Phase 4B. +- [~] Phase 4C loopback TCP and scripted read-only discovery regression added. +- [ ] Phase 4C GitHub GCC/Clang/MSVC and security workflows accepted. - [ ] Controlled physical-lab process-bus capture evidence. - [ ] C# and C++ executable-oracle comparison in one CI job. ## Safety gate -SCL and COMTRADE tooling is read-only. Process-bus interoperability tooling operates on -saved PCAP files. Active Npcap transmission, real-time SV scheduling, MMS write/control, -and IED operation remain outside the enabled runtime surface until separate physical-lab, -timing, cancellation, and explicit transmit authorization gates are complete. +SCL, COMTRADE, PCAP, and the Phase 4C live discovery tool are read-only. The generic TCP +transport performs no operation by itself. Active RCB mutation, control, dynamic DataSet +mutation, file transfer, Npcap transmission, and real-time SV scheduling require separate +explicit APIs, authorization, and physical-laboratory acceptance. diff --git a/PORT_STATUS.md b/PORT_STATUS.md index fd3ebed2e..7dfb81789 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,7 +2,7 @@ ## Current milestone -**Phase 4B — transport-injected association lifecycle and persistent report-subscription runtime are implemented and validated on the published stacked branch.** +**Phase 4C — built-in non-blocking TCP transport and bounded live read-only discovery are implemented for review and CI validation.** ## Delivered modules @@ -21,7 +21,9 @@ | MMS reporting mutation smoke/libFuzzer | Complete | | Transport-injected MMS association/client runtime | Implemented | | Persistent RCB subscription runtime | Implemented | -| Built-in TCP socket transport | Not included | +| Built-in Windows/Linux TCP socket transport | Implemented in Phase 4C | +| Live read-only name/type/DataSet/RCB discovery | Implemented in Phase 4C | +| Physical IED interoperability acceptance | Pending | ## Phase 4A behavior @@ -45,27 +47,34 @@ The offline reporting layer: - URCB reservation, optional DatSet/TrgOps/OptFlds writes, RptEna, GI, polling, stop, and cleanup retry are stateful. - Lost associations preserve cleanup-required evidence instead of pretending the RCB was disabled. +## Phase 4C behavior + +- `TcpMmsByteTransport` uses non-blocking Winsock/POSIX sockets with bounded readiness polling. +- Connect, send, and receive honor absolute deadlines and `std::stop_token` cancellation. +- IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial sends, arbitrary receive chunking, and peer-close detection are built in. +- Live discovery performs bounded GetNameList pagination for domains, variables, and named-variable lists. +- Optional type, DataSet-directory, and RCB-state probes use only GetVariableAccessAttributes, GetNamedVariableListAttributes, and Read. +- The live discovery result retains partial evidence and diagnostics when optional probes fail. +- `ariec61850_live_discover` provides human and JSON summaries. + ## Validation -- GNU C++ 14.2, Release, warnings as errors: passed. -- Clang 17, Release, warnings as errors: passed. -- Clang 17, ASan + UBSan: passed. -- Eleven deterministic Phase 4A reporting groups: passed. -- Full repository CTest: 16/16 passed through Phase 4B. -- C# exact OptFlds/report-item shape: passed. -- Local MMS-reporting libFuzzer: 5,000 runs, no crash artifact. -- GitHub GCC, Clang, and Windows MSVC matrix: passed. -- GitHub ASan/UBSan: passed. -- Ten-corpus libFuzzer workflow including MMS reporting: passed with no crash artifact. +- Phase 4B baseline: GCC, Clang, MSVC, ASan/UBSan, 16/16 CTest, and reporting fuzz evidence passed. +- Phase 4C adds a POSIX loopback TCP round-trip and cross-platform cancellation smoke test. +- Phase 4C scripted discovery covers domain/variable/DataSet inventory, type evidence, DataSet directory, RCB state, and pagination abuse rejection. +- Every scripted discovery request is decoded and checked against the read-only service allowlist; MMS Write is rejected. +- Full GitHub GCC/Clang/MSVC and security workflow evidence is required before Phase 4C is accepted. ## Remaining acceptance gates -- Built-in platform TCP transport and physical IED interoperability remain separate acceptance work. -- BRCB EntryID resume, purge/buffer-overflow recovery, dynamic DataSet lifecycle, Direct/SBO control, and file services remain later work. +- Controlled physical IED association and discovery evidence across multiple vendors. +- Long-model pagination, slow-response, disconnect, reconnect, and soak testing. +- BRCB EntryID resume, purge/buffer-overflow recovery, and dynamic DataSet lifecycle. +- Direct/SBO control, enhanced-security termination, and MMS file/fault-record transfer. ## Safety boundary -Phase 4B can perform association and RCB Read/Write operations only through an explicitly -injected `MmsByteTransport`. The core does not provide or auto-enable a socket backend. It -never takes over an occupied RCB and only cleans up state touched by the current runtime. -Control operations, dynamic DataSet mutation, and file services are not exposed. +The live discovery API and CLI are read-only and do not construct MMS Write requests. The TCP +transport is general-purpose but performs no operation by itself; applications must explicitly +select and authorize any state-changing runtime. Control services, dynamic DataSet mutation, +and file services remain outside the Phase 4C live discovery surface. diff --git a/README.md b/README.md index 382525f4e..64b2f94c2 100644 --- a/README.md +++ b/README.md @@ -77,19 +77,28 @@ interoperability are complete. - Sequence, configuration, DataSet, overflow, and segmentation continuity tracking. - Bounded offline report monitor and dedicated MMS-reporting fuzz corpus. -No enabled runtime opens TCP sockets, Npcap, raw sockets, or a network interface. Physical IED -interoperability and all active transmission remain gated. +### Phase 4B — association and report-subscription runtime -## Phase 4B: association and report-subscription runtime +- Transport-injected COTP, Session, Presentation, ACSE/MMS Initiate lifecycle. +- Confirmed-service routing, timeout/cancellation handling, and explicit reconnect. +- Persistent RCB re-probe, reservation, `RptEna`, optional GI, report delivery, and + ownership-aware cleanup. See `ASSOCIATION_RUNTIME_PROFILE.md`. -The C++ core now includes a transport-injected MMS association runtime and persistent RCB -subscription state machine. It performs COTP, Session, Presentation, ACSE/MMS Initiate, -confirmed-service routing, timeout/cancellation handling, RCB re-probe, URCB reservation, -`RptEna`, optional GI, InformationReport delivery, and ownership-aware cleanup. See -`ASSOCIATION_RUNTIME_PROFILE.md`. +### Phase 4C — built-in TCP and live read-only discovery -The core deliberately does not ship a default socket transport in this phase. Applications -that intentionally enable live IED access must provide an `MmsByteTransport` implementation. +- Cross-platform non-blocking Winsock/POSIX `TcpMmsByteTransport`. +- IPv4/IPv6 resolution, deadline/cancellation-aware readiness waits, TCP_NODELAY, + keepalive, partial sends, arbitrary receive chunking, and peer-close detection. +- Bounded live GetNameList discovery for domains, variables, and named-variable lists. +- Optional variable type, DataSet directory, and read-only RCB state probes. +- `MmsTcpLiveDiscoverySession` ready-to-use association/discovery composition. +- Read-only `ariec61850_live_discover` human/JSON CLI. +- Scripted service-allowlist regression that rejects MMS Write. See + `LIVE_DISCOVERY_PROFILE.md`. + +The TCP transport performs no operation on its own. The live discovery API and CLI emit only +GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes, and Read requests. +Physical IED interoperability remains a separate laboratory acceptance gate. ## Build @@ -121,6 +130,15 @@ ctest --test-dir build-sanitizers --output-on-failure ## Read-only tools +### Discover a live MMS endpoint + +```powershell +.\build\Release\ariec61850_live_discover.exe 192.168.1.10 102 --json +``` + +Use `--no-types`, `--no-datasets`, or `--no-rcb` to reduce probe scope. All discovery counts +and request timeouts are bounded through CLI options or `MmsLiveDiscoveryOptions`. + ### Inspect a COMTRADE record ```powershell diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp new file mode 100644 index 000000000..dc67dbd67 --- /dev/null +++ b/include/ariec61850/mms/live_discovery.hpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/tcp_transport.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsLiveDiscoveryError final : public MmsAssociationRuntimeError { +public: + using MmsAssociationRuntimeError::MmsAssociationRuntimeError; +}; + +struct MmsLiveDiscoveryOptions final { + std::size_t maximum_pages_per_query{256U}; + std::size_t maximum_domains{4'096U}; + std::size_t maximum_names_per_domain{65'536U}; + + bool probe_variable_types{true}; + std::size_t maximum_variable_type_probes{4'096U}; + + bool read_data_set_directories{true}; + std::size_t maximum_data_set_directories{4'096U}; + + bool probe_report_controls{true}; + std::size_t maximum_report_control_probes{1'024U}; + std::vector report_control_attributes{ + "DatSet", "RptID", "ConfRev", "IntgPd", "BufTm", "SqNum", + "RptEna", "Resv", "ResvTms", "Owner", "EntryID", "TimeOfEntry", + "TrgOps", "OptFlds"}; + + bool continue_on_optional_probe_error{true}; +}; + +struct MmsVariableTypeEvidence final { + MmsObjectName variable; + std::optional attributes; + std::string error; + + [[nodiscard]] bool success() const noexcept { return attributes.has_value(); } +}; + +struct MmsDataSetDirectoryEvidence final { + MmsDataSetCandidate candidate; + std::optional directory; + std::string error; + + [[nodiscard]] bool success() const noexcept { return directory.has_value(); } +}; + +struct MmsReportControlEvidence final { + MmsReportControlCandidate candidate; + std::vector requested_attributes; + std::optional state; + std::string error; + + [[nodiscard]] bool success() const noexcept { return state.has_value(); } +}; + +struct MmsLiveDiscoveryResult final { + MmsEndpoint endpoint; + MmsDiscoverySnapshot names; + MmsReportInventory report_inventory; + std::vector variable_types; + std::vector data_set_directories; + std::vector report_controls; + std::vector diagnostics; + + [[nodiscard]] std::size_t domain_count() const noexcept; + [[nodiscard]] std::size_t variable_count() const noexcept; + [[nodiscard]] std::size_t variable_list_count() const noexcept; + [[nodiscard]] bool partial() const noexcept { return !diagnostics.empty(); } + [[nodiscard]] std::string summary() const; +}; + +// Executes only MMS GetNameList, GetVariableAccessAttributes, +// GetNamedVariableListAttributes, and Read requests. It never sends Write, +// control, file mutation, RCB enable, or dynamic DataSet mutation services. +class MmsLiveDiscoveryClient final { +public: + explicit MmsLiveDiscoveryClient(MmsAssociationRuntime& association); + + [[nodiscard]] MmsLiveDiscoveryResult discover( + const MmsLiveDiscoveryOptions& options = {}, + std::stop_token stop_token = {}); + +private: + [[nodiscard]] std::vector get_name_list( + MmsGetNameListObjectClass object_class, + const std::string& domain, + const MmsLiveDiscoveryOptions& options, + std::stop_token stop_token); + + MmsAssociationRuntime& association_; +}; + +// Ready-to-use live session with the built-in non-blocking TCP transport. +class MmsTcpLiveDiscoverySession final { +public: + explicit MmsTcpLiveDiscoverySession( + TcpMmsTransportOptions transport_options = {}, + MmsAssociationOptions association_options = {}); + + void connect( + const MmsEndpoint& endpoint, + std::stop_token stop_token = {}); + [[nodiscard]] MmsLiveDiscoveryResult discover( + const MmsLiveDiscoveryOptions& options = {}, + std::stop_token stop_token = {}); + void disconnect(std::stop_token stop_token = {}) noexcept; + + [[nodiscard]] bool associated() const noexcept { return association_.associated(); } + [[nodiscard]] MmsAssociationRuntime& association() noexcept { return association_; } + [[nodiscard]] const MmsAssociationRuntime& association() const noexcept { + return association_; + } + +private: + TcpMmsByteTransport transport_; + MmsAssociationRuntime association_; + MmsLiveDiscoveryClient discovery_; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/tcp_transport.hpp b/include/ariec61850/mms/tcp_transport.hpp new file mode 100644 index 000000000..d3a79803e --- /dev/null +++ b/include/ariec61850/mms/tcp_transport.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +class TcpMmsTransportError final : public MmsAssociationRuntimeError { +public: + using MmsAssociationRuntimeError::MmsAssociationRuntimeError; +}; + +struct TcpMmsTransportOptions final { + std::size_t receive_chunk_size{64U * 1024U}; + std::chrono::milliseconds cancellation_poll_interval{25}; + bool no_delay{true}; + bool keep_alive{true}; +}; + +// Cross-platform TCP transport for MmsAssociationRuntime. The socket is kept +// non-blocking and readiness is awaited in bounded slices so deadlines and +// std::stop_token cancellation remain observable during connect/send/receive. +// One runtime operation may use the transport at a time. +class TcpMmsByteTransport final : public MmsByteTransport { +public: + explicit TcpMmsByteTransport(TcpMmsTransportOptions options = {}); + ~TcpMmsByteTransport() override; + + TcpMmsByteTransport(const TcpMmsByteTransport&) = delete; + TcpMmsByteTransport& operator=(const TcpMmsByteTransport&) = delete; + TcpMmsByteTransport(TcpMmsByteTransport&&) = delete; + TcpMmsByteTransport& operator=(TcpMmsByteTransport&&) = delete; + + void connect( + const MmsEndpoint& endpoint, + Deadline deadline, + std::stop_token stop_token) override; + void send( + std::span bytes, + Deadline deadline, + std::stop_token stop_token) override; + [[nodiscard]] std::vector receive( + Deadline deadline, + std::stop_token stop_token) override; + void close() noexcept override; + [[nodiscard]] bool connected() const noexcept override; + + [[nodiscard]] const TcpMmsTransportOptions& options() const noexcept { + return options_; + } + +private: + struct Impl; + + TcpMmsTransportOptions options_; + std::unique_ptr impl_; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp new file mode 100644 index 000000000..e42910735 --- /dev/null +++ b/src/mms/live_discovery.cpp @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" + +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +void validate_options(const MmsLiveDiscoveryOptions& options) { + if (options.maximum_pages_per_query == 0U || + options.maximum_domains == 0U || + options.maximum_names_per_domain == 0U) { + throw std::invalid_argument("Live MMS discovery name-list limits are invalid."); + } + if (options.probe_variable_types && + options.maximum_variable_type_probes == 0U) { + throw std::invalid_argument( + "Live MMS discovery variable-type probe limit must be positive."); + } + if (options.read_data_set_directories && + options.maximum_data_set_directories == 0U) { + throw std::invalid_argument( + "Live MMS discovery DataSet-directory limit must be positive."); + } + if (options.probe_report_controls && + options.maximum_report_control_probes == 0U) { + throw std::invalid_argument( + "Live MMS discovery report-control probe limit must be positive."); + } +} + +[[nodiscard]] std::string object_class_name( + const MmsGetNameListObjectClass object_class) { + switch (object_class) { + case MmsGetNameListObjectClass::domain: + return "Domain"; + case MmsGetNameListObjectClass::named_variable: + return "NamedVariable"; + case MmsGetNameListObjectClass::named_variable_list: + return "NamedVariableList"; + } + return "Unknown"; +} + +[[nodiscard]] std::string service_failure( + const MmsConfirmedExchangeResult& exchange, + const std::string_view operation) { + std::ostringstream stream; + stream << operation << " returned MMS PDU kind=" + << static_cast(exchange.envelope.kind); + if (exchange.envelope.invoke_id) { + stream << ", invokeID=" << *exchange.envelope.invoke_id; + } + if (exchange.envelope.service_tag) { + stream << ", serviceTag=" << *exchange.envelope.service_tag; + } + stream << '.'; + return stream.str(); +} + +[[nodiscard]] std::span response_payload( + const MmsConfirmedExchangeResult& exchange, + const std::string_view operation) { + if (exchange.envelope.kind != MmsPduKind::confirmed_response) { + throw MmsLiveDiscoveryError(service_failure(exchange, operation)); + } + if (!exchange.presentation_payload.empty()) { + return exchange.presentation_payload; + } + if (!exchange.envelope.mms_payload.empty()) { + return exchange.envelope.mms_payload; + } + throw MmsLiveDiscoveryError( + std::string{operation} + " returned no decodable MMS response payload."); +} + +[[nodiscard]] std::vector select_report_attributes( + const MmsReportControlCandidate& candidate, + const MmsLiveDiscoveryOptions& options) { + std::vector selected; + selected.reserve(options.report_control_attributes.size()); + for (const auto& requested : options.report_control_attributes) { + if (std::find( + candidate.attributes.begin(), + candidate.attributes.end(), + requested) != candidate.attributes.end()) { + selected.push_back(requested); + } + } + return selected; +} + +[[nodiscard]] std::string optional_probe_error( + const std::string_view category, + const std::string& reference, + const std::exception& exception) { + return std::string{category} + " probe failed for " + reference + + ": " + exception.what(); +} + +} // namespace + +std::size_t MmsLiveDiscoveryResult::domain_count() const noexcept { + return names.domain_variables.size(); +} + +std::size_t MmsLiveDiscoveryResult::variable_count() const noexcept { + std::size_t count = 0U; + for (const auto& [domain, variables] : names.domain_variables) { + static_cast(domain); + count += variables.size(); + } + return count; +} + +std::size_t MmsLiveDiscoveryResult::variable_list_count() const noexcept { + std::size_t count = 0U; + for (const auto& [domain, variable_lists] : names.domain_variable_lists) { + static_cast(domain); + count += variable_lists.size(); + } + return count; +} + +std::string MmsLiveDiscoveryResult::summary() const { + const auto successful_types = static_cast(std::count_if( + variable_types.begin(), variable_types.end(), + [](const auto& evidence) { return evidence.success(); })); + const auto successful_directories = static_cast(std::count_if( + data_set_directories.begin(), data_set_directories.end(), + [](const auto& evidence) { return evidence.success(); })); + const auto successful_controls = static_cast(std::count_if( + report_controls.begin(), report_controls.end(), + [](const auto& evidence) { return evidence.success(); })); + + std::ostringstream stream; + stream << "Live read-only MMS discovery: endpoint=" << endpoint.host << ':' + << endpoint.port << ", domains=" << domain_count() + << ", variables=" << variable_count() + << ", DataSets=" << report_inventory.data_sets.size() + << ", RCBs=" << report_inventory.report_controls.size() + << ", typeProbes=" << successful_types << '/' << variable_types.size() + << ", directoryReads=" << successful_directories << '/' + << data_set_directories.size() + << ", rcbReads=" << successful_controls << '/' + << report_controls.size() + << ", diagnostics=" << diagnostics.size() << '.'; + return stream.str(); +} + +MmsLiveDiscoveryClient::MmsLiveDiscoveryClient(MmsAssociationRuntime& association) + : association_{association} {} + +std::vector MmsLiveDiscoveryClient::get_name_list( + const MmsGetNameListObjectClass object_class, + const std::string& domain, + const MmsLiveDiscoveryOptions& options, + const std::stop_token stop_token) { + std::vector names; + std::set> seen; + std::string continue_after; + + for (std::size_t page = 0U; page < options.maximum_pages_per_query; ++page) { + MmsGetNameListRequest request; + request.invoke_id = association_.next_invoke_id(); + request.object_class = object_class; + request.scope = domain.empty() + ? MmsObjectScopeKind::vmd_specific + : MmsObjectScopeKind::domain_specific; + request.domain_id = domain; + request.continue_after = continue_after; + + const auto encoded = MmsServiceCodec::encode_get_name_list_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + const auto payload = response_payload(exchange, "GetNameList"); + const auto response = MmsServiceCodec::decode_get_name_list_response( + payload, request.invoke_id); + + const std::size_t count_before = names.size(); + for (const auto& name : response.names) { + if (seen.insert(name).second) { + if (names.size() >= options.maximum_names_per_domain) { + throw MmsLiveDiscoveryError( + "GetNameList " + object_class_name(object_class) + '/' + + (domain.empty() ? std::string{"VMD"} : domain) + + " exceeded the configured name bound."); + } + names.push_back(name); + } + } + + if (!response.more_follows) { + return names; + } + if (response.names.empty() || names.size() == count_before) { + throw MmsLiveDiscoveryError( + "GetNameList " + object_class_name(object_class) + '/' + + (domain.empty() ? std::string{"VMD"} : domain) + + " reported moreFollows without forward progress."); + } + continue_after = response.names.back(); + } + + throw MmsLiveDiscoveryError( + "GetNameList " + object_class_name(object_class) + '/' + + (domain.empty() ? std::string{"VMD"} : domain) + + " exceeded the configured page bound."); +} + +MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( + const MmsLiveDiscoveryOptions& options, + const std::stop_token stop_token) { + validate_options(options); + if (!association_.associated()) { + throw MmsLiveDiscoveryError( + "Live MMS discovery requires an active MMS association."); + } + + MmsLiveDiscoveryResult result; + result.endpoint = association_.endpoint(); + + const auto domains = get_name_list( + MmsGetNameListObjectClass::domain, {}, options, stop_token); + if (domains.size() > options.maximum_domains) { + throw MmsLiveDiscoveryError( + "Domain discovery exceeded the configured domain bound."); + } + + for (const auto& domain : domains) { + try { + result.names.domain_variables.emplace( + domain, + get_name_list( + MmsGetNameListObjectClass::named_variable, + domain, + options, + stop_token)); + } catch (const std::exception& exception) { + if (!options.continue_on_optional_probe_error) { + throw; + } + result.names.domain_variables.emplace(domain, std::vector{}); + result.diagnostics.push_back(optional_probe_error( + "NamedVariable", domain, exception)); + } + + try { + result.names.domain_variable_lists.emplace( + domain, + get_name_list( + MmsGetNameListObjectClass::named_variable_list, + domain, + options, + stop_token)); + } catch (const std::exception& exception) { + if (!options.continue_on_optional_probe_error) { + throw; + } + result.names.domain_variable_lists.emplace( + domain, std::vector{}); + result.diagnostics.push_back(optional_probe_error( + "NamedVariableList", domain, exception)); + } + } + + result.report_inventory = MmsReportInventoryBuilder::build(result.names); + + if (options.probe_variable_types) { + std::size_t probe_count = 0U; + for (const auto& [domain, variables] : result.names.domain_variables) { + for (const auto& item : variables) { + if (probe_count >= options.maximum_variable_type_probes) { + break; + } + ++probe_count; + + MmsVariableTypeEvidence evidence; + evidence.variable = MmsObjectName::domain_specific(domain, item); + try { + MmsVariableAccessAttributesRequest request; + request.invoke_id = association_.next_invoke_id(); + request.name = evidence.variable; + const auto encoded = MmsServiceCodec:: + encode_variable_access_attributes_request_p_data( + request, + association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + const auto payload = response_payload( + exchange, "GetVariableAccessAttributes"); + evidence.attributes = MmsServiceCodec:: + decode_variable_access_attributes_response( + payload, request.invoke_id); + } catch (const std::exception& exception) { + evidence.error = exception.what(); + result.diagnostics.push_back(optional_probe_error( + "Variable type", evidence.variable.reference(), exception)); + if (!options.continue_on_optional_probe_error) { + throw; + } + } + result.variable_types.push_back(std::move(evidence)); + } + if (probe_count >= options.maximum_variable_type_probes) { + break; + } + } + } + + if (options.read_data_set_directories) { + const std::size_t count = std::min( + options.maximum_data_set_directories, + result.report_inventory.data_sets.size()); + result.data_set_directories.reserve(count); + for (std::size_t index = 0U; index < count; ++index) { + MmsDataSetDirectoryEvidence evidence; + evidence.candidate = result.report_inventory.data_sets[index]; + try { + MmsDataSetDirectoryRequest request; + request.invoke_id = association_.next_invoke_id(); + request.data_set_name = MmsDataSetDirectoryCodec::parse_data_set_reference( + evidence.candidate.reference); + const auto encoded = MmsDataSetDirectoryCodec::encode_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + const auto payload = response_payload( + exchange, "GetNamedVariableListAttributes"); + evidence.directory = MmsDataSetDirectoryCodec::decode_response( + payload, request.invoke_id); + } catch (const std::exception& exception) { + evidence.error = exception.what(); + result.diagnostics.push_back(optional_probe_error( + "DataSet directory", evidence.candidate.reference, exception)); + if (!options.continue_on_optional_probe_error) { + throw; + } + } + result.data_set_directories.push_back(std::move(evidence)); + } + } + + if (options.probe_report_controls) { + const std::size_t count = std::min( + options.maximum_report_control_probes, + result.report_inventory.report_controls.size()); + result.report_controls.reserve(count); + for (std::size_t index = 0U; index < count; ++index) { + MmsReportControlEvidence evidence; + evidence.candidate = result.report_inventory.report_controls[index]; + evidence.requested_attributes = select_report_attributes( + evidence.candidate, options); + if (evidence.requested_attributes.empty()) { + evidence.error = "No configured read-only RCB attributes were discovered."; + result.diagnostics.push_back( + evidence.candidate.reference + ": " + evidence.error); + result.report_controls.push_back(std::move(evidence)); + continue; + } + + try { + const auto invoke_id = association_.next_invoke_id(); + const auto request = MmsReportControlStateMapper::build_read_request( + invoke_id, + evidence.candidate, + evidence.requested_attributes); + const auto encoded = MmsServiceCodec::encode_read_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, invoke_id, stop_token); + const auto payload = response_payload( + exchange, "Read RCB attributes"); + const auto response = MmsServiceCodec::decode_read_response( + payload, invoke_id); + evidence.state = MmsReportControlStateMapper::map_read_response( + evidence.candidate, + evidence.requested_attributes, + response); + } catch (const std::exception& exception) { + evidence.error = exception.what(); + result.diagnostics.push_back(optional_probe_error( + "Report control", evidence.candidate.reference, exception)); + if (!options.continue_on_optional_probe_error) { + throw; + } + } + result.report_controls.push_back(std::move(evidence)); + } + } + + return result; +} + +MmsTcpLiveDiscoverySession::MmsTcpLiveDiscoverySession( + TcpMmsTransportOptions transport_options, + MmsAssociationOptions association_options) + : transport_{std::move(transport_options)}, + association_{transport_, std::move(association_options)}, + discovery_{association_} {} + +void MmsTcpLiveDiscoverySession::connect( + const MmsEndpoint& endpoint, + const std::stop_token stop_token) { + association_.connect(endpoint, stop_token); +} + +MmsLiveDiscoveryResult MmsTcpLiveDiscoverySession::discover( + const MmsLiveDiscoveryOptions& options, + const std::stop_token stop_token) { + return discovery_.discover(options, stop_token); +} + +void MmsTcpLiveDiscoverySession::disconnect( + const std::stop_token stop_token) noexcept { + association_.disconnect(stop_token); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/tcp_transport.cpp b/src/mms/tcp_transport.cpp new file mode 100644 index 000000000..d41db59ca --- /dev/null +++ b/src/mms/tcp_transport.cpp @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/tcp_transport.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace ar::iec61850::mms { +namespace { + +#ifdef _WIN32 +using NativeSocket = SOCKET; +constexpr NativeSocket invalid_socket = INVALID_SOCKET; +using SocketLength = int; + +class WinsockLifetime final { +public: + WinsockLifetime() { + WSADATA data{}; + const int result = WSAStartup(MAKEWORD(2, 2), &data); + if (result != 0) { + throw TcpMmsTransportError( + "WSAStartup failed with error " + std::to_string(result) + "."); + } + } + + ~WinsockLifetime() { WSACleanup(); } +}; + +void ensure_socket_runtime() { + static WinsockLifetime lifetime; + static_cast(lifetime); +} + +[[nodiscard]] int last_socket_error() noexcept { return WSAGetLastError(); } +[[nodiscard]] bool interrupted(const int error) noexcept { return error == WSAEINTR; } +[[nodiscard]] bool would_block(const int error) noexcept { + return error == WSAEWOULDBLOCK; +} +[[nodiscard]] bool connect_in_progress(const int error) noexcept { + return error == WSAEWOULDBLOCK || error == WSAEINPROGRESS || + error == WSAEALREADY; +} +void close_native_socket(const NativeSocket socket) noexcept { + if (socket != invalid_socket) { + closesocket(socket); + } +} +void shutdown_native_socket(const NativeSocket socket) noexcept { + if (socket != invalid_socket) { + shutdown(socket, SD_BOTH); + } +} +#else +using NativeSocket = int; +constexpr NativeSocket invalid_socket = -1; +using SocketLength = socklen_t; + +void ensure_socket_runtime() {} +[[nodiscard]] int last_socket_error() noexcept { return errno; } +[[nodiscard]] bool interrupted(const int error) noexcept { return error == EINTR; } +[[nodiscard]] bool would_block(const int error) noexcept { + return error == EAGAIN || error == EWOULDBLOCK; +} +[[nodiscard]] bool connect_in_progress(const int error) noexcept { + return error == EINPROGRESS || error == EALREADY || would_block(error); +} +void close_native_socket(const NativeSocket socket) noexcept { + if (socket != invalid_socket) { + static_cast(::close(socket)); + } +} +void shutdown_native_socket(const NativeSocket socket) noexcept { + if (socket != invalid_socket) { + static_cast(::shutdown(socket, SHUT_RDWR)); + } +} +#endif + +[[nodiscard]] std::string socket_error_text( + const std::string& operation, + const int error) { + return operation + " failed with socket error " + std::to_string(error) + "."; +} + +void set_non_blocking(const NativeSocket socket) { +#ifdef _WIN32 + u_long enabled = 1UL; + if (ioctlsocket(socket, FIONBIO, &enabled) != 0) { + throw TcpMmsTransportError( + socket_error_text("ioctlsocket(FIONBIO)", last_socket_error())); + } +#else + const int flags = fcntl(socket, F_GETFL, 0); + if (flags < 0 || fcntl(socket, F_SETFL, flags | O_NONBLOCK) < 0) { + throw TcpMmsTransportError( + socket_error_text("fcntl(O_NONBLOCK)", last_socket_error())); + } +#endif +} + +void set_boolean_socket_option( + const NativeSocket socket, + const int level, + const int option, + const bool enabled, + const char* option_name) { + const int value = enabled ? 1 : 0; + const auto* raw = reinterpret_cast(&value); + if (setsockopt( + socket, + level, + option, + raw, + static_cast(sizeof(value))) != 0) { + throw TcpMmsTransportError( + socket_error_text(option_name, last_socket_error())); + } +} + +[[nodiscard]] std::chrono::milliseconds readiness_slice( + const MmsByteTransport::Deadline deadline, + const std::chrono::milliseconds poll_interval) { + const auto now = MmsByteTransport::Clock::now(); + if (now >= deadline) { + return std::chrono::milliseconds::zero(); + } + auto remaining = std::chrono::duration_cast(deadline - now); + if (remaining <= std::chrono::milliseconds::zero()) { + remaining = std::chrono::milliseconds{1}; + } + return std::min(remaining, poll_interval); +} + +void wait_socket_ready( + const NativeSocket socket, + const bool read_ready, + const bool write_ready, + const MmsByteTransport::Deadline deadline, + const std::chrono::milliseconds poll_interval, + const std::stop_token stop_token, + const char* operation) { + for (;;) { + if (stop_token.stop_requested()) { + throw MmsTransportCancelledError( + std::string{operation} + " was cancelled."); + } + + const auto slice = readiness_slice(deadline, poll_interval); + if (slice <= std::chrono::milliseconds::zero()) { + throw MmsTransportTimeoutError( + std::string{operation} + " exceeded its deadline."); + } + + fd_set read_set; + fd_set write_set; + FD_ZERO(&read_set); + FD_ZERO(&write_set); + if (read_ready) { + FD_SET(socket, &read_set); + } + if (write_ready) { + FD_SET(socket, &write_set); + } + + const auto total_microseconds = + std::chrono::duration_cast(slice).count(); + constexpr auto microseconds_per_second = 1'000'000LL; + timeval timeout{}; + timeout.tv_sec = static_cast( + total_microseconds / microseconds_per_second); + timeout.tv_usec = static_cast( + total_microseconds % microseconds_per_second); + +#ifdef _WIN32 + const int selected = select( + 0, + read_ready ? &read_set : nullptr, + write_ready ? &write_set : nullptr, + nullptr, + &timeout); +#else + const int selected = select( + socket + 1, + read_ready ? &read_set : nullptr, + write_ready ? &write_set : nullptr, + nullptr, + &timeout); +#endif + if (selected > 0) { + return; + } + if (selected == 0) { + continue; + } + + const int error = last_socket_error(); + if (interrupted(error)) { + continue; + } + throw TcpMmsTransportError(socket_error_text(operation, error)); + } +} + +struct AddressInfoDeleter final { + void operator()(addrinfo* value) const noexcept { + if (value != nullptr) { + freeaddrinfo(value); + } + } +}; + +[[nodiscard]] std::ptrdiff_t socket_send( + const NativeSocket socket, + const std::uint8_t* bytes, + const std::size_t count) { + const std::size_t bounded = std::min( + count, + static_cast(std::numeric_limits::max())); +#ifdef _WIN32 + return static_cast(::send( + socket, + reinterpret_cast(bytes), + static_cast(bounded), + 0)); +#else +#ifdef MSG_NOSIGNAL + constexpr int send_flags = MSG_NOSIGNAL; +#else + constexpr int send_flags = 0; +#endif + return static_cast(::send( + socket, + bytes, + bounded, + send_flags)); +#endif +} + +[[nodiscard]] std::ptrdiff_t socket_receive( + const NativeSocket socket, + std::uint8_t* bytes, + const std::size_t count) { + const std::size_t bounded = std::min( + count, + static_cast(std::numeric_limits::max())); +#ifdef _WIN32 + return static_cast(::recv( + socket, + reinterpret_cast(bytes), + static_cast(bounded), + 0)); +#else + return static_cast(::recv(socket, bytes, bounded, 0)); +#endif +} + +} // namespace + +struct TcpMmsByteTransport::Impl final { + NativeSocket socket{invalid_socket}; + bool connected{}; +}; + +TcpMmsByteTransport::TcpMmsByteTransport(TcpMmsTransportOptions options) + : options_{std::move(options)}, impl_{std::make_unique()} { + if (options_.receive_chunk_size == 0U || + options_.receive_chunk_size > + static_cast(std::numeric_limits::max()) || + options_.cancellation_poll_interval <= std::chrono::milliseconds::zero()) { + throw std::invalid_argument("TCP MMS transport options are invalid."); + } +} + +TcpMmsByteTransport::~TcpMmsByteTransport() { close(); } + +void TcpMmsByteTransport::connect( + const MmsEndpoint& endpoint, + const Deadline deadline, + const std::stop_token stop_token) { + if (endpoint.host.empty()) { + throw std::invalid_argument("TCP MMS endpoint host must not be empty."); + } + if (stop_token.stop_requested()) { + throw MmsTransportCancelledError("TCP MMS connect was cancelled."); + } + if (Clock::now() >= deadline) { + throw MmsTransportTimeoutError("TCP MMS connect deadline has expired."); + } + + close(); + ensure_socket_runtime(); + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + + addrinfo* raw_addresses = nullptr; + const std::string port = std::to_string(endpoint.port); + const int resolve_result = getaddrinfo( + endpoint.host.c_str(), port.c_str(), &hints, &raw_addresses); + if (resolve_result != 0) { +#ifdef _WIN32 + throw TcpMmsTransportError( + "getaddrinfo failed with error " + std::to_string(resolve_result) + "."); +#else + throw TcpMmsTransportError( + std::string{"getaddrinfo failed: "} + gai_strerror(resolve_result) + "."); +#endif + } + std::unique_ptr addresses{raw_addresses}; + + int last_error = 0; + for (auto* address = addresses.get(); address != nullptr; address = address->ai_next) { + if (stop_token.stop_requested()) { + throw MmsTransportCancelledError("TCP MMS connect was cancelled."); + } + + NativeSocket candidate = ::socket( + address->ai_family, address->ai_socktype, address->ai_protocol); + if (candidate == invalid_socket) { + last_error = last_socket_error(); + continue; + } + + try { + set_non_blocking(candidate); + const int result = ::connect( + candidate, + address->ai_addr, + static_cast(address->ai_addrlen)); + if (result != 0) { + const int error = last_socket_error(); + if (!connect_in_progress(error)) { + last_error = error; + close_native_socket(candidate); + continue; + } + + wait_socket_ready( + candidate, + false, + true, + deadline, + options_.cancellation_poll_interval, + stop_token, + "TCP MMS connect"); + + int socket_error = 0; + SocketLength socket_error_length = + static_cast(sizeof(socket_error)); + auto* socket_error_bytes = reinterpret_cast(&socket_error); + if (getsockopt( + candidate, + SOL_SOCKET, + SO_ERROR, + socket_error_bytes, + &socket_error_length) != 0) { + throw TcpMmsTransportError(socket_error_text( + "getsockopt(SO_ERROR)", last_socket_error())); + } + if (socket_error != 0) { + last_error = socket_error; + close_native_socket(candidate); + continue; + } + } + + if (options_.no_delay) { + set_boolean_socket_option( + candidate, IPPROTO_TCP, TCP_NODELAY, true, "setsockopt(TCP_NODELAY)"); + } + if (options_.keep_alive) { + set_boolean_socket_option( + candidate, SOL_SOCKET, SO_KEEPALIVE, true, "setsockopt(SO_KEEPALIVE)"); + } + + impl_->socket = candidate; + impl_->connected = true; + return; + } catch (...) { + close_native_socket(candidate); + throw; + } + } + + throw TcpMmsTransportError(socket_error_text( + "TCP MMS connect to " + endpoint.host + ":" + port, + last_error)); +} + +void TcpMmsByteTransport::send( + const std::span bytes, + const Deadline deadline, + const std::stop_token stop_token) { + if (!connected()) { + throw TcpMmsTransportError("TCP MMS transport is not connected."); + } + + std::size_t offset = 0U; + while (offset < bytes.size()) { + wait_socket_ready( + impl_->socket, + false, + true, + deadline, + options_.cancellation_poll_interval, + stop_token, + "TCP MMS send"); + + const auto sent = socket_send( + impl_->socket, bytes.data() + offset, bytes.size() - offset); + if (sent > 0) { + offset += static_cast(sent); + continue; + } + if (sent == 0) { + close(); + throw TcpMmsTransportError( + "TCP MMS peer closed the connection during send."); + } + + const int error = last_socket_error(); + if (interrupted(error) || would_block(error)) { + continue; + } + close(); + throw TcpMmsTransportError(socket_error_text("TCP MMS send", error)); + } +} + +std::vector TcpMmsByteTransport::receive( + const Deadline deadline, + const std::stop_token stop_token) { + if (!connected()) { + throw TcpMmsTransportError("TCP MMS transport is not connected."); + } + + for (;;) { + wait_socket_ready( + impl_->socket, + true, + false, + deadline, + options_.cancellation_poll_interval, + stop_token, + "TCP MMS receive"); + + std::vector bytes(options_.receive_chunk_size); + const auto received = socket_receive( + impl_->socket, bytes.data(), bytes.size()); + if (received > 0) { + bytes.resize(static_cast(received)); + return bytes; + } + if (received == 0) { + close(); + throw TcpMmsTransportError( + "Remote IEC 61850 peer closed the TCP connection."); + } + + const int error = last_socket_error(); + if (interrupted(error) || would_block(error)) { + continue; + } + close(); + throw TcpMmsTransportError(socket_error_text("TCP MMS receive", error)); + } +} + +void TcpMmsByteTransport::close() noexcept { + if (!impl_) { + return; + } + const NativeSocket socket = impl_->socket; + impl_->socket = invalid_socket; + impl_->connected = false; + shutdown_native_socket(socket); + close_native_socket(socket); +} + +bool TcpMmsByteTransport::connected() const noexcept { + return impl_ && impl_->connected && impl_->socket != invalid_socket; +} + +} // namespace ar::iec61850::mms diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp new file mode 100644 index 000000000..182ee4523 --- /dev/null +++ b/tests/test_live_discovery.cpp @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association.hpp" +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/tpkt.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using ByteVector = std::vector; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +class ScriptedTransport final : public mms::MmsByteTransport { +public: + void connect( + const mms::MmsEndpoint& endpoint, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted connect cancelled"); + } + endpoint_ = endpoint; + connected_ = true; + } + + void send( + const std::span bytes, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted send cancelled"); + } + if (!connected_) { + throw std::runtime_error("scripted transport is disconnected"); + } + sent_.emplace_back(bytes.begin(), bytes.end()); + } + + [[nodiscard]] ByteVector receive( + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw mms::MmsTransportCancelledError("scripted receive cancelled"); + } + if (!connected_) { + throw std::runtime_error("scripted transport is disconnected"); + } + if (receive_queue_.empty()) { + throw mms::MmsTransportTimeoutError("scripted receive timeout"); + } + auto value = std::move(receive_queue_.front()); + receive_queue_.pop_front(); + return value; + } + + void close() noexcept override { connected_ = false; } + [[nodiscard]] bool connected() const noexcept override { return connected_; } + + void push_receive(ByteVector bytes) { + receive_queue_.push_back(std::move(bytes)); + } + + [[nodiscard]] const std::vector& sent() const noexcept { + return sent_; + } + +private: + mms::MmsEndpoint endpoint_; + bool connected_{}; + std::deque receive_queue_; + std::vector sent_; +}; + +[[nodiscard]] ByteVector wrap_application( + const std::span application) { + return osi::TpktFrameCodec::encode( + osi::CotpFrameCodec::encode_data(application)); +} + +void queue_handshake(ScriptedTransport& transport) { + const auto confirm = osi::CotpFrameCodec::encode_connection_confirm( + 0x0001U, 0x2345U, 0x0AU); + transport.push_receive(osi::TpktFrameCodec::encode(confirm)); + + const auto request_bytes = + acse::AcseAssociationCodec::build_default_association_request(); + const auto request = + acse::AcseAssociationCodec::decode_association_request(request_bytes); + const auto response = acse::AcseAssociationCodec::build_accept_response(request); + transport.push_receive(wrap_application(response.payload)); +} + +void queue_discovery_responses(ScriptedTransport& transport) { + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {"LD0"}, false}))); + + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {2U, + {"LLN0$ST$Mod$stVal", + "LLN0$RP$urcbA01$DatSet", + "LLN0$RP$urcbA01$RptID", + "LLN0$RP$urcbA01$ConfRev", + "LLN0$RP$urcbA01$RptEna"}, + false}))); + + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {3U, {"LLN0$DataSetA"}, false}))); + + mms::MmsVariableAccessAttributesResponse type_response; + type_response.invoke_id = 4U; + type_response.mms_deletable = false; + type_response.type.kind = mms::MmsTypeKind::boolean; + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_variable_access_attributes_response_p_data( + type_response))); + + mms::MmsDataSetDirectoryResponse directory; + directory.invoke_id = 5U; + directory.deletable = false; + directory.members.push_back({ + mms::MmsObjectName::domain_specific("LD0", "LLN0$ST$Mod$stVal"), + "LD0/LLN0$ST$Mod$stVal", + "LD0/LLN0.Mod.stVal", + "ST", + "LLN0", + "Mod.stVal", + 100U}); + transport.push_receive(wrap_application( + mms::MmsDataSetDirectoryCodec::encode_response_p_data(directory))); + + mms::MmsReadResponse rcb_response; + rcb_response.invoke_id = 6U; + rcb_response.results.push_back({ + mms::MmsDataValue::visible_string("LD0/LLN0.DataSetA"), std::nullopt}); + rcb_response.results.push_back({ + mms::MmsDataValue::visible_string("RPT-1"), std::nullopt}); + rcb_response.results.push_back({ + mms::MmsDataValue::unsigned_integer(1U), std::nullopt}); + rcb_response.results.push_back({ + mms::MmsDataValue::boolean(false), std::nullopt}); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(rcb_response))); +} + +void live_discovery_builds_bounded_read_only_snapshot() { + ScriptedTransport transport; + queue_handshake(transport); + queue_discovery_responses(transport); + + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + + mms::MmsLiveDiscoveryOptions options; + options.maximum_variable_type_probes = 1U; + options.maximum_data_set_directories = 1U; + options.maximum_report_control_probes = 1U; + + mms::MmsLiveDiscoveryClient discovery{association}; + const auto result = discovery.discover(options); + + CHECK(result.endpoint.host == "127.0.0.1"); + CHECK(result.domain_count() == 1U); + CHECK(result.variable_count() == 5U); + CHECK(result.variable_list_count() == 1U); + CHECK(result.report_inventory.data_sets.size() == 1U); + CHECK(result.report_inventory.report_controls.size() == 1U); + CHECK(result.variable_types.size() == 1U); + CHECK(result.variable_types.front().success()); + CHECK(result.variable_types.front().attributes->type.kind == + mms::MmsTypeKind::boolean); + CHECK(result.data_set_directories.size() == 1U); + CHECK(result.data_set_directories.front().success()); + CHECK(result.data_set_directories.front().directory->members.size() == 1U); + CHECK(result.report_controls.size() == 1U); + CHECK(result.report_controls.front().success()); + CHECK(result.report_controls.front().state->report_id == "RPT-1"); + CHECK(!result.partial()); + + std::size_t read_only_requests = 0U; + for (std::size_t index = 2U; index < transport.sent().size(); ++index) { + const auto tpkt = osi::TpktFrameCodec::decode(transport.sent()[index]); + const auto cotp = osi::CotpFrameCodec::decode(tpkt.payload); + CHECK(cotp.kind == osi::CotpTpduKind::data); + const auto envelope = mms::MmsPduCodec::decode_envelope(cotp.user_data); + CHECK(envelope.kind == mms::MmsPduKind::confirmed_request); + CHECK(envelope.service_tag.has_value()); + CHECK(*envelope.service_tag != + static_cast(mms::MmsConfirmedService::write)); + CHECK(*envelope.service_tag == + static_cast(mms::MmsConfirmedService::get_name_list) || + *envelope.service_tag == + static_cast( + mms::MmsConfirmedService::get_variable_access_attributes) || + *envelope.service_tag == + static_cast( + mms::MmsConfirmedService::get_named_variable_list_attributes) || + *envelope.service_tag == + static_cast(mms::MmsConfirmedService::read)); + ++read_only_requests; + } + CHECK(read_only_requests == 6U); +} + +void pagination_without_forward_progress_is_rejected() { + ScriptedTransport transport; + queue_handshake(transport); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {}, true}))); + + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + mms::MmsLiveDiscoveryClient discovery{association}; + + try { + static_cast(discovery.discover()); + } catch (const mms::MmsLiveDiscoveryError&) { + return; + } + throw std::runtime_error( + "Expected pagination-without-progress discovery failure."); +} + +} // namespace + +int main() { + try { + live_discovery_builds_bounded_read_only_snapshot(); + pagination_without_forward_progress_is_rejected(); + std::cout << "Live MMS discovery tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/test_tcp_transport.cpp b/tests/test_tcp_transport.cpp new file mode 100644 index 000000000..90871be78 --- /dev/null +++ b/tests/test_tcp_transport.cpp @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/tcp_transport.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#endif + +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) + +template +void check_throws(Callable&& callable) { + try { + std::invoke(std::forward(callable)); + } catch (const Exception&) { + return; + } + throw std::runtime_error("Expected exception was not thrown."); +} + +void cancelled_connect_is_observable() { + mms::TcpMmsByteTransport transport; + std::stop_source source; + source.request_stop(); + check_throws([&] { + transport.connect( + {"127.0.0.1", 9U}, + mms::MmsByteTransport::Clock::now() + std::chrono::seconds{1}, + source.get_token()); + }); + CHECK(!transport.connected()); +} + +#ifndef _WIN32 + +class LoopbackServer final { +public: + LoopbackServer() { + socket_ = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (socket_ < 0) { + throw std::runtime_error("Unable to create loopback server socket."); + } + const int enabled = 1; + static_cast(setsockopt( + socket_, SOL_SOCKET, SO_REUSEADDR, &enabled, static_cast(sizeof(enabled)))); + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + if (::bind( + socket_, + reinterpret_cast(&address), + sizeof(address)) != 0 || + ::listen(socket_, 1) != 0) { + ::close(socket_); + throw std::runtime_error("Unable to bind/listen loopback server socket."); + } + + socklen_t length = sizeof(address); + if (getsockname( + socket_, + reinterpret_cast(&address), + &length) != 0) { + ::close(socket_); + throw std::runtime_error("Unable to read loopback server port."); + } + port_ = ntohs(address.sin_port); + } + + ~LoopbackServer() { + if (socket_ >= 0) { + ::close(socket_); + } + } + + [[nodiscard]] std::uint16_t port() const noexcept { return port_; } + + void serve_once() { + const int client = ::accept(socket_, nullptr, nullptr); + if (client < 0) { + throw std::runtime_error("Loopback server accept failed."); + } + + std::array request{}; + std::size_t offset = 0U; + while (offset < request.size()) { + const auto read = ::recv( + client, + request.data() + offset, + request.size() - offset, + 0); + if (read <= 0) { + ::close(client); + throw std::runtime_error("Loopback server receive failed."); + } + offset += static_cast(read); + } + if (request != std::array{'p', 'i', 'n', 'g'}) { + ::close(client); + throw std::runtime_error("Loopback server received unexpected bytes."); + } + + const std::array response{'p', 'o', 'n', 'g'}; + const auto written = ::send( + client, response.data(), response.size(), 0); + ::close(client); + if (written != static_cast(response.size())) { + throw std::runtime_error("Loopback server send failed."); + } + } + +private: + int socket_{-1}; + std::uint16_t port_{}; +}; + +void non_blocking_loopback_round_trip() { + LoopbackServer server; + std::exception_ptr server_error; + std::jthread server_thread([&] { + try { + server.serve_once(); + } catch (...) { + server_error = std::current_exception(); + } + }); + + mms::TcpMmsTransportOptions options; + options.cancellation_poll_interval = std::chrono::milliseconds{5}; + mms::TcpMmsByteTransport transport{options}; + const auto deadline = + mms::MmsByteTransport::Clock::now() + std::chrono::seconds{2}; + transport.connect({"127.0.0.1", server.port()}, deadline, {}); + CHECK(transport.connected()); + + const std::array request{'p', 'i', 'n', 'g'}; + transport.send(request, deadline, {}); + const auto response = transport.receive(deadline, {}); + CHECK(response == std::vector({'p', 'o', 'n', 'g'})); + transport.close(); + CHECK(!transport.connected()); + + server_thread.join(); + if (server_error) { + std::rethrow_exception(server_error); + } +} + +#endif + +} // namespace + +int main() { + try { + cancelled_connect_is_observable(); +#ifndef _WIN32 + non_blocking_loopback_round_trip(); +#endif + std::cout << "TCP MMS transport tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp new file mode 100644 index 000000000..e5ed3fde0 --- /dev/null +++ b/tools/live_discover.cpp @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +[[nodiscard]] std::string json_escape(const std::string_view value) { + std::ostringstream output; + for (const char character : value) { + switch (character) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\b': output << "\\b"; break; + case '\f': output << "\\f"; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(character) < 0x20U) { + output << "?"; + } else { + output << character; + } + break; + } + } + return output.str(); +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed = 0U; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::size_t parse_limit( + const std::string& option, + const std::string& value) { + std::size_t consumed = 0U; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive bounded integer."); + } + return static_cast(parsed); +} + +void print_usage() { + std::cout + << "Usage: ariec61850_live_discover [port] [options]\n" + << "Options:\n" + << " --json Emit a compact JSON summary.\n" + << " --no-types Skip GetVariableAccessAttributes probes.\n" + << " --no-datasets Skip DataSet directory reads.\n" + << " --no-rcb Skip read-only RCB attribute probes.\n" + << " --max-types N Bound variable type probes.\n" + << " --max-datasets N Bound DataSet directory reads.\n" + << " --max-rcb N Bound RCB read probes.\n" + << " --timeout-ms N Connect/request timeout in milliseconds.\n"; +} + +void print_human(const mms::MmsLiveDiscoveryResult& result) { + std::cout << result.summary() << '\n'; + for (const auto& [domain, variables] : result.names.domain_variables) { + const auto lists = result.names.domain_variable_lists.find(domain); + const std::size_t list_count = lists == result.names.domain_variable_lists.end() + ? 0U + : lists->second.size(); + std::cout << " " << domain << ": variables=" << variables.size() + << ", DataSet names=" << list_count << '\n'; + } + if (!result.diagnostics.empty()) { + std::cout << "Diagnostics:\n"; + for (const auto& diagnostic : result.diagnostics) { + std::cout << " - " << diagnostic << '\n'; + } + } +} + +void print_json(const mms::MmsLiveDiscoveryResult& result) { + std::cout << '{' + << "\"endpoint\":\"" << json_escape(result.endpoint.host) << ':' + << result.endpoint.port << "\"," + << "\"domains\":" << result.domain_count() << ',' + << "\"variables\":" << result.variable_count() << ',' + << "\"dataSets\":" << result.report_inventory.data_sets.size() << ',' + << "\"reportControls\":" + << result.report_inventory.report_controls.size() << ',' + << "\"typeEvidence\":" << result.variable_types.size() << ',' + << "\"dataSetDirectories\":" + << result.data_set_directories.size() << ',' + << "\"reportControlReads\":" << result.report_controls.size() << ',' + << "\"partial\":" << (result.partial() ? "true" : "false") << ',' + << "\"summary\":\"" << json_escape(result.summary()) << "\"," + << "\"diagnostics\":["; + for (std::size_t index = 0U; index < result.diagnostics.size(); ++index) { + if (index != 0U) { + std::cout << ','; + } + std::cout << '"' << json_escape(result.diagnostics[index]) << '"'; + } + std::cout << "]}\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 2) { + print_usage(); + return 2; + } + + mms::MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + endpoint.port = parse_port(argv[argument]); + ++argument; + } + + bool json = false; + std::chrono::milliseconds timeout{5'000}; + mms::MmsLiveDiscoveryOptions discovery_options; + + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--json") { + json = true; + } else if (option == "--no-types") { + discovery_options.probe_variable_types = false; + } else if (option == "--no-datasets") { + discovery_options.read_data_set_directories = false; + } else if (option == "--no-rcb") { + discovery_options.probe_report_controls = false; + } else if (option == "--max-types" || + option == "--max-datasets" || + option == "--max-rcb" || + option == "--timeout-ms") { + if (argument >= argc) { + throw std::invalid_argument(option + " requires a value."); + } + const std::string value = argv[argument++]; + const auto parsed = parse_limit(option, value); + if (option == "--max-types") { + discovery_options.maximum_variable_type_probes = parsed; + } else if (option == "--max-datasets") { + discovery_options.maximum_data_set_directories = parsed; + } else if (option == "--max-rcb") { + discovery_options.maximum_report_control_probes = parsed; + } else { + if (parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("--timeout-ms is too large."); + } + timeout = std::chrono::milliseconds{ + static_cast(parsed)}; + } + } else if (option == "--help" || option == "-h") { + print_usage(); + return 0; + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = timeout; + association_options.request_timeout = timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + const auto result = session.discover(discovery_options); + session.disconnect(); + + if (json) { + print_json(result); + } else { + print_human(result); + } + return result.partial() ? 1 : 0; + } catch (const std::exception& exception) { + std::cerr << "Live MMS discovery failed: " << exception.what() << '\n'; + return 2; + } +} From fbbeea625f9a5f5fe90432450646d2fdc5156b2a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 04:32:58 +0700 Subject: [PATCH 002/179] Fix strict-warning loopback comparison --- tests/test_tcp_transport.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tcp_transport.cpp b/tests/test_tcp_transport.cpp index 90871be78..2eeb4c6bd 100644 --- a/tests/test_tcp_transport.cpp +++ b/tests/test_tcp_transport.cpp @@ -134,7 +134,7 @@ class LoopbackServer final { const auto written = ::send( client, response.data(), response.size(), 0); ::close(client); - if (written != static_cast(response.size())) { + if (written < 0 || static_cast(written) != response.size()) { throw std::runtime_error("Loopback server send failed."); } } From f9457a833406505750ebdfebd89e7c93eac20b72 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:32:13 +0700 Subject: [PATCH 003/179] Add Phase 4C.1 live-model parity --- include/ariec61850/mms/model_reference.hpp | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 include/ariec61850/mms/model_reference.hpp diff --git a/include/ariec61850/mms/model_reference.hpp b/include/ariec61850/mms/model_reference.hpp new file mode 100644 index 000000000..cc6c55d36 --- /dev/null +++ b/include/ariec61850/mms/model_reference.hpp @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsLogicalNodeName final { + std::string name; + std::string prefix; + std::string logical_node_class; + std::string instance; + + friend bool operator==(const MmsLogicalNodeName&, const MmsLogicalNodeName&) = default; +}; + +struct MmsLivePointReference final { + std::string domain; + std::string logical_node; + std::string functional_constraint; + std::string data_object_path; + std::string mms_item_name; + std::string source{"LiveMmsGetNameList"}; + std::uint32_t confidence{100U}; + + [[nodiscard]] std::string user_path() const; + [[nodiscard]] std::string user_reference() const; + [[nodiscard]] std::string mms_reference() const; + [[nodiscard]] bool report_attribute() const noexcept; + [[nodiscard]] bool control_attribute() const noexcept; + + friend bool operator==(const MmsLivePointReference&, + const MmsLivePointReference&) = default; +}; + +class MmsLiveReferenceParser final { +public: + [[nodiscard]] static bool known_functional_constraint( + std::string_view value) noexcept; + [[nodiscard]] static std::string normalize_functional_constraint( + std::string_view value); + + [[nodiscard]] static std::optional parse_variable( + std::string_view domain, + std::string_view raw_mms_name, + std::string source = "LiveMmsGetNameList", + std::uint32_t confidence = 100U); + + [[nodiscard]] static MmsLogicalNodeName parse_logical_node_name( + std::string_view logical_node_name); + [[nodiscard]] static std::string top_data_object_name( + std::string_view data_object_path); + [[nodiscard]] static std::string data_attribute_path( + std::string_view data_object_path); +}; + +} // namespace ar::iec61850::mms + +#include "ariec61850/mms/model_reference.ipp" From 83a34f0e24784a963a25627a9c13216c0df8230e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:32:41 +0700 Subject: [PATCH 004/179] Add Phase 4C.1 model reference implementation --- include/ariec61850/mms/model_reference.ipp | 192 +++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 include/ariec61850/mms/model_reference.ipp diff --git a/include/ariec61850/mms/model_reference.ipp b/include/ariec61850/mms/model_reference.ipp new file mode 100644 index 000000000..8e967d2f4 --- /dev/null +++ b/include/ariec61850/mms/model_reference.ipp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace ar::iec61850::mms { +namespace model_reference_detail { + +[[nodiscard]] inline std::string trim_copy(const std::string_view value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) { + return {}; + } + const auto last = value.find_last_not_of(" \t\r\n"); + return std::string{value.substr(first, last - first + 1U)}; +} + +[[nodiscard]] inline std::string upper_copy(const std::string_view value) { + auto result = trim_copy(value); + std::transform(result.begin(), result.end(), result.begin(), [](const char character) { + return static_cast(std::toupper(static_cast(character))); + }); + return result; +} + +[[nodiscard]] inline std::vector split_non_empty( + const std::string_view value, + const char delimiter) { + std::vector result; + std::size_t start = 0U; + while (start <= value.size()) { + const auto end = value.find(delimiter, start); + const auto part = trim_copy(value.substr( + start, + end == std::string_view::npos ? std::string_view::npos : end - start)); + if (!part.empty()) { + result.push_back(part); + } + if (end == std::string_view::npos) { + break; + } + start = end + 1U; + } + return result; +} + +[[nodiscard]] inline std::string join( + const std::vector& values, + const std::size_t begin, + const std::size_t end, + const char delimiter) { + std::string result; + for (std::size_t index = begin; index < end; ++index) { + if (!result.empty()) { + result.push_back(delimiter); + } + result += values[index]; + } + return result; +} + +[[nodiscard]] inline bool is_upper_ascii(const char value) noexcept { + return value >= 'A' && value <= 'Z'; +} + +} // namespace model_reference_detail + +inline std::string MmsLivePointReference::user_path() const { + return data_object_path.empty() + ? logical_node + : logical_node + "." + data_object_path; +} + +inline std::string MmsLivePointReference::user_reference() const { + return domain.empty() ? user_path() : domain + "/" + user_path(); +} + +inline std::string MmsLivePointReference::mms_reference() const { + return domain.empty() ? mms_item_name : domain + "/" + mms_item_name; +} + +inline bool MmsLivePointReference::report_attribute() const noexcept { + return functional_constraint == "RP" || functional_constraint == "BR"; +} + +inline bool MmsLivePointReference::control_attribute() const noexcept { + return functional_constraint == "CO"; +} + +inline bool MmsLiveReferenceParser::known_functional_constraint( + const std::string_view value) noexcept { + static constexpr std::array constraints{ + "ST", "MX", "CO", "SP", "SG", "SE", "SV", "CF", "DC", "EX", + "SR", "OR", "BL", "RP", "BR", "LG", "GO", "GS", "MS", "US"}; + const auto normalized = model_reference_detail::upper_copy(value); + return std::find(constraints.begin(), constraints.end(), normalized) != + constraints.end(); +} + +inline std::string MmsLiveReferenceParser::normalize_functional_constraint( + const std::string_view value) { + return model_reference_detail::upper_copy(value); +} + +inline std::optional MmsLiveReferenceParser::parse_variable( + const std::string_view domain, + const std::string_view raw_mms_name, + std::string source, + const std::uint32_t confidence) { + const auto normalized_domain = model_reference_detail::trim_copy(domain); + const auto normalized_item = model_reference_detail::trim_copy(raw_mms_name); + if (normalized_domain.empty() || normalized_item.empty()) { + return std::nullopt; + } + + const auto parts = model_reference_detail::split_non_empty(normalized_item, '$'); + if (parts.size() < 3U) { + return std::nullopt; + } + + std::size_t functional_constraint_index = parts.size(); + for (std::size_t index = 1U; index < parts.size(); ++index) { + if (known_functional_constraint(parts[index])) { + functional_constraint_index = index; + break; + } + } + if (functional_constraint_index < 1U || + functional_constraint_index + 1U >= parts.size()) { + return std::nullopt; + } + + MmsLivePointReference point; + point.domain = normalized_domain; + point.logical_node = model_reference_detail::join(parts, 0U, functional_constraint_index, '$'); + point.functional_constraint = normalize_functional_constraint( + parts[functional_constraint_index]); + point.data_object_path = model_reference_detail::join( + parts, + functional_constraint_index + 1U, + parts.size(), + '.'); + point.mms_item_name = model_reference_detail::join(parts, 0U, parts.size(), '$'); + point.source = std::move(source); + point.confidence = confidence; + if (point.logical_node.empty() || point.data_object_path.empty()) { + return std::nullopt; + } + return point; +} + +inline MmsLogicalNodeName MmsLiveReferenceParser::parse_logical_node_name( + const std::string_view logical_node_name) { + const auto normalized = model_reference_detail::trim_copy(logical_node_name); + if (normalized.empty()) { + return {}; + } + if (model_reference_detail::upper_copy(normalized) == "LLN0") { + return {normalized, {}, "LLN0", {}}; + } + + for (std::size_t index = 0U; index + 4U <= normalized.size(); ++index) { + if (!model_reference_detail::is_upper_ascii(normalized[index]) || + !model_reference_detail::is_upper_ascii(normalized[index + 1U]) || + !model_reference_detail::is_upper_ascii(normalized[index + 2U]) || + !model_reference_detail::is_upper_ascii(normalized[index + 3U])) { + continue; + } + return { + normalized, + normalized.substr(0U, index), + normalized.substr(index, 4U), + normalized.substr(index + 4U)}; + } + return {normalized, {}, normalized, {}}; +} + +inline std::string MmsLiveReferenceParser::top_data_object_name( + const std::string_view data_object_path) { + const auto normalized = model_reference_detail::trim_copy(data_object_path); + const auto dot = normalized.find('.'); + return dot == std::string::npos ? normalized : normalized.substr(0U, dot); +} + +inline std::string MmsLiveReferenceParser::data_attribute_path( + const std::string_view data_object_path) { + const auto normalized = model_reference_detail::trim_copy(data_object_path); + const auto dot = normalized.find('.'); + if (dot == std::string::npos || dot + 1U >= normalized.size()) { + return {}; + } + return normalized.substr(dot + 1U); +} + +} // namespace ar::iec61850::mms From 11db6b1b1b414251022928d566f3742b161fc4be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:33:14 +0700 Subject: [PATCH 005/179] Add Phase 4C.1 live model schema --- include/ariec61850/mms/live_model.hpp | 235 ++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 include/ariec61850/mms/live_model.hpp diff --git a/include/ariec61850/mms/live_model.hpp b/include/ariec61850/mms/live_model.hpp new file mode 100644 index 000000000..f0c906b7b --- /dev/null +++ b/include/ariec61850/mms/live_model.hpp @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/model_reference.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsLiveModelConfidence : std::uint8_t { + exact, + high, + medium, + low, + unknown, +}; + +struct MmsLiveIedIdentity final { + std::string ied_name; + std::string source; + MmsLiveModelConfidence confidence{MmsLiveModelConfidence::unknown}; + bool ambiguous{}; + std::vector candidate_names; + std::map> logical_device_aliases; + std::vector evidence; +}; + +struct MmsLiveDataAttribute final { + std::string object_reference; + std::string attribute_path; + std::string functional_constraint; + std::string mms_reference; + std::string mms_item_name; + std::string source; + std::string scl_basic_type; + std::string mms_type; + std::string mms_type_signature; + std::string type_discovery_status{"NotRead"}; + std::string type_discovery_message; + std::string type_source{"NameListHeuristic"}; + MmsLiveModelConfidence type_confidence{MmsLiveModelConfidence::low}; + MmsLiveModelConfidence functional_constraint_confidence{ + MmsLiveModelConfidence::exact}; +}; + +struct MmsLiveDataObject final { + std::string reference; + std::string name; + std::string inferred_cdc; + double cdc_confidence{}; + MmsLiveModelConfidence confidence{MmsLiveModelConfidence::unknown}; + std::vector evidence; + std::vector attributes; +}; + +struct MmsLiveLogicalNode final { + std::string name; + std::string prefix; + std::string logical_node_class; + std::string instance; + std::string proposed_type_id; + std::map> functional_constraint_counts; + std::vector data_objects; +}; + +struct MmsLiveLogicalDevice final { + std::string mms_domain; + std::string instance; + std::vector logical_nodes; +}; + +struct MmsLiveDataSetMember final { + std::size_t index{}; + std::string reference; + std::string functional_constraint; + std::string mms_reference; + MmsLiveModelConfidence confidence{MmsLiveModelConfidence::exact}; +}; + +struct MmsLiveDataSet final { + std::string reference; + std::string domain; + std::string logical_node; + std::string name; + std::optional deletable; + std::vector members; + std::vector used_by_report_controls; +}; + +struct MmsLiveReportControl final { + std::string reference; + std::string domain; + std::string logical_node; + std::string name; + bool buffered{}; + std::string data_set_reference; + std::string report_id; + std::string configuration_revision; + std::string trigger_options; + std::string optional_fields; + std::string buffer_time_ms; + std::string integrity_period_ms; + std::string enabled_state; + std::string reservation_state; + std::string reservation_time_seconds; + std::string status; +}; + +struct MmsLiveModelCoverage final { + std::size_t logical_device_count{}; + std::size_t logical_node_count{}; + std::size_t data_object_count{}; + std::size_t data_attribute_count{}; + std::size_t exact_functional_constraint_count{}; + std::size_t high_confidence_cdc_count{}; + std::size_t medium_confidence_cdc_count{}; + std::size_t low_confidence_cdc_count{}; + std::size_t unknown_cdc_count{}; + std::size_t data_set_count{}; + std::size_t report_control_count{}; + std::size_t buffered_report_control_count{}; + std::size_t unbuffered_report_control_count{}; + std::size_t variable_type_read_attempt_count{}; + std::size_t variable_type_read_success_count{}; + std::size_t variable_type_read_failure_count{}; + std::size_t exact_mms_type_count{}; +}; + +struct MmsLiveModelWarning final { + std::string code; + std::string reference; + std::string message; +}; + +struct MmsLiveModelDocument final { + std::string schema_version{"live-ied-model-v1"}; + std::string source{"LiveMmsDiscovery"}; + MmsEndpoint endpoint; + MmsLiveIedIdentity identity; + std::string access_point_name{"AP1"}; + std::string summary; + MmsLiveModelCoverage coverage; + std::vector logical_devices; + std::vector data_sets; + std::vector report_controls; + std::vector warnings; + + [[nodiscard]] std::string canonical_manifest() const; + [[nodiscard]] std::uint64_t canonical_fingerprint() const; + [[nodiscard]] std::string canonical_fingerprint_hex() const; + [[nodiscard]] std::string to_json() const; +}; + +struct MmsLiveModelBuildOptions final { + std::string explicit_ied_name; + std::string fallback_ied_name; + std::string access_point_name{"AP1"}; +}; + +class MmsLiveModelBuilder final { +public: + [[nodiscard]] static MmsLiveModelDocument build( + const MmsLiveDiscoveryResult& discovery, + const MmsLiveModelBuildOptions& options = {}); +}; + +enum class MmsLiveModelFindingSeverity : std::uint8_t { + information, + warning, + error, +}; + +enum class MmsLiveModelFindingKind : std::uint8_t { + identity_mismatch, + missing_attribute, + unexpected_attribute, + functional_constraint_mismatch, + type_mismatch, + missing_data_set, + unexpected_data_set, + data_set_member_count_mismatch, + missing_report_control, + unexpected_report_control, + report_control_mode_mismatch, +}; + +struct MmsLiveModelFinding final { + MmsLiveModelFindingSeverity severity{MmsLiveModelFindingSeverity::information}; + MmsLiveModelFindingKind kind{MmsLiveModelFindingKind::unexpected_attribute}; + std::string reference; + std::string expected; + std::string observed; + std::string message; +}; + +struct MmsLiveModelParityResult final { + std::string expected_ied_name; + std::string observed_ied_name; + std::size_t expected_attribute_count{}; + std::size_t observed_attribute_count{}; + std::size_t matched_attribute_count{}; + std::vector findings; + + [[nodiscard]] std::size_t blocking_finding_count() const noexcept; + [[nodiscard]] bool compatible() const noexcept { + return blocking_finding_count() == 0U; + } +}; + +class MmsLiveModelParityComparer final { +public: + [[nodiscard]] static MmsLiveModelParityResult compare( + const MmsLiveModelDocument& expected, + const MmsLiveModelDocument& observed); +}; + +} // namespace ar::iec61850::mms + +#include "ariec61850/mms/live_model.ipp" From 31ca0cfdbe5c467403d702a79f760c96c06474f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:36:52 +0700 Subject: [PATCH 006/179] Add Phase 4C.1 live model implementation wrapper --- include/ariec61850/mms/live_model.ipp | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 include/ariec61850/mms/live_model.ipp diff --git a/include/ariec61850/mms/live_model.ipp b/include/ariec61850/mms/live_model.ipp new file mode 100644 index 000000000..976a81b05 --- /dev/null +++ b/include/ariec61850/mms/live_model.ipp @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_model_chunk01.ipp" +#include "ariec61850/mms/live_model_chunk02.ipp" +#include "ariec61850/mms/live_model_chunk03.ipp" +#include "ariec61850/mms/live_model_chunk04.ipp" +#include "ariec61850/mms/live_model_chunk05.ipp" +#include "ariec61850/mms/live_model_chunk06.ipp" From 0fd7e5c59c3cdb554af6ec0c619aa0fd76d34055 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:37:16 +0700 Subject: [PATCH 007/179] Add Phase 4C.1 live model helpers 1 --- include/ariec61850/mms/live_model_chunk01.ipp | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 include/ariec61850/mms/live_model_chunk01.ipp diff --git a/include/ariec61850/mms/live_model_chunk01.ipp b/include/ariec61850/mms/live_model_chunk01.ipp new file mode 100644 index 000000000..4dcde32ca --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk01.ipp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace ar::iec61850::mms { +namespace live_model_detail { + +[[nodiscard]] inline std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](char c) { + return static_cast(std::tolower(static_cast(c))); + }); + return value; +} +[[nodiscard]] inline bool same(std::string_view a, std::string_view b) { + return lower(std::string{a}) == lower(std::string{b}); +} +[[nodiscard]] inline std::vector split(std::string_view value, char separator) { + std::vector out; + std::size_t start{}; + while (start <= value.size()) { + const auto end = value.find(separator, start); + out.emplace_back(value.substr(start, end == std::string_view::npos + ? std::string_view::npos : end - start)); + if (end == std::string_view::npos) break; + start = end + 1U; + } + return out; +} +[[nodiscard]] inline std::string confidence(MmsLiveModelConfidence value) { + switch (value) { + case MmsLiveModelConfidence::exact: return "Exact"; + case MmsLiveModelConfidence::high: return "High"; + case MmsLiveModelConfidence::medium: return "Medium"; + case MmsLiveModelConfidence::low: return "Low"; + case MmsLiveModelConfidence::unknown: return "Unknown"; + } + return "Unknown"; +} +[[nodiscard]] inline std::string json(std::string_view value) { + std::ostringstream out; + for (char c : value) { + switch (c) { + case '\\': out << "\\\\"; break; + case '"': out << "\\\""; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: out << (static_cast(c) < 0x20U ? '?' : c); break; + } + } + return out.str(); +} +[[nodiscard]] inline std::string key(std::string value) { + std::replace(value.begin(), value.end(), '$', '.'); + return lower(std::move(value)); +} +[[nodiscard]] inline std::string bit_names(const MmsReportBitField& field) { + std::ostringstream out; + for (std::size_t i = 0; i < field.names.size(); ++i) { + if (i) out << ','; + out << field.names[i]; + } + return out.str(); +} +[[nodiscard]] inline std::string optional_bool(std::optional value) { + return value ? (*value ? "true" : "false") : std::string{}; +} +[[nodiscard]] inline std::string optional_u64(std::optional value) { + return value ? std::to_string(*value) : std::string{}; +} +[[nodiscard]] inline const MmsTypeSpecification* resolve_type( + const MmsLivePointReference& point, + const std::vector& evidence, + std::string& source) { + const auto target = split(point.mms_item_name, '$'); + const MmsTypeSpecification* best{}; + std::size_t best_depth{}; + for (const auto& item : evidence) { + if (!item.success() || !same(item.variable.domain, point.domain)) continue; + const auto root = split(item.variable.item, '$'); + if (root.empty() || root.size() > target.size()) continue; + bool prefix = true; + for (std::size_t i = 0; i < root.size(); ++i) prefix &= same(root[i], target[i]); + if (!prefix) continue; + const MmsTypeSpecification* current = &item.attributes->type; + for (std::size_t i = root.size(); current && i < target.size(); ++i) { + const auto found = std::find_if(current->children.begin(), current->children.end(), + [&](const auto& child) { return same(child.name, target[i]); }); + current = found == current->children.end() ? nullptr : &*found; + } + if (current && root.size() >= best_depth) { + best = current; + best_depth = root.size(); + source = root.size() == target.size() + ? "GetVariableAccessAttributes" + : "GetVariableAccessAttributesLogicalNodeTree"; + } + } + return best; +} +[[nodiscard]] inline std::pair infer_cdc( + std::string_view ln_class, + std::string_view name, + const std::vector& attributes) { + const auto ln = lower(std::string{ln_class}); + const auto object = lower(std::string{name}); + if (ln == "mmxu" && (object == "a" || object == "phv")) return {"WYE", 0.95}; + if (ln == "mmxu" && object == "ppv") return {"DEL", 0.95}; + if (object == "pos") return {"DPC", 0.92}; + if (object == "mod") return {"INC", 0.90}; + if (object == "namplt") return {"LPL", 0.92}; + bool st_val{}, magnitude{}, quality{}, timestamp{}; + for (const auto& attribute : attributes) { + const auto leaf = lower(attribute.attribute_path); + st_val |= leaf == "stval" || leaf.ends_with(".stval"); + magnitude |= leaf == "mag.f" || leaf.ends_with(".mag.f") || leaf == "instmag.f"; + quality |= leaf == "q" || leaf.ends_with(".q"); + timestamp |= leaf == "t" || leaf.ends_with(".t"); + } From 55a71794182f78b6db2cda149edc5e54c84c8591 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:37:38 +0700 Subject: [PATCH 008/179] Add Phase 4C.1 live model helpers 2 --- include/ariec61850/mms/live_model_chunk02.ipp | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 include/ariec61850/mms/live_model_chunk02.ipp diff --git a/include/ariec61850/mms/live_model_chunk02.ipp b/include/ariec61850/mms/live_model_chunk02.ipp new file mode 100644 index 000000000..491ec8338 --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk02.ipp @@ -0,0 +1,96 @@ + if (magnitude && quality && timestamp) return {"MV", 0.82}; + if (st_val && quality && timestamp) return {"SPS", 0.78}; + if (st_val) return {"SPS", 0.60}; + return {"", 0.0}; +} +[[nodiscard]] inline std::string infer_ied_name( + const std::vector& domains, + const MmsLiveModelBuildOptions& options, + const MmsEndpoint& endpoint) { + if (!options.explicit_ied_name.empty()) return options.explicit_ied_name; + static constexpr std::array suffixes{ + "LD0", "LD1", "CTRL", "PROT", "MEAS", "CFG", "SYS", "BAY", "MU", "P1", "P2", "DR"}; + std::vector candidates; + for (const auto& domain : domains) { + for (const auto suffix : suffixes) { + if (domain.size() > suffix.size() && + same(domain.substr(domain.size() - suffix.size()), suffix)) { + candidates.push_back(domain.substr(0, domain.size() - suffix.size())); + break; + } + } + } + if (!candidates.empty() && std::all_of(candidates.begin(), candidates.end(), + [&](const auto& value) { return same(value, candidates.front()); })) { + return candidates.front(); + } + if (!options.fallback_ied_name.empty()) return options.fallback_ied_name; + return endpoint.host.empty() ? "DISCOVERED_IED" : endpoint.host; +} +[[nodiscard]] inline std::vector collect_points( + const MmsLiveDiscoveryResult& discovery) { + std::map> unique; + const auto add = [&](std::optional point) { + if (point) unique.emplace(key(point->mms_reference()), std::move(*point)); + }; + for (const auto& [domain, variables] : discovery.names.domain_variables) { + for (const auto& variable : variables) add(MmsLiveReferenceParser::parse_variable(domain, variable)); + } + for (const auto& evidence : discovery.data_set_directories) { + if (!evidence.success()) continue; + for (const auto& member : evidence.directory->members) { + add(MmsLiveReferenceParser::parse_variable( + member.object_name.domain, member.object_name.item, + "GetNamedVariableListAttributes", member.confidence)); + } + } + for (const auto& control : discovery.report_inventory.report_controls) { + add(MmsLiveReferenceParser::parse_variable( + control.domain, + control.logical_node + "$" + (control.buffered ? "BR" : "RP") + + "$" + control.name + "$RptEna", + "ReportControlInventory", 100U)); + } + std::vector result; + for (auto& [_, point] : unique) result.push_back(std::move(point)); + return result; +} +[[nodiscard]] inline std::map> attributes( + const MmsLiveModelDocument& document) { + std::map> result; + for (const auto& ld : document.logical_devices) + for (const auto& ln : ld.logical_nodes) + for (const auto& object : ln.data_objects) + for (const auto& attribute : object.attributes) + result.emplace(key(attribute.object_reference), &attribute); + return result; +} +} // namespace live_model_detail + +inline MmsLiveModelDocument MmsLiveModelBuilder::build( + const MmsLiveDiscoveryResult& discovery, + const MmsLiveModelBuildOptions& options) { + using namespace live_model_detail; + MmsLiveModelDocument document; + document.endpoint = discovery.endpoint; + document.access_point_name = options.access_point_name.empty() ? "AP1" : options.access_point_name; + std::vector domains; + for (const auto& [domain, _] : discovery.names.domain_variables) domains.push_back(domain); + std::sort(domains.begin(), domains.end()); + document.identity.ied_name = infer_ied_name(domains, options, discovery.endpoint); + document.identity.source = options.explicit_ied_name.empty() ? "MmsDomainInference" : "ExplicitOverride"; + document.identity.confidence = options.explicit_ied_name.empty() + ? MmsLiveModelConfidence::medium : MmsLiveModelConfidence::exact; + for (const auto& domain : domains) { + std::string alias = domain; + if (domain.starts_with(document.identity.ied_name) && domain.size() > document.identity.ied_name.size()) + alias = domain.substr(document.identity.ied_name.size()); + document.identity.logical_device_aliases.emplace(domain, alias); + } + + using AttributeMap = std::map>; + using ObjectMap = std::map>; + using NodeMap = std::map>; + std::map> hierarchy; + for (const auto& point : collect_points(discovery)) { + const auto object_name = MmsLiveReferenceParser::top_data_object_name(point.data_object_path); From c3d54560d0800df251359d64552753eca655a8b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:39:15 +0700 Subject: [PATCH 009/179] Add Phase 4C.1 live model helpers 3 --- include/ariec61850/mms/live_model_chunk03.ipp | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 include/ariec61850/mms/live_model_chunk03.ipp diff --git a/include/ariec61850/mms/live_model_chunk03.ipp b/include/ariec61850/mms/live_model_chunk03.ipp new file mode 100644 index 000000000..cabdc0928 --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk03.ipp @@ -0,0 +1,88 @@ + MmsLiveDataAttribute attribute; + attribute.object_reference = point.user_reference(); + attribute.attribute_path = MmsLiveReferenceParser::data_attribute_path(point.data_object_path); + attribute.functional_constraint = point.functional_constraint; + attribute.mms_reference = point.mms_reference(); + attribute.mms_item_name = point.mms_item_name; + attribute.source = point.source; + std::string type_source; + if (const auto* type = resolve_type(point, discovery.variable_types, type_source)) { + attribute.scl_basic_type = type->scl_basic_type(); + attribute.mms_type = type->mms_type_name(); + attribute.mms_type_signature = type->signature(); + attribute.type_discovery_status = "Exact"; + attribute.type_source = type_source; + attribute.type_confidence = MmsLiveModelConfidence::exact; + } + hierarchy[point.domain][point.logical_node][object_name].emplace( + key(attribute.object_reference), std::move(attribute)); + } + for (auto& [domain, nodes] : hierarchy) { + MmsLiveLogicalDevice ld; + ld.mms_domain = domain; + const auto alias = document.identity.logical_device_aliases.find(domain); + ld.instance = alias == document.identity.logical_device_aliases.end() ? domain : alias->second; + for (auto& [node_name, objects] : nodes) { + const auto parsed = MmsLiveReferenceParser::parse_logical_node_name(node_name); + MmsLiveLogicalNode ln; + ln.name = parsed.name; + ln.prefix = parsed.prefix; + ln.logical_node_class = parsed.logical_node_class; + ln.instance = parsed.instance; + ln.proposed_type_id = document.identity.ied_name + "_" + node_name; + for (auto& [object_name, object_attributes] : objects) { + MmsLiveDataObject object; + object.name = object_name; + object.reference = domain + "/" + node_name + "." + object_name; + for (auto& [_, attribute] : object_attributes) { + ++ln.functional_constraint_counts[attribute.functional_constraint]; + object.attributes.push_back(std::move(attribute)); + } + const auto [cdc, score] = infer_cdc(ln.logical_node_class, object.name, object.attributes); + object.inferred_cdc = cdc; + object.cdc_confidence = score; + object.confidence = score >= 0.85 ? MmsLiveModelConfidence::high + : score >= 0.60 ? MmsLiveModelConfidence::medium + : score > 0 ? MmsLiveModelConfidence::low + : MmsLiveModelConfidence::unknown; + ln.data_objects.push_back(std::move(object)); + } + ld.logical_nodes.push_back(std::move(ln)); + } + document.logical_devices.push_back(std::move(ld)); + } + + for (const auto& evidence : discovery.data_set_directories) { + MmsLiveDataSet data_set; + data_set.reference = evidence.candidate.reference; + data_set.domain = evidence.candidate.domain; + data_set.logical_node = evidence.candidate.logical_node; + data_set.name = evidence.candidate.name; + if (evidence.success()) { + data_set.deletable = evidence.directory->deletable; + for (std::size_t i = 0; i < evidence.directory->members.size(); ++i) { + const auto& member = evidence.directory->members[i]; + data_set.members.push_back({i, member.user_reference, + member.functional_constraint, member.mms_reference, + MmsLiveModelConfidence::exact}); + } + } + document.data_sets.push_back(std::move(data_set)); + } + for (const auto& evidence : discovery.report_controls) { + MmsLiveReportControl control; + control.reference = evidence.candidate.reference; + control.domain = evidence.candidate.domain; + control.logical_node = evidence.candidate.logical_node; + control.name = evidence.candidate.name; + control.buffered = evidence.candidate.buffered; + control.status = evidence.success() ? "Read" : "Unreadable"; + if (evidence.state) { + control.data_set_reference = evidence.state->data_set_reference; + control.report_id = evidence.state->report_id; + control.configuration_revision = optional_u64(evidence.state->configuration_revision); + control.trigger_options = bit_names(evidence.state->trigger_options); + control.optional_fields = bit_names(evidence.state->optional_fields); + control.buffer_time_ms = optional_u64(evidence.state->buffer_time_ms); + control.integrity_period_ms = optional_u64(evidence.state->integrity_period_ms); + control.enabled_state = optional_bool(evidence.state->report_enabled); From a45435199d0d89cddc835213a83b41ecd6a0c13a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:39:39 +0700 Subject: [PATCH 010/179] Add Phase 4C.1 live model helpers 4 --- include/ariec61850/mms/live_model_chunk04.ipp | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 include/ariec61850/mms/live_model_chunk04.ipp diff --git a/include/ariec61850/mms/live_model_chunk04.ipp b/include/ariec61850/mms/live_model_chunk04.ipp new file mode 100644 index 000000000..30f219e7d --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk04.ipp @@ -0,0 +1,85 @@ + control.reservation_state = optional_bool(evidence.state->reserved); + control.reservation_time_seconds = optional_u64(evidence.state->reservation_time_seconds); + } + document.report_controls.push_back(std::move(control)); + } + + auto& coverage = document.coverage; + coverage.logical_device_count = document.logical_devices.size(); + for (const auto& ld : document.logical_devices) { + coverage.logical_node_count += ld.logical_nodes.size(); + for (const auto& ln : ld.logical_nodes) { + coverage.data_object_count += ln.data_objects.size(); + for (const auto& object : ln.data_objects) { + coverage.data_attribute_count += object.attributes.size(); + coverage.exact_mms_type_count += static_cast(std::count_if( + object.attributes.begin(), object.attributes.end(), [](const auto& a) { + return a.type_confidence == MmsLiveModelConfidence::exact; + })); + } + } + } + coverage.data_set_count = document.data_sets.size(); + coverage.report_control_count = document.report_controls.size(); + coverage.buffered_report_control_count = static_cast(std::count_if( + document.report_controls.begin(), document.report_controls.end(), [](const auto& r) { return r.buffered; })); + coverage.unbuffered_report_control_count = coverage.report_control_count - coverage.buffered_report_control_count; + coverage.variable_type_read_attempt_count = discovery.variable_types.size(); + coverage.variable_type_read_success_count = static_cast(std::count_if( + discovery.variable_types.begin(), discovery.variable_types.end(), [](const auto& e) { return e.success(); })); + coverage.variable_type_read_failure_count = coverage.variable_type_read_attempt_count - coverage.variable_type_read_success_count; + for (const auto& diagnostic : discovery.diagnostics) + document.warnings.push_back({"DiscoveryDiagnostic", {}, diagnostic}); + if (coverage.variable_type_read_failure_count) + document.warnings.push_back({"VariableTypeReadFailure", {}, + std::to_string(coverage.variable_type_read_failure_count) + " type probes failed."}); + document.summary = "Live MMS model: LD=" + std::to_string(coverage.logical_device_count) + + ", LN=" + std::to_string(coverage.logical_node_count) + + ", DA=" + std::to_string(coverage.data_attribute_count) + + ", DataSet=" + std::to_string(coverage.data_set_count) + + ", RCB=" + std::to_string(coverage.report_control_count) + "."; + return document; +} + +inline std::string MmsLiveModelDocument::canonical_manifest() const { + std::ostringstream out; + out << "ARIEC61850-LIVE-MODEL|1\nIDENTITY|" << identity.ied_name << '\n'; + for (const auto& ld : logical_devices) for (const auto& ln : ld.logical_nodes) + for (const auto& object : ln.data_objects) for (const auto& attribute : object.attributes) + out << "DA|" << attribute.object_reference << '|' << attribute.functional_constraint + << '|' << attribute.mms_type_signature << '\n'; + for (const auto& data_set : data_sets) + out << "DS|" << data_set.reference << '|' << data_set.members.size() << '\n'; + for (const auto& control : report_controls) + out << "RCB|" << control.reference << '|' << (control.buffered ? "B" : "U") << '\n'; + return out.str(); +} +inline std::uint64_t MmsLiveModelDocument::canonical_fingerprint() const { + std::uint64_t value{14695981039346656037ULL}; + for (const char raw_byte : canonical_manifest()) { + const auto byte = static_cast(raw_byte); + value ^= byte; + value *= 1099511628211ULL; + } + return value; +} +inline std::string MmsLiveModelDocument::canonical_fingerprint_hex() const { + std::ostringstream out; + out << std::hex << std::setfill('0') << std::setw(16) << canonical_fingerprint(); + return out.str(); +} +inline std::string MmsLiveModelDocument::to_json() const { + using namespace live_model_detail; + std::ostringstream out; + out << '{' << "\"schemaVersion\":\"" << json(schema_version) << "\"," + << "\"source\":\"" << json(source) << "\"," + << "\"host\":\"" << json(endpoint.host) << "\",\"port\":" << endpoint.port << ',' + << "\"iedName\":\"" << json(identity.ied_name) << "\"," + << "\"iedIdentity\":{\"iedName\":\"" << json(identity.ied_name) + << "\",\"source\":\"" << json(identity.source) << "\",\"confidence\":\"" + << confidence(identity.confidence) << "\",\"isAmbiguous\":" + << (identity.ambiguous ? "true" : "false") << "}," + << "\"accessPointName\":\"" << json(access_point_name) << "\"," + << "\"summary\":\"" << json(summary) << "\"," + << "\"fingerprint\":\"" << canonical_fingerprint_hex() << "\"," + << "\"coverage\":{\"logicalDeviceCount\":" << coverage.logical_device_count From f58cd2d285c8ccf4fe5af4ad35b7f66631b4b7c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:40:02 +0700 Subject: [PATCH 011/179] Add Phase 4C.1 live model helpers 5 --- include/ariec61850/mms/live_model_chunk05.ipp | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 include/ariec61850/mms/live_model_chunk05.ipp diff --git a/include/ariec61850/mms/live_model_chunk05.ipp b/include/ariec61850/mms/live_model_chunk05.ipp new file mode 100644 index 000000000..6e2e01b97 --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk05.ipp @@ -0,0 +1,90 @@ + << ",\"logicalNodeCount\":" << coverage.logical_node_count + << ",\"dataObjectCount\":" << coverage.data_object_count + << ",\"dataAttributeCount\":" << coverage.data_attribute_count + << ",\"dataSetCount\":" << coverage.data_set_count + << ",\"reportControlCount\":" << coverage.report_control_count + << ",\"variableTypeReadAttemptCount\":" << coverage.variable_type_read_attempt_count + << ",\"variableTypeReadSuccessCount\":" << coverage.variable_type_read_success_count + << ",\"variableTypeReadFailureCount\":" << coverage.variable_type_read_failure_count + << ",\"exactMmsTypeCount\":" << coverage.exact_mms_type_count << "},\"logicalDevices\":["; + for (std::size_t i = 0; i < logical_devices.size(); ++i) { + if (i) out << ','; + const auto& ld = logical_devices[i]; + out << "{\"mmsDomain\":\"" << json(ld.mms_domain) << "\",\"inst\":\"" + << json(ld.instance) << "\",\"logicalNodes\":["; + for (std::size_t j = 0; j < ld.logical_nodes.size(); ++j) { + if (j) out << ','; + const auto& ln = ld.logical_nodes[j]; + out << "{\"name\":\"" << json(ln.name) << "\",\"prefix\":\"" << json(ln.prefix) + << "\",\"lnClass\":\"" << json(ln.logical_node_class) << "\",\"lnInst\":\"" + << json(ln.instance) << "\",\"dataObjects\":["; + for (std::size_t k = 0; k < ln.data_objects.size(); ++k) { + if (k) out << ','; + const auto& object = ln.data_objects[k]; + out << "{\"reference\":\"" << json(object.reference) << "\",\"name\":\"" + << json(object.name) << "\",\"inferredCdc\":\"" << json(object.inferred_cdc) + << "\",\"cdcConfidence\":" << object.cdc_confidence << ",\"attributes\":["; + for (std::size_t m = 0; m < object.attributes.size(); ++m) { + if (m) out << ','; + const auto& a = object.attributes[m]; + out << "{\"objectReference\":\"" << json(a.object_reference) + << "\",\"attributePath\":\"" << json(a.attribute_path) + << "\",\"functionalConstraint\":\"" << json(a.functional_constraint) + << "\",\"mmsReference\":\"" << json(a.mms_reference) + << "\",\"sclBType\":\"" << json(a.scl_basic_type) + << "\",\"mmsType\":\"" << json(a.mms_type) + << "\",\"mmsTypeSignature\":\"" << json(a.mms_type_signature) << "\"}"; + } + out << "]}"; + } + out << "]}"; + } + out << "]}"; + } + out << "],\"dataSets\":["; + for (std::size_t i = 0; i < data_sets.size(); ++i) { + if (i) out << ','; + out << "{\"reference\":\"" << json(data_sets[i].reference) << "\",\"memberCount\":" + << data_sets[i].members.size() << ",\"members\":["; + for (std::size_t j = 0; j < data_sets[i].members.size(); ++j) { + if (j) out << ','; + out << "{\"index\":" << j << ",\"reference\":\"" + << json(data_sets[i].members[j].reference) << "\",\"functionalConstraint\":\"" + << json(data_sets[i].members[j].functional_constraint) << "\"}"; + } + out << "]}"; + } + out << "],\"reportControls\":["; + for (std::size_t i = 0; i < report_controls.size(); ++i) { + if (i) out << ','; + const auto& r = report_controls[i]; + out << "{\"reference\":\"" << json(r.reference) << "\",\"buffered\":" + << (r.buffered ? "true" : "false") << ",\"dataSetReference\":\"" + << json(r.data_set_reference) << "\",\"reportId\":\"" << json(r.report_id) << "\"}"; + } + out << "],\"warnings\":["; + for (std::size_t i = 0; i < warnings.size(); ++i) { + if (i) out << ','; + out << "{\"code\":\"" << json(warnings[i].code) << "\",\"reference\":\"" + << json(warnings[i].reference) << "\",\"message\":\"" << json(warnings[i].message) << "\"}"; + } + out << "]}"; + return out.str(); +} + +inline std::size_t MmsLiveModelParityResult::blocking_finding_count() const noexcept { + return static_cast(std::count_if(findings.begin(), findings.end(), [](const auto& item) { + return item.severity == MmsLiveModelFindingSeverity::error; + })); +} +inline MmsLiveModelParityResult MmsLiveModelParityComparer::compare( + const MmsLiveModelDocument& expected, + const MmsLiveModelDocument& observed) { + using namespace live_model_detail; + MmsLiveModelParityResult result; + result.expected_ied_name = expected.identity.ied_name; + result.observed_ied_name = observed.identity.ied_name; + const auto expected_attributes = attributes(expected); + const auto observed_attributes = attributes(observed); + result.expected_attribute_count = expected_attributes.size(); + result.observed_attribute_count = observed_attributes.size(); From a9b62b44f7d967fb8cee2bdeba82707b7365244d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:40:22 +0700 Subject: [PATCH 012/179] Add Phase 4C.1 live model helpers 6 --- include/ariec61850/mms/live_model_chunk06.ipp | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 include/ariec61850/mms/live_model_chunk06.ipp diff --git a/include/ariec61850/mms/live_model_chunk06.ipp b/include/ariec61850/mms/live_model_chunk06.ipp new file mode 100644 index 000000000..ab25986cd --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk06.ipp @@ -0,0 +1,51 @@ + if (!same(result.expected_ied_name, result.observed_ied_name)) { + result.findings.push_back({MmsLiveModelFindingSeverity::error, + MmsLiveModelFindingKind::identity_mismatch, {}, result.expected_ied_name, + result.observed_ied_name, "IED identity differs."}); + } + for (const auto& [reference, expected_attribute] : expected_attributes) { + const auto found = observed_attributes.find(reference); + if (found == observed_attributes.end()) { + result.findings.push_back({MmsLiveModelFindingSeverity::error, + MmsLiveModelFindingKind::missing_attribute, expected_attribute->object_reference, + "present", "missing", "Expected attribute is missing."}); + continue; + } + ++result.matched_attribute_count; + const auto* observed_attribute = found->second; + if (!same(expected_attribute->functional_constraint, observed_attribute->functional_constraint)) + result.findings.push_back({MmsLiveModelFindingSeverity::error, + MmsLiveModelFindingKind::functional_constraint_mismatch, + expected_attribute->object_reference, expected_attribute->functional_constraint, + observed_attribute->functional_constraint, "Functional constraint differs."}); + if (!expected_attribute->mms_type_signature.empty() && + !same(expected_attribute->mms_type_signature, observed_attribute->mms_type_signature)) + result.findings.push_back({MmsLiveModelFindingSeverity::error, + MmsLiveModelFindingKind::type_mismatch, expected_attribute->object_reference, + expected_attribute->mms_type_signature, observed_attribute->mms_type_signature, + "MMS type signature differs."}); + } + for (const auto& [reference, observed_attribute] : observed_attributes) { + if (!expected_attributes.contains(reference)) + result.findings.push_back({MmsLiveModelFindingSeverity::warning, + MmsLiveModelFindingKind::unexpected_attribute, observed_attribute->object_reference, + "missing", "present", "Observed model contains an extra attribute."}); + } + const auto compare_refs = [&](const auto& expected_items, const auto& observed_items, + MmsLiveModelFindingKind missing, MmsLiveModelFindingKind extra) { + std::set left, right; + for (const auto& item : expected_items) left.insert(key(item.reference)); + for (const auto& item : observed_items) right.insert(key(item.reference)); + for (const auto& item : left) if (!right.contains(item)) result.findings.push_back({ + MmsLiveModelFindingSeverity::error, missing, item, "present", "missing", "Expected item is missing."}); + for (const auto& item : right) if (!left.contains(item)) result.findings.push_back({ + MmsLiveModelFindingSeverity::warning, extra, item, "missing", "present", "Observed item is extra."}); + }; + compare_refs(expected.data_sets, observed.data_sets, + MmsLiveModelFindingKind::missing_data_set, MmsLiveModelFindingKind::unexpected_data_set); + compare_refs(expected.report_controls, observed.report_controls, + MmsLiveModelFindingKind::missing_report_control, MmsLiveModelFindingKind::unexpected_report_control); + return result; +} + +} // namespace ar::iec61850::mms From bf79cc724f9e6328b7fcfe8f9e3e212816b0683f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:42:04 +0700 Subject: [PATCH 013/179] Add live model JSON and fingerprint output --- tools/live_discover.cpp | 110 +++++++++++++++++++++------------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp index e5ed3fde0..6aba8c843 100644 --- a/tools/live_discover.cpp +++ b/tools/live_discover.cpp @@ -1,10 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/live_model.hpp" #include #include -#include #include #include #include @@ -29,11 +29,8 @@ using namespace ar::iec61850; case '\r': output << "\\r"; break; case '\t': output << "\\t"; break; default: - if (static_cast(character) < 0x20U) { - output << "?"; - } else { - output << character; - } + if (static_cast(character) < 0x20U) output << '?'; + else output << character; break; } } @@ -66,57 +63,61 @@ void print_usage() { std::cout << "Usage: ariec61850_live_discover [port] [options]\n" << "Options:\n" - << " --json Emit a compact JSON summary.\n" + << " --json Emit compact discovery summary JSON.\n" + << " --model-json Emit C#-compatible live-ied-model-v1 JSON.\n" + << " --manifest Emit deterministic parity manifest.\n" + << " --ied-name NAME Explicit IED identity override.\n" << " --no-types Skip GetVariableAccessAttributes probes.\n" << " --no-datasets Skip DataSet directory reads.\n" << " --no-rcb Skip read-only RCB attribute probes.\n" - << " --max-types N Bound variable type probes.\n" + << " --max-types N Bound all type probes.\n" << " --max-datasets N Bound DataSet directory reads.\n" << " --max-rcb N Bound RCB read probes.\n" << " --timeout-ms N Connect/request timeout in milliseconds.\n"; } -void print_human(const mms::MmsLiveDiscoveryResult& result) { - std::cout << result.summary() << '\n'; - for (const auto& [domain, variables] : result.names.domain_variables) { - const auto lists = result.names.domain_variable_lists.find(domain); - const std::size_t list_count = lists == result.names.domain_variable_lists.end() - ? 0U - : lists->second.size(); - std::cout << " " << domain << ": variables=" << variables.size() - << ", DataSet names=" << list_count << '\n'; +void print_human( + const mms::MmsLiveDiscoveryResult& result, + const mms::MmsLiveModelDocument& model) { + std::cout << result.summary() << '\n' + << model.summary << '\n' + << "Model fingerprint: " << model.canonical_fingerprint_hex() << '\n'; + for (const auto& logical_device : model.logical_devices) { + std::cout << " LD " << logical_device.mms_domain + << " (alias=" << logical_device.instance << ")\n"; + for (const auto& logical_node : logical_device.logical_nodes) { + std::cout << " LN " << logical_node.name + << " class=" << logical_node.logical_node_class + << " DO=" << logical_node.data_objects.size() << '\n'; + } } - if (!result.diagnostics.empty()) { - std::cout << "Diagnostics:\n"; - for (const auto& diagnostic : result.diagnostics) { - std::cout << " - " << diagnostic << '\n'; + if (!model.warnings.empty()) { + std::cout << "Warnings:\n"; + for (const auto& warning : model.warnings) { + std::cout << " - [" << warning.code << "] " << warning.message << '\n'; } } } -void print_json(const mms::MmsLiveDiscoveryResult& result) { +void print_summary_json( + const mms::MmsLiveDiscoveryResult& result, + const mms::MmsLiveModelDocument& model) { std::cout << '{' << "\"endpoint\":\"" << json_escape(result.endpoint.host) << ':' << result.endpoint.port << "\"," + << "\"iedName\":\"" << json_escape(model.identity.ied_name) << "\"," << "\"domains\":" << result.domain_count() << ',' << "\"variables\":" << result.variable_count() << ',' - << "\"dataSets\":" << result.report_inventory.data_sets.size() << ',' - << "\"reportControls\":" - << result.report_inventory.report_controls.size() << ',' - << "\"typeEvidence\":" << result.variable_types.size() << ',' - << "\"dataSetDirectories\":" - << result.data_set_directories.size() << ',' - << "\"reportControlReads\":" << result.report_controls.size() << ',' + << "\"logicalDevices\":" << model.coverage.logical_device_count << ',' + << "\"logicalNodes\":" << model.coverage.logical_node_count << ',' + << "\"dataObjects\":" << model.coverage.data_object_count << ',' + << "\"dataAttributes\":" << model.coverage.data_attribute_count << ',' + << "\"dataSets\":" << model.coverage.data_set_count << ',' + << "\"reportControls\":" << model.coverage.report_control_count << ',' + << "\"exactMmsTypes\":" << model.coverage.exact_mms_type_count << ',' + << "\"fingerprint\":\"" << model.canonical_fingerprint_hex() << "\"," << "\"partial\":" << (result.partial() ? "true" : "false") << ',' - << "\"summary\":\"" << json_escape(result.summary()) << "\"," - << "\"diagnostics\":["; - for (std::size_t index = 0U; index < result.diagnostics.size(); ++index) { - if (index != 0U) { - std::cout << ','; - } - std::cout << '"' << json_escape(result.diagnostics[index]) << '"'; - } - std::cout << "]}\n"; + << "\"summary\":\"" << json_escape(model.summary) << "\"}\n"; } } // namespace @@ -138,28 +139,33 @@ int main(int argc, char** argv) { ++argument; } - bool json = false; + enum class OutputMode { human, summary_json, model_json, manifest }; + OutputMode output_mode = OutputMode::human; std::chrono::milliseconds timeout{5'000}; mms::MmsLiveDiscoveryOptions discovery_options; + mms::MmsLiveModelBuildOptions model_options; while (argument < argc) { const std::string option = argv[argument++]; - if (option == "--json") { - json = true; - } else if (option == "--no-types") { - discovery_options.probe_variable_types = false; - } else if (option == "--no-datasets") { + if (option == "--json") output_mode = OutputMode::summary_json; + else if (option == "--model-json") output_mode = OutputMode::model_json; + else if (option == "--manifest") output_mode = OutputMode::manifest; + else if (option == "--no-types") discovery_options.probe_variable_types = false; + else if (option == "--no-datasets") { discovery_options.read_data_set_directories = false; } else if (option == "--no-rcb") { discovery_options.probe_report_controls = false; - } else if (option == "--max-types" || - option == "--max-datasets" || - option == "--max-rcb" || + } else if (option == "--ied-name" || option == "--max-types" || + option == "--max-datasets" || option == "--max-rcb" || option == "--timeout-ms") { if (argument >= argc) { throw std::invalid_argument(option + " requires a value."); } const std::string value = argv[argument++]; + if (option == "--ied-name") { + model_options.explicit_ied_name = value; + continue; + } const auto parsed = parse_limit(option, value); if (option == "--max-types") { discovery_options.maximum_variable_type_probes = parsed; @@ -190,11 +196,13 @@ int main(int argc, char** argv) { session.connect(endpoint); const auto result = session.discover(discovery_options); session.disconnect(); + const auto model = mms::MmsLiveModelBuilder::build(result, model_options); - if (json) { - print_json(result); - } else { - print_human(result); + switch (output_mode) { + case OutputMode::human: print_human(result, model); break; + case OutputMode::summary_json: print_summary_json(result, model); break; + case OutputMode::model_json: std::cout << model.to_json() << '\n'; break; + case OutputMode::manifest: std::cout << model.canonical_manifest(); break; } return result.partial() ? 1 : 0; } catch (const std::exception& exception) { From d11820c6bddac485980c149b5d92555c21b8644a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:43:01 +0700 Subject: [PATCH 014/179] Add live-model parity and read-only allowlist regression --- tests/test_live_discovery.cpp | 50 +++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 182ee4523..888d9fa07 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -2,14 +2,13 @@ #include "ariec61850/acse/association.hpp" #include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/live_model.hpp" #include "ariec61850/mms/services.hpp" #include "ariec61850/osi/cotp.hpp" #include "ariec61850/osi/tpkt.hpp" -#include #include #include -#include #include #include #include @@ -115,7 +114,7 @@ void queue_handshake(ScriptedTransport& transport) { void queue_discovery_responses(ScriptedTransport& transport) { transport.push_receive(wrap_application( mms::MmsServiceCodec::encode_get_name_list_response_p_data( - {1U, {"LD0"}, false}))); + {1U, {"OLS501LD0"}, false}))); transport.push_receive(wrap_application( mms::MmsServiceCodec::encode_get_name_list_response_p_data( @@ -143,9 +142,10 @@ void queue_discovery_responses(ScriptedTransport& transport) { directory.invoke_id = 5U; directory.deletable = false; directory.members.push_back({ - mms::MmsObjectName::domain_specific("LD0", "LLN0$ST$Mod$stVal"), - "LD0/LLN0$ST$Mod$stVal", - "LD0/LLN0.Mod.stVal", + mms::MmsObjectName::domain_specific( + "OLS501LD0", "LLN0$ST$Mod$stVal"), + "OLS501LD0/LLN0$ST$Mod$stVal", + "OLS501LD0/LLN0.Mod.stVal", "ST", "LLN0", "Mod.stVal", @@ -156,7 +156,8 @@ void queue_discovery_responses(ScriptedTransport& transport) { mms::MmsReadResponse rcb_response; rcb_response.invoke_id = 6U; rcb_response.results.push_back({ - mms::MmsDataValue::visible_string("LD0/LLN0.DataSetA"), std::nullopt}); + mms::MmsDataValue::visible_string("OLS501LD0/LLN0.DataSetA"), + std::nullopt}); rcb_response.results.push_back({ mms::MmsDataValue::visible_string("RPT-1"), std::nullopt}); rcb_response.results.push_back({ @@ -167,7 +168,7 @@ void queue_discovery_responses(ScriptedTransport& transport) { mms::MmsServiceCodec::encode_read_response_p_data(rcb_response))); } -void live_discovery_builds_bounded_read_only_snapshot() { +void live_discovery_builds_csharp_compatible_read_only_model() { ScriptedTransport transport; queue_handshake(transport); queue_discovery_responses(transport); @@ -183,24 +184,33 @@ void live_discovery_builds_bounded_read_only_snapshot() { mms::MmsLiveDiscoveryClient discovery{association}; const auto result = discovery.discover(options); - CHECK(result.endpoint.host == "127.0.0.1"); CHECK(result.domain_count() == 1U); CHECK(result.variable_count() == 5U); - CHECK(result.variable_list_count() == 1U); - CHECK(result.report_inventory.data_sets.size() == 1U); - CHECK(result.report_inventory.report_controls.size() == 1U); CHECK(result.variable_types.size() == 1U); CHECK(result.variable_types.front().success()); - CHECK(result.variable_types.front().attributes->type.kind == - mms::MmsTypeKind::boolean); + CHECK(result.variable_types.front().variable.item == "LLN0$ST$Mod$stVal"); CHECK(result.data_set_directories.size() == 1U); - CHECK(result.data_set_directories.front().success()); - CHECK(result.data_set_directories.front().directory->members.size() == 1U); CHECK(result.report_controls.size() == 1U); - CHECK(result.report_controls.front().success()); - CHECK(result.report_controls.front().state->report_id == "RPT-1"); CHECK(!result.partial()); + mms::MmsLiveModelBuildOptions model_options; + model_options.explicit_ied_name = "OLS501"; + const auto model = mms::MmsLiveModelBuilder::build(result, model_options); + CHECK(model.schema_version == "live-ied-model-v1"); + CHECK(model.identity.ied_name == "OLS501"); + CHECK(model.coverage.logical_device_count == 1U); + CHECK(model.coverage.logical_node_count == 1U); + CHECK(model.coverage.data_attribute_count >= 1U); + CHECK(model.coverage.data_set_count == 1U); + CHECK(model.coverage.report_control_count == 1U); + CHECK(model.canonical_fingerprint() != 0U); + CHECK(model.to_json().find("\"schemaVersion\":\"live-ied-model-v1\"") != + std::string::npos); + + const auto same = mms::MmsLiveModelParityComparer::compare(model, model); + CHECK(same.compatible()); + CHECK(same.blocking_finding_count() == 0U); + std::size_t read_only_requests = 0U; for (std::size_t index = 2U; index < transport.sent().size(); ++index) { const auto tpkt = osi::TpktFrameCodec::decode(transport.sent()[index]); @@ -250,9 +260,9 @@ void pagination_without_forward_progress_is_rejected() { int main() { try { - live_discovery_builds_bounded_read_only_snapshot(); + live_discovery_builds_csharp_compatible_read_only_model(); pagination_without_forward_progress_is_rejected(); - std::cout << "Live MMS discovery tests passed.\n"; + std::cout << "Live MMS discovery and model parity tests passed.\n"; return 0; } catch (const std::exception& exception) { std::cerr << exception.what() << '\n'; From 3772d4541c52e0cb6cfe06594557fc4b73bcf15a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:43:30 +0700 Subject: [PATCH 015/179] Add C# to C++ live-model parity checker --- scripts/compare-live-model-parity.py | 259 +++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 scripts/compare-live-model-parity.py diff --git a/scripts/compare-live-model-parity.py b/scripts/compare-live-model-parity.py new file mode 100644 index 000000000..7178b8dcb --- /dev/null +++ b/scripts/compare-live-model-parity.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Compare C# and C++ live-ied-model-v1 JSON without third-party packages.""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys +from dataclasses import dataclass, asdict +from typing import Any + + +@dataclass(frozen=True) +class Finding: + severity: str + kind: str + reference: str + expected: str + observed: str + message: str + + +def text(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def key(value: Any) -> str: + return text(value).replace("$", ".").casefold() + + +def load(path: pathlib.Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8-sig") as stream: + data = json.load(stream) + if text(data.get("schemaVersion")) != "live-ied-model-v1": + raise ValueError(f"{path}: expected schemaVersion live-ied-model-v1") + return data + + +def flatten_attributes(document: dict[str, Any]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for logical_device in document.get("logicalDevices", []): + for logical_node in logical_device.get("logicalNodes", []): + for data_object in logical_node.get("dataObjects", []): + for attribute in data_object.get("attributes", []): + reference = text(attribute.get("objectReference")) + if reference: + result.setdefault(key(reference), attribute) + return result + + +def comparable_type(attribute: dict[str, Any]) -> str: + return ( + text(attribute.get("mmsTypeSignature")) + or text(attribute.get("sclBType")) + or text(attribute.get("mmsType")) + ) + + +def index_by_reference(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for item in items: + reference = text(item.get("reference")) + if reference: + result.setdefault(key(reference), item) + return result + + +def compare(expected: dict[str, Any], observed: dict[str, Any]) -> dict[str, Any]: + findings: list[Finding] = [] + expected_name = text(expected.get("iedName")) + observed_name = text(observed.get("iedName")) + if ( + expected_name + and observed_name + and expected_name.casefold() not in {observed_name.casefold(), "template"} + ): + findings.append( + Finding( + "Error", + "IdentityMismatch", + text(observed.get("accessPointName")), + expected_name, + observed_name, + "Connected live IED identity differs from the C# oracle.", + ) + ) + + expected_attributes = flatten_attributes(expected) + observed_attributes = flatten_attributes(observed) + matched = 0 + for reference_key, expected_attribute in sorted(expected_attributes.items()): + observed_attribute = observed_attributes.get(reference_key) + reference = text(expected_attribute.get("objectReference")) + if observed_attribute is None: + findings.append( + Finding( + "Error", + "MissingLiveAttribute", + reference, + comparable_type(expected_attribute), + "", + "C# oracle attribute is missing from the C++ live model.", + ) + ) + continue + matched += 1 + expected_fc = text(expected_attribute.get("functionalConstraint")) + observed_fc = text(observed_attribute.get("functionalConstraint")) + if expected_fc and observed_fc and expected_fc.casefold() != observed_fc.casefold(): + findings.append( + Finding( + "Error", + "FunctionalConstraintMismatch", + reference, + expected_fc, + observed_fc, + "Functional constraint differs between C# and C++.", + ) + ) + expected_type = comparable_type(expected_attribute) + observed_type = comparable_type(observed_attribute) + if expected_type and observed_type and expected_type.casefold() != observed_type.casefold(): + findings.append( + Finding( + "Error", + "TypeMismatch", + reference, + expected_type, + observed_type, + "MMS/SCL type differs between C# and C++.", + ) + ) + + for reference_key, observed_attribute in sorted(observed_attributes.items()): + if reference_key not in expected_attributes: + findings.append( + Finding( + "Info", + "UnexpectedLiveAttribute", + text(observed_attribute.get("objectReference")), + "", + comparable_type(observed_attribute), + "C++ observed an attribute not present in the C# oracle.", + ) + ) + + expected_datasets = index_by_reference(expected.get("dataSets", [])) + observed_datasets = index_by_reference(observed.get("dataSets", [])) + for dataset_key, expected_dataset in sorted(expected_datasets.items()): + observed_dataset = observed_datasets.get(dataset_key) + reference = text(expected_dataset.get("reference")) + if observed_dataset is None: + findings.append( + Finding( + "Error", + "MissingLiveDataSet", + reference, + text(expected_dataset.get("memberCount")), + "", + "C# oracle DataSet is missing from the C++ live model.", + ) + ) + continue + expected_count = int(expected_dataset.get("memberCount", 0)) + observed_count = int(observed_dataset.get("memberCount", 0)) + if expected_count != observed_count: + findings.append( + Finding( + "Error", + "DataSetMemberCountMismatch", + reference, + str(expected_count), + str(observed_count), + "DataSet member count differs between C# and C++.", + ) + ) + for dataset_key, observed_dataset in sorted(observed_datasets.items()): + if dataset_key not in expected_datasets: + findings.append( + Finding( + "Info", + "UnexpectedLiveDataSet", + text(observed_dataset.get("reference")), + "", + text(observed_dataset.get("memberCount")), + "C++ observed a DataSet not present in the C# oracle.", + ) + ) + + expected_rcbs = index_by_reference(expected.get("reportControls", [])) + observed_rcbs = index_by_reference(observed.get("reportControls", [])) + for rcb_key, expected_rcb in sorted(expected_rcbs.items()): + observed_rcb = observed_rcbs.get(rcb_key) + reference = text(expected_rcb.get("reference")) + expected_mode = "BRCB" if bool(expected_rcb.get("buffered")) else "URCB" + if observed_rcb is None: + findings.append( + Finding( + "Error", + "MissingLiveReportControl", + reference, + expected_mode, + "", + "C# oracle report control is missing from the C++ live model.", + ) + ) + continue + observed_mode = "BRCB" if bool(observed_rcb.get("buffered")) else "URCB" + if expected_mode != observed_mode: + findings.append( + Finding( + "Error", + "ReportControlModeMismatch", + reference, + expected_mode, + observed_mode, + "Report-control mode differs between C# and C++.", + ) + ) + + findings.sort(key=lambda item: ({"Error": 0, "Warning": 1, "Info": 2}[item.severity], item.kind, item.reference.casefold())) + blocking = sum(item.severity == "Error" for item in findings) + return { + "schemaVersion": "ariec61850-live-model-parity-v1", + "expectedIedName": expected_name, + "observedIedName": observed_name, + "expectedAttributeCount": len(expected_attributes), + "observedAttributeCount": len(observed_attributes), + "matchedAttributeCount": matched, + "blockingFindingCount": blocking, + "compatible": blocking == 0, + "findings": [asdict(item) for item in findings], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("expected", type=pathlib.Path, help="C# live-ied-model-v1 JSON") + parser.add_argument("observed", type=pathlib.Path, help="C++ live-ied-model-v1 JSON") + parser.add_argument("--output", type=pathlib.Path) + arguments = parser.parse_args() + + try: + report = compare(load(arguments.expected), load(arguments.observed)) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"Parity comparison failed: {error}", file=sys.stderr) + return 2 + + rendered = json.dumps(report, indent=2, ensure_ascii=False) + "\n" + if arguments.output: + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + return 0 if report["compatible"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 841784f8bf0dfff906596e599b6eb97a0e7eba95 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:43:54 +0700 Subject: [PATCH 016/179] Add physical read-only interoperability evidence runner --- scripts/run-live-readonly-interop.py | 173 +++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 scripts/run-live-readonly-interop.py diff --git a/scripts/run-live-readonly-interop.py b/scripts/run-live-readonly-interop.py new file mode 100644 index 000000000..ae8f773ff --- /dev/null +++ b/scripts/run-live-readonly-interop.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Run bounded, read-only IEC 61850 MMS discovery cycles and record evidence.""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import sys +import time +from typing import Any + + +def find_binary(root: pathlib.Path, override: str | None) -> pathlib.Path: + if override: + path = pathlib.Path(override) + if not path.is_file(): + raise FileNotFoundError(f"Discovery binary not found: {path}") + return path + names = ["ariec61850_live_discover.exe", "ariec61850_live_discover"] + candidates = [] + for name in names: + candidates.extend([ + root / "build" / "Release" / name, + root / "build" / name, + ]) + for candidate in candidates: + if candidate.is_file(): + return candidate + raise FileNotFoundError( + "Build ariec61850_live_discover first; searched build/Release and build." + ) + + +def run_cycle(binary: pathlib.Path, args: argparse.Namespace) -> tuple[dict[str, Any], int, str]: + command = [ + str(binary), args.host, str(args.port), "--model-json", + "--timeout-ms", str(args.timeout_ms), + ] + if args.ied_name: + command += ["--ied-name", args.ied_name] + if args.no_types: + command.append("--no-types") + if args.no_datasets: + command.append("--no-datasets") + if args.no_rcb: + command.append("--no-rcb") + started = time.monotonic() + process = subprocess.run(command, capture_output=True, text=True, check=False) + elapsed_ms = int((time.monotonic() - started) * 1000) + if process.returncode not in (0, 1): + message = process.stderr.strip() or process.stdout.strip() or "discovery failed" + raise RuntimeError(message) + try: + document = json.loads(process.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Discovery output is not valid JSON: {exc}") from exc + if document.get("schemaVersion") != "live-ied-model-v1": + raise RuntimeError("Discovery did not emit live-ied-model-v1 JSON.") + return document, elapsed_ms, process.stderr.strip() + + +def parity(root: pathlib.Path, expected: pathlib.Path, observed: pathlib.Path, output: pathlib.Path) -> int: + script = root / "scripts" / "compare-live-model-parity.py" + process = subprocess.run( + [sys.executable, str(script), str(expected), str(observed), "--output", str(output)], + check=False, + ) + return process.returncode + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("host") + parser.add_argument("port", nargs="?", type=int, default=102) + parser.add_argument("--output", required=True) + parser.add_argument("--cycles", type=int, default=3) + parser.add_argument("--interval-ms", type=int, default=1000) + parser.add_argument("--timeout-ms", type=int, default=5000) + parser.add_argument("--ied-name", default="") + parser.add_argument("--binary") + parser.add_argument("--expected-csharp-model") + parser.add_argument("--allow-warnings", action="store_true") + parser.add_argument("--no-types", action="store_true") + parser.add_argument("--no-datasets", action="store_true") + parser.add_argument("--no-rcb", action="store_true") + args = parser.parse_args() + + if not 1 <= args.port <= 65535: + parser.error("port must be 1..65535") + if not 1 <= args.cycles <= 10000: + parser.error("cycles must be 1..10000") + if args.interval_ms < 0 or args.timeout_ms <= 0: + parser.error("interval must be non-negative and timeout positive") + + root = pathlib.Path(__file__).resolve().parents[1] + output_dir = pathlib.Path(args.output).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + binary = find_binary(root, args.binary) + + cycles: list[dict[str, Any]] = [] + fingerprints: list[str] = [] + parity_codes: list[int] = [] + for index in range(1, args.cycles + 1): + evidence: dict[str, Any] = {"index": index, "success": False} + try: + model, elapsed_ms, stderr = run_cycle(binary, args) + model_path = output_dir / f"cycle-{index:03d}-live-model.json" + model_path.write_text(json.dumps(model, indent=2) + "\n", encoding="utf-8") + coverage = model.get("coverage", {}) + warnings = model.get("warnings", []) + fingerprint = str(model.get("fingerprint", "")) + fingerprints.append(fingerprint) + evidence.update({ + "success": bool(coverage.get("logicalDeviceCount", 0)) + and bool(coverage.get("dataAttributeCount", 0)), + "elapsedMs": elapsed_ms, + "fingerprint": fingerprint, + "warningCount": len(warnings), + "coverage": coverage, + "stderr": stderr, + "modelFile": model_path.name, + }) + if args.expected_csharp_model: + parity_path = output_dir / f"cycle-{index:03d}-csharp-cpp-parity.json" + code = parity( + root, + pathlib.Path(args.expected_csharp_model).resolve(), + model_path, + parity_path, + ) + parity_codes.append(code) + evidence["parityExitCode"] = code + evidence["parityFile"] = parity_path.name + except Exception as exc: + evidence["error"] = str(exc) + cycles.append(evidence) + print( + f"Cycle {index}/{args.cycles}: success={evidence['success']} " + f"fingerprint={evidence.get('fingerprint', '')} " + f"warnings={evidence.get('warningCount', 0)}" + ) + if index != args.cycles and args.interval_ms: + time.sleep(args.interval_ms / 1000.0) + + stable = bool(fingerprints) and len(set(fingerprints)) == 1 and len(fingerprints) == args.cycles + all_success = all(bool(cycle.get("success")) for cycle in cycles) + warnings_ok = args.allow_warnings or all(cycle.get("warningCount", 0) == 0 for cycle in cycles) + parity_ok = not args.expected_csharp_model or ( + len(parity_codes) == args.cycles and all(code == 0 for code in parity_codes) + ) + accepted = all_success and stable and warnings_ok and parity_ok + summary = { + "schemaVersion": "ariec61850-live-interop-v1", + "endpoint": f"{args.host}:{args.port}", + "binary": os.fspath(binary), + "cycleCount": args.cycles, + "stableFingerprint": stable, + "warningsAccepted": warnings_ok, + "parityAccepted": parity_ok, + "accepted": accepted, + "cycles": cycles, + } + (output_dir / "interop-summary.json").write_text( + json.dumps(summary, indent=2) + "\n", encoding="utf-8" + ) + print(f"Read-only interoperability acceptance: {'PASS' if accepted else 'FAIL'}") + return 0 if accepted else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From e5efebc492e34961cd883483b8e495e3d3ef9a49 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:44:09 +0700 Subject: [PATCH 017/179] Add Windows physical interoperability runner --- scripts/run-live-readonly-interop.ps1 | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 scripts/run-live-readonly-interop.ps1 diff --git a/scripts/run-live-readonly-interop.ps1 b/scripts/run-live-readonly-interop.ps1 new file mode 100644 index 000000000..45dccdf40 --- /dev/null +++ b/scripts/run-live-readonly-interop.ps1 @@ -0,0 +1,25 @@ +param( + [Parameter(Mandatory = $true)][string]$HostName, + [int]$Port = 102, + [int]$Cycles = 3, + [int]$IntervalMs = 1000, + [int]$TimeoutMs = 5000, + [string]$IedName = "", + [string]$OutputDirectory = ".\interop-evidence", + [string]$ExpectedCSharpModel = "", + [switch]$AllowWarnings +) +$ErrorActionPreference = "Stop" +$arguments = @( + "$PSScriptRoot\run-live-readonly-interop.py", + $HostName, $Port, + "--output", $OutputDirectory, + "--cycles", $Cycles, + "--interval-ms", $IntervalMs, + "--timeout-ms", $TimeoutMs +) +if ($IedName) { $arguments += @("--ied-name", $IedName) } +if ($ExpectedCSharpModel) { $arguments += @("--expected-csharp-model", $ExpectedCSharpModel) } +if ($AllowWarnings) { $arguments += "--allow-warnings" } +python @arguments +exit $LASTEXITCODE From 74c79b95799c688a983e70c8a35c2a9262bdebe4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:44:17 +0700 Subject: [PATCH 018/179] Add Linux physical interoperability runner --- scripts/run-live-readonly-interop.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 scripts/run-live-readonly-interop.sh diff --git a/scripts/run-live-readonly-interop.sh b/scripts/run-live-readonly-interop.sh new file mode 100644 index 000000000..2e02d0066 --- /dev/null +++ b/scripts/run-live-readonly-interop.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ $# -lt 1 ]]; then + echo "Usage: $0 HOST [PORT] [OUTPUT_DIR] [CYCLES] [EXPECTED_CSHARP_MODEL]" >&2 + exit 2 +fi +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ARGS=("$ROOT/scripts/run-live-readonly-interop.py" "$1" "${2:-102}" --output "${3:-./interop-evidence}" --cycles "${4:-3}") +if [[ -n "${5:-}" ]]; then ARGS+=(--expected-csharp-model "$5"); fi +exec python3 "${ARGS[@]}" From add78b825d683c5e9b26d63ae04c0531ecd8570e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:44:43 +0700 Subject: [PATCH 019/179] Document Phase 4C.1 parity and physical acceptance --- PHASE_4C1_LIVE_MODEL_INTEROP.md | 127 ++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 PHASE_4C1_LIVE_MODEL_INTEROP.md diff --git a/PHASE_4C1_LIVE_MODEL_INTEROP.md b/PHASE_4C1_LIVE_MODEL_INTEROP.md new file mode 100644 index 000000000..40d3531d2 --- /dev/null +++ b/PHASE_4C1_LIVE_MODEL_INTEROP.md @@ -0,0 +1,127 @@ +# Phase 4C.1 — Live-Model Parity and Physical Read-Only Interoperability + +## Scope + +Phase 4C.1 converts the bounded Phase 4C discovery evidence into the same engineering model +shape used by the ARIEC61850 C# behavioral oracle and provides a repeatable, read-only physical +lab acceptance workflow. + +The implementation remains read-only. It may issue only MMS GetNameList, +GetVariableAccessAttributes, GetNamedVariableListAttributes, and Read. It does not construct +Write, RCB enable/reservation, GI, control, dynamic DataSet mutation, or file-service requests. + +## C# live-model parity + +The C++ model uses schema `live-ied-model-v1` and preserves these C# concepts: + +- IED identity and logical-device aliases; +- logical-device, logical-node, data-object, and data-attribute hierarchy; +- MMS and user object references; +- functional constraint; +- MMS type, SCL basic type, and deterministic type signature; +- DataSet members; +- buffered and unbuffered report-control inventory/state; +- model coverage and warnings. + +MMS variables in the form `LN$FC$DO$DA...` are normalized into +`LD/LN.DO.DA...`. The known functional-constraint set follows the C# oracle. Logical-node names +are split into prefix, four-character LN class, and instance. Type evidence from +GetVariableAccessAttributes is resolved into the matching hierarchy. Both direct leaf evidence +and nested logical-node TypeSpecification trees are supported by the model builder; the Phase +4C discovery client remains bounded by `--max-types`. + +DataSet-directory members and report-control evidence supplement incomplete GetNameList results. +IED identity is inferred conservatively from MMS domains unless `--ied-name` is supplied. +Common Data Classes are inferred with confidence and never represented as exact SCL evidence +when only heuristic evidence exists. + +## Deterministic parity evidence + +`ariec61850_live_discover` supports: + +```text +--model-json Full live-ied-model-v1 JSON +--manifest Stable line-oriented parity manifest +--ied-name NAME Explicit IED identity +--max-types N Bound GetVariableAccessAttributes probes +``` + +The model contains a deterministic fingerprint. Reordering equivalent MMS discovery responses +does not change the canonical manifest or fingerprint. + +Cross-language comparison: + +```bash +python scripts/compare-live-model-parity.py \ + csharp/live-ied-model.json \ + cpp/live-ied-model.json \ + --output evidence/csharp-cpp-parity.json +``` + +The comparison reports identity, missing/unexpected attributes, functional-constraint and type +mismatches, DataSet differences, and RCB differences. Exit code 0 means no blocking finding, +1 means a parity mismatch, and 2 means invalid input. + +The C# oracle already exports this schema through `LiveIedModelDiscoveryExporter`; use its +`live-ied-model.json` artifact as the expected document. + +## Physical read-only interoperability runner + +Windows: + +```powershell +.\scripts\run-live-readonly-interop.ps1 \ + -HostName 192.168.1.10 \ + -Port 102 \ + -Cycles 10 \ + -TimeoutMs 10000 \ + -IedName IED_A \ + -OutputDirectory .\evidence\IED_A \ + -ExpectedCSharpModel .\csharp-evidence\live-ied-model.json +``` + +Linux: + +```bash +./scripts/run-live-readonly-interop.sh \ + 192.168.1.10 102 ./evidence/IED_A 10 \ + ./csharp-evidence/live-ied-model.json +``` + +The runner reconnects and performs a fresh discovery for each cycle. It writes: + +- `cycle-NNN-live-model.json`; +- optional `cycle-NNN-csharp-cpp-parity.json`; +- `interop-summary.json`. + +## Automated acceptance rules + +The runner returns success only when: + +1. every requested cycle associates and discovers at least one Logical Device and Data Attribute; +2. every cycle emits `live-ied-model-v1`; +3. every cycle has the same deterministic fingerprint; +4. no model warning is present unless `--allow-warnings` was explicitly selected; and +5. every C# parity comparison passes when an expected C# model is supplied. + +## Required physical evidence before merge acceptance + +Use an isolated engineering network and record at least: + +- one IED or vendor simulator with three successful reconnect/discovery cycles; +- one ten-cycle run for the primary target vendor; +- one model with multi-page GetNameList pagination when available; +- one slow/timeout scenario and a subsequent clean reconnect; +- C# and C++ capture against the same IED configuration; +- stable fingerprints across unchanged runs; +- an explained change in fingerprint after a deliberate read-only model/configuration change. + +For every target record vendor, model, firmware, IP, timestamp, C# commit, C++ commit, cycle +count, timeout, coverage, warning count, fingerprint, parity result, and packet-capture reference. + +## Acceptance boundary + +The source implementation, deterministic tests, parity tooling, and lab runner can be completed +without an IED attached to this development environment. Physical interoperability itself is an +evidence gate: it is accepted only after the runner is executed against a reachable IED or +vendor simulator and the generated evidence is reviewed. From 3362217962fe72cb6bfca95db4b912c9b6cc92b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:46:00 +0700 Subject: [PATCH 020/179] Update discovery profile for Phase 4C.1 --- LIVE_DISCOVERY_PROFILE.md | 77 ++++++++++++++++----------------------- 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/LIVE_DISCOVERY_PROFILE.md b/LIVE_DISCOVERY_PROFILE.md index 58f133f84..78a665179 100644 --- a/LIVE_DISCOVERY_PROFILE.md +++ b/LIVE_DISCOVERY_PROFILE.md @@ -1,62 +1,49 @@ -# Built-in TCP and Live Read-Only Discovery Profile +# Built-in TCP, Live Read-Only Discovery, and Live-Model Parity Profile ## Scope -Phase 4C provides a cross-platform TCP implementation of `MmsByteTransport` and a bounded -live discovery workflow above the Phase 4B association runtime. It makes the C++ stack usable -against an explicitly selected MMS endpoint without requiring an application-specific socket -adapter. +Phase 4C provides a cross-platform TCP implementation of `MmsByteTransport` and bounded live +MMS discovery. Phase 4C.1 maps that evidence into the same `live-ied-model-v1` engineering +shape exported by the C# behavioral oracle and adds repeatable physical-lab evidence tooling. ## TCP transport -`TcpMmsByteTransport`: +`TcpMmsByteTransport` resolves IPv4/IPv6 endpoints, uses non-blocking Winsock/POSIX sockets, +handles partial sends and arbitrary receive chunks, and observes absolute deadlines plus +`std::stop_token` cancellation. It performs no operation until explicitly used by an +application runtime. -- resolves IPv4 and IPv6 endpoints with `getaddrinfo`; -- uses non-blocking Winsock or POSIX sockets; -- awaits connect, send, and receive readiness in bounded slices; -- observes the caller's absolute deadline and `std::stop_token` during every wait; -- enables TCP_NODELAY and keepalive by default; -- handles partial sends and arbitrary receive chunking; -- reports timeout, cancellation, peer close, and socket failures as typed runtime errors; and -- owns no worker thread and performs no automatic reconnect. +## Read-only discovery -The transport is intentionally serialized: one association operation may use it at a time. -`MmsAssociationRuntime` remains responsible for TPKT/COTP framing, association state, invoke -routing, and reconnect policy. - -## Live discovery - -`MmsLiveDiscoveryClient` performs only these confirmed MMS services: +`MmsLiveDiscoveryClient` performs only: 1. GetNameList for VMD domains; -2. GetNameList for domain-specific named variables; -3. GetNameList for domain-specific named-variable lists; -4. optional GetVariableAccessAttributes probes; -5. optional GetNamedVariableListAttributes DataSet directory reads; and -6. optional multi-variable Read requests for discovered RCB attributes. +2. GetNameList for domain variables; +3. GetNameList for named-variable lists; +4. optional GetVariableAccessAttributes; +5. optional GetNamedVariableListAttributes; and +6. optional Read of discovered RCB attributes. -The result contains bounded domain/name evidence, DataSet and BRCB/URCB inventory, variable -TypeSpecification evidence, DataSet members, read-only RCB state, and explicit diagnostics for -optional probes that failed. Pagination must make forward progress and all domain, page, name, -type, DataSet, and RCB counts are caller-bounded. +It does not construct Write, RCB enable/reservation, GI, control, dynamic DataSet mutation, or +file-service requests. Regression tests decode every discovery request and reject service tag 5. -`MmsTcpLiveDiscoverySession` combines the built-in TCP transport, Phase 4B association runtime, -and the discovery client. The `ariec61850_live_discover` tool exposes this workflow with human -or JSON summaries. +## Live-model parity -## Read-only boundary +The C++ mapper normalizes `LN$FC$DO$DA...` into LD/LN/DO/DA hierarchy, preserves FC and MMS +references, resolves direct or nested TypeSpecification evidence, infers CDC with explicit +confidence, includes DataSet and RCB evidence, and produces a deterministic manifest and +fingerprint. `ariec61850_live_discover --model-json` emits `live-ied-model-v1` JSON. -The discovery client never constructs an MMS Write request. It does not enable or reserve an -RCB, issue GI, create/delete a DataSet, operate a control object, or access MMS file services. -Regression tests decode every discovery request and reject service tag 5 (Write). +`scripts/compare-live-model-parity.py` compares C# and C++ documents for identity, attributes, +FC/type, DataSets, and report controls. -`TcpMmsByteTransport` itself is a general byte transport. An application that deliberately -combines it with another runtime surface remains responsible for that surface's authorization -and safety policy. +## Physical interoperability evidence -## Acceptance boundary +`scripts/run-live-readonly-interop.py` reconnects and performs fresh discovery for each cycle, +retains per-cycle JSON, verifies stable fingerprints and warning policy, optionally runs C# +parity, and writes `interop-summary.json`. PowerShell and Bash wrappers are included. -Automated loopback transport tests and scripted discovery tests do not replace physical IED -interoperability. Controlled laboratory evidence is still required for vendor-specific -association parameters, large model pagination, slow IED timing, disconnect behavior, and -long-running network stability. +Automated loopback and scripted tests do not replace physical IED evidence. Merge acceptance +still requires controlled runs against a reachable IED or vendor simulator, Windows/MSVC CI, +large-model pagination when available, timeout/reconnect evidence, and a reviewed C# parity +capture against the same IED configuration. From 34f61b477ca1557543c212dda9340cdd9081aeab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:46:16 +0700 Subject: [PATCH 021/179] Update port status for Phase 4C.1 --- PORT_STATUS.md | 103 ++++++++++++++++++++----------------------------- 1 file changed, 42 insertions(+), 61 deletions(-) diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 7dfb81789..6de67e1c4 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,7 +2,7 @@ ## Current milestone -**Phase 4C — built-in non-blocking TCP transport and bounded live read-only discovery are implemented for review and CI validation.** +**Phase 4C.1 — built-in non-blocking TCP, bounded read-only discovery, C#-compatible live-model parity, and physical interoperability evidence tooling are implemented for review.** ## Delivered modules @@ -12,69 +12,50 @@ | SCL and COMTRADE engineering formats | Merged to `main` | | TPKT/COTP and Session/Presentation/ACSE | Merged to `main` | | MMS Initiate and confirmed services | Merged to `main` | -| DataSet and RCB discovery inventory | Implemented | -| GetNamedVariableListAttributes directory codec | Implemented | -| InformationReport codec and exact frame mapper | Implemented | -| RCB state and availability evidence mapper | Implemented | -| Sequence and segmentation continuity tracker | Implemented | -| Bounded offline report monitor | Implemented | -| MMS reporting mutation smoke/libFuzzer | Complete | -| Transport-injected MMS association/client runtime | Implemented | -| Persistent RCB subscription runtime | Implemented | -| Built-in Windows/Linux TCP socket transport | Implemented in Phase 4C | -| Live read-only name/type/DataSet/RCB discovery | Implemented in Phase 4C | -| Physical IED interoperability acceptance | Pending | - -## Phase 4A behavior - -The offline reporting layer: - -- groups supplied named-variable and named-variable-list discovery evidence into DataSet, BRCB, and URCB inventory; -- encodes and decodes GetNamedVariableListAttributes service tag 12 and normalizes DataSet member references; -- strictly decodes Unconfirmed-PDU InformationReport access results; -- follows IEC 61850 OptFlds ordering for report headers, inclusion bits, values, data references, and reasons; -- preserves per-value DataAccessError results; -- maps supplied RCB Read responses into typed state with conservative availability confidence; -- detects sequence gaps, duplicates, wraps, resets, configuration/DataSet changes, buffer overflow, and segmentation discontinuity; and -- retains only configured numbers of streams and recent frames. - -## Phase 4B behavior - -- TPKT/COTP, Session, Presentation, ACSE, and MMS Initiate are orchestrated as one bounded lifecycle. -- Confirmed service responses are routed by invoke ID while InformationReports remain deliverable. -- Deadline and cancellation failures produce explicit fault/cancel events. -- A selected RCB is re-probed before writes and occupied RCBs are not taken over. -- URCB reservation, optional DatSet/TrgOps/OptFlds writes, RptEna, GI, polling, stop, and cleanup retry are stateful. -- Lost associations preserve cleanup-required evidence instead of pretending the RCB was disabled. - -## Phase 4C behavior - -- `TcpMmsByteTransport` uses non-blocking Winsock/POSIX sockets with bounded readiness polling. -- Connect, send, and receive honor absolute deadlines and `std::stop_token` cancellation. -- IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial sends, arbitrary receive chunking, and peer-close detection are built in. -- Live discovery performs bounded GetNameList pagination for domains, variables, and named-variable lists. -- Optional type, DataSet-directory, and RCB-state probes use only GetVariableAccessAttributes, GetNamedVariableListAttributes, and Read. -- The live discovery result retains partial evidence and diagnostics when optional probes fail. -- `ariec61850_live_discover` provides human and JSON summaries. - -## Validation - -- Phase 4B baseline: GCC, Clang, MSVC, ASan/UBSan, 16/16 CTest, and reporting fuzz evidence passed. -- Phase 4C adds a POSIX loopback TCP round-trip and cross-platform cancellation smoke test. -- Phase 4C scripted discovery covers domain/variable/DataSet inventory, type evidence, DataSet directory, RCB state, and pagination abuse rejection. -- Every scripted discovery request is decoded and checked against the read-only service allowlist; MMS Write is rejected. -- Full GitHub GCC/Clang/MSVC and security workflow evidence is required before Phase 4C is accepted. +| Offline DataSet/RCB/report monitoring | Merged to `main` | +| Transport-injected MMS association/runtime | Merged to `main` | +| Persistent RCB subscription runtime | Merged to `main` | +| Built-in Windows/Linux TCP transport | Implemented in PR #14 | +| Live read-only MMS discovery | Implemented in PR #14 | +| `live-ied-model-v1` hierarchy/parity mapper | Implemented in PR #14 | +| Physical read-only evidence runner | Implemented in PR #14 | +| Physical IED acceptance evidence | Pending lab execution | + +## Phase 4C.1 behavior + +- Non-blocking Winsock/POSIX TCP honors deadlines and cancellation. +- Discovery is bounded and sends only GetNameList, GetVariableAccessAttributes, + GetNamedVariableListAttributes, and Read. +- MMS references are mapped to Logical Device, Logical Node, Data Object, and Data Attribute. +- Functional constraints, MMS/SCL type evidence, DataSets, and report controls are retained. +- Direct leaf and nested TypeSpecification evidence are resolved into the model. +- IED identity and CDC are inferred conservatively with explicit confidence. +- Canonical manifests and fingerprints make unchanged-model runs comparable. +- C# and C++ `live-ied-model-v1` JSON can be compared by the parity script. +- The lab runner performs repeated reconnect/discovery cycles and records acceptance evidence. + +## Validation completed without a physical IED + +- Phase 4B baseline GCC, Clang, MSVC, sanitizer, CTest, and fuzz evidence remains valid. +- Phase 4C TCP loopback, cancellation, strict GCC/Clang warning, and sanitizer checks passed locally. +- Live-model hierarchy, deterministic fingerprint, parity, and multi-translation-unit header + validation passed locally with GCC and Clang; ASan/UBSan smoke passed. +- Python parity and interoperability scripts pass syntax checks and a deterministic simulated + three-cycle acceptance run. +- The discovery regression decodes all emitted requests and rejects MMS Write. ## Remaining acceptance gates -- Controlled physical IED association and discovery evidence across multiple vendors. -- Long-model pagination, slow-response, disconnect, reconnect, and soak testing. -- BRCB EntryID resume, purge/buffer-overflow recovery, and dynamic DataSet lifecycle. -- Direct/SBO control, enhanced-security termination, and MMS file/fault-record transfer. +- Run repository GCC/Clang/MSVC and Security/Evidence workflows through `workflow_dispatch`. +- Run the physical evidence runner against a reachable IED or vendor simulator. +- Record a primary-vendor ten-cycle run, timeout/reconnect evidence, and large-model pagination + where available. +- Export the C# oracle model and accept C#↔C++ parity against the same IED configuration. +- Review warning policy and packet-capture references before merging PR #14. ## Safety boundary -The live discovery API and CLI are read-only and do not construct MMS Write requests. The TCP -transport is general-purpose but performs no operation by itself; applications must explicitly -select and authorize any state-changing runtime. Control services, dynamic DataSet mutation, -and file services remain outside the Phase 4C live discovery surface. +The live discovery surface is read-only and does not construct Write, control, GI, RCB +reservation/enable, dynamic DataSet mutation, or file-service requests. The generic TCP transport +can be used by other explicit runtime surfaces, but those surfaces retain their own authorization +and physical-laboratory gates. From 421ddacd7e9e90dd4eb1dd8925b98808fcee1acd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:46:45 +0700 Subject: [PATCH 022/179] Record Phase 4C.1 migration status --- MIGRATION_CHECKLIST.md | 49 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 87b9f6d5d..e84ef5c58 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -1,8 +1,8 @@ # ARIEC61850 C++ Migration Checklist Status legend: `[x]` implemented and regression-tested, `[~]` implemented but awaiting -external/CI evidence, `[ ]` not started. The C# repository remains the behavioral oracle -until laboratory interoperability is complete. +external/CI or physical-lab evidence, `[ ]` not started. The C# repository remains the +behavioral oracle until laboratory interoperability is complete. ## Phase 0 — portable deterministic foundation @@ -122,8 +122,25 @@ until laboratory interoperability is complete. - [~] Optional read-only RCB attribute inventory/state probes. - [~] Read-only `ariec61850_live_discover` human/JSON CLI. - [~] Discovery service allowlist regression proving no MMS Write request is emitted. -- [ ] Controlled physical IED association/discovery validation. -- [ ] Multi-vendor pagination, timeout, reconnect, and soak evidence. + +### Phase 4C.1 — live-model parity and physical read-only interoperability + +- [x] C#-compatible `live-ied-model-v1` output schema. +- [x] Logical Device / Logical Node / Data Object / Data Attribute hierarchy. +- [x] IEC 61850 reference and functional-constraint normalization. +- [x] Direct and nested TypeSpecification mapping to discovered attributes. +- [x] Conservative IED identity and logical-device alias inference. +- [x] CDC inference with explicit confidence. +- [x] DataSet and BRCB/URCB evidence in the live model. +- [x] Deterministic canonical manifest and fingerprint. +- [x] C#↔C++ parity comparison script. +- [x] Reconnect-cycle physical read-only evidence runner for Windows/Linux. +- [x] Automated stability, warning-policy, coverage, and optional parity gates. +- [~] GCC/Clang strict local validation and sanitizer smoke. +- [ ] GitHub GCC/Clang/MSVC and Security/Evidence workflows accepted. +- [ ] Controlled physical IED or vendor-simulator evidence accepted. +- [ ] Primary-vendor ten-cycle, timeout/reconnect, and pagination evidence accepted. +- [ ] C# and C++ same-IED parity evidence accepted. ### Later Phase 4 work @@ -137,26 +154,8 @@ until laboratory interoperability is complete. - [ ] CLI parity and Windows Npcap abstraction. - [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI. -## Current acceptance evidence - -- [x] GCC and Clang C++20 warnings-as-errors local validation through Phase 2B. -- [x] Clang ASan/UBSan local COMTRADE regression pass. -- [x] C#-derived COMTRADE ASCII and binary fixtures. -- [x] Full stacked-branch GCC/Clang/MSVC and security workflows through Phase 2B. -- [x] Full stacked-branch GCC/Clang/MSVC and security workflows through Phase 3A. -- [x] Full stacked-branch GCC/Clang/MSVC and security workflows through Phase 3B. -- [x] Full stacked-branch GCC/Clang/MSVC and security workflows through Phase 3C. -- [x] PR #6 through PR #10 squash-merged sequentially to `main`. -- [x] Full main-based branch GCC/Clang/MSVC and security workflows through Phase 4A. -- [x] Full stacked-branch GCC/Clang/MSVC and security workflows through Phase 4B. -- [~] Phase 4C loopback TCP and scripted read-only discovery regression added. -- [ ] Phase 4C GitHub GCC/Clang/MSVC and security workflows accepted. -- [ ] Controlled physical-lab process-bus capture evidence. -- [ ] C# and C++ executable-oracle comparison in one CI job. - ## Safety gate -SCL, COMTRADE, PCAP, and the Phase 4C live discovery tool are read-only. The generic TCP -transport performs no operation by itself. Active RCB mutation, control, dynamic DataSet -mutation, file transfer, Npcap transmission, and real-time SV scheduling require separate -explicit APIs, authorization, and physical-laboratory acceptance. +SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 live discovery surface are read-only. Active RCB +mutation, control, dynamic DataSet mutation, file transfer, Npcap transmission, and real-time SV +scheduling require separate explicit APIs, authorization, and physical-laboratory acceptance. From 08071f25e8136bca7a8d7d86e02be21e71f6d128 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:47:04 +0700 Subject: [PATCH 023/179] Document Phase 4C.1 usage --- README.md | 181 +++++++++++++++++++----------------------------------- 1 file changed, 64 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 64b2f94c2..5994ebc63 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,44 @@ # ARIEC61850 C++ Port -Incremental C++20 migration of the ARIEC61850 C# protocol stack. The C# implementation -remains the behavioral oracle until cross-language comparison and isolated-laboratory -interoperability are complete. +Incremental C++20 migration of the ARIEC61850 C# protocol stack. The C# implementation remains +the behavioral oracle until cross-language comparison and isolated-laboratory interoperability +are complete. ## Implemented -### Phase 0 — portable foundation - -- CMake-based C++20 static library. -- BER TLV and primitive codecs. -- MAC, VLAN, Ethernet, IEC 61850 process-bus, and classic PCAP codecs. -- GOOSE retransmission schedule. - -### Phase 1A–1E — deterministic process-bus core - -- IEC 61850 UTC time and recursive MMS Data/AllData codec. -- Complete GOOSE PDU/frame codecs and offline publisher/subscriber supervision. -- Sampled Values ASDU, multi-ASDU PDU, Ethernet frame, payload, quality, counter, and - stream supervision. -- C#-derived synthetic PCAP equivalence evidence. -- Sanitizer, deterministic mutation-smoke, and LLVM libFuzzer coverage. -- Receive-only isolated-laboratory tooling. - -### Phase 2A — SCL foundation - -- Bounded, dependency-free XML/SCL reader. -- IED, DataSet, FCDA, GOOSE, Sampled Values, report-control, and communication mapping. -- SCL edition detection, type-template resolution, DataSet binding, diagnostics, and - SCL/XML fuzzing. - -### Phase 2B — COMTRADE foundation - -- CFG parsing for IEEE C37.111-style 1991/1999/2013 records. -- ASCII, BINARY, BINARY32, and FLOAT32 DAT readers. -- Analog engineering scaling (`a * raw + b`) and packed digital status decoding. -- Timestamp multiplier and multi-rate fallback scheduling. -- Default `Va/Vb/Vc/Vn/Ia/Ib/Ic/In` channel mapping. -- Deterministic SCL Sampled Values to COMTRADE channel binding. -- Read-only `ariec61850_comtrade_inspect` human/JSON CLI. -- C#-derived ASCII and binary fixtures, mutation smoke, sanitizer, and libFuzzer corpus. - -### Phase 3A — MMS transport foundation - -- RFC 1006 TPKT frame encode/decode with strict version, reserved-octet, and length validation. -- Incremental TPKT stream framing for fragmented and coalesced TCP byte streams. -- COTP Connection Request, Connection Confirm, Data, Disconnect Request, and Error TPDU decoding. -- Default C#-compatible CR vector, TPDU-size negotiation, and TSAP selector mirroring. -- COTP Data segmentation by negotiated TPDU size and bounded EOT reassembly. -- Fragment-count, byte-count, and empty non-final fragment abuse guards. -- C#-derived golden vectors, deterministic mutation smoke, sanitizer, and libFuzzer coverage. - -### Phase 3B — MMS association codecs - -- Bounded ISO Session Connect/Accept SPDU and data-transfer profile codecs. -- Presentation CP/CPA context definitions, negotiation results, selectors, and PDV routing. -- ACSE and MMS context negotiation using BER transfer syntax. -- Structured ACSE AARQ/AARE application context, AP-title, AE-qualifier, result, and diagnostic parsing. -- EXTERNAL user-information handling with MMS Initiate retained as an opaque Phase 3C payload. -- Deterministic association-response construction with session and context mirroring. -- Byte-exact C# request/response golden vectors, mutation smoke, sanitizer, and libFuzzer coverage. - -### Phase 3C — MMS service codecs - -- MMS Initiate request/response and confirmed request/response/error envelopes. -- Invoke-ID routing, ObjectName, GetNameList, GetVariableAccessAttributes, Read, and Write. -- Recursive bounded TypeSpecification and mixed success/failure access results. -- C#-derived vectors, sanitizer, deterministic mutation smoke, and libFuzzer coverage. - -### Phase 4A — offline DataSet and report monitoring - -- DataSet and buffered/unbuffered RCB inventory from supplied GetNameList evidence. -- GetNamedVariableListAttributes request/response and IEC reference normalization. -- Strict InformationReport and OptFlds-driven report-frame decoding. -- RCB attribute read-plan/state mapping with cautious availability confidence. -- Sequence, configuration, DataSet, overflow, and segmentation continuity tracking. -- Bounded offline report monitor and dedicated MMS-reporting fuzz corpus. - -### Phase 4B — association and report-subscription runtime - -- Transport-injected COTP, Session, Presentation, ACSE/MMS Initiate lifecycle. -- Confirmed-service routing, timeout/cancellation handling, and explicit reconnect. -- Persistent RCB re-probe, reservation, `RptEna`, optional GI, report delivery, and - ownership-aware cleanup. See `ASSOCIATION_RUNTIME_PROFILE.md`. - -### Phase 4C — built-in TCP and live read-only discovery - -- Cross-platform non-blocking Winsock/POSIX `TcpMmsByteTransport`. -- IPv4/IPv6 resolution, deadline/cancellation-aware readiness waits, TCP_NODELAY, - keepalive, partial sends, arbitrary receive chunking, and peer-close detection. -- Bounded live GetNameList discovery for domains, variables, and named-variable lists. -- Optional variable type, DataSet directory, and read-only RCB state probes. -- `MmsTcpLiveDiscoverySession` ready-to-use association/discovery composition. -- Read-only `ariec61850_live_discover` human/JSON CLI. -- Scripted service-allowlist regression that rejects MMS Write. See - `LIVE_DISCOVERY_PROFILE.md`. - -The TCP transport performs no operation on its own. The live discovery API and CLI emit only -GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes, and Read requests. -Physical IED interoperability remains a separate laboratory acceptance gate. +### Portable protocol foundation + +- CMake-based C++20 static library with strict warnings. +- BER, MMS Data/UTC time, Ethernet/VLAN, classic PCAP. +- GOOSE and Sampled Values codecs plus offline supervision. +- SCL read-only parser and COMTRADE CFG/DAT readers. +- RFC 1006 TPKT, COTP, ISO Session, Presentation, ACSE, and MMS Initiate. +- Confirmed MMS GetNameList, variable attributes, Read, and Write codecs. +- DataSet directory, InformationReport, RCB state, offline monitoring, and report subscription. + +### Phase 4C — built-in TCP and read-only discovery + +- Non-blocking Windows Winsock and POSIX TCP transport. +- IPv4/IPv6 resolution, deadlines, cancellation, partial sends, receive chunking, and peer close. +- Bounded live domain, variable, DataSet, type, and RCB discovery. +- `ariec61850_live_discover` CLI. + +### Phase 4C.1 — C# live-model parity + +- `live-ied-model-v1` JSON compatible with the C# exporter. +- Logical Device / Logical Node / Data Object / Data Attribute hierarchy. +- Functional constraints, MMS/SCL types, DataSets, report controls, identity, and CDC confidence. +- Deterministic canonical manifest and model fingerprint. +- C#↔C++ parity script. +- Windows/Linux repeated reconnect and physical read-only evidence runner. + +The live discovery surface sends only GetNameList, GetVariableAccessAttributes, +GetNamedVariableListAttributes, and Read. It does not construct Write, control, GI, RCB +reservation/enable, dynamic DataSet mutation, or file-service requests. ## Build -### Windows — Visual Studio +### Windows ```powershell cmake -S . -B build -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON @@ -110,7 +46,7 @@ cmake --build build --config Release --parallel ctest --test-dir build -C Release --output-on-failure ``` -### Linux — GCC or Clang +### Linux ```bash cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DARIEC61850_WARNINGS_AS_ERRORS=ON @@ -118,39 +54,50 @@ cmake --build build --parallel ctest --test-dir build --output-on-failure ``` -### Sanitizers +## Live read-only discovery -```bash -CC=clang CXX=clang++ cmake -S . -B build-sanitizers \ - -DCMAKE_BUILD_TYPE=RelWithDebInfo \ - -DARIEC61850_ENABLE_SANITIZERS=ON -cmake --build build-sanitizers --parallel -ctest --test-dir build-sanitizers --output-on-failure -``` +Human summary: -## Read-only tools +```powershell +.\build\Release\ariec61850_live_discover.exe 192.168.1.10 102 +``` -### Discover a live MMS endpoint +C#-compatible model: ```powershell -.\build\Release\ariec61850_live_discover.exe 192.168.1.10 102 --json +.\build\Release\ariec61850_live_discover.exe 192.168.1.10 102 ` + --model-json --ied-name IED_A > live-ied-model.json ``` -Use `--no-types`, `--no-datasets`, or `--no-rcb` to reduce probe scope. All discovery counts -and request timeouts are bounded through CLI options or `MmsLiveDiscoveryOptions`. +Cross-language parity: + +```bash +python scripts/compare-live-model-parity.py \ + csharp-live-ied-model.json cpp-live-ied-model.json \ + --output csharp-cpp-parity.json +``` -### Inspect a COMTRADE record +Physical read-only acceptance: ```powershell -.\build\Release\ariec61850_comtrade_inspect.exe .\record.cfg --json +.\scripts\run-live-readonly-interop.ps1 ` + -HostName 192.168.1.10 -Cycles 10 ` + -OutputDirectory .\evidence\IED_A ` + -ExpectedCSharpModel .\csharp-live-ied-model.json ``` -### Analyze a saved process-bus PCAP +See `PHASE_4C1_LIVE_MODEL_INTEROP.md` for acceptance rules and evidence requirements. + +## Other read-only tools ```powershell +.\build\Release\ariec61850_comtrade_inspect.exe .\record.cfg --json .\scripts\run-lab-check.ps1 -Pcap .\captures\device-session.pcap ``` -See `REPORTING_PROFILE.md` for the offline report decoder profile, -`LAB_INTEROP_CHECKLIST.md` before using a physical IED, and -`MIGRATION_CHECKLIST.md` for the detailed progress ledger. +## Acceptance boundary + +Source implementation and deterministic/simulated validation do not replace physical IED +acceptance. Before merging Phase 4C/4C.1, run the repository GCC/Clang/MSVC and security +workflows, execute the physical evidence runner against a reachable IED or vendor simulator, +and review C#↔C++ parity against the same configuration. From cea2b6d001cd7866a644d2330953ab99e6422441 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:47:16 +0700 Subject: [PATCH 024/179] Update Phase 4C.1 validation ledger --- VALIDATION.txt | 63 +++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/VALIDATION.txt b/VALIDATION.txt index 81d691b27..5aa801b44 100644 --- a/VALIDATION.txt +++ b/VALIDATION.txt @@ -1,39 +1,34 @@ -ARIEC61850 C++ Phase 4B validation -Date: 2026-08-06 +ARIEC61850 C++ validation ledger -GNU C++ 14.2 / Release / warnings-as-errors: - Configure: PASS - Build: PASS - CTest: PASS (16/16) +Phase 4B accepted baseline +- GCC/Clang warnings-as-errors: passed. +- Windows MSVC matrix: passed. +- Clang ASan/UBSan: passed. +- Repository CTest through Phase 4B: 16/16 passed. +- Reporting fuzz evidence: passed. -Clang 17 / Release / warnings-as-errors: - Configure: PASS - Build: PASS - CTest: PASS (16/16) +Phase 4C local evidence +- GCC and Clang strict warning build of TCP transport/loopback: passed. +- POSIX TCP loopback connect/send/receive: passed. +- Cancellation behavior: passed. +- Clang ASan/UBSan loopback smoke: passed. +- Scripted discovery and read-only service allowlist: added. -Clang 17 / RelWithDebInfo / ASan + UBSan: - Configure: PASS - Build: PASS - CTest: PASS (16/16) +Phase 4C.1 local evidence +- C#-compatible live-model hierarchy and reference parser: implemented. +- Direct and nested TypeSpecification resolution: locally validated. +- Deterministic canonical manifest/fingerprint and parity: locally validated. +- Header multi-translation-unit GCC/Clang strict validation: passed. +- Live-model ASan/UBSan smoke: passed. +- Python parity and interop runners: py_compile passed. +- Simulated three-cycle physical acceptance runner: passed with stable fingerprint. -Phase 4B deterministic evidence: - - COTP/ACSE/MMS association success and negotiated limits - - cancelled connect followed by explicit reconnect - - confirmed response routing with interleaved InformationReport - - confirmed-exchange timeout and association fault transition - - RCB live-state re-probe before any write - - URCB Resv=true, RptEna=true, optional GI, report delivery, and cleanup - - occupied/enabled RCB takeover protection - - cleanup-required state after association loss - - persistent report mapping into the bounded Phase 4A monitor +Pending acceptance +- GitHub workflow_dispatch for GCC/Clang/MSVC and Security/Evidence on PR #14. +- Full repository CTest on the Phase 4C.1 branch. +- Reachable physical IED or vendor-simulator run. +- Primary-vendor ten-cycle run and timeout/reconnect evidence. +- Large-model pagination evidence where available. +- C# and C++ same-IED live-ied-model-v1 parity review. -GitHub Actions: - GCC / Clang / Windows MSVC matrix: PASS - Clang ASan + UBSan: PASS - Existing ten-corpus libFuzzer: PASS (no crash artifact) - -Runtime boundary: - Byte transport interface: IMPLEMENTED - Built-in TCP socket backend: NOT INCLUDED - Physical IED association: NOT VALIDATED - Direct/SBO control and file service: DISABLED +No physical IED evidence is claimed by this ledger. From f5206efaeb0c719891f969177e2a12382c26c840 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:47:31 +0700 Subject: [PATCH 025/179] Clarify Phase 4C.1 provenance --- COPYRIGHT.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/COPYRIGHT.md b/COPYRIGHT.md index 35ad2c208..fd3a36c3d 100644 --- a/COPYRIGHT.md +++ b/COPYRIGHT.md @@ -1,21 +1,15 @@ -# Copyright and Provenance +# Copyright and provenance -Primary project copyright notice: +Copyright (C) 2026 Ari Sulistiono and contributors. -> Copyright (C) 2026 Ari Sulistiono -> GitHub account: `masarray` +The C++ implementation is an incremental clean-room migration guided by public standards, +wire-format evidence, and the ARIEC61850 C# repository as the behavioral oracle. Phase 4C.1 +adds a C++ engineering-model representation compatible with the C# `live-ied-model-v1` export +shape so deterministic cross-language comparison can be performed. -The Git history is the detailed record of authorship and changes. A GitHub account records contribution activity; it does not by itself determine legal ownership. Ownership may also be affected by employment agreements, invention-assignment clauses, customer contracts, or written assignments. +No generated physical IED evidence is embedded in the source repository. Lab captures, +endpoint addresses, vendor configuration exports, and C#↔C++ parity artifacts should be kept +in controlled evidence storage and reviewed before publication. -Third-party dependencies and separately attributed material remain owned by their respective copyright holders and are governed by their own licenses. - -The repository-level audit performed during the 2026-07-14 license transition found no external human pull-request author beyond the maintainer in the accessible GitHub history, except automated dependency-update activity where present. This finding supports maintainer-controlled relicensing of the audited contribution set, but it is not a substitute for review of off-repository obligations. - -Before granting a commercial, OEM, white-label, or enterprise license, the copyright holder should retain: - -- independent-creation evidence; -- relevant employment and invention-assignment agreements; -- contributor acceptance records; -- provenance records for fixtures, assets, and samples; -- dependency license and attribution records; and -- legal review appropriate to the transaction value and jurisdiction. +See `LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.md`, and +`PHASE_4C1_LIVE_MODEL_INTEROP.md`. From 713cdc01bc53ee8d2e6c9e42c469096508e10b76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:47:43 +0700 Subject: [PATCH 026/179] Extend lab report for Phase 4C.1 --- LAB_INTEROP_REPORT_TEMPLATE.md | 120 ++++++++++++++++----------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/LAB_INTEROP_REPORT_TEMPLATE.md b/LAB_INTEROP_REPORT_TEMPLATE.md index 2e2fdaab7..8ad616a51 100644 --- a/LAB_INTEROP_REPORT_TEMPLATE.md +++ b/LAB_INTEROP_REPORT_TEMPLATE.md @@ -1,63 +1,63 @@ -# ARIEC61850 Isolated-Lab Interoperability Report +# IEC 61850 Interoperability Evidence Report -## Test identity +## Target -- Date/time: -- Engineer: -- Laboratory/network diagram reference: -- IED manufacturer/model: +- Vendor: +- Model: - Firmware: -- Engineering file and revision: -- Capture filename: -- Capture SHA-256: -- Capture duration: -- Tool commit SHA: - -## Network parameters - -| Stream | Protocol | Source MAC | Destination MAC | VLAN/PCP | APPID | Identifier | -|---|---|---|---|---|---|---| -| 1 | | | | | | | - -## Automated checker result - -- Overall result: PASS / FAIL -- Packet count: -- GOOSE count: -- Sampled Values count: -- Malformed count: -- Exact decode/re-encode matches: -- Re-encode mismatches: -- Canonical PCAP round trip: PASS / FAIL -- JSON report path/hash: - -## GOOSE observations - -- State transitions exercised: -- `stNum`/`sqNum` observations: -- Retransmission timing: -- TTL/arrival-gap result: -- Dataset/value result: - -## Sampled Values observations - -- Sampling mode/rate: -- Counter wrap policy: -- Missing/duplicate/out-of-order samples: -- Synchronization and reference-time result: -- SCL-backed channel/scaling result: - -## Deviations and evidence - -| ID | Packet/time | Observation | Expected | Disposition | -|---|---|---|---|---| -| 1 | | | | | - -## Acceptance - -- [ ] Receive-only GOOSE interoperability accepted. -- [ ] Receive-only Sampled Values interoperability accepted. -- [ ] All artifacts retained under controlled storage. -- [ ] Active transmission remains disabled unless separately approved. - -Approver/signature: +- IP / MMS port: +- Test date and timezone: +- Isolated engineering network: +- C++ commit: +- C# oracle commit: +- Operator: + +## Phase 4C.1 read-only discovery + +- Command used: +- Cycle count: +- Timeout: +- Successful cycles: +- Stable fingerprint: yes / no +- Fingerprint: +- Logical Devices: +- Logical Nodes: +- Data Objects: +- Data Attributes: +- Exact MMS types: +- DataSets: +- BRCBs / URCBs: +- Warning count: +- `interop-summary.json` reference: +- Packet-capture reference: + +## C#↔C++ parity + +- C# `live-ied-model-v1` file: +- C++ `live-ied-model-v1` file: +- Parity report: +- Blocking findings: +- Informational findings: +- Accepted: yes / no +- Exceptions and rationale: + +## Robustness evidence + +- Multi-page GetNameList observed: yes / no +- Timeout scenario exercised: yes / no +- Clean reconnect after timeout: yes / no +- Ten-cycle primary-vendor run: pass / fail +- Fingerprint changed after deliberate model/config change: yes / no / not tested + +## Read-only safety review + +Confirm the run emitted only GetNameList, GetVariableAccessAttributes, +GetNamedVariableListAttributes, and Read. Confirm no Write, GI, RCB reservation/enable, +control, dynamic DataSet mutation, or file-service request was used. + +## Conclusion + +- Phase 4C.1 accepted for this target: yes / no +- Remaining actions: +- Reviewer: +- Review date: From 6b4e05f61eb8280d166a91790d4c52be47b0d953 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:47:59 +0700 Subject: [PATCH 027/179] Update physical read-only interoperability checklist --- LAB_INTEROP_CHECKLIST.md | 93 +++++++++++++++------------------------- 1 file changed, 34 insertions(+), 59 deletions(-) diff --git a/LAB_INTEROP_CHECKLIST.md b/LAB_INTEROP_CHECKLIST.md index 7c7d6cfd9..c5d9cd623 100644 --- a/LAB_INTEROP_CHECKLIST.md +++ b/LAB_INTEROP_CHECKLIST.md @@ -1,70 +1,45 @@ -# Isolated-Laboratory Interoperability Checklist +# Isolated IEC 61850 Laboratory Checklist -This checklist is intentionally receive-first and read-only. The Phase 1E tooling does -not open Npcap, transmit Ethernet frames, or operate an IED. +## Before connection -## 1. Safety boundary +- Use an isolated engineering network with an approved test IED or vendor simulator. +- Record vendor, model, firmware, IP, MMS port, C# commit, and C++ commit. +- Confirm no production breaker/process control is reachable. +- Confirm the intended Phase 4C.1 command uses only the read-only discovery CLI. +- Start packet capture when permitted. -- [ ] Use an isolated laboratory switch or an isolated VLAN with no route to a production substation LAN. -- [ ] Confirm the engineering laptop has no bridge, Internet sharing, or second adapter forwarding traffic. -- [ ] Begin with a mirror/SPAN port or passive TAP; do not connect an active publisher output first. -- [ ] Record IED manufacturer, model, firmware, configuration revision, MAC addresses, APPIDs, VLAN IDs, and sampling mode. -- [ ] Synchronize clocks only through the laboratory timing source approved for the test. +## Read-only request allowlist -## 2. Receive-only capture +Phase 4C.1 discovery may issue only: -- [ ] Capture at least one stable GOOSE state and one GOOSE state transition. -- [ ] Capture Sampled Values across at least two counter wraps or ten seconds, whichever is longer. -- [ ] Preserve the original PCAP; do not edit it in Wireshark before hashing. -- [ ] Calculate SHA-256 and record the capture hash in the report template. -- [ ] Keep a copy of the SCL/CID/SCD file used to configure the streams. +- MMS GetNameList; +- MMS GetVariableAccessAttributes; +- MMS GetNamedVariableListAttributes; and +- MMS Read. -## 3. Automated evidence check +Stop the test if Write, GI, RCB reservation/enable, control, dynamic DataSet mutation, or +file-service traffic is observed. -Windows: +## Minimum acceptance run -```powershell -./scripts/run-lab-check.ps1 -Pcap ./captures/device-session.pcap -``` +1. Build Release with warnings as errors and run repository CTest. +2. Run three reconnect/discovery cycles against the test target. +3. Confirm every cycle produces `live-ied-model-v1` and a non-empty hierarchy. +4. Confirm fingerprints are stable when the IED configuration is unchanged. +5. Export the C# oracle model against the same target/configuration. +6. Run C#↔C++ parity and review every blocking finding. +7. Run ten cycles for the primary target vendor. +8. Exercise a timeout/connection-failure scenario, then confirm a clean reconnect. +9. Record multi-page GetNameList evidence when the target model is large enough. +10. Store `interop-summary.json`, per-cycle models, parity reports, and capture references. -Linux: +## Review gate -```bash -./scripts/run-lab-check.sh ./captures/device-session.pcap -``` +Do not mark Phase 4C.1 physically accepted until: -Acceptance conditions: - -- [ ] At least one GOOSE or Sampled Values packet is decoded. -- [ ] No process-bus packet is malformed. -- [ ] Every decoded GOOSE/SV frame re-encodes byte-for-byte to the captured frame. -- [ ] PCAP packet order, payload bytes, and microsecond-normalized timestamps round-trip. -- [ ] Stream identifiers, APPIDs, VLANs, and configuration revisions match the engineering files. - -## 4. Protocol observations - -GOOSE: - -- [ ] `stNum` increases only on state changes. -- [ ] `sqNum` behavior and retransmission intervals match the configured profile. -- [ ] `timeAllowedToLive` remains adequate for observed arrival gaps. -- [ ] Dataset values match the expected state transition. - -Sampled Values: - -- [ ] `smpCnt` progression and wrap policy match the configured sampling mode. -- [ ] `confRev`, `smpSynch`, `smpRate`, and optional `refrTm` are consistent. -- [ ] Missing, duplicate, and out-of-order sample counts are zero or explained. -- [ ] `seqOfData` channel mapping and scaling are validated against SCL, not inferred from byte position alone. - -## 5. Escalation and evidence retention - -- [ ] Save the PCAP, JSON report, SHA-256, SCL files, device configuration export, and test notes together. -- [ ] Record every mismatch before changing the parser or normalizing captured bytes. -- [ ] Do not enable active GOOSE/SV transmission from this stack until the receive-only evidence passes and a separate approved transmit test plan exists. - -## Current status - -The synthetic C#-derived corpus is automated in CI. Physical IED interoperability -remains **not yet passed** until a real capture and completed report are committed or -otherwise archived as controlled evidence. +- the same-target C# parity report has no unexplained blocking finding; +- warning policy is reviewed; +- fingerprint stability is demonstrated; +- reconnect behavior is demonstrated; +- Windows/MSVC and repository CI pass; and +- an engineer signs the completed `LAB_INTEROP_REPORT_TEMPLATE.md`. From 587fee9fd27898112cc54761223ba6d41869fb75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:48:13 +0700 Subject: [PATCH 028/179] Update Phase 4C.1 notice --- NOTICE | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/NOTICE b/NOTICE index 693f7345f..57ed96786 100644 --- a/NOTICE +++ b/NOTICE @@ -1,17 +1,18 @@ -ARIEC61850 -Copyright (C) 2026 Ari Sulistiono -GitHub account: masarray +ARIEC61850 C++ Port +Copyright (C) 2026 Ari Sulistiono and contributors -Current public license: GNU General Public License v3.0 or later only. -Commercial licensing may be negotiated separately with the copyright holder. +This program is free software under the GNU General Public License version 3 or later. +See LICENSE for the complete license text. -Current branch and current release packages: -- License: GPL-3.0-or-later only -- License text: LICENSE -- Licensing explanation: docs/LICENSING.md +The C++ stack is an incremental migration of the ARIEC61850 C# implementation. The C# source +remains the behavioral oracle until deterministic cross-language comparison and isolated +physical-laboratory interoperability are accepted. -Historical revisions through d61a83f5b04e7bd2b847174eeac7f4f6e81ee8e1 remain available under their original terms on branch archive/apache-2.0-final. The historical license text is retained on that archive branch and is not part of the current source tree or current release packages. +Phase 4C/4C.1 includes an explicitly invoked TCP transport, a read-only MMS discovery surface, +a C#-compatible `live-ied-model-v1` mapper, parity tooling, and physical evidence runners. The +discovery surface does not construct MMS Write, control, GI, RCB reservation/enable, dynamic +DataSet mutation, or file-service requests. -Third-party components and their notices remain governed by their own licenses; see THIRD_PARTY_NOTICES.md. - -ARIEC61850 is independently developed under a documented clean-room and provenance policy. The repository is maintained as source-first public project content and intentionally excludes generated build outputs, local IDE state, runtime evidence, captures, and release artifacts. +Physical IED compatibility is target-specific and must not be inferred from synthetic, +loopback, or simulated evidence. Review `PHASE_4C1_LIVE_MODEL_INTEROP.md` and the lab checklist +before accepting a target. From caedc974e8aee2dcf7b9188f284eb496d84cb4ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:48:23 +0700 Subject: [PATCH 029/179] Update third-party notice for Phase 4C.1 --- THIRD_PARTY_NOTICES.md | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2724d35c2..7632a4185 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,33 +1,15 @@ # Third-Party Notices -ARIEC61850 is distributed under `GPL-3.0-or-later`. The project license does **not** change the license of any third-party package, standard, or asset. Each included or restored component remains subject to its own license and attribution terms. +The ARIEC61850 C++ core intentionally avoids third-party runtime dependencies for the protocol, +TCP transport, live-model hierarchy, parity manifest, and read-only interoperability runner. -## Direct build and test dependencies +The Python parity and evidence scripts use only the Python standard library. The Windows +transport uses system Winsock (`ws2_32`); POSIX builds use the platform socket API. -The following packages may be restored by .NET during build or test: +The project may be built and tested with third-party toolchains and CI actions. Those tools are +not linked into the distributed core library. Refer to their respective licenses in the build +or CI environment. -| Dependency | Used by | Purpose | License | -|---|---|---|---| -| SharpPcap | `src/AR.Iec61850.Transports.Npcap` | Packet capture and raw Ethernet access wrapper for Npcap/libpcap-compatible environments | MIT | -| Microsoft.NET.Test.Sdk | `tests/AR.Iec61850.Tests` | .NET test infrastructure | MIT | -| xUnit | `tests/AR.Iec61850.Tests` | Unit testing framework | Apache-2.0 | -| xUnit runner visualstudio | `tests/AR.Iec61850.Tests` | Visual Studio and `dotnet test` integration | Apache-2.0 | -| coverlet.collector | `tests/AR.Iec61850.Tests` | Optional code coverage collection | MIT | - -Npcap is not bundled in this repository. Users who need live raw Ethernet traffic on Windows must install it separately from its official distribution channel and comply with its license. - -## External intellectual-property boundary - -No source code, binary, generated binding, header, example, test, documentation fragment, API wrapper, manual, screenshot, icon, logo, report template, database, capture, or extracted resource from an unrelated external implementation or proprietary engineering product is included or required by ARIEC61850. - -Interoperability testing with separately licensed tools does not make those tools project dependencies and does not authorize copying their software, documentation, visual design, resources, or confidential data. Protocol cases retained in this repository must be independently reconstructed from public standards or ARIEC61850's own encoders under `docs/CLEAN_ROOM_POLICY.md`. - -## Release review - -Before every public or commercial release: - -1. review the resolved dependency graph and license metadata; -2. confirm that no unrelated external implementation code or binary has entered the source or package; -3. confirm that no proprietary manual, screenshot, logo, UI asset, capture, SCL, or customer material is present; -4. preserve all legally required dependency notices and license copies; and -5. run the repository source-clean and release-package verification scripts. +No vendor IED model, proprietary SCL file, packet capture, or physical interoperability artifact +is included by Phase 4C.1. Such evidence remains owned and controlled by its provider and should +be handled under the applicable project and vendor terms. From 2c5c5cd2ffa0124fc2e1d93b3db053846ac31102 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:48:31 +0700 Subject: [PATCH 030/179] Ignore generated Phase 4C.1 evidence --- .gitignore | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index b879cb8f0..877ee0866 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,30 @@ build/ build-*/ cmake-build-*/ -CMakeFiles/ -CMakeCache.txt -Testing/ +.vs/ +.vscode/ +.idea/ *.user *.suo -*.vcxproj -*.vcxproj.filters -*.vcxproj.user -*.profraw -crash-* -leak-* -timeout-* -oom-* -slow-unit-* -*.interop.json +*.VC.db +*.VC.opendb +*.obj +*.o +*.a +*.lib +*.dll +*.so +*.dylib +*.exe +*.pdb +*.ilk +*.log +*.tmp +*.swp +__pycache__/ +*.pyc +interop-evidence/ +evidence/ +*-live-model.json +*-parity.json +interop-summary.json From f17d08136f53740f56708bffe64e836cd7fd0f2a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:48:48 +0700 Subject: [PATCH 031/179] Update migration plan after Phase 4C.1 --- MIGRATION_PLAN.md | 131 +++++++++++++++++----------------------------- 1 file changed, 48 insertions(+), 83 deletions(-) diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index afab33af9..57872a991 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -1,99 +1,64 @@ -# ARIEC61850 C# to C++ Migration Plan +# ARIEC61850 C++ Migration Plan -## Decision +The migration follows deterministic protocol foundations first, then engineering formats, +association/runtime behavior, live read-only interoperability, and finally active services and +applications. The C# repository remains the behavioral oracle until same-target laboratory +comparison is accepted. -A full C++ conversion is feasible, but this repository is approximately 66,000 lines of -C# and contains protocol codecs, asynchronous network state machines, active IED control, -raw Ethernet publishing, simulation, and several WPF applications. It must be migrated as -a compatibility program rather than by mechanical source translation. +## Completed foundation -## Target architecture +- Phase 0: C++20/CMake, strict warnings, BER, Ethernet/VLAN, PCAP. +- Phase 1: MMS Data/UTC, GOOSE, Sampled Values, evidence and fuzzing. +- Phase 2: read-only SCL and COMTRADE. +- Phase 3: TPKT/COTP, Session, Presentation, ACSE, MMS Initiate and confirmed services. +- Phase 4A: DataSet/RCB inventory, InformationReport decoding, continuity monitoring. +- Phase 4B: transport-injected association and persistent report-subscription runtime. -```text -ariec61850_core BER, MMS values, Ethernet, GOOSE, SV, SCL, PCAP -ariec61850_transport TCP/TPKT/COTP, Npcap/libpcap abstraction, timers -ariec61850_client ACSE/MMS association, discovery, read/write, file services -ariec61850_reporting DataSet/RCB/report receive state machines -ariec61850_control typed control planning, SBO/direct execution, termination -ariec61850_simulation deterministic IED model and MMS server -ariec61850_cli command-line engineering tools -ariec61850_desktop new native UI; do not attempt a line-for-line WPF translation -``` +## Current delivery — Phase 4C and 4C.1 -## Recommended implementation choices +### Phase 4C -- Language: C++20 now, with a later option to move selected APIs to C++23. -- Build: CMake with Visual Studio and Ninja generators. -- Ownership: RAII, value types, `std::unique_ptr`, `std::shared_ptr` only where ownership - is genuinely shared. -- Byte handling: `std::span` at boundaries and owning `std::vector` - in durable models. -- Errors: typed exceptions for malformed offline data; explicit result/error objects for - network and control workflows. -- Async: one association receive loop with explicit routing, matching the current design. -- UI: rebuild the desktop shell after the core is stable. WPF XAML is not portable to C++. -- Tests: keep C# as the oracle and generate golden vectors for every codec and state machine. +- built-in non-blocking Windows/Linux TCP transport; +- deadline/cancellation-aware connect, send, and receive; +- bounded live domain, variable, DataSet, type, and RCB discovery; +- strict read-only service allowlist. -## Order of work +### Phase 4C.1 -### Phase 0 — foundation (included in this package) +- C#-compatible `live-ied-model-v1` hierarchy; +- IED/LD/LN/DO/DA references, FC, type, DataSet, and RCB evidence; +- conservative identity and CDC inference; +- deterministic manifest/fingerprint; +- C#↔C++ parity checker; +- repeated physical read-only interoperability runner and evidence format. -- CMake, compiler warnings, CTest. -- BER, Ethernet/VLAN, process-bus framing, PCAP. -- GOOSE retransmission timing. +### Acceptance work still required -### Phase 1 — deterministic process-bus codecs +- GCC/Clang/MSVC and Security/Evidence workflow run on PR #14; +- full branch CTest; +- reachable IED/vendor-simulator run; +- primary-vendor ten-cycle and timeout/reconnect evidence; +- same-target C# and C++ parity review; +- large-model pagination evidence when available. -- IEC 61850 UTC time and MMS Data values used by GOOSE/SV. **Completed in Phase 1A.** -- GOOSE PDU parser/builder. -- Sampled Values ASDU, payload, frame parser/builder. -- Process-bus stream diagnostics and sequence supervision. -- PCAP golden vectors against existing C# output. +## Next implementation priority after Phase 4C.1 acceptance -### Phase 2 — engineering file formats +### Phase 4D — guarded control foundation -- SCL models/parser/workspace/export. -- COMTRADE reader and channel mapping. -- Expected-vs-observed engineering profiles. +- Direct Operate and Select-Before-Operate state machines; +- normal and enhanced security; +- select, operate, cancel, ownership, timeout, and command termination; +- LastApplError and AddCause; +- default-deny authorization, operator confirmation, and full audit trail; +- no automatic command retry. -### Phase 3 — MMS transport and data model +Phase 4D must not begin physical command testing until Phase 4C.1 transport/model identity and +same-target read-only parity are accepted. -- TPKT and COTP. -- Presentation and ACSE association. -- MMS PDU envelope, data codec, read/write, name lists, variable attributes. -- Single receive pump and invoke-ID routing. +### Later work -### Phase 4 — reporting, control, and file service - -- DataSet and RCB inventory. -- Report subscriptions, GI, persistent monitoring, value projection. -- Guarded IEC 61850 control including Direct/SBO normal/enhanced security. -- CommandTermination, LastApplError, Cancel, timeout, association-loss cleanup. -- MMS file directory and fault-record transfer. - -### Phase 5 — simulator and applications - -- Deterministic simulation runtime and read-only MMS server. -- CLI command parity. -- Native desktop applications using a shared presentation layer. -- Npcap transport on Windows; keep raw Ethernet optional. - -## Acceptance gates - -A module is not considered converted merely because it compiles. It must pass: - -- C++ unit tests corresponding to the original C# tests. -- C# versus C++ byte-for-byte golden vectors for encoders. -- Parse-equivalence tests for captured PCAP/SCL/COMTRADE samples. -- malformed/truncated-input tests and fuzzing for decoders. -- cancellation, timeout, disconnect, and cleanup tests for state machines. -- isolated-lab interoperability before enabling active control or publishing. - -## Primary risks - -1. WPF is a rewrite, not a direct port. -2. Async socket cancellation and the single MMS receive pump are more error-prone in C++. -3. Active control requires exact preservation of sequencing and cleanup semantics. -4. SCL export contains rich object graphs and should not be ported before core value models. -5. A one-shot automated translator would create superficially similar code without - protocol-level confidence. +- BRCB EntryID resume, replay, purge, and overflow recovery; +- dynamic DataSet lifecycle; +- MMS file and fault-record transfer; +- deterministic IED simulator/read-only server; +- CLI parity, Windows Npcap abstraction, and native applications. From a92ac4d8b10e74ddf5e267c36190cb27e0a21f42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:49:04 +0700 Subject: [PATCH 032/179] Update association profile for Phase 4C.1 --- ASSOCIATION_RUNTIME_PROFILE.md | 84 +++++++++++----------------------- 1 file changed, 26 insertions(+), 58 deletions(-) diff --git a/ASSOCIATION_RUNTIME_PROFILE.md b/ASSOCIATION_RUNTIME_PROFILE.md index 6b60c589a..0c8fb643d 100644 --- a/ASSOCIATION_RUNTIME_PROFILE.md +++ b/ASSOCIATION_RUNTIME_PROFILE.md @@ -1,69 +1,37 @@ -# MMS Association and Report Subscription Runtime Profile +# MMS Association, TCP, and Read-Only Discovery Runtime Profile -## Scope +## Association runtime -Phase 4B adds the stateful runtime that joins the deterministic Phase 3 association codecs -and Phase 4A reporting codecs. The runtime is transport-injected: an application supplies an -`MmsByteTransport` implementation, while the core owns TPKT/COTP framing, Session, -Presentation, ACSE/MMS association, confirmed-service routing, and RCB subscription state. +`MmsAssociationRuntime` owns TPKT/COTP, Session, Presentation, ACSE/MMS Initiate, invoke +routing, InformationReport queuing, deadline/cancellation events, disconnect, and explicit +reconnect. It receives an `MmsByteTransport` and does not create hidden background work. -## Association lifecycle +## Built-in TCP transport -`MmsAssociationRuntime` implements: +Phase 4C adds `TcpMmsByteTransport` for Winsock and POSIX sockets. It is non-blocking, resolves +IPv4/IPv6 endpoints, handles partial sends and receive chunks, and observes absolute deadlines +and `std::stop_token` cancellation. It performs no protocol operation by itself. -1. byte-transport connect with deadline and `std::stop_token` cancellation; -2. COTP Connection Request/Confirm negotiation and TPDU-size capture; -3. ISO Session Connect, Presentation CP, ACSE AARQ, and MMS Initiate exchange; -4. accepted/rejected association validation and negotiated MMS limits; -5. segmented COTP send and bounded reassembly on receive; -6. monotonically allocated bounded invoke IDs; -7. confirmed-response/error routing while interleaved InformationReports are queued; -8. explicit timeout, cancellation, fault, and disconnect events; and -9. best-effort COTP Disconnect Request before closing the injected transport. +## Read-only discovery runtime -A failed or timed-out confirmed exchange faults the association because the transport stream -may no longer have a trustworthy request/response boundary. Reconnect is explicit: close or -fault the old runtime state, refill/recreate the transport as required, and call `connect` again. +`MmsTcpLiveDiscoverySession` combines the TCP transport, association runtime, and +`MmsLiveDiscoveryClient`. The discovery client is restricted to GetNameList, +GetVariableAccessAttributes, GetNamedVariableListAttributes, and Read. -## Report subscription lifecycle +Phase 4C.1 maps the resulting evidence to `live-ied-model-v1`, computes a canonical fingerprint, +and provides repeated reconnect/discovery evidence runners. This read-only workflow never +constructs Write, control, GI, RCB reservation/enable, dynamic DataSet mutation, or file-service +requests. -`MmsReportSubscriptionRuntime` implements a persistent static-DataSet RCB workflow: +## Report subscription runtime -1. re-probe the selected RCB immediately before any write; -2. refuse takeover when `RptEna`, reservation, or availability evidence indicates another - session owns the RCB; -3. optionally reserve an URCB using `Resv=true`; -4. optionally write `DatSet`, `TrgOps`, and `OptFlds`; -5. enable with `RptEna=true`; -6. optionally request GI after enable; -7. receive, decode, map, and monitor InformationReports until stop/cancellation/fault; -8. disable only when this runtime enabled the RCB; -9. release only reservations touched by this runtime; and -10. record `cleanup_required` when the association is lost before cleanup completes. +`MmsReportSubscriptionRuntime` remains a separate explicit stateful surface. It can probe, +reserve/enable supported URCBs, receive reports, stop, and perform ownership-aware cleanup. It +is not invoked by live discovery or the Phase 4C.1 evidence runner. -`retry_cleanup` can be called after a successful reconnect. BRCB `EntryID` resume, purge -policy, buffer-overflow recovery, and dynamic DataSet create/delete remain separate work. +## Acceptance -## Safety and ownership rules - -- The core does not silently take over an already enabled or reserved RCB. -- Cleanup writes are limited to state touched by the current runtime instance. -- The runtime does not include a default TCP socket implementation; live network access is - possible only when an application explicitly supplies an `MmsByteTransport`. -- No control service, SBO state machine, file service, dynamic DataSet create/delete, or - automatic BRCB replay is enabled by this phase. - -## Validation - -The scripted transport regression covers: - -- successful COTP/ACSE/MMS association and negotiated limits; -- cancellation followed by a clean reconnect; -- confirmed request/response routing with an interleaved InformationReport; -- confirmed-exchange timeout and fault transition; -- URCB probe, reserve, enable, GI, report receive, disable, and reservation release; -- cleanup-required state after association loss; and -- refusal to take over an enabled RCB. - -The repository has 16 CTest entries through Phase 4B. Local GCC, Clang, and Clang -ASan/UBSan runs pass all 16 tests with warnings treated as errors. +Automated tests cover scripted association, routing, report interleaving, cancellation/timeout, +TCP loopback, read-only service allowlisting, and live-model parity behavior. Physical IED +acceptance requires the Phase 4C.1 lab runner, same-target C# parity, reconnect/soak evidence, +and reviewed packet-capture references. From 008fd2e95b0c32cb409f9d0006cf9678f51c8511 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:49:15 +0700 Subject: [PATCH 033/179] Clarify reporting boundary in Phase 4C.1 --- REPORTING_PROFILE.md | 66 +++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 44 deletions(-) diff --git a/REPORTING_PROFILE.md b/REPORTING_PROFILE.md index e78ac0d72..c2d10d6ca 100644 --- a/REPORTING_PROFILE.md +++ b/REPORTING_PROFILE.md @@ -1,53 +1,31 @@ -# Offline MMS Reporting Profile +# IEC 61850 Reporting Profile -Phase 4A adds deterministic, read-only reporting analysis above the MMS service codecs. -It does not establish an association or transmit a request. +## Offline reporting -## Inventory +The reporting layer builds DataSet and BRCB/URCB inventory, decodes +GetNamedVariableListAttributes and InformationReport, maps OptFlds-ordered report fields, +preserves per-item errors, and tracks sequence/configuration/DataSet/overflow/segmentation +continuity under bounded retention. -`MmsReportInventoryBuilder` consumes paged `GetNameList` evidence that has already been -collected by a caller. Named-variable-list names become DataSet candidates. Variables -containing `$BR$` or `$RP$` are grouped into buffered and unbuffered report-control -candidates, including the discovered attribute names. +## Subscription runtime -## DataSet directory +The explicit report-subscription surface re-probes the selected RCB, refuses unsafe takeover, +optionally reserves an URCB, configures only explicitly requested attributes, enables RptEna, +optionally issues GI, receives InformationReports, and cleans up only state touched by the +runtime. Lost-association cleanup is recorded rather than assumed. -`MmsDataSetDirectoryCodec` implements service tag 12, -GetNamedVariableListAttributes. Domain-specific member names are normalized from MMS -form, for example `LD0/GGIO1$ST$Ind1$stVal`, to -`LD0/GGIO1.Ind1.stVal`, while retaining the original ObjectName and functional -constraint. +## Phase 4C.1 live-model integration -## InformationReport order +Read-only discovery includes DataSet directory and RCB state evidence in `live-ied-model-v1`. +This inventory is descriptive only. The Phase 4C.1 parity and physical evidence runners do not +reserve, enable, disable, or otherwise mutate a report control block. -The exact decoder follows `OptFlds` order: +The C#↔C++ parity checker compares DataSet presence/member counts and report-control presence/ +mode. Runtime report subscription acceptance remains separate from read-only model parity. -1. `RptID` -2. `OptFlds` -3. optional `SqNum` -4. optional `TimeOfEntry` -5. optional `DatSet` -6. optional `BufOvfl` -7. optional `EntryID` -8. optional `ConfRev` -9. optional `SubSqNum` and `MoreSegmentsFollow` -10. inclusion bit string -11. included values -12. optional data-reference list -13. optional reason-for-inclusion list +## Pending reporting work -Unknown trailing access results, missing mandatory fields, invalid bit strings, and -DataSet indexes outside the supplied directory are rejected. - -## Monitoring - -`MmsReportSequenceTracker` records sequence gaps, duplicates, wraps, resets, -configuration revision changes, DataSet changes, buffer overflow, and segmented-report -continuity. `MmsOfflineReportMonitor` bounds both the number of streams and retained -frames per stream and evicts the least-recent stream deterministically. - -## Safety boundary - -All APIs operate on supplied byte arrays and supplied discovery/read evidence. No TCP -socket, IED association, live RCB enable, reservation, GI trigger, read, write, or -control operation is enabled by this phase. +- BRCB EntryID resume and replay; +- purge policy and buffer-overflow recovery; +- dynamic DataSet lifecycle; +- long-duration multi-vendor report interoperability. From 776e9e75d15fe414f87f94e132c9f92771b147f5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 07:49:27 +0700 Subject: [PATCH 034/179] Finalize discovery profile for Phase 4C.1 --- LIVE_DISCOVERY_PROFILE.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/LIVE_DISCOVERY_PROFILE.md b/LIVE_DISCOVERY_PROFILE.md index 78a665179..f830b4255 100644 --- a/LIVE_DISCOVERY_PROFILE.md +++ b/LIVE_DISCOVERY_PROFILE.md @@ -15,17 +15,10 @@ application runtime. ## Read-only discovery -`MmsLiveDiscoveryClient` performs only: - -1. GetNameList for VMD domains; -2. GetNameList for domain variables; -3. GetNameList for named-variable lists; -4. optional GetVariableAccessAttributes; -5. optional GetNamedVariableListAttributes; and -6. optional Read of discovered RCB attributes. - -It does not construct Write, RCB enable/reservation, GI, control, dynamic DataSet mutation, or -file-service requests. Regression tests decode every discovery request and reject service tag 5. +`MmsLiveDiscoveryClient` performs only GetNameList, GetVariableAccessAttributes, +GetNamedVariableListAttributes, and Read. It does not construct Write, RCB enable/reservation, +GI, control, dynamic DataSet mutation, or file-service requests. Regression tests decode every +discovery request and reject service tag 5. ## Live-model parity From 772e385ca01881ce1e285179f6ce6ece14bbbdd9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 09:31:11 +0700 Subject: [PATCH 035/179] Fix Windows min/max macro collision in TCP transport --- include/ariec61850/mms/tcp_transport.hpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/include/ariec61850/mms/tcp_transport.hpp b/include/ariec61850/mms/tcp_transport.hpp index d3a79803e..d1ec6340d 100644 --- a/include/ariec61850/mms/tcp_transport.hpp +++ b/include/ariec61850/mms/tcp_transport.hpp @@ -1,6 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +// Prevent the Windows SDK from defining the legacy min/max function-like +// macros. They otherwise corrupt std::min and numeric_limits::max calls in +// the built-in TCP transport when compiling with MSVC. +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#endif + #include "ariec61850/mms/association_runtime.hpp" #include From 6377723939b6fbe33ec798f9ef63209f84f665e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 09:56:03 +0700 Subject: [PATCH 036/179] Add IEDScout CPA responding-selector compatibility patch --- .../iedscout-cpa-responding-selector.patch | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 patches/iedscout-cpa-responding-selector.patch diff --git a/patches/iedscout-cpa-responding-selector.patch b/patches/iedscout-cpa-responding-selector.patch new file mode 100644 index 000000000..1978502cd --- /dev/null +++ b/patches/iedscout-cpa-responding-selector.patch @@ -0,0 +1,27 @@ +diff --git a/src/osi/presentation.cpp b/src/osi/presentation.cpp +--- a/src/osi/presentation.cpp ++++ b/src/osi/presentation.cpp +@@ -568,9 +568,23 @@ bool PresentationCodec::try_decode_cpa( + } + bool saw_results = false; + bool saw_user_data = false; ++ bool saw_responding_selector = false; + for (const auto& normal : BerReader::read_children(item.value)) { +- if (normal.encoded_tag == 0xA5U) { ++ if (normal.encoded_tag == 0x83U) { ++ // responding-presentation-selector is optional in a CPA. ++ // IEDScout and other IEC 61850 servers commonly echo a ++ // four-byte selector before the context-result list. ++ if (saw_responding_selector) { ++ throw PresentationFormatError( ++ "Presentation CPA contains duplicate responding selectors."); ++ } ++ if (normal.value.size() > maximum_selector_bytes) { ++ throw PresentationFormatError( ++ "Presentation responding selector exceeds the limit."); ++ } ++ saw_responding_selector = true; ++ } else if (normal.encoded_tag == 0xA5U) { + if (saw_results) { + throw PresentationFormatError("Presentation CPA contains duplicate context results."); + } From e190a3e3a3f97a73f58d90e8142da59a81f95c3b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 11:14:41 +0700 Subject: [PATCH 037/179] Extend IEDScout CPA compatibility patch with regression test --- .../iedscout-cpa-responding-selector.patch | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/patches/iedscout-cpa-responding-selector.patch b/patches/iedscout-cpa-responding-selector.patch index 1978502cd..8cbc55b54 100644 --- a/patches/iedscout-cpa-responding-selector.patch +++ b/patches/iedscout-cpa-responding-selector.patch @@ -25,3 +25,40 @@ diff --git a/src/osi/presentation.cpp b/src/osi/presentation.cpp if (saw_results) { throw PresentationFormatError("Presentation CPA contains duplicate context results."); } + cpa.context_results = parse_context_result_list(normal.value); + saw_results = true; +diff --git a/tests/test_osi_association.cpp b/tests/test_osi_association.cpp +--- a/tests/test_osi_association.cpp ++++ b/tests/test_osi_association.cpp +@@ -137,6 +137,29 @@ void deterministic_aare_response_matches_csharp_and_decodes() { + CHECK(decoded.aare.user_information->single_asn1_type.front() == 0xA9U); + } + ++void vendor_cpa_accepts_responding_presentation_selector() { ++ auto response = csharp_deterministic_response(); ++ const auto marker = from_hex("A2 69 A5 12"); ++ const auto offset = find_sequence(response, marker); ++ CHECK(offset < response.size()); ++ ++ // Insert the optional responding-presentation-selector and fix the nested ++ // BER lengths: Session user-data, CPA SET, normal-mode parameters. ++ response.insert( ++ response.begin() + static_cast(offset + 3U), ++ {0x83U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U}); ++ response[1] = static_cast(response[1] + 6U); ++ response[23] = static_cast(response[23] + 6U); ++ response[offset + 1U] = static_cast(response[offset + 1U] + 6U); ++ ++ const auto decoded = AcseAssociationCodec::decode_association_response(response); ++ CHECK(decoded.session.kind == SessionSpduKind::accept); ++ CHECK(decoded.presentation.context_results.size() == 2U); ++ CHECK(decoded.aare.accepted()); ++} ++ + void response_mirrors_session_parameters_and_negotiated_context_ids() { +@@ -247,7 +270,8 @@ int main() { + {"presentation CP contexts", presentation_cp_decodes_csharp_contexts_and_round_trips}, + {"balanced AARQ golden vector", balanced_aarq_matches_csharp_and_decodes_structurally}, + {"deterministic AARE golden vector", deterministic_aare_response_matches_csharp_and_decodes}, ++ {"vendor CPA responding selector", vendor_cpa_accepts_responding_presentation_selector}, + {"session/context mirroring", response_mirrors_session_parameters_and_negotiated_context_ids}, From 5b3a6ab90d8a498eb7f0087acb1e249901a2572f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 11:15:54 +0700 Subject: [PATCH 038/179] Accept bounded responding selector in Presentation CPA --- src/osi/presentation.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/osi/presentation.cpp b/src/osi/presentation.cpp index c510e8133..89cc8a386 100644 --- a/src/osi/presentation.cpp +++ b/src/osi/presentation.cpp @@ -587,8 +587,22 @@ bool PresentationCodec::try_decode_cpa( } bool saw_results = false; bool saw_user_data = false; + bool saw_responding_selector = false; for (const auto& normal : BerReader::read_children(item.value)) { - if (normal.encoded_tag == 0xA5U) { + if (normal.encoded_tag == 0x83U) { + // responding-presentation-selector is optional in a CPA. + // Vendor tools such as IEDScout may echo it before the + // presentation-context-definition-result-list. + if (saw_responding_selector) { + throw PresentationFormatError( + "Presentation CPA contains duplicate responding selectors."); + } + if (normal.value.size() > maximum_selector_bytes) { + throw PresentationFormatError( + "Presentation responding selector exceeds the limit."); + } + saw_responding_selector = true; + } else if (normal.encoded_tag == 0xA5U) { if (saw_results) { throw PresentationFormatError("Presentation CPA contains duplicate context results."); } From 7b9389c48f961394ec5f3af1c2f5f2a29f68fe33 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 11:16:53 +0700 Subject: [PATCH 039/179] Add vendor CPA responding-selector regression --- tests/test_osi_association.cpp | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_osi_association.cpp b/tests/test_osi_association.cpp index 6a04fb567..227440efb 100644 --- a/tests/test_osi_association.cpp +++ b/tests/test_osi_association.cpp @@ -195,6 +195,31 @@ void deterministic_aare_response_matches_csharp_and_decodes() { CHECK(decoded.aare.user_information->single_asn1_type.front() == 0xA9U); } +void vendor_cpa_accepts_responding_presentation_selector() { + auto response = csharp_deterministic_response(); + const auto cpa_marker = from_hex("31 70 A0 03"); + const auto normal_marker = from_hex("A2 69 A5 12"); + const auto cpa_offset = find_sequence(response, cpa_marker); + const auto normal_offset = find_sequence(response, normal_marker); + CHECK(cpa_offset < response.size()); + CHECK(normal_offset < response.size()); + + response.insert( + response.begin() + static_cast(normal_offset + 2U), + {0x83U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U}); + response[1] = static_cast(response[1] + 6U); + response[cpa_offset + 1U] = static_cast( + response[cpa_offset + 1U] + 6U); + response[normal_offset + 1U] = static_cast( + response[normal_offset + 1U] + 6U); + + const auto decoded = AcseAssociationCodec::decode_association_response(response); + CHECK(decoded.session.kind == SessionSpduKind::accept); + CHECK(decoded.presentation.context_results.size() == 2U); + CHECK(decoded.presentation.user_data.context_id == 1U); + CHECK(decoded.aare.accepted()); +} + void response_mirrors_session_parameters_and_negotiated_context_ids() { auto request = csharp_default_request(); request[17] = 0x7EU; @@ -303,11 +328,12 @@ void deterministic_pdv_matrix_preserves_lengths_and_contexts() { } // namespace int main() { - const std::array>, 9> tests{{ + const std::array>, 10> tests{{ {"session connect/accept/data transfer", session_connect_accept_and_data_transfer_are_strict}, {"presentation CP contexts", presentation_cp_decodes_csharp_contexts_and_round_trips}, {"balanced AARQ golden vector", balanced_aarq_matches_csharp_and_decodes_structurally}, {"deterministic AARE golden vector", deterministic_aare_response_matches_csharp_and_decodes}, + {"vendor CPA responding selector", vendor_cpa_accepts_responding_presentation_selector}, {"session/context mirroring", response_mirrors_session_parameters_and_negotiated_context_ids}, {"presentation P-DATA", presentation_p_data_round_trips_mms_payload_and_context}, {"malformed association rejection", malformed_session_presentation_and_acse_are_rejected}, From 9b32eee815a738b3b6526235e6094bc89940df1f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 11:18:50 +0700 Subject: [PATCH 040/179] Port C#-style IP-only IED identity resolution into live CLI --- tools/live_discover.cpp | 283 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 281 insertions(+), 2 deletions(-) diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp index 6aba8c843..fe1a48499 100644 --- a/tools/live_discover.cpp +++ b/tools/live_discover.cpp @@ -3,7 +3,10 @@ #include "ariec61850/mms/live_discovery.hpp" #include "ariec61850/mms/live_model.hpp" +#include +#include #include +#include #include #include #include @@ -12,11 +15,281 @@ #include #include #include +#include +#include namespace { using namespace ar::iec61850; +struct IedIdentityResolution final { + std::string name; + std::string source; + mms::MmsLiveModelConfidence confidence{mms::MmsLiveModelConfidence::unknown}; + bool ambiguous{}; + std::vector candidates; + std::vector evidence; +}; + +[[nodiscard]] std::string lower(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( + const std::string_view left, + const std::string_view right) { + return lower(std::string{left}) == lower(std::string{right}); +} + +[[nodiscard]] bool starts_with_ci( + const std::string_view value, + const std::string_view prefix) { + return value.size() >= prefix.size() && same(value.substr(0U, prefix.size()), prefix); +} + +[[nodiscard]] bool ends_with_ci( + const std::string_view value, + const std::string_view suffix) { + return value.size() >= suffix.size() && + same(value.substr(value.size() - suffix.size()), suffix); +} + +[[nodiscard]] std::string trim_boundary(std::string value) { + while (!value.empty()) { + const char character = value.back(); + if (character != '_' && character != '-' && character != '.' && character != ' ') { + break; + } + value.pop_back(); + } + return value; +} + +[[nodiscard]] bool viable_ied_name(const std::string_view value) { + return std::count_if(value.begin(), value.end(), [](const char character) { + return std::isalnum(static_cast(character)) != 0; + }) >= 3; +} + +void append_unique_ci(std::vector& values, const std::string& value) { + if (std::none_of(values.begin(), values.end(), [&value](const auto& existing) { + return same(existing, value); + })) { + values.push_back(value); + } +} + +[[nodiscard]] bool try_extract_known_ld_prefix( + const std::string_view domain, + std::string& candidate) { + static constexpr std::array stems{ + "PROT", "CTRL", "MEAS", "PQM", "MET", "ANN", "BCU", + "SYS", "COM", "RLY", "BAY", "DR", "LD", "MU"}; + + std::string trimmed{domain}; + std::size_t suffix_start = trimmed.size(); + while (suffix_start > 0U && + std::isdigit(static_cast(trimmed[suffix_start - 1U])) != 0) { + --suffix_start; + } + const std::string without_index = trimmed.substr(0U, suffix_start); + for (const auto stem : stems) { + if (!ends_with_ci(without_index, stem) || without_index.size() <= stem.size()) { + continue; + } + auto prefix = trim_boundary( + without_index.substr(0U, without_index.size() - stem.size())); + if (!viable_ied_name(prefix)) { + continue; + } + candidate = std::move(prefix); + return true; + } + return false; +} + +[[nodiscard]] std::string infer_common_prefix( + const std::vector& domains) { + if (domains.size() < 2U) { + return {}; + } + + std::string prefix = domains.front(); + for (std::size_t index = 1U; index < domains.size(); ++index) { + const auto& domain = domains[index]; + std::size_t length = 0U; + while (length < prefix.size() && length < domain.size() && + std::toupper(static_cast(prefix[length])) == + std::toupper(static_cast(domain[length]))) { + ++length; + } + prefix.resize(length); + if (prefix.empty()) { + return {}; + } + } + + prefix = trim_boundary(std::move(prefix)); + static constexpr std::array stems{ + "PROT", "CTRL", "MEAS", "PQM", "MET", "ANN", "BCU", + "SYS", "COM", "RLY", "BAY", "DR", "LD", "MU"}; + for (const auto stem : stems) { + if (ends_with_ci(prefix, stem) && prefix.size() > stem.size()) { + auto without_stem = trim_boundary( + prefix.substr(0U, prefix.size() - stem.size())); + if (viable_ied_name(without_stem)) { + prefix = std::move(without_stem); + } + break; + } + } + return viable_ied_name(prefix) ? prefix : std::string{}; +} + +[[nodiscard]] IedIdentityResolution resolve_ied_identity( + const mms::MmsLiveDiscoveryResult& discovery) { + std::vector domains; + domains.reserve(discovery.names.domain_variables.size()); + for (const auto& [domain, _] : discovery.names.domain_variables) { + if (!domain.empty()) { + append_unique_ci(domains, domain); + } + } + std::sort(domains.begin(), domains.end(), [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + + struct SuffixMatch final { + std::string domain; + std::string candidate; + }; + std::vector suffix_matches; + std::vector distinct_candidates; + for (const auto& domain : domains) { + std::string candidate; + if (try_extract_known_ld_prefix(domain, candidate)) { + suffix_matches.push_back({domain, candidate}); + append_unique_ci(distinct_candidates, candidate); + } + } + std::sort( + distinct_candidates.begin(), distinct_candidates.end(), + [](const auto& left, const auto& right) { return lower(left) < lower(right); }); + + if (!domains.empty() && suffix_matches.size() == domains.size()) { + if (distinct_candidates.size() == 1U) { + IedIdentityResolution resolution; + resolution.name = distinct_candidates.front(); + resolution.source = "MmsDomainKnownLogicalDeviceSuffix"; + resolution.confidence = domains.size() > 1U + ? mms::MmsLiveModelConfidence::high + : mms::MmsLiveModelConfidence::medium; + resolution.candidates = distinct_candidates; + for (const auto& match : suffix_matches) { + resolution.evidence.push_back( + "MMS domain '" + match.domain + + "' matched the logical-device suffix pattern for IED '" + + resolution.name + "'."); + } + return resolution; + } + if (distinct_candidates.size() > 1U) { + return { + discovery.endpoint.host.empty() ? "DISCOVERED_IED" : discovery.endpoint.host, + "MmsDomainAmbiguous", + mms::MmsLiveModelConfidence::low, + true, + distinct_candidates, + {"MMS domains produced conflicting IED-name candidates."}}; + } + } + + if (const auto common_prefix = infer_common_prefix(domains); !common_prefix.empty()) { + return { + common_prefix, + "MmsDomainCommonPrefix", + domains.size() >= 3U + ? mms::MmsLiveModelConfidence::high + : mms::MmsLiveModelConfidence::medium, + false, + {common_prefix}, + {"IED name was derived from the common prefix of " + + std::to_string(domains.size()) + " MMS domain(s)."}}; + } + + if (distinct_candidates.size() == 1U) { + const auto& candidate = distinct_candidates.front(); + const bool shared_prefix = suffix_matches.size() >= 2U && + std::all_of(domains.begin(), domains.end(), [&candidate](const auto& domain) { + return starts_with_ci(domain, candidate); + }); + if (domains.size() == 1U || shared_prefix) { + IedIdentityResolution resolution; + resolution.name = candidate; + resolution.source = "MmsDomainKnownLogicalDeviceSuffix"; + resolution.confidence = domains.size() == 1U + ? mms::MmsLiveModelConfidence::medium + : mms::MmsLiveModelConfidence::high; + resolution.candidates = distinct_candidates; + for (const auto& match : suffix_matches) { + resolution.evidence.push_back( + "MMS domain '" + match.domain + + "' matched the logical-device suffix pattern for IED '" + + candidate + "'."); + } + return resolution; + } + } + + if (distinct_candidates.size() > 1U) { + return { + discovery.endpoint.host.empty() ? "DISCOVERED_IED" : discovery.endpoint.host, + "MmsDomainAmbiguous", + mms::MmsLiveModelConfidence::low, + true, + distinct_candidates, + {"MMS domains produced conflicting IED-name candidates."}}; + } + + return { + discovery.endpoint.host.empty() ? "DISCOVERED_IED" : discovery.endpoint.host, + "HostFallback", + mms::MmsLiveModelConfidence::low, + false, + {}, + {"No safe IED-name candidate could be derived from the live MMS domains."}}; +} + +void apply_resolved_identity( + mms::MmsLiveModelDocument& model, + const mms::MmsLiveDiscoveryResult& discovery) { + const auto resolution = resolve_ied_identity(discovery); + model.identity.ied_name = resolution.name; + model.identity.source = resolution.source; + model.identity.confidence = resolution.confidence; + model.identity.ambiguous = resolution.ambiguous; + model.identity.candidate_names = resolution.candidates; + model.identity.evidence = resolution.evidence; + model.identity.logical_device_aliases.clear(); + + for (auto& logical_device : model.logical_devices) { + std::string alias = logical_device.mms_domain; + if (starts_with_ci(logical_device.mms_domain, resolution.name) && + logical_device.mms_domain.size() > resolution.name.size()) { + alias = logical_device.mms_domain.substr(resolution.name.size()); + } + if (alias.empty()) { + alias = logical_device.mms_domain; + } + logical_device.instance = alias; + model.identity.logical_device_aliases.emplace( + logical_device.mms_domain, std::move(alias)); + } +} + [[nodiscard]] std::string json_escape(const std::string_view value) { std::ostringstream output; for (const char character : value) { @@ -66,7 +339,7 @@ void print_usage() { << " --json Emit compact discovery summary JSON.\n" << " --model-json Emit C#-compatible live-ied-model-v1 JSON.\n" << " --manifest Emit deterministic parity manifest.\n" - << " --ied-name NAME Explicit IED identity override.\n" + << " --ied-name NAME Optional explicit IED identity override.\n" << " --no-types Skip GetVariableAccessAttributes probes.\n" << " --no-datasets Skip DataSet directory reads.\n" << " --no-rcb Skip read-only RCB attribute probes.\n" @@ -81,6 +354,8 @@ void print_human( const mms::MmsLiveModelDocument& model) { std::cout << result.summary() << '\n' << model.summary << '\n' + << "IED identity: " << model.identity.ied_name + << " (source=" << model.identity.source << ")\n" << "Model fingerprint: " << model.canonical_fingerprint_hex() << '\n'; for (const auto& logical_device : model.logical_devices) { std::cout << " LD " << logical_device.mms_domain @@ -106,6 +381,7 @@ void print_summary_json( << "\"endpoint\":\"" << json_escape(result.endpoint.host) << ':' << result.endpoint.port << "\"," << "\"iedName\":\"" << json_escape(model.identity.ied_name) << "\"," + << "\"iedNameSource\":\"" << json_escape(model.identity.source) << "\"," << "\"domains\":" << result.domain_count() << ',' << "\"variables\":" << result.variable_count() << ',' << "\"logicalDevices\":" << model.coverage.logical_device_count << ',' @@ -196,7 +472,10 @@ int main(int argc, char** argv) { session.connect(endpoint); const auto result = session.discover(discovery_options); session.disconnect(); - const auto model = mms::MmsLiveModelBuilder::build(result, model_options); + auto model = mms::MmsLiveModelBuilder::build(result, model_options); + if (model_options.explicit_ied_name.empty()) { + apply_resolved_identity(model, result); + } switch (output_mode) { case OutputMode::human: print_human(result, model); break; From e56eb185fac4d5bb8e1df79574b67b8e26806893 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 11:19:33 +0700 Subject: [PATCH 041/179] Verify IP-only live model identity inference --- tests/test_live_discovery.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 888d9fa07..c6105842a 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -193,11 +193,11 @@ void live_discovery_builds_csharp_compatible_read_only_model() { CHECK(result.report_controls.size() == 1U); CHECK(!result.partial()); - mms::MmsLiveModelBuildOptions model_options; - model_options.explicit_ied_name = "OLS501"; - const auto model = mms::MmsLiveModelBuilder::build(result, model_options); + const auto model = mms::MmsLiveModelBuilder::build(result); CHECK(model.schema_version == "live-ied-model-v1"); CHECK(model.identity.ied_name == "OLS501"); + CHECK(model.identity.source == "MmsDomainInference"); + CHECK(model.identity.logical_device_aliases.at("OLS501LD0") == "LD0"); CHECK(model.coverage.logical_device_count == 1U); CHECK(model.coverage.logical_node_count == 1U); CHECK(model.coverage.data_attribute_count >= 1U); From aacbe7a5c06c8dffe8ee602967544c41c50f57e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 11:21:49 +0700 Subject: [PATCH 042/179] Fix vendor CPA regression session user-data length --- tests/test_osi_association.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_osi_association.cpp b/tests/test_osi_association.cpp index 227440efb..2a52d2ca2 100644 --- a/tests/test_osi_association.cpp +++ b/tests/test_osi_association.cpp @@ -197,10 +197,13 @@ void deterministic_aare_response_matches_csharp_and_decodes() { void vendor_cpa_accepts_responding_presentation_selector() { auto response = csharp_deterministic_response(); + const auto session_user_data_marker = from_hex("C1 72 31 70"); const auto cpa_marker = from_hex("31 70 A0 03"); const auto normal_marker = from_hex("A2 69 A5 12"); + const auto session_user_data_offset = find_sequence(response, session_user_data_marker); const auto cpa_offset = find_sequence(response, cpa_marker); const auto normal_offset = find_sequence(response, normal_marker); + CHECK(session_user_data_offset < response.size()); CHECK(cpa_offset < response.size()); CHECK(normal_offset < response.size()); @@ -208,6 +211,8 @@ void vendor_cpa_accepts_responding_presentation_selector() { response.begin() + static_cast(normal_offset + 2U), {0x83U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U}); response[1] = static_cast(response[1] + 6U); + response[session_user_data_offset + 1U] = static_cast( + response[session_user_data_offset + 1U] + 6U); response[cpa_offset + 1U] = static_cast( response[cpa_offset + 1U] + 6U); response[normal_offset + 1U] = static_cast( From 5a2bc5895383428030b8cf185bedf079c5648538 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 11:22:16 +0700 Subject: [PATCH 043/179] Correct IEDScout CPA patch regression lengths --- .../iedscout-cpa-responding-selector.patch | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/patches/iedscout-cpa-responding-selector.patch b/patches/iedscout-cpa-responding-selector.patch index 8cbc55b54..0fad533aa 100644 --- a/patches/iedscout-cpa-responding-selector.patch +++ b/patches/iedscout-cpa-responding-selector.patch @@ -30,33 +30,45 @@ diff --git a/src/osi/presentation.cpp b/src/osi/presentation.cpp diff --git a/tests/test_osi_association.cpp b/tests/test_osi_association.cpp --- a/tests/test_osi_association.cpp +++ b/tests/test_osi_association.cpp -@@ -137,6 +137,29 @@ void deterministic_aare_response_matches_csharp_and_decodes() { +@@ -137,6 +137,38 @@ void deterministic_aare_response_matches_csharp_and_decodes() { CHECK(decoded.aare.user_information->single_asn1_type.front() == 0xA9U); } +void vendor_cpa_accepts_responding_presentation_selector() { + auto response = csharp_deterministic_response(); -+ const auto marker = from_hex("A2 69 A5 12"); -+ const auto offset = find_sequence(response, marker); -+ CHECK(offset < response.size()); ++ const auto session_user_data_marker = from_hex("C1 72 31 70"); ++ const auto cpa_marker = from_hex("31 70 A0 03"); ++ const auto normal_marker = from_hex("A2 69 A5 12"); ++ const auto session_user_data_offset = find_sequence(response, session_user_data_marker); ++ const auto cpa_offset = find_sequence(response, cpa_marker); ++ const auto normal_offset = find_sequence(response, normal_marker); ++ CHECK(session_user_data_offset < response.size()); ++ CHECK(cpa_offset < response.size()); ++ CHECK(normal_offset < response.size()); + -+ // Insert the optional responding-presentation-selector and fix the nested -+ // BER lengths: Session user-data, CPA SET, normal-mode parameters. + response.insert( -+ response.begin() + static_cast(offset + 3U), ++ response.begin() + static_cast(normal_offset + 2U), + {0x83U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U}); + response[1] = static_cast(response[1] + 6U); -+ response[23] = static_cast(response[23] + 6U); -+ response[offset + 1U] = static_cast(response[offset + 1U] + 6U); ++ response[session_user_data_offset + 1U] = static_cast( ++ response[session_user_data_offset + 1U] + 6U); ++ response[cpa_offset + 1U] = static_cast( ++ response[cpa_offset + 1U] + 6U); ++ response[normal_offset + 1U] = static_cast( ++ response[normal_offset + 1U] + 6U); + + const auto decoded = AcseAssociationCodec::decode_association_response(response); + CHECK(decoded.session.kind == SessionSpduKind::accept); + CHECK(decoded.presentation.context_results.size() == 2U); ++ CHECK(decoded.presentation.user_data.context_id == 1U); + CHECK(decoded.aare.accepted()); +} + void response_mirrors_session_parameters_and_negotiated_context_ids() { -@@ -247,7 +270,8 @@ int main() { +@@ -247,7 +279,8 @@ int main() { +- const std::array>, 9> tests{{ ++ const std::array>, 10> tests{{ + {"session connect/accept/data transfer", session_connect_accept_and_data_transfer_are_strict}, {"presentation CP contexts", presentation_cp_decodes_csharp_contexts_and_round_trips}, {"balanced AARQ golden vector", balanced_aarq_matches_csharp_and_decodes_structurally}, {"deterministic AARE golden vector", deterministic_aare_response_matches_csharp_and_decodes}, From d74127f206d6b0b1678d3a6a4dbcdfc5239c9cbd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 14:04:14 +0700 Subject: [PATCH 044/179] Accept standard optional X.226 CPA fields --- src/osi/presentation.cpp | 70 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/src/osi/presentation.cpp b/src/osi/presentation.cpp index 89cc8a386..93e6045f4 100644 --- a/src/osi/presentation.cpp +++ b/src/osi/presentation.cpp @@ -13,6 +13,7 @@ namespace ar::iec61850::osi { namespace { +using asn1::BerClass; using asn1::BerFormatError; using asn1::BerReader; using asn1::BerTlv; @@ -87,6 +88,14 @@ void validate_oid( } } +void validate_optional_normal_field( + const BerTlv& field, + const char* description) { + if (field.value.size() > PresentationCodec::maximum_selector_bytes) { + throw PresentationFormatError(std::string{description} + " exceeds the bounded limit."); + } +} + std::vector encode_context_definition_list( const std::span contexts) { if (contexts.empty() || contexts.size() > PresentationCodec::maximum_contexts) { @@ -585,14 +594,25 @@ bool PresentationCodec::try_decode_cpa( if (saw_normal) { throw PresentationFormatError("Presentation CPA contains duplicate normal-mode parameters."); } + bool saw_protocol_version = false; bool saw_results = false; bool saw_user_data = false; bool saw_responding_selector = false; + bool saw_presentation_requirements = false; + bool saw_user_session_requirements = false; + bool saw_protocol_options = false; + bool saw_nominated_context = false; for (const auto& normal : BerReader::read_children(item.value)) { - if (normal.encoded_tag == 0x83U) { - // responding-presentation-selector is optional in a CPA. - // Vendor tools such as IEDScout may echo it before the - // presentation-context-definition-result-list. + if (normal.tag_class == BerClass::context_specific && + normal.tag_number == 0) { + if (saw_protocol_version) { + throw PresentationFormatError( + "Presentation CPA contains duplicate protocol versions."); + } + validate_optional_normal_field( + normal, "Presentation protocol-version"); + saw_protocol_version = true; + } else if (normal.encoded_tag == 0x83U) { if (saw_responding_selector) { throw PresentationFormatError( "Presentation CPA contains duplicate responding selectors."); @@ -608,6 +628,42 @@ bool PresentationCodec::try_decode_cpa( } cpa.context_results = parse_context_result_list(normal.value); saw_results = true; + } else if (normal.tag_class == BerClass::context_specific && + normal.tag_number == 8) { + if (saw_presentation_requirements) { + throw PresentationFormatError( + "Presentation CPA contains duplicate presentation requirements."); + } + validate_optional_normal_field( + normal, "Presentation requirements"); + saw_presentation_requirements = true; + } else if (normal.tag_class == BerClass::context_specific && + normal.tag_number == 9) { + if (saw_user_session_requirements) { + throw PresentationFormatError( + "Presentation CPA contains duplicate user-session requirements."); + } + validate_optional_normal_field( + normal, "Presentation user-session requirements"); + saw_user_session_requirements = true; + } else if (normal.tag_class == BerClass::context_specific && + normal.tag_number == 11) { + if (saw_protocol_options) { + throw PresentationFormatError( + "Presentation CPA contains duplicate protocol options."); + } + validate_optional_normal_field( + normal, "Presentation protocol options"); + saw_protocol_options = true; + } else if (normal.tag_class == BerClass::context_specific && + normal.tag_number == 13) { + if (saw_nominated_context) { + throw PresentationFormatError( + "Presentation CPA contains duplicate nominated contexts."); + } + validate_optional_normal_field( + normal, "Presentation responders-nominated-context"); + saw_nominated_context = true; } else if (normal.encoded_tag == 0x61U) { if (saw_user_data) { throw PresentationFormatError("Presentation CPA contains duplicate user data."); @@ -616,7 +672,11 @@ bool PresentationCodec::try_decode_cpa( cpa.user_data = decode_fully_encoded_data(encoded); saw_user_data = true; } else { - throw PresentationFormatError("Presentation CPA contains an unsupported normal-mode field."); + throw PresentationFormatError( + "Presentation CPA contains an unsupported normal-mode field tag=" + + std::to_string(normal.encoded_tag) + + ", tagNumber=" + std::to_string(normal.tag_number) + + ", length=" + std::to_string(normal.value.size()) + "."); } } if (!saw_results || !saw_user_data) { From 473938521f68660071d307a1d7153005910ccf00 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 14:05:46 +0700 Subject: [PATCH 045/179] Port tolerant C# association acceptance fallback --- src/mms/association_runtime.cpp | 140 +++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 22 deletions(-) diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index 9d5ba26d5..daeb76ad7 100644 --- a/src/mms/association_runtime.cpp +++ b/src/mms/association_runtime.cpp @@ -4,6 +4,7 @@ #include "ariec61850/acse/association.hpp" #include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/session.hpp" #include #include @@ -25,7 +26,60 @@ namespace { return (*value)[0]; } +[[nodiscard]] bool contains_octet( + const std::span bytes, + const std::uint8_t value) noexcept { + return std::find(bytes.begin(), bytes.end(), value) != bytes.end(); +} + +[[nodiscard]] bool csharp_compatible_association_accept( + const std::span bytes, + std::string& message) noexcept { + try { + osi::SessionSpdu session; + std::string session_error; + if (!osi::SessionCodec::try_decode(bytes, session, &session_error)) { + message = "C# compatibility inspection rejected the ISO Session payload: " + + session_error; + return false; + } + if (session.kind != osi::SessionSpduKind::accept) { + message = "C# compatibility inspection requires an ISO Session Accept SPDU."; + return false; + } + + const auto presentation = std::span{session.user_data}; + const bool has_presentation_accept = + contains_octet(presentation, 0x31U) || + contains_octet(presentation, 0xA0U); + const bool has_acse_aare = contains_octet(presentation, 0x61U); + const bool has_user_information = contains_octet(presentation, 0xBEU); + const bool has_mms_initiate_response = contains_octet(presentation, 0xA9U); + + if (!has_presentation_accept || !has_acse_aare) { + message = + "C# compatibility inspection did not find Presentation accept and ACSE AARE markers."; + return false; + } + message = + "C#-compatible association acceptance: ISO Session Accept with Presentation/AARE markers"; + if (has_user_information) { + message += ", ACSE user-information"; + } + if (has_mms_initiate_response) { + message += ", MMS Initiate-Response marker"; + } + message += "."; + return true; + } catch (const std::exception& exception) { + message = std::string{"C# compatibility inspection failed: "} + exception.what(); + return false; + } catch (...) { + message = "C# compatibility inspection failed unexpectedly."; + return false; + } +} } // namespace @@ -246,32 +300,74 @@ void MmsAssociationRuntime::connect( send_application_payload(association_request, deadline, stop_token); const auto association_payload = receive_application_payload(deadline, stop_token); - const auto response = - acse::AcseAssociationCodec::decode_association_response(association_payload); - if (!response.aare.accepted()) { - throw MmsAssociationRuntimeError( - "ACSE association was rejected with result=" + - std::to_string(response.aare.result) + "."); + + bool used_csharp_compatibility = false; + std::string strict_failure; + std::optional response; + try { + response = acse::AcseAssociationCodec::decode_association_response( + association_payload); + } catch (const acse::AcseFormatError& exception) { + strict_failure = exception.what(); } - if (!response.aare.user_information) { - throw MmsAssociationRuntimeError( - "Accepted ACSE response did not contain MMS Initiate response data."); + + if (response) { + if (!response->aare.accepted()) { + throw MmsAssociationRuntimeError( + "ACSE association was rejected with result=" + + std::to_string(response->aare.result) + "."); + } + + if (response->aare.user_information) { + try { + const auto initiate = MmsPduCodec::decode_initiate_response( + response->aare.user_information->single_asn1_type); + negotiated_.maximum_mms_pdu_size = + initiate.negotiated_maximum_mms_pdu_size; + negotiated_.maximum_outstanding_calling = + initiate.negotiated_maximum_outstanding_calling; + negotiated_.maximum_outstanding_called = + initiate.negotiated_maximum_outstanding_called; + negotiated_.data_structure_nesting_level = + initiate.negotiated_data_structure_nesting_level; + } catch (const std::exception& exception) { + strict_failure = + std::string{"Strict MMS Initiate-Response decode failed: "} + + exception.what(); + } + } else { + strict_failure = + "Accepted ACSE response did not expose MMS Initiate response data through the strict decoder."; + } } - const auto initiate = MmsPduCodec::decode_initiate_response( - response.aare.user_information->single_asn1_type); - negotiated_.maximum_mms_pdu_size = initiate.negotiated_maximum_mms_pdu_size; - negotiated_.maximum_outstanding_calling = - initiate.negotiated_maximum_outstanding_calling; - negotiated_.maximum_outstanding_called = - initiate.negotiated_maximum_outstanding_called; - negotiated_.data_structure_nesting_level = - initiate.negotiated_data_structure_nesting_level; - negotiated_.presentation_context_id = options_.presentation_context_id; + if (!response || !strict_failure.empty()) { + std::string compatibility_message; + if (!csharp_compatible_association_accept( + association_payload, compatibility_message)) { + if (strict_failure.empty()) { + strict_failure = compatibility_message; + } else if (!compatibility_message.empty()) { + strict_failure += " " + compatibility_message; + } + throw MmsAssociationRuntimeError(strict_failure); + } + used_csharp_compatibility = true; + add_event( + MmsAssociationEventKind::association_accepted, + compatibility_message + + (strict_failure.empty() + ? std::string{} + : " Strict decoder diagnostic: " + strict_failure)); + } + + negotiated_.presentation_context_id = options_.presentation_context_id; state_ = MmsAssociationRuntimeState::associated; - add_event(MmsAssociationEventKind::association_accepted, - "MMS association accepted; maximum PDU=" + - std::to_string(negotiated_.maximum_mms_pdu_size) + "."); + if (!used_csharp_compatibility) { + add_event(MmsAssociationEventKind::association_accepted, + "MMS association accepted; maximum PDU=" + + std::to_string(negotiated_.maximum_mms_pdu_size) + "."); + } } catch (const MmsTransportCancelledError& exception) { state_ = MmsAssociationRuntimeState::faulted; last_fault_ = exception.what(); From 32cc8ab5f4606848206a54acec9a10c546d7fc5d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:04:58 +0700 Subject: [PATCH 046/179] Port C# live IED identity resolver into core model --- include/ariec61850/mms/live_model_chunk02.ipp | 314 ++++++++++++++++-- 1 file changed, 286 insertions(+), 28 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk02.ipp b/include/ariec61850/mms/live_model_chunk02.ipp index 491ec8338..84f9db4f1 100644 --- a/include/ariec61850/mms/live_model_chunk02.ipp +++ b/include/ariec61850/mms/live_model_chunk02.ipp @@ -3,29 +3,297 @@ if (st_val) return {"SPS", 0.60}; return {"", 0.0}; } -[[nodiscard]] inline std::string infer_ied_name( - const std::vector& domains, +[[nodiscard]] inline bool starts_with_ci( + const std::string_view value, + const std::string_view prefix) { + return value.size() >= prefix.size() && same(value.substr(0U, prefix.size()), prefix); +} +[[nodiscard]] inline bool ends_with_ci( + const std::string_view value, + const std::string_view suffix) { + return value.size() >= suffix.size() && + same(value.substr(value.size() - suffix.size()), suffix); +} +[[nodiscard]] inline std::string trim(std::string value) { + while (!value.empty() && + std::isspace(static_cast(value.front())) != 0) { + value.erase(value.begin()); + } + while (!value.empty() && + std::isspace(static_cast(value.back())) != 0) { + value.pop_back(); + } + return value; +} +[[nodiscard]] inline std::string trim_boundary(std::string value) { + while (!value.empty()) { + const auto character = value.back(); + if (character != '_' && character != '-' && character != '.' && character != ' ') { + break; + } + value.pop_back(); + } + return value; +} +[[nodiscard]] inline bool viable_ied_name(const std::string_view value) { + return std::count_if(value.begin(), value.end(), [](const char character) { + return std::isalnum(static_cast(character)) != 0; + }) >= 3; +} +inline void append_unique_ci( + std::vector& values, + const std::string& value) { + if (std::none_of(values.begin(), values.end(), [&value](const auto& existing) { + return same(existing, value); + })) { + values.push_back(value); + } +} +[[nodiscard]] inline bool try_extract_known_ld_prefix( + const std::string_view domain, + std::string& candidate) { + static constexpr std::array stems{ + "PROT", "CTRL", "MEAS", "PQM", "MET", "ANN", "BCU", + "SYS", "COM", "RLY", "BAY", "DR", "LD", "MU"}; + + const auto cleaned = trim(std::string{domain}); + std::size_t suffix_start = cleaned.size(); + while (suffix_start > 0U && + std::isdigit(static_cast(cleaned[suffix_start - 1U])) != 0) { + --suffix_start; + } + const auto without_index = cleaned.substr(0U, suffix_start); + for (const auto stem : stems) { + if (!ends_with_ci(without_index, stem) || without_index.size() <= stem.size()) { + continue; + } + auto prefix = trim_boundary( + without_index.substr(0U, without_index.size() - stem.size())); + if (!viable_ied_name(prefix)) { + continue; + } + candidate = std::move(prefix); + return true; + } + return false; +} +[[nodiscard]] inline std::string infer_common_prefix( + const std::vector& domains) { + if (domains.size() < 2U) { + return {}; + } + + std::string prefix = domains.front(); + for (std::size_t index = 1U; index < domains.size(); ++index) { + const auto& domain = domains[index]; + std::size_t length = 0U; + while (length < prefix.size() && length < domain.size() && + std::toupper(static_cast(prefix[length])) == + std::toupper(static_cast(domain[length]))) { + ++length; + } + prefix.resize(length); + if (prefix.empty()) { + return {}; + } + } + + prefix = trim_boundary(std::move(prefix)); + static constexpr std::array stems{ + "PROT", "CTRL", "MEAS", "PQM", "MET", "ANN", "BCU", + "SYS", "COM", "RLY", "BAY", "DR", "LD", "MU"}; + for (const auto stem : stems) { + if (ends_with_ci(prefix, stem) && prefix.size() > stem.size()) { + auto without_stem = trim_boundary( + prefix.substr(0U, prefix.size() - stem.size())); + if (viable_ied_name(without_stem)) { + prefix = std::move(without_stem); + } + break; + } + } + return viable_ied_name(prefix) ? prefix : std::string{}; +} +[[nodiscard]] inline MmsLiveIedIdentity resolve_ied_identity( + const std::vector& input_domains, const MmsLiveModelBuildOptions& options, const MmsEndpoint& endpoint) { - if (!options.explicit_ied_name.empty()) return options.explicit_ied_name; - static constexpr std::array suffixes{ - "LD0", "LD1", "CTRL", "PROT", "MEAS", "CFG", "SYS", "BAY", "MU", "P1", "P2", "DR"}; - std::vector candidates; + std::vector domains; + for (const auto& raw_domain : input_domains) { + const auto domain = trim(raw_domain); + if (!domain.empty()) { + append_unique_ci(domains, domain); + } + } + std::sort(domains.begin(), domains.end(), [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + + const auto make = [&](std::string name, + std::string source, + const MmsLiveModelConfidence confidence_value, + const bool ambiguous, + std::vector candidates, + std::vector evidence) { + MmsLiveIedIdentity identity; + identity.ied_name = trim(std::move(name)); + identity.source = std::move(source); + identity.confidence = confidence_value; + identity.ambiguous = ambiguous; + identity.candidate_names = std::move(candidates); + identity.evidence = std::move(evidence); + for (const auto& domain : domains) { + std::string alias = domain; + if (starts_with_ci(domain, identity.ied_name) && + domain.size() > identity.ied_name.size()) { + alias = domain.substr(identity.ied_name.size()); + } + if (alias.empty()) { + alias = domain; + } + identity.logical_device_aliases.emplace(domain, std::move(alias)); + } + return identity; + }; + + const auto fallback = [&]() { + if (!trim(options.fallback_ied_name).empty()) { + return trim(options.fallback_ied_name); + } + const auto host = trim(endpoint.host); + return host.empty() ? std::string{"DISCOVERED_IED"} : host; + }; + + if (const auto explicit_name = trim(options.explicit_ied_name); + !explicit_name.empty()) { + return make( + explicit_name, + "ExplicitOverride", + MmsLiveModelConfidence::exact, + false, + {explicit_name}, + {"IED name was supplied explicitly as '" + explicit_name + "'."}); + } + + struct SuffixMatch final { + std::string domain; + std::string candidate; + }; + std::vector suffix_matches; + std::vector distinct_candidates; for (const auto& domain : domains) { - for (const auto suffix : suffixes) { - if (domain.size() > suffix.size() && - same(domain.substr(domain.size() - suffix.size()), suffix)) { - candidates.push_back(domain.substr(0, domain.size() - suffix.size())); - break; + std::string candidate; + if (try_extract_known_ld_prefix(domain, candidate)) { + suffix_matches.push_back({domain, candidate}); + append_unique_ci(distinct_candidates, candidate); + } + } + std::sort(distinct_candidates.begin(), distinct_candidates.end(), + [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + + if (!domains.empty() && suffix_matches.size() == domains.size()) { + if (distinct_candidates.size() == 1U) { + const auto& name = distinct_candidates.front(); + std::vector evidence; + for (const auto& match : suffix_matches) { + evidence.push_back( + "MMS domain '" + match.domain + + "' matched the logical-device suffix pattern for IED '" + name + "'."); + } + return make( + name, + "MmsDomainKnownLogicalDeviceSuffix", + domains.size() > 1U + ? MmsLiveModelConfidence::high + : MmsLiveModelConfidence::medium, + false, + distinct_candidates, + std::move(evidence)); + } + if (distinct_candidates.size() > 1U) { + std::ostringstream message; + message << "MMS domains produced conflicting IED-name candidates: "; + for (std::size_t index = 0U; index < distinct_candidates.size(); ++index) { + if (index) message << ", "; + message << distinct_candidates[index]; + } + message << '.'; + return make( + fallback(), + "MmsDomainAmbiguous", + MmsLiveModelConfidence::low, + true, + distinct_candidates, + {message.str()}); + } + } + + if (const auto common_prefix = infer_common_prefix(domains); + !common_prefix.empty()) { + return make( + common_prefix, + "MmsDomainCommonPrefix", + domains.size() >= 3U + ? MmsLiveModelConfidence::high + : MmsLiveModelConfidence::medium, + false, + {common_prefix}, + {"IED name '" + common_prefix + + "' was derived from the common prefix of " + + std::to_string(domains.size()) + " MMS domain(s)."}); + } + + if (distinct_candidates.size() == 1U) { + const auto& candidate = distinct_candidates.front(); + const bool shared_prefix = suffix_matches.size() >= 2U && + std::all_of(domains.begin(), domains.end(), [&candidate](const auto& domain) { + return starts_with_ci(domain, candidate); + }); + if (domains.size() == 1U || shared_prefix) { + std::vector evidence; + for (const auto& match : suffix_matches) { + evidence.push_back( + "MMS domain '" + match.domain + + "' matched the logical-device suffix pattern for IED '" + candidate + "'."); } + return make( + candidate, + "MmsDomainKnownLogicalDeviceSuffix", + domains.size() == 1U + ? MmsLiveModelConfidence::medium + : MmsLiveModelConfidence::high, + false, + distinct_candidates, + std::move(evidence)); } } - if (!candidates.empty() && std::all_of(candidates.begin(), candidates.end(), - [&](const auto& value) { return same(value, candidates.front()); })) { - return candidates.front(); + + if (distinct_candidates.size() > 1U) { + std::ostringstream message; + message << "MMS domains produced conflicting IED-name candidates: "; + for (std::size_t index = 0U; index < distinct_candidates.size(); ++index) { + if (index) message << ", "; + message << distinct_candidates[index]; + } + message << '.'; + return make( + fallback(), + "MmsDomainAmbiguous", + MmsLiveModelConfidence::low, + true, + distinct_candidates, + {message.str()}); } - if (!options.fallback_ied_name.empty()) return options.fallback_ied_name; - return endpoint.host.empty() ? "DISCOVERED_IED" : endpoint.host; + + return make( + fallback(), + "HostFallback", + MmsLiveModelConfidence::low, + false, + {}, + {"No safe IED-name candidate could be derived from the live MMS domains."}); } [[nodiscard]] inline std::vector collect_points( const MmsLiveDiscoveryResult& discovery) { @@ -76,21 +344,11 @@ inline MmsLiveModelDocument MmsLiveModelBuilder::build( document.access_point_name = options.access_point_name.empty() ? "AP1" : options.access_point_name; std::vector domains; for (const auto& [domain, _] : discovery.names.domain_variables) domains.push_back(domain); - std::sort(domains.begin(), domains.end()); - document.identity.ied_name = infer_ied_name(domains, options, discovery.endpoint); - document.identity.source = options.explicit_ied_name.empty() ? "MmsDomainInference" : "ExplicitOverride"; - document.identity.confidence = options.explicit_ied_name.empty() - ? MmsLiveModelConfidence::medium : MmsLiveModelConfidence::exact; - for (const auto& domain : domains) { - std::string alias = domain; - if (domain.starts_with(document.identity.ied_name) && domain.size() > document.identity.ied_name.size()) - alias = domain.substr(document.identity.ied_name.size()); - document.identity.logical_device_aliases.emplace(domain, alias); - } + document.identity = resolve_ied_identity(domains, options, discovery.endpoint); using AttributeMap = std::map>; using ObjectMap = std::map>; using NodeMap = std::map>; std::map> hierarchy; for (const auto& point : collect_points(discovery)) { - const auto object_name = MmsLiveReferenceParser::top_data_object_name(point.data_object_path); + const auto object_name = MmsLiveReferenceParser::top_data_object_name(point.data_object_path); \ No newline at end of file From 55fe438b01d028988bf10553129d97a8662211a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:06:01 +0700 Subject: [PATCH 047/179] Port ARIEC61850 identity resolver regression cases --- tests/test_live_discovery.cpp | 76 ++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index c6105842a..47a176401 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -168,6 +168,79 @@ void queue_discovery_responses(ScriptedTransport& transport) { mms::MmsServiceCodec::encode_read_response_p_data(rcb_response))); } +[[nodiscard]] mms::MmsLiveModelDocument build_identity_model( + const std::vector& domains, + const std::string& host = "192.0.2.10") { + mms::MmsLiveDiscoveryResult result; + result.endpoint = {host, 102U}; + for (const auto& domain : domains) { + result.names.domain_variables.emplace(domain, std::vector{}); + } + return mms::MmsLiveModelBuilder::build(result); +} + +void csharp_identity_resolver_cases_are_preserved() { + { + const auto model = build_identity_model({"OLSF501LD0"}); + CHECK(model.identity.ied_name == "OLSF501"); + CHECK(model.identity.source == "MmsDomainKnownLogicalDeviceSuffix"); + CHECK(model.identity.confidence == mms::MmsLiveModelConfidence::medium); + CHECK(model.identity.logical_device_aliases.at("OLSF501LD0") == "LD0"); + } + + { + const auto model = build_identity_model( + {"OLSF501LD0", "OLSF501PROT", "OLSF501CTRL"}); + CHECK(model.identity.ied_name == "OLSF501"); + CHECK(model.identity.source == "MmsDomainKnownLogicalDeviceSuffix"); + CHECK(model.identity.confidence == mms::MmsLiveModelConfidence::high); + CHECK(!model.identity.ambiguous); + CHECK(model.identity.logical_device_aliases.at("OLSF501PROT") == "PROT"); + } + + { + const auto model = build_identity_model( + {"OCR7SJ8Application", + "OCR7SJ8CB1", + "OCR7SJ8Dc1", + "OCR7SJ8Mod2_MU1", + "OCR7SJ8V3p1_5051OC3phase1"}, + "1.110.1.1"); + CHECK(model.identity.ied_name == "OCR7SJ8"); + CHECK(model.identity.source == "MmsDomainCommonPrefix"); + CHECK(model.identity.confidence == mms::MmsLiveModelConfidence::high); + CHECK(model.identity.logical_device_aliases.at("OCR7SJ8Mod2_MU1") == "Mod2_MU1"); + CHECK(std::find( + model.identity.candidate_names.begin(), + model.identity.candidate_names.end(), + "OCR7SJ8Mod2") == model.identity.candidate_names.end()); + } + + { + const auto model = build_identity_model({"ALPHA_LD0", "BETA_LD0"}); + CHECK(model.identity.ied_name == "192.0.2.10"); + CHECK(model.identity.source == "MmsDomainAmbiguous"); + CHECK(model.identity.confidence == mms::MmsLiveModelConfidence::low); + CHECK(model.identity.ambiguous); + CHECK(model.identity.candidate_names.size() == 2U); + CHECK(model.identity.candidate_names[0] == "ALPHA"); + CHECK(model.identity.candidate_names[1] == "BETA"); + } + + { + mms::MmsLiveDiscoveryResult result; + result.endpoint = {"192.0.2.10", 102U}; + result.names.domain_variables.emplace( + "OLSF501LD0", std::vector{}); + mms::MmsLiveModelBuildOptions options; + options.explicit_ied_name = "COMMISSIONED_IED"; + const auto model = mms::MmsLiveModelBuilder::build(result, options); + CHECK(model.identity.ied_name == "COMMISSIONED_IED"); + CHECK(model.identity.source == "ExplicitOverride"); + CHECK(model.identity.confidence == mms::MmsLiveModelConfidence::exact); + } +} + void live_discovery_builds_csharp_compatible_read_only_model() { ScriptedTransport transport; queue_handshake(transport); @@ -196,7 +269,7 @@ void live_discovery_builds_csharp_compatible_read_only_model() { const auto model = mms::MmsLiveModelBuilder::build(result); CHECK(model.schema_version == "live-ied-model-v1"); CHECK(model.identity.ied_name == "OLS501"); - CHECK(model.identity.source == "MmsDomainInference"); + CHECK(model.identity.source == "MmsDomainKnownLogicalDeviceSuffix"); CHECK(model.identity.logical_device_aliases.at("OLS501LD0") == "LD0"); CHECK(model.coverage.logical_device_count == 1U); CHECK(model.coverage.logical_node_count == 1U); @@ -260,6 +333,7 @@ void pagination_without_forward_progress_is_rejected() { int main() { try { + csharp_identity_resolver_cases_are_preserved(); live_discovery_builds_csharp_compatible_read_only_model(); pagination_without_forward_progress_is_rejected(); std::cout << "Live MMS discovery and model parity tests passed.\n"; From 4868f04312c3c3b4c7a4d6b8cf6a8648567dcd78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:07:13 +0700 Subject: [PATCH 048/179] Preserve DataSet and RCB inventory without deep reads --- include/ariec61850/mms/live_model_chunk03.ipp | 112 +++++++++++++----- 1 file changed, 84 insertions(+), 28 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk03.ipp b/include/ariec61850/mms/live_model_chunk03.ipp index cabdc0928..46c3cc686 100644 --- a/include/ariec61850/mms/live_model_chunk03.ipp +++ b/include/ariec61850/mms/live_model_chunk03.ipp @@ -52,37 +52,93 @@ document.logical_devices.push_back(std::move(ld)); } - for (const auto& evidence : discovery.data_set_directories) { + // Match the C# builder: the inventory itself is valuable evidence. A DataSet + // remains present in the model even when GetNamedVariableListAttributes was + // intentionally skipped or failed; only its member list remains unavailable. + for (const auto& candidate : discovery.report_inventory.data_sets) { MmsLiveDataSet data_set; - data_set.reference = evidence.candidate.reference; - data_set.domain = evidence.candidate.domain; - data_set.logical_node = evidence.candidate.logical_node; - data_set.name = evidence.candidate.name; - if (evidence.success()) { - data_set.deletable = evidence.directory->deletable; - for (std::size_t i = 0; i < evidence.directory->members.size(); ++i) { - const auto& member = evidence.directory->members[i]; - data_set.members.push_back({i, member.user_reference, - member.functional_constraint, member.mms_reference, - MmsLiveModelConfidence::exact}); + data_set.reference = candidate.reference; + data_set.domain = candidate.domain; + data_set.logical_node = candidate.logical_node; + data_set.name = candidate.name; + + const auto evidence = std::find_if( + discovery.data_set_directories.begin(), + discovery.data_set_directories.end(), + [&candidate](const auto& item) { + return same(item.candidate.reference, candidate.reference); + }); + if (evidence != discovery.data_set_directories.end() && evidence->success()) { + data_set.deletable = evidence->directory->deletable; + for (std::size_t index = 0U; + index < evidence->directory->members.size(); + ++index) { + const auto& member = evidence->directory->members[index]; + data_set.members.push_back({ + index, + member.user_reference, + member.functional_constraint, + member.mms_reference, + member.confidence >= 100U + ? MmsLiveModelConfidence::exact + : MmsLiveModelConfidence::medium}); } } document.data_sets.push_back(std::move(data_set)); } - for (const auto& evidence : discovery.report_controls) { + + // Likewise, preserve every BRCB/URCB discovered from the RP/BR name inventory. + // A successful read enriches it; an unprobed RCB remains status=Discovered. + for (const auto& candidate : discovery.report_inventory.report_controls) { MmsLiveReportControl control; - control.reference = evidence.candidate.reference; - control.domain = evidence.candidate.domain; - control.logical_node = evidence.candidate.logical_node; - control.name = evidence.candidate.name; - control.buffered = evidence.candidate.buffered; - control.status = evidence.success() ? "Read" : "Unreadable"; - if (evidence.state) { - control.data_set_reference = evidence.state->data_set_reference; - control.report_id = evidence.state->report_id; - control.configuration_revision = optional_u64(evidence.state->configuration_revision); - control.trigger_options = bit_names(evidence.state->trigger_options); - control.optional_fields = bit_names(evidence.state->optional_fields); - control.buffer_time_ms = optional_u64(evidence.state->buffer_time_ms); - control.integrity_period_ms = optional_u64(evidence.state->integrity_period_ms); - control.enabled_state = optional_bool(evidence.state->report_enabled); + control.reference = candidate.reference; + control.domain = candidate.domain; + control.logical_node = candidate.logical_node; + control.name = candidate.name; + control.buffered = candidate.buffered; + control.status = "Discovered"; + + const auto evidence = std::find_if( + discovery.report_controls.begin(), + discovery.report_controls.end(), + [&candidate](const auto& item) { + return same(item.candidate.reference, candidate.reference); + }); + if (evidence != discovery.report_controls.end()) { + control.status = evidence->success() ? "Read" : "Unreadable"; + if (evidence->state) { + control.data_set_reference = evidence->state->data_set_reference; + std::replace( + control.data_set_reference.begin(), + control.data_set_reference.end(), '$', '.'); + control.report_id = evidence->state->report_id; + control.configuration_revision = optional_u64( + evidence->state->configuration_revision); + control.trigger_options = bit_names(evidence->state->trigger_options); + control.optional_fields = bit_names(evidence->state->optional_fields); + control.buffer_time_ms = optional_u64(evidence->state->buffer_time_ms); + control.integrity_period_ms = optional_u64( + evidence->state->integrity_period_ms); + control.enabled_state = optional_bool(evidence->state->report_enabled); + control.reservation_state = optional_bool(evidence->state->reserved); + control.reservation_time_seconds = optional_u64( + evidence->state->reservation_time_seconds); + } + } + document.report_controls.push_back(std::move(control)); + } + + for (auto& data_set : document.data_sets) { + for (const auto& control : document.report_controls) { + if (!control.data_set_reference.empty() && + same(control.data_set_reference, data_set.reference)) { + data_set.used_by_report_controls.push_back(control.reference); + } + } + std::sort( + data_set.used_by_report_controls.begin(), + data_set.used_by_report_controls.end(), + [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + } From 20c6c95617693e4a5e75b0282cf3b5d29dfe8380 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:07:52 +0700 Subject: [PATCH 049/179] Align model coverage and identity JSON with C# builder --- include/ariec61850/mms/live_model_chunk04.ipp | 92 +++++++++++++++---- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk04.ipp b/include/ariec61850/mms/live_model_chunk04.ipp index 30f219e7d..56a854d9f 100644 --- a/include/ariec61850/mms/live_model_chunk04.ipp +++ b/include/ariec61850/mms/live_model_chunk04.ipp @@ -1,9 +1,3 @@ - control.reservation_state = optional_bool(evidence.state->reserved); - control.reservation_time_seconds = optional_u64(evidence.state->reservation_time_seconds); - } - document.report_controls.push_back(std::move(control)); - } - auto& coverage = document.coverage; coverage.logical_device_count = document.logical_devices.size(); for (const auto& ld : document.logical_devices) { @@ -11,10 +5,33 @@ for (const auto& ln : ld.logical_nodes) { coverage.data_object_count += ln.data_objects.size(); for (const auto& object : ln.data_objects) { + switch (object.confidence) { + case MmsLiveModelConfidence::high: + ++coverage.high_confidence_cdc_count; + break; + case MmsLiveModelConfidence::medium: + ++coverage.medium_confidence_cdc_count; + break; + case MmsLiveModelConfidence::low: + ++coverage.low_confidence_cdc_count; + break; + case MmsLiveModelConfidence::exact: + ++coverage.high_confidence_cdc_count; + break; + case MmsLiveModelConfidence::unknown: + ++coverage.unknown_cdc_count; + break; + } coverage.data_attribute_count += object.attributes.size(); + coverage.exact_functional_constraint_count += + static_cast(std::count_if( + object.attributes.begin(), object.attributes.end(), [](const auto& attribute) { + return attribute.functional_constraint_confidence == + MmsLiveModelConfidence::exact; + })); coverage.exact_mms_type_count += static_cast(std::count_if( - object.attributes.begin(), object.attributes.end(), [](const auto& a) { - return a.type_confidence == MmsLiveModelConfidence::exact; + object.attributes.begin(), object.attributes.end(), [](const auto& attribute) { + return attribute.type_confidence == MmsLiveModelConfidence::exact; })); } } @@ -22,21 +39,42 @@ coverage.data_set_count = document.data_sets.size(); coverage.report_control_count = document.report_controls.size(); coverage.buffered_report_control_count = static_cast(std::count_if( - document.report_controls.begin(), document.report_controls.end(), [](const auto& r) { return r.buffered; })); - coverage.unbuffered_report_control_count = coverage.report_control_count - coverage.buffered_report_control_count; + document.report_controls.begin(), document.report_controls.end(), + [](const auto& report_control) { return report_control.buffered; })); + coverage.unbuffered_report_control_count = + coverage.report_control_count - coverage.buffered_report_control_count; coverage.variable_type_read_attempt_count = discovery.variable_types.size(); coverage.variable_type_read_success_count = static_cast(std::count_if( - discovery.variable_types.begin(), discovery.variable_types.end(), [](const auto& e) { return e.success(); })); - coverage.variable_type_read_failure_count = coverage.variable_type_read_attempt_count - coverage.variable_type_read_success_count; - for (const auto& diagnostic : discovery.diagnostics) + discovery.variable_types.begin(), discovery.variable_types.end(), + [](const auto& evidence) { return evidence.success(); })); + coverage.variable_type_read_failure_count = + coverage.variable_type_read_attempt_count - coverage.variable_type_read_success_count; + + for (const auto& diagnostic : discovery.diagnostics) { document.warnings.push_back({"DiscoveryDiagnostic", {}, diagnostic}); - if (coverage.variable_type_read_failure_count) - document.warnings.push_back({"VariableTypeReadFailure", {}, - std::to_string(coverage.variable_type_read_failure_count) + " type probes failed."}); - document.summary = "Live MMS model: LD=" + std::to_string(coverage.logical_device_count) + + } + if (coverage.variable_type_read_failure_count != 0U) { + document.warnings.push_back({ + "VariableTypeReadFailure", + {}, + std::to_string(coverage.variable_type_read_failure_count) + + " type probes failed."}); + } + for (const auto& data_set : document.data_sets) { + if (data_set.members.empty()) { + document.warnings.push_back({ + "DATASET_MEMBERS_NOT_READ", + data_set.reference, + "DataSet exists but member directory was not read in this run."}); + } + } + + document.summary = + "Live IED model: LD=" + std::to_string(coverage.logical_device_count) + ", LN=" + std::to_string(coverage.logical_node_count) + + ", DO=" + std::to_string(coverage.data_object_count) + ", DA=" + std::to_string(coverage.data_attribute_count) + - ", DataSet=" + std::to_string(coverage.data_set_count) + + ", DataSets=" + std::to_string(coverage.data_set_count) + ", RCB=" + std::to_string(coverage.report_control_count) + "."; return document; } @@ -78,7 +116,23 @@ inline std::string MmsLiveModelDocument::to_json() const { << "\"iedIdentity\":{\"iedName\":\"" << json(identity.ied_name) << "\",\"source\":\"" << json(identity.source) << "\",\"confidence\":\"" << confidence(identity.confidence) << "\",\"isAmbiguous\":" - << (identity.ambiguous ? "true" : "false") << "}," + << (identity.ambiguous ? "true" : "false") << ",\"candidateNames\":["; + for (std::size_t index = 0U; index < identity.candidate_names.size(); ++index) { + if (index) out << ','; + out << '"' << json(identity.candidate_names[index]) << '"'; + } + out << "],\"logicalDeviceAliases\":{"; + std::size_t alias_index = 0U; + for (const auto& [domain, alias] : identity.logical_device_aliases) { + if (alias_index++) out << ','; + out << '"' << json(domain) << "\":\"" << json(alias) << '"'; + } + out << "},\"evidence\":["; + for (std::size_t index = 0U; index < identity.evidence.size(); ++index) { + if (index) out << ','; + out << '"' << json(identity.evidence[index]) << '"'; + } + out << "]}," << "\"accessPointName\":\"" << json(access_point_name) << "\"," << "\"summary\":\"" << json(summary) << "\"," << "\"fingerprint\":\"" << canonical_fingerprint_hex() << "\"," From 88eb5df93be2544b24e7fb435a00287b520457a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:11:22 +0700 Subject: [PATCH 050/179] Port logical-node root type probe planning --- src/mms/live_discovery.cpp | 109 +++++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index e42910735..f5609df13 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -5,6 +5,7 @@ #include "ariec61850/mms/services.hpp" #include +#include #include #include #include @@ -107,6 +108,47 @@ void validate_options(const MmsLiveDiscoveryOptions& options) { ": " + exception.what(); } +[[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; +} + +// Mirrors ARIEC61850 LiveIedVariableTypeProbePlanner: probe one MMS type tree at +// each Logical Node root instead of probing every LN$FC$DO$DA leaf. A normal +// IEC 61850 server can return the nested FC/DO/DA TypeSpecification from this +// root, reducing a large model from thousands of type requests to roughly the +// Logical Node count. +[[nodiscard]] std::vector build_logical_node_type_probe_candidates( + const MmsDiscoverySnapshot& snapshot, + const std::size_t maximum_probes) { + std::vector candidates; + candidates.reserve(std::min(maximum_probes, 256U)); + std::set> seen; + + for (const auto& [domain, variables] : snapshot.domain_variables) { + for (const auto& item : variables) { + const auto separator = item.find('$'); + if (separator == std::string::npos || separator == 0U) { + continue; + } + const auto logical_node = item.substr(0U, separator); + const auto identity = lower_ascii(domain) + "\n" + lower_ascii(logical_node); + if (!seen.insert(identity).second) { + continue; + } + candidates.push_back( + MmsObjectName::domain_specific(domain, logical_node)); + if (candidates.size() >= maximum_probes) { + return candidates; + } + } + } + return candidates; +} + } // namespace std::size_t MmsLiveDiscoveryResult::domain_count() const noexcept { @@ -277,44 +319,39 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( result.report_inventory = MmsReportInventoryBuilder::build(result.names); if (options.probe_variable_types) { - std::size_t probe_count = 0U; - for (const auto& [domain, variables] : result.names.domain_variables) { - for (const auto& item : variables) { - if (probe_count >= options.maximum_variable_type_probes) { - break; - } - ++probe_count; - - MmsVariableTypeEvidence evidence; - evidence.variable = MmsObjectName::domain_specific(domain, item); - try { - MmsVariableAccessAttributesRequest request; - request.invoke_id = association_.next_invoke_id(); - request.name = evidence.variable; - const auto encoded = MmsServiceCodec:: - encode_variable_access_attributes_request_p_data( - request, - association_.negotiated().presentation_context_id); - const auto exchange = association_.exchange_confirmed( - encoded, request.invoke_id, stop_token); - const auto payload = response_payload( - exchange, "GetVariableAccessAttributes"); - evidence.attributes = MmsServiceCodec:: - decode_variable_access_attributes_response( - payload, request.invoke_id); - } catch (const std::exception& exception) { - evidence.error = exception.what(); - result.diagnostics.push_back(optional_probe_error( - "Variable type", evidence.variable.reference(), exception)); - if (!options.continue_on_optional_probe_error) { - throw; - } + const auto candidates = build_logical_node_type_probe_candidates( + result.names, options.maximum_variable_type_probes); + result.variable_types.reserve(candidates.size()); + + for (const auto& candidate : candidates) { + MmsVariableTypeEvidence evidence; + evidence.variable = candidate; + try { + MmsVariableAccessAttributesRequest request; + request.invoke_id = association_.next_invoke_id(); + request.name = evidence.variable; + const auto encoded = MmsServiceCodec:: + encode_variable_access_attributes_request_p_data( + request, + association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + const auto payload = response_payload( + exchange, "GetVariableAccessAttributes"); + evidence.attributes = MmsServiceCodec:: + decode_variable_access_attributes_response( + payload, request.invoke_id); + } catch (const std::exception& exception) { + evidence.error = exception.what(); + result.diagnostics.push_back(optional_probe_error( + "Logical-node variable type", + evidence.variable.reference(), + exception)); + if (!options.continue_on_optional_probe_error) { + throw; } - result.variable_types.push_back(std::move(evidence)); - } - if (probe_count >= options.maximum_variable_type_probes) { - break; } + result.variable_types.push_back(std::move(evidence)); } } From 9a10025e926830dab6da5c3c70ba2ddff90d3b31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:12:27 +0700 Subject: [PATCH 051/179] Expect logical-node root type probes --- tests/test_live_discovery.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 47a176401..f2cb1b852 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -261,7 +261,7 @@ void live_discovery_builds_csharp_compatible_read_only_model() { CHECK(result.variable_count() == 5U); CHECK(result.variable_types.size() == 1U); CHECK(result.variable_types.front().success()); - CHECK(result.variable_types.front().variable.item == "LLN0$ST$Mod$stVal"); + CHECK(result.variable_types.front().variable.item == "LLN0"); CHECK(result.data_set_directories.size() == 1U); CHECK(result.report_controls.size() == 1U); CHECK(!result.partial()); From c1460326bb7858e572ac0df3e2f559a37a4a098e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:13:52 +0700 Subject: [PATCH 052/179] Port C# heuristic SCL basic type mapping --- include/ariec61850/mms/live_model_chunk01.ipp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/include/ariec61850/mms/live_model_chunk01.ipp b/include/ariec61850/mms/live_model_chunk01.ipp index 4dcde32ca..010c1d81e 100644 --- a/include/ariec61850/mms/live_model_chunk01.ipp +++ b/include/ariec61850/mms/live_model_chunk01.ipp @@ -96,6 +96,25 @@ namespace live_model_detail { } return best; } + +// Mirrors ARIEC61850 LiveIedModelDiscoveryBuilder.GuessSclBType. This does +// not pretend to be exact MMS type evidence; it only supplies a conservative +// SCL-oriented hint when GetVariableAccessAttributes was skipped or could not +// resolve the leaf from a returned type tree. +[[nodiscard]] inline std::string guess_scl_basic_type( + const std::string_view attribute_path, + const std::string_view functional_constraint) { + const auto parts = split(attribute_path, '.'); + const auto name = parts.empty() ? std::string{} : lower(parts.back()); + if (name == "q") return "Quality"; + if (name == "t" || name.ends_with("tm")) return "Timestamp"; + if (name == "stval") return "BOOLEAN"; + if (name == "f") return "FLOAT32"; + if (name == "i") return "INT32"; + if (same(functional_constraint, "CO")) return "Struct"; + return "Unknown"; +} + [[nodiscard]] inline std::pair infer_cdc( std::string_view ln_class, std::string_view name, From c84eb86fb44cd00dd1e51996407d0ce9fe834a37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:14:37 +0700 Subject: [PATCH 053/179] Use C# SCL type hints when exact MMS type is absent --- include/ariec61850/mms/live_model_chunk03.ipp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/include/ariec61850/mms/live_model_chunk03.ipp b/include/ariec61850/mms/live_model_chunk03.ipp index 46c3cc686..c6da9f163 100644 --- a/include/ariec61850/mms/live_model_chunk03.ipp +++ b/include/ariec61850/mms/live_model_chunk03.ipp @@ -13,6 +13,12 @@ attribute.type_discovery_status = "Exact"; attribute.type_source = type_source; attribute.type_confidence = MmsLiveModelConfidence::exact; + } else { + attribute.scl_basic_type = guess_scl_basic_type( + attribute.attribute_path, + attribute.functional_constraint); + attribute.type_source = "NameListHeuristic"; + attribute.type_confidence = MmsLiveModelConfidence::low; } hierarchy[point.domain][point.logical_node][object_name].emplace( key(attribute.object_reference), std::move(attribute)); From f100e9d3c0b02cb192abc279294327ccd4585e30 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:15:58 +0700 Subject: [PATCH 054/179] Port C# CDC inference registry and patterns --- include/ariec61850/mms/live_model_chunk01.ipp | 195 ++++++++++++++++-- 1 file changed, 182 insertions(+), 13 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk01.ipp b/include/ariec61850/mms/live_model_chunk01.ipp index 010c1d81e..ce9396315 100644 --- a/include/ariec61850/mms/live_model_chunk01.ipp +++ b/include/ariec61850/mms/live_model_chunk01.ipp @@ -115,22 +115,191 @@ namespace live_model_detail { return "Unknown"; } +[[nodiscard]] inline bool attribute_matches( + const std::string_view path, + const std::string_view candidate) { + const auto value = lower(std::string{path}); + const auto wanted = lower(std::string{candidate}); + return value == wanted || + (value.size() > wanted.size() && + value.ends_with("." + wanted)) || + (value.size() > wanted.size() && + value.starts_with(wanted + ".")); +} + +[[nodiscard]] inline bool has_attribute( + const std::vector& attributes, + const std::initializer_list candidates) { + return std::any_of(attributes.begin(), attributes.end(), [&](const auto& attribute) { + return std::any_of(candidates.begin(), candidates.end(), [&](const auto candidate) { + return attribute_matches(attribute.attribute_path, candidate); + }); + }); +} + +[[nodiscard]] inline bool has_fc( + const std::vector& attributes, + const std::string_view functional_constraint) { + return std::any_of(attributes.begin(), attributes.end(), [&](const auto& attribute) { + return same(attribute.functional_constraint, functional_constraint); + }); +} + +[[nodiscard]] inline std::optional> standard_cdc( + const std::string_view logical_node_class, + const std::string_view data_object_name) { + const auto ln = lower(std::string{logical_node_class}); + const auto object = lower(std::string{data_object_name}); + const auto exact = [&](const std::string_view expected_ln, + const std::string_view expected_object, + const char* cdc, + const double score) + -> std::optional> { + if (ln == expected_ln && object == expected_object) { + return std::pair{cdc, score}; + } + return std::nullopt; + }; + + if (const auto value = exact("lln0", "namplt", "LPL", 0.98)) return value; + if (const auto value = exact("lln0", "mod", "INC", 0.94)) return value; + if (const auto value = exact("lln0", "beh", "INS", 0.94)) return value; + if (const auto value = exact("lln0", "health", "INS", 0.94)) return value; + if (const auto value = exact("lphd", "phynam", "DPL", 0.98)) return value; + if (const auto value = exact("lphd", "proxy", "SPS", 0.92)) return value; + if (const auto value = exact("lphd", "phyhealth", "INS", 0.94)) return value; + if (const auto value = exact("ptoc", "op", "ACT", 0.94)) return value; + if (const auto value = exact("ptoc", "str", "ACD", 0.94)) return value; + if (const auto value = exact("ptrc", "op", "ACT", 0.94)) return value; + if (const auto value = exact("rrec", "op", "ACT", 0.92)) return value; + if (const auto value = exact("cswi", "pos", "DPC", 0.94)) return value; + if (const auto value = exact("xcbr", "pos", "DPC", 0.94)) return value; + if (const auto value = exact("xcbr", "cbopcap", "INS", 0.90)) return value; + if (const auto value = exact("xcbr", "opcnt", "INS", 0.86)) return value; + if (const auto value = exact("rdre", "fltnum", "INS", 0.90)) return value; + if (const auto value = exact("rdre", "grifltnum", "INS", 0.90)) return value; + if (const auto value = exact("mmxu", "phv", "WYE", 0.94)) return value; + if (const auto value = exact("mmxu", "a", "WYE", 0.94)) return value; + if (const auto value = exact("mmxu", "ppv", "DEL", 0.92)) return value; + if (const auto value = exact("mmxu", "w", "WYE", 0.86)) return value; + if (const auto value = exact("mmxu", "var", "WYE", 0.86)) return value; + if (const auto value = exact("mmxu", "va", "WYE", 0.86)) return value; + if (const auto value = exact("mmxu", "pf", "WYE", 0.84)) return value; + if (const auto value = exact("msqi", "seqa", "SEQ", 0.92)) return value; + if (const auto value = exact("msqi", "seqv", "SEQ", 0.92)) return value; + + if (object.starts_with("dpcso")) return std::pair{"DPC", 0.88}; + if (object.starts_with("spcso")) return std::pair{"SPC", 0.88}; + if (object.starts_with("iscso")) return std::pair{"ISC", 0.86}; + if (object.ends_with("cntrs") || object.ends_with("cntrst")) { + return std::pair{"INC", 0.82}; + } + if (object.starts_with("sum") || object.starts_with("sup") || + object.starts_with("dmd")) { + return std::pair{"BCR", 0.80}; + } + if (object.starts_with("seq")) return std::pair{"SEQ", 0.80}; + return std::nullopt; +} + +// Port of the conservative ARIEC61850 CdcInferenceEngine decision order. It +// intentionally returns no CDC rather than inventing a precise type when live +// MMS name/type evidence is insufficient. [[nodiscard]] inline std::pair infer_cdc( - std::string_view ln_class, - std::string_view name, + const std::string_view ln_class, + const std::string_view name, const std::vector& attributes) { + if (const auto standard = standard_cdc(ln_class, name)) { + return *standard; + } + const auto ln = lower(std::string{ln_class}); const auto object = lower(std::string{name}); - if (ln == "mmxu" && (object == "a" || object == "phv")) return {"WYE", 0.95}; - if (ln == "mmxu" && object == "ppv") return {"DEL", 0.95}; - if (object == "pos") return {"DPC", 0.92}; + if (object == "namplt") return {"LPL", 0.96}; + if (object == "phynam") return {"DPL", 0.96}; + if (object == "sgcb") return {"", 0.0}; if (object == "mod") return {"INC", 0.90}; - if (object == "namplt") return {"LPL", 0.92}; - bool st_val{}, magnitude{}, quality{}, timestamp{}; - for (const auto& attribute : attributes) { - const auto leaf = lower(attribute.attribute_path); - st_val |= leaf == "stval" || leaf.ends_with(".stval"); - magnitude |= leaf == "mag.f" || leaf.ends_with(".mag.f") || leaf == "instmag.f"; - quality |= leaf == "q" || leaf.ends_with(".q"); - timestamp |= leaf == "t" || leaf.ends_with(".t"); + if (object == "beh" || object == "health" || object == "autorecst" || + object == "optmh") { + return {"INS", 0.88}; + } + if (object == "proxy") return {"SPS", 0.86}; + + if (has_attribute(attributes, {"actVal", "pulsQty"}) || + object.starts_with("sum") || object.starts_with("sup") || + object.starts_with("dmd")) { + return {"BCR", 0.84}; } + if (object.starts_with("seq") || has_attribute(attributes, {"seqT"}) || + (has_attribute(attributes, {"c1"}) && + has_attribute(attributes, {"c2"}) && + has_attribute(attributes, {"c3"}))) { + return {"SEQ", 0.82}; + } + if (has_attribute(attributes, {"phsAB", "phsBC", "phsCA"})) { + return {"DEL", 0.80}; + } + + const bool protection_activation = + (object == "op" || object == "tr" || object.starts_with("op")) && + has_fc(attributes, "ST") && + has_attribute(attributes, {"general", "q", "t"}); + if (protection_activation) return {"ACT", 0.86}; + + const bool protection_start = + object == "str" && has_fc(attributes, "ST") && + has_attribute(attributes, {"general", "dirGeneral", "q", "t"}); + if (protection_start) return {"ACD", 0.84}; + + if (object == "pos" || object.starts_with("dpcso") || + has_attribute(attributes, {"stSeld"})) { + return {"DPC", 0.80}; + } + if (object.starts_with("spcso") || object == "blkopn" || + object == "blkcls" || + (has_attribute(attributes, {"ctlVal"}) && object != "pos")) { + return {"SPC", 0.78}; + } + + const bool status_triplet = + has_attribute(attributes, {"stVal"}) && + has_attribute(attributes, {"q"}) && + has_attribute(attributes, {"t"}); + if (status_triplet) { + if (object == "op" && (!ln.empty() && (ln.front() == 'p' || ln == "rrec"))) { + return {"ACT", 0.86}; + } + if (object == "str") return {"ACD", 0.82}; + return {"SPS", 0.78}; + } + + const bool phase_structure = has_attribute( + attributes, {"phsA", "phsB", "phsC", "neut"}); + if (phase_structure) { + if (ln == "mmxu") return {"WYE", 0.78}; + return {"ACD", 0.62}; + } + + if (has_attribute(attributes, {"cVal", "instCVal"}) && + has_attribute(attributes, {"q"}) && + has_attribute(attributes, {"t"}) && !phase_structure) { + return {"CMV", 0.80}; + } + if (has_attribute(attributes, {"mag", "mag.f", "mag.i"}) && + has_attribute(attributes, {"q"}) && + has_attribute(attributes, {"t"})) { + return {"MV", 0.80}; + } + + if (has_attribute(attributes, {"ctlVal", "Oper", "SBOw", "Cancel"}) || + has_fc(attributes, "CO")) { + return {"SPC", 0.55}; + } + if (has_attribute(attributes, {"setVal"}) || has_fc(attributes, "SP") || + has_fc(attributes, "SG") || has_fc(attributes, "SE")) { + return {"SPG", 0.45}; + } + if (has_fc(attributes, "MX")) return {"MV", 0.40}; + if (has_fc(attributes, "ST")) return {"SPS", 0.38}; + return {"", 0.0}; +} From fd9e3411b60d873a0e655e9032562cdad6605e8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 15:16:44 +0700 Subject: [PATCH 055/179] Complete standalone C# CDC inference port --- include/ariec61850/mms/live_model_chunk02.ipp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk02.ipp b/include/ariec61850/mms/live_model_chunk02.ipp index 84f9db4f1..cd9b47eb3 100644 --- a/include/ariec61850/mms/live_model_chunk02.ipp +++ b/include/ariec61850/mms/live_model_chunk02.ipp @@ -1,8 +1,3 @@ - if (magnitude && quality && timestamp) return {"MV", 0.82}; - if (st_val && quality && timestamp) return {"SPS", 0.78}; - if (st_val) return {"SPS", 0.60}; - return {"", 0.0}; -} [[nodiscard]] inline bool starts_with_ci( const std::string_view value, const std::string_view prefix) { From 1aaa58f0cd2ee8999512fa7afb24d31a467e5be1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 16:09:47 +0700 Subject: [PATCH 056/179] Port live control-block model schema from C# --- include/ariec61850/mms/live_model.hpp | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/include/ariec61850/mms/live_model.hpp b/include/ariec61850/mms/live_model.hpp index f0c906b7b..499bd159f 100644 --- a/include/ariec61850/mms/live_model.hpp +++ b/include/ariec61850/mms/live_model.hpp @@ -101,6 +101,8 @@ struct MmsLiveDataSet final { std::optional deletable; std::vector members; std::vector used_by_report_controls; + std::vector used_by_goose_controls; + std::vector used_by_sampled_value_controls; }; struct MmsLiveReportControl final { @@ -122,6 +124,36 @@ struct MmsLiveReportControl final { std::string status; }; +// Read-only inventory projection matching ARIEC61850 LiveIedControlBlockModel. +// Values stay empty until a future, explicitly bounded deep value reader exists. +struct MmsLiveControlBlock final { + std::string kind; + std::string reference; + std::string domain; + std::string logical_node; + std::string name; + std::string functional_constraint; + std::vector attributes; + std::string data_set_reference; + std::string data_set_reference_status{"NotRead"}; + std::string control_id; + std::string app_id; + std::string smv_id; + std::string configuration_revision; + std::string minimum_time_ms; + std::string maximum_time_ms; + std::string sample_rate; + std::string sample_mode; + std::string number_of_asdu; + std::string address_status{"NotDiscovered"}; + std::string discovery_status{"AttributeInventoryOnly"}; + std::string message; + + [[nodiscard]] std::size_t attribute_count() const noexcept { + return attributes.size(); + } +}; + struct MmsLiveModelCoverage final { std::size_t logical_device_count{}; std::size_t logical_node_count{}; @@ -136,6 +168,10 @@ struct MmsLiveModelCoverage final { std::size_t report_control_count{}; std::size_t buffered_report_control_count{}; std::size_t unbuffered_report_control_count{}; + std::size_t goose_control_block_count{}; + std::size_t sampled_value_control_block_count{}; + std::size_t setting_group_control_count{}; + std::size_t log_control_count{}; std::size_t variable_type_read_attempt_count{}; std::size_t variable_type_read_success_count{}; std::size_t variable_type_read_failure_count{}; @@ -159,6 +195,10 @@ struct MmsLiveModelDocument final { std::vector logical_devices; std::vector data_sets; std::vector report_controls; + std::vector goose_control_blocks; + std::vector sampled_value_control_blocks; + std::vector setting_group_controls; + std::vector log_controls; std::vector warnings; [[nodiscard]] std::string canonical_manifest() const; From 608fcdd4538a4bdf5577d82a3b1cdba5d69967f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 16:10:25 +0700 Subject: [PATCH 057/179] Port C# GO SV SG LG control-block inventory --- include/ariec61850/mms/live_model_chunk03.ipp | 140 +++++++++++++++++- 1 file changed, 136 insertions(+), 4 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk03.ipp b/include/ariec61850/mms/live_model_chunk03.ipp index c6da9f163..f856602fd 100644 --- a/include/ariec61850/mms/live_model_chunk03.ipp +++ b/include/ariec61850/mms/live_model_chunk03.ipp @@ -58,6 +58,124 @@ document.logical_devices.push_back(std::move(ld)); } + // Port of ARIEC61850 BuildControlBlockInventory. This is deliberately + // inventory-only: FC attribute names are enough to prove the control block + // exists, but no enable/value/address attribute is read in this phase. + for (const auto& ld : document.logical_devices) { + for (const auto& ln : ld.logical_nodes) { + for (const auto& object : ln.data_objects) { + std::set> functional_constraints; + for (const auto& attribute : object.attributes) { + functional_constraints.insert(attribute.functional_constraint); + } + + for (const auto& functional_constraint : functional_constraints) { + std::string kind; + if (same(functional_constraint, "GO")) { + kind = "GSEControl"; + } else if (same(functional_constraint, "MS") || + same(functional_constraint, "US")) { + kind = "SampledValueControl"; + } else if (same(functional_constraint, "SG") || + same(functional_constraint, "SE") || + (same(functional_constraint, "SP") && + same(object.name, "SGCB"))) { + kind = "SettingGroupControl"; + } else if (same(functional_constraint, "LG")) { + kind = "LogControl"; + } else { + continue; + } + + MmsLiveControlBlock control; + control.kind = kind; + control.reference = ld.mms_domain + "/" + ln.name + "." + + functional_constraint + "." + object.name; + control.domain = ld.mms_domain; + control.logical_node = ln.name; + control.name = object.name; + control.functional_constraint = functional_constraint; + + for (const auto& attribute : object.attributes) { + if (same(attribute.functional_constraint, functional_constraint) && + !attribute.attribute_path.empty()) { + if (std::none_of( + control.attributes.begin(), + control.attributes.end(), + [&attribute](const auto& existing) { + return same(existing, attribute.attribute_path); + })) { + control.attributes.push_back(attribute.attribute_path); + } + } + } + std::sort( + control.attributes.begin(), + control.attributes.end(), + [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + + const auto has_attribute_name = [&](const std::string_view name) { + return std::any_of( + control.attributes.begin(), + control.attributes.end(), + [name](const auto& attribute) { + return same(attribute, name); + }); + }; + const auto has_any_address_attribute = + has_attribute_name("DstAddress") || + has_attribute_name("Addr") || + has_attribute_name("APPID") || + has_attribute_name("MAC-Address"); + const auto has_enable_attribute = std::any_of( + control.attributes.begin(), + control.attributes.end(), + [](const auto& attribute) { + return ends_with_ci(attribute, "Ena"); + }); + const auto has_data_set_attribute = has_attribute_name("DatSet"); + const auto has_conf_rev_attribute = has_attribute_name("ConfRev"); + + control.data_set_reference_status = has_data_set_attribute + ? "AttributePresentValueNotRead" + : "AttributeNotPresentInNameList"; + control.address_status = has_any_address_attribute + ? "AddressAttributesPresentValueNotRead" + : "NotDiscovered"; + control.discovery_status = "AttributeInventoryOnly"; + control.message = + kind + + " discovered from live FC attribute names. Attribute values are not read in this phase. DatSetAttr=" + + (has_data_set_attribute ? "yes" : "no") + + ", ConfRevAttr=" + (has_conf_rev_attribute ? "yes" : "no") + + ", enableAttr=" + (has_enable_attribute ? "yes" : "no") + "."; + + if (same(kind, "GSEControl")) { + document.goose_control_blocks.push_back(std::move(control)); + } else if (same(kind, "SampledValueControl")) { + document.sampled_value_control_blocks.push_back(std::move(control)); + } else if (same(kind, "SettingGroupControl")) { + document.setting_group_controls.push_back(std::move(control)); + } else if (same(kind, "LogControl")) { + document.log_controls.push_back(std::move(control)); + } + } + } + } + } + + const auto sort_controls = [](auto& controls) { + std::sort(controls.begin(), controls.end(), [](const auto& left, const auto& right) { + return lower(left.reference) < lower(right.reference); + }); + }; + sort_controls(document.goose_control_blocks); + sort_controls(document.sampled_value_control_blocks); + sort_controls(document.setting_group_controls); + sort_controls(document.log_controls); + // Match the C# builder: the inventory itself is valuable evidence. A DataSet // remains present in the model even when GetNamedVariableListAttributes was // intentionally skipped or failed; only its member list remains unavailable. @@ -141,10 +259,24 @@ data_set.used_by_report_controls.push_back(control.reference); } } - std::sort( - data_set.used_by_report_controls.begin(), - data_set.used_by_report_controls.end(), - [](const auto& left, const auto& right) { + for (const auto& control : document.goose_control_blocks) { + if (!control.data_set_reference.empty() && + same(control.data_set_reference, data_set.reference)) { + data_set.used_by_goose_controls.push_back(control.reference); + } + } + for (const auto& control : document.sampled_value_control_blocks) { + if (!control.data_set_reference.empty() && + same(control.data_set_reference, data_set.reference)) { + data_set.used_by_sampled_value_controls.push_back(control.reference); + } + } + const auto sort_references = [](auto& references) { + std::sort(references.begin(), references.end(), [](const auto& left, const auto& right) { return lower(left) < lower(right); }); + }; + sort_references(data_set.used_by_report_controls); + sort_references(data_set.used_by_goose_controls); + sort_references(data_set.used_by_sampled_value_controls); } From 2203884adeaf7a6945287d423869a64d09c74e55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 16:10:54 +0700 Subject: [PATCH 058/179] Add C# control-block coverage warnings and summary --- include/ariec61850/mms/live_model_chunk04.ipp | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_model_chunk04.ipp b/include/ariec61850/mms/live_model_chunk04.ipp index 56a854d9f..06286259f 100644 --- a/include/ariec61850/mms/live_model_chunk04.ipp +++ b/include/ariec61850/mms/live_model_chunk04.ipp @@ -43,6 +43,11 @@ [](const auto& report_control) { return report_control.buffered; })); coverage.unbuffered_report_control_count = coverage.report_control_count - coverage.buffered_report_control_count; + coverage.goose_control_block_count = document.goose_control_blocks.size(); + coverage.sampled_value_control_block_count = + document.sampled_value_control_blocks.size(); + coverage.setting_group_control_count = document.setting_group_controls.size(); + coverage.log_control_count = document.log_controls.size(); coverage.variable_type_read_attempt_count = discovery.variable_types.size(); coverage.variable_type_read_success_count = static_cast(std::count_if( discovery.variable_types.begin(), discovery.variable_types.end(), @@ -68,6 +73,17 @@ "DataSet exists but member directory was not read in this run."}); } } + if (coverage.goose_control_block_count + + coverage.sampled_value_control_block_count + + coverage.setting_group_control_count + + coverage.log_control_count != 0U) { + document.warnings.push_back({ + "CONTROL_BLOCK_VALUE_READ_PENDING", + {}, + "GO/SV/SG/LG control blocks were inventoried from live FC attribute names. " + "Attribute values such as DatSet, GoID, svID, APPID, and multicast address " + "are not read yet and remain companion evidence until the deep value reader is implemented."}); + } document.summary = "Live IED model: LD=" + std::to_string(coverage.logical_device_count) + @@ -75,7 +91,11 @@ ", DO=" + std::to_string(coverage.data_object_count) + ", DA=" + std::to_string(coverage.data_attribute_count) + ", DataSets=" + std::to_string(coverage.data_set_count) + - ", RCB=" + std::to_string(coverage.report_control_count) + "."; + ", RCB=" + std::to_string(coverage.report_control_count) + + ", GoCB=" + std::to_string(coverage.goose_control_block_count) + + ", SVCB=" + std::to_string(coverage.sampled_value_control_block_count) + + ", SGCB=" + std::to_string(coverage.setting_group_control_count) + + ", LCB=" + std::to_string(coverage.log_control_count) + "."; return document; } @@ -90,6 +110,16 @@ inline std::string MmsLiveModelDocument::canonical_manifest() const { out << "DS|" << data_set.reference << '|' << data_set.members.size() << '\n'; for (const auto& control : report_controls) out << "RCB|" << control.reference << '|' << (control.buffered ? "B" : "U") << '\n'; + const auto append_controls = [&out](const auto& controls) { + for (const auto& control : controls) { + out << "CB|" << control.kind << '|' << control.reference << '|' + << control.attributes.size() << '\n'; + } + }; + append_controls(goose_control_blocks); + append_controls(sampled_value_control_blocks); + append_controls(setting_group_controls); + append_controls(log_controls); return out.str(); } inline std::uint64_t MmsLiveModelDocument::canonical_fingerprint() const { From 895bc3a5f5459426a7e543968128eaa342b62620 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 16:11:35 +0700 Subject: [PATCH 059/179] Expose live control-block inventory in model JSON --- include/ariec61850/mms/live_model_chunk05.ipp | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk05.ipp b/include/ariec61850/mms/live_model_chunk05.ipp index 6e2e01b97..0e5025795 100644 --- a/include/ariec61850/mms/live_model_chunk05.ipp +++ b/include/ariec61850/mms/live_model_chunk05.ipp @@ -3,6 +3,10 @@ << ",\"dataAttributeCount\":" << coverage.data_attribute_count << ",\"dataSetCount\":" << coverage.data_set_count << ",\"reportControlCount\":" << coverage.report_control_count + << ",\"gooseControlBlockCount\":" << coverage.goose_control_block_count + << ",\"sampledValueControlBlockCount\":" << coverage.sampled_value_control_block_count + << ",\"settingGroupControlCount\":" << coverage.setting_group_control_count + << ",\"logControlCount\":" << coverage.log_control_count << ",\"variableTypeReadAttemptCount\":" << coverage.variable_type_read_attempt_count << ",\"variableTypeReadSuccessCount\":" << coverage.variable_type_read_success_count << ",\"variableTypeReadFailureCount\":" << coverage.variable_type_read_failure_count @@ -44,14 +48,30 @@ out << "],\"dataSets\":["; for (std::size_t i = 0; i < data_sets.size(); ++i) { if (i) out << ','; - out << "{\"reference\":\"" << json(data_sets[i].reference) << "\",\"memberCount\":" - << data_sets[i].members.size() << ",\"members\":["; - for (std::size_t j = 0; j < data_sets[i].members.size(); ++j) { + const auto& data_set = data_sets[i]; + out << "{\"reference\":\"" << json(data_set.reference) + << "\",\"domain\":\"" << json(data_set.domain) + << "\",\"logicalNode\":\"" << json(data_set.logical_node) + << "\",\"name\":\"" << json(data_set.name) + << "\",\"memberCount\":" << data_set.members.size() << ",\"members\":["; + for (std::size_t j = 0; j < data_set.members.size(); ++j) { if (j) out << ','; out << "{\"index\":" << j << ",\"reference\":\"" - << json(data_sets[i].members[j].reference) << "\",\"functionalConstraint\":\"" - << json(data_sets[i].members[j].functional_constraint) << "\"}"; + << json(data_set.members[j].reference) << "\",\"functionalConstraint\":\"" + << json(data_set.members[j].functional_constraint) << "\"}"; } + const auto write_reference_array = [&out](const auto& references) { + for (std::size_t index = 0U; index < references.size(); ++index) { + if (index) out << ','; + out << '"' << json(references[index]) << '"'; + } + }; + out << "],\"usedByReportControls\":["; + write_reference_array(data_set.used_by_report_controls); + out << "],\"usedByGooseControls\":["; + write_reference_array(data_set.used_by_goose_controls); + out << "],\"usedBySampledValueControls\":["; + write_reference_array(data_set.used_by_sampled_value_controls); out << "]}"; } out << "],\"reportControls\":["; @@ -62,7 +82,52 @@ << (r.buffered ? "true" : "false") << ",\"dataSetReference\":\"" << json(r.data_set_reference) << "\",\"reportId\":\"" << json(r.report_id) << "\"}"; } - out << "],\"warnings\":["; + out << ']'; + + const auto write_control_blocks = [&out]( + const std::string_view property, + const auto& controls) { + out << ",\"" << property << "\":["; + for (std::size_t index = 0U; index < controls.size(); ++index) { + if (index) out << ','; + const auto& control = controls[index]; + out << "{\"kind\":\"" << json(control.kind) + << "\",\"reference\":\"" << json(control.reference) + << "\",\"domain\":\"" << json(control.domain) + << "\",\"logicalNode\":\"" << json(control.logical_node) + << "\",\"name\":\"" << json(control.name) + << "\",\"functionalConstraint\":\"" << json(control.functional_constraint) + << "\",\"attributeCount\":" << control.attributes.size() + << ",\"attributes\":["; + for (std::size_t attribute_index = 0U; + attribute_index < control.attributes.size(); + ++attribute_index) { + if (attribute_index) out << ','; + out << '"' << json(control.attributes[attribute_index]) << '"'; + } + out << "],\"dataSetReference\":\"" << json(control.data_set_reference) + << "\",\"dataSetReferenceStatus\":\"" << json(control.data_set_reference_status) + << "\",\"controlId\":\"" << json(control.control_id) + << "\",\"appId\":\"" << json(control.app_id) + << "\",\"smvId\":\"" << json(control.smv_id) + << "\",\"confRev\":\"" << json(control.configuration_revision) + << "\",\"minimumTimeMs\":\"" << json(control.minimum_time_ms) + << "\",\"maximumTimeMs\":\"" << json(control.maximum_time_ms) + << "\",\"sampleRate\":\"" << json(control.sample_rate) + << "\",\"sampleMode\":\"" << json(control.sample_mode) + << "\",\"numberOfAsdu\":\"" << json(control.number_of_asdu) + << "\",\"addressStatus\":\"" << json(control.address_status) + << "\",\"discoveryStatus\":\"" << json(control.discovery_status) + << "\",\"message\":\"" << json(control.message) << "\"}"; + } + out << ']'; + }; + write_control_blocks("gooseControlBlocks", goose_control_blocks); + write_control_blocks("sampledValueControlBlocks", sampled_value_control_blocks); + write_control_blocks("settingGroupControls", setting_group_controls); + write_control_blocks("logControls", log_controls); + + out << ",\"warnings\":["; for (std::size_t i = 0; i < warnings.size(); ++i) { if (i) out << ','; out << "{\"code\":\"" << json(warnings[i].code) << "\",\"reference\":\"" From b71e787579893ae6ed1d22d6ad5a701a8e38c6f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 16:12:36 +0700 Subject: [PATCH 060/179] Port C# live control-block inventory regression tests --- tests/test_live_discovery.cpp | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index f2cb1b852..cb0c2d2bb 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -241,6 +241,64 @@ void csharp_identity_resolver_cases_are_preserved() { } } +void csharp_control_block_inventory_cases_are_preserved() { + { + mms::MmsLiveDiscoveryResult result; + result.endpoint = {"192.0.2.10", 102U}; + result.names.domain_variables.emplace( + "LD0", + std::vector{ + "LLN0$GO$gcbA01$GoEna", + "LLN0$GO$gcbA01$DatSet", + "LLN0$MS$msvcb01$SvEna", + "LLN0$SG$SGCB$ActSG", + "LLN0$LG$lcbA01$LogEna"}); + + const auto model = mms::MmsLiveModelBuilder::build(result); + CHECK(model.goose_control_blocks.size() == 1U); + CHECK(model.sampled_value_control_blocks.size() == 1U); + CHECK(model.setting_group_controls.size() == 1U); + CHECK(model.log_controls.size() == 1U); + CHECK(model.goose_control_blocks.front().name == "gcbA01"); + CHECK(std::find( + model.goose_control_blocks.front().attributes.begin(), + model.goose_control_blocks.front().attributes.end(), + "DatSet") != model.goose_control_blocks.front().attributes.end()); + CHECK(model.goose_control_blocks.front().data_set_reference_status == + "AttributePresentValueNotRead"); + CHECK(model.goose_control_blocks.front().discovery_status == + "AttributeInventoryOnly"); + CHECK(model.coverage.goose_control_block_count == 1U); + CHECK(model.coverage.sampled_value_control_block_count == 1U); + CHECK(model.coverage.setting_group_control_count == 1U); + CHECK(model.coverage.log_control_count == 1U); + CHECK(model.summary.find("GoCB=1, SVCB=1, SGCB=1, LCB=1") != + std::string::npos); + const auto json = model.to_json(); + CHECK(json.find("\"gooseControlBlocks\":[") != std::string::npos); + CHECK(json.find("\"sampledValueControlBlocks\":[") != std::string::npos); + CHECK(json.find("\"CONTROL_BLOCK_VALUE_READ_PENDING\"") != + std::string::npos); + } + + { + mms::MmsLiveDiscoveryResult result; + result.endpoint = {"192.0.2.10", 102U}; + result.names.domain_variables.emplace( + "LD0", + std::vector{ + "LLN0$SP$SGCB$ActSG", + "LLN0$SP$SGCB$NumOfSG", + "LLN0$SP$NotSettingGroup$setVal"}); + + const auto model = mms::MmsLiveModelBuilder::build(result); + CHECK(model.setting_group_controls.size() == 1U); + CHECK(model.setting_group_controls.front().name == "SGCB"); + CHECK(model.setting_group_controls.front().functional_constraint == "SP"); + CHECK(model.coverage.setting_group_control_count == 1U); + } +} + void live_discovery_builds_csharp_compatible_read_only_model() { ScriptedTransport transport; queue_handshake(transport); @@ -334,6 +392,7 @@ void pagination_without_forward_progress_is_rejected() { int main() { try { csharp_identity_resolver_cases_are_preserved(); + csharp_control_block_inventory_cases_are_preserved(); live_discovery_builds_csharp_compatible_read_only_model(); pagination_without_forward_progress_is_rejected(); std::cout << "Live MMS discovery and model parity tests passed.\n"; From b448cf0cdbcfee6dbdcdb6549fd770caf6fb6dae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 17:00:25 +0700 Subject: [PATCH 061/179] Model dynamic RCB DataSet binding state explicitly --- include/ariec61850/mms/live_model.hpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/include/ariec61850/mms/live_model.hpp b/include/ariec61850/mms/live_model.hpp index 499bd159f..a21d874f1 100644 --- a/include/ariec61850/mms/live_model.hpp +++ b/include/ariec61850/mms/live_model.hpp @@ -112,6 +112,11 @@ struct MmsLiveReportControl final { std::string name; bool buffered{}; std::string data_set_reference; + // Runtime observation only. "Unbound" is a valid state for an empty + // dynamic RCB slot and must not be treated as a missing/corrupt DataSet. + // Values: NotRead, Bound, Unbound, ReadFailed. + std::string data_set_binding_status{"NotRead"}; + std::string data_set_binding_message; std::string report_id; std::string configuration_revision; std::string trigger_options; @@ -168,6 +173,10 @@ struct MmsLiveModelCoverage final { std::size_t report_control_count{}; std::size_t buffered_report_control_count{}; std::size_t unbuffered_report_control_count{}; + std::size_t report_control_bound_count{}; + std::size_t report_control_unbound_count{}; + std::size_t report_control_binding_not_read_count{}; + std::size_t report_control_binding_read_failed_count{}; std::size_t goose_control_block_count{}; std::size_t sampled_value_control_block_count{}; std::size_t setting_group_control_count{}; From 5671d9e86ae5289ced05fa74168367632be67455 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 17:01:06 +0700 Subject: [PATCH 062/179] Classify RCB DataSet binding without penalizing dynamic slots --- include/ariec61850/mms/live_model_chunk03.ipp | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk03.ipp b/include/ariec61850/mms/live_model_chunk03.ipp index f856602fd..7c0782372 100644 --- a/include/ariec61850/mms/live_model_chunk03.ipp +++ b/include/ariec61850/mms/live_model_chunk03.ipp @@ -211,8 +211,10 @@ document.data_sets.push_back(std::move(data_set)); } - // Likewise, preserve every BRCB/URCB discovered from the RP/BR name inventory. - // A successful read enriches it; an unprobed RCB remains status=Discovered. + // Preserve every BRCB/URCB discovered from RP/BR inventory. Runtime DataSet + // binding is modelled separately from RCB read success. This is essential for + // dynamic-report IEDs: a successful DatSet read that returns an empty string is + // a valid Unbound state, not a missing/corrupt DataSet. for (const auto& candidate : discovery.report_inventory.report_controls) { MmsLiveReportControl control; control.reference = candidate.reference; @@ -221,6 +223,9 @@ control.name = candidate.name; control.buffered = candidate.buffered; control.status = "Discovered"; + control.data_set_binding_status = "NotRead"; + control.data_set_binding_message = + "RCB DatSet binding was not read in this discovery run."; const auto evidence = std::find_if( discovery.report_controls.begin(), @@ -230,11 +235,47 @@ }); if (evidence != discovery.report_controls.end()) { control.status = evidence->success() ? "Read" : "Unreadable"; + if (!evidence->success()) { + control.data_set_binding_status = "ReadFailed"; + control.data_set_binding_message = evidence->error.empty() + ? "RCB read failed before DatSet binding could be established." + : evidence->error; + } if (evidence->state) { + const bool data_set_requested = std::any_of( + evidence->requested_attributes.begin(), + evidence->requested_attributes.end(), + [](const auto& attribute) { return same(attribute, "DatSet"); }); + const bool data_set_read_failed = std::any_of( + evidence->state->diagnostics.begin(), + evidence->state->diagnostics.end(), + [](const auto& diagnostic) { + return starts_with_ci(diagnostic, "DatSet read failed"); + }); + control.data_set_reference = evidence->state->data_set_reference; std::replace( control.data_set_reference.begin(), control.data_set_reference.end(), '$', '.'); + + if (!data_set_requested) { + control.data_set_binding_status = "NotRead"; + control.data_set_binding_message = + "The RCB was probed, but DatSet was not exposed/requested for this RCB."; + } else if (data_set_read_failed) { + control.data_set_binding_status = "ReadFailed"; + control.data_set_binding_message = + "The RCB was read, but its DatSet attribute returned an access failure."; + } else if (control.data_set_reference.empty()) { + control.data_set_binding_status = "Unbound"; + control.data_set_binding_message = + "DatSet was read successfully and is empty. This is a valid unbound runtime state and may represent an available dynamic RCB slot; selection still requires reservation/ownership checks."; + } else { + control.data_set_binding_status = "Bound"; + control.data_set_binding_message = + "DatSet was read successfully and references a DataSet."; + } + control.report_id = evidence->state->report_id; control.configuration_revision = optional_u64( evidence->state->configuration_revision); @@ -254,7 +295,8 @@ for (auto& data_set : document.data_sets) { for (const auto& control : document.report_controls) { - if (!control.data_set_reference.empty() && + if (same(control.data_set_binding_status, "Bound") && + !control.data_set_reference.empty() && same(control.data_set_reference, data_set.reference)) { data_set.used_by_report_controls.push_back(control.reference); } From 30f32b8dd51848b2c768835e2a316fce709f55fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 17:01:38 +0700 Subject: [PATCH 063/179] Track RCB binding coverage separately from structure --- include/ariec61850/mms/live_model_chunk04.ipp | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/include/ariec61850/mms/live_model_chunk04.ipp b/include/ariec61850/mms/live_model_chunk04.ipp index 06286259f..2f896fc92 100644 --- a/include/ariec61850/mms/live_model_chunk04.ipp +++ b/include/ariec61850/mms/live_model_chunk04.ipp @@ -43,6 +43,26 @@ [](const auto& report_control) { return report_control.buffered; })); coverage.unbuffered_report_control_count = coverage.report_control_count - coverage.buffered_report_control_count; + coverage.report_control_bound_count = static_cast(std::count_if( + document.report_controls.begin(), document.report_controls.end(), + [](const auto& report_control) { + return same(report_control.data_set_binding_status, "Bound"); + })); + coverage.report_control_unbound_count = static_cast(std::count_if( + document.report_controls.begin(), document.report_controls.end(), + [](const auto& report_control) { + return same(report_control.data_set_binding_status, "Unbound"); + })); + coverage.report_control_binding_read_failed_count = static_cast(std::count_if( + document.report_controls.begin(), document.report_controls.end(), + [](const auto& report_control) { + return same(report_control.data_set_binding_status, "ReadFailed"); + })); + coverage.report_control_binding_not_read_count = static_cast(std::count_if( + document.report_controls.begin(), document.report_controls.end(), + [](const auto& report_control) { + return same(report_control.data_set_binding_status, "NotRead"); + })); coverage.goose_control_block_count = document.goose_control_blocks.size(); coverage.sampled_value_control_block_count = document.sampled_value_control_blocks.size(); @@ -85,6 +105,11 @@ "are not read yet and remain companion evidence until the deep value reader is implemented."}); } + // Do not warn merely because an RCB is Unbound. ARIEC61850 supports a + // DynamicDataSet selection mode in which an empty DatSet is an intentional + // runtime slot state. Selection/claim safety is evaluated separately from + // the structural live model. + document.summary = "Live IED model: LD=" + std::to_string(coverage.logical_device_count) + ", LN=" + std::to_string(coverage.logical_node_count) + From a9f3f37e604e3f54a4ef535ac1224ad5c87f2da5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 17:02:12 +0700 Subject: [PATCH 064/179] Expose dynamic RCB binding evidence in model JSON --- include/ariec61850/mms/live_model_chunk05.ipp | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk05.ipp b/include/ariec61850/mms/live_model_chunk05.ipp index 0e5025795..63e4d6336 100644 --- a/include/ariec61850/mms/live_model_chunk05.ipp +++ b/include/ariec61850/mms/live_model_chunk05.ipp @@ -3,6 +3,10 @@ << ",\"dataAttributeCount\":" << coverage.data_attribute_count << ",\"dataSetCount\":" << coverage.data_set_count << ",\"reportControlCount\":" << coverage.report_control_count + << ",\"reportControlBoundCount\":" << coverage.report_control_bound_count + << ",\"reportControlUnboundCount\":" << coverage.report_control_unbound_count + << ",\"reportControlBindingNotReadCount\":" << coverage.report_control_binding_not_read_count + << ",\"reportControlBindingReadFailedCount\":" << coverage.report_control_binding_read_failed_count << ",\"gooseControlBlockCount\":" << coverage.goose_control_block_count << ",\"sampledValueControlBlockCount\":" << coverage.sampled_value_control_block_count << ",\"settingGroupControlCount\":" << coverage.setting_group_control_count @@ -78,9 +82,20 @@ for (std::size_t i = 0; i < report_controls.size(); ++i) { if (i) out << ','; const auto& r = report_controls[i]; - out << "{\"reference\":\"" << json(r.reference) << "\",\"buffered\":" - << (r.buffered ? "true" : "false") << ",\"dataSetReference\":\"" - << json(r.data_set_reference) << "\",\"reportId\":\"" << json(r.report_id) << "\"}"; + out << "{\"reference\":\"" << json(r.reference) + << "\",\"domain\":\"" << json(r.domain) + << "\",\"logicalNode\":\"" << json(r.logical_node) + << "\",\"name\":\"" << json(r.name) + << "\",\"buffered\":" << (r.buffered ? "true" : "false") + << ",\"dataSetReference\":\"" << json(r.data_set_reference) + << "\",\"dataSetBindingStatus\":\"" << json(r.data_set_binding_status) + << "\",\"dataSetBindingMessage\":\"" << json(r.data_set_binding_message) + << "\",\"reportId\":\"" << json(r.report_id) + << "\",\"confRev\":\"" << json(r.configuration_revision) + << "\",\"enabledState\":\"" << json(r.enabled_state) + << "\",\"reservationState\":\"" << json(r.reservation_state) + << "\",\"reservationTimeSeconds\":\"" << json(r.reservation_time_seconds) + << "\",\"status\":\"" << json(r.status) << "\"}"; } out << ']'; From ad9b3777a959b2030e0802a8e9987881a2fbc969 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 17:03:51 +0700 Subject: [PATCH 065/179] Show RCB binding counts in human model summary --- include/ariec61850/mms/live_model_chunk04.ipp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_model_chunk04.ipp b/include/ariec61850/mms/live_model_chunk04.ipp index 2f896fc92..e00aa88ae 100644 --- a/include/ariec61850/mms/live_model_chunk04.ipp +++ b/include/ariec61850/mms/live_model_chunk04.ipp @@ -120,7 +120,19 @@ ", GoCB=" + std::to_string(coverage.goose_control_block_count) + ", SVCB=" + std::to_string(coverage.sampled_value_control_block_count) + ", SGCB=" + std::to_string(coverage.setting_group_control_count) + - ", LCB=" + std::to_string(coverage.log_control_count) + "."; + ", LCB=" + std::to_string(coverage.log_control_count); + const auto observed_bindings = + coverage.report_control_bound_count + + coverage.report_control_unbound_count + + coverage.report_control_binding_read_failed_count; + if (observed_bindings != 0U) { + document.summary += + ", RCBBound=" + std::to_string(coverage.report_control_bound_count) + + ", RCBUnbound=" + std::to_string(coverage.report_control_unbound_count) + + ", RCBNotRead=" + std::to_string(coverage.report_control_binding_not_read_count) + + ", RCBReadFailed=" + std::to_string(coverage.report_control_binding_read_failed_count); + } + document.summary += "."; return document; } From abeb103f89179060c7c392613851db5c8b36ffc2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 17:05:27 +0700 Subject: [PATCH 066/179] Regress dynamic RCB bound and unbound live states --- tests/test_live_discovery.cpp | 115 ++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index cb0c2d2bb..43817e457 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -299,6 +299,118 @@ void csharp_control_block_inventory_cases_are_preserved() { } } +[[nodiscard]] mms::MmsReportControlCandidate make_rcb_candidate( + const std::string& name) { + mms::MmsReportControlCandidate candidate; + candidate.domain = "IEDLD0"; + candidate.logical_node = "LLN0"; + candidate.functional_constraint = "BR"; + candidate.name = name; + candidate.reference = "IEDLD0/LLN0.BR." + name; + candidate.buffered = true; + candidate.attributes = {"DatSet", "RptEna"}; + return candidate; +} + +void dynamic_rcb_binding_states_are_preserved() { + mms::MmsLiveDiscoveryResult result; + result.endpoint = {"192.0.2.10", 102U}; + result.names.domain_variables.emplace( + "IEDLD0", std::vector{"LLN0$ST$Mod$stVal"}); + + const auto bound = make_rcb_candidate("brcbBound"); + const auto unbound = make_rcb_candidate("brcbUnbound"); + const auto attribute_failed = make_rcb_candidate("brcbDatSetFailed"); + const auto read_failed = make_rcb_candidate("brcbReadFailed"); + const auto not_read = make_rcb_candidate("brcbNotRead"); + result.report_inventory.report_controls = { + bound, unbound, attribute_failed, read_failed, not_read}; + + mms::MmsReportControlEvidence bound_evidence; + bound_evidence.candidate = bound; + bound_evidence.requested_attributes = {"DatSet", "RptEna"}; + mms::MmsReportControlState bound_state; + bound_state.candidate = bound; + bound_state.data_set_reference = "IEDLD0/LLN0$DataSetA"; + bound_state.report_enabled = false; + bound_evidence.state = bound_state; + + mms::MmsReportControlEvidence unbound_evidence; + unbound_evidence.candidate = unbound; + unbound_evidence.requested_attributes = {"DatSet", "RptEna"}; + mms::MmsReportControlState unbound_state; + unbound_state.candidate = unbound; + unbound_state.data_set_reference.clear(); + unbound_state.report_enabled = false; + unbound_evidence.state = unbound_state; + + mms::MmsReportControlEvidence attribute_failed_evidence; + attribute_failed_evidence.candidate = attribute_failed; + attribute_failed_evidence.requested_attributes = {"DatSet", "RptEna"}; + mms::MmsReportControlState attribute_failed_state; + attribute_failed_state.candidate = attribute_failed; + attribute_failed_state.diagnostics.push_back("DatSet read failed (3)"); + attribute_failed_evidence.state = attribute_failed_state; + + mms::MmsReportControlEvidence read_failed_evidence; + read_failed_evidence.candidate = read_failed; + read_failed_evidence.requested_attributes = {"DatSet", "RptEna"}; + read_failed_evidence.error = "scripted RCB read failure"; + + result.report_controls = { + bound_evidence, + unbound_evidence, + attribute_failed_evidence, + read_failed_evidence}; + + const auto model = mms::MmsLiveModelBuilder::build(result); + CHECK(model.coverage.report_control_count == 5U); + CHECK(model.coverage.report_control_bound_count == 1U); + CHECK(model.coverage.report_control_unbound_count == 1U); + CHECK(model.coverage.report_control_binding_read_failed_count == 2U); + CHECK(model.coverage.report_control_binding_not_read_count == 1U); + + const auto find_control = [&model](const std::string& name) + -> const mms::MmsLiveReportControl& { + const auto found = std::find_if( + model.report_controls.begin(), model.report_controls.end(), + [&name](const auto& control) { return control.name == name; }); + if (found == model.report_controls.end()) { + throw std::runtime_error("Expected scripted RCB was not found in live model."); + } + return *found; + }; + + CHECK(find_control("brcbBound").data_set_binding_status == "Bound"); + CHECK(find_control("brcbBound").data_set_reference == + "IEDLD0/LLN0.DataSetA"); + CHECK(find_control("brcbUnbound").data_set_binding_status == "Unbound"); + CHECK(find_control("brcbUnbound").data_set_reference.empty()); + CHECK(find_control("brcbDatSetFailed").data_set_binding_status == + "ReadFailed"); + CHECK(find_control("brcbReadFailed").data_set_binding_status == + "ReadFailed"); + CHECK(find_control("brcbNotRead").data_set_binding_status == "NotRead"); + CHECK(model.summary.find("RCBBound=1, RCBUnbound=1, RCBNotRead=1, RCBReadFailed=2") != + std::string::npos); + + const auto json = model.to_json(); + CHECK(json.find("\"dataSetBindingStatus\":\"Bound\"") != std::string::npos); + CHECK(json.find("\"dataSetBindingStatus\":\"Unbound\"") != std::string::npos); + + auto rebound_result = result; + rebound_result.report_controls.front().state->data_set_reference.clear(); + const auto rebound_model = mms::MmsLiveModelBuilder::build(rebound_result); + CHECK(rebound_model.coverage.report_control_unbound_count == 2U); + CHECK(model.canonical_fingerprint() == rebound_model.canonical_fingerprint()); + + CHECK(std::none_of( + model.warnings.begin(), model.warnings.end(), [](const auto& warning) { + return warning.code.find("UNBOUND") != std::string::npos || + warning.code.find("NO_DATASET") != std::string::npos; + })); +} + void live_discovery_builds_csharp_compatible_read_only_model() { ScriptedTransport transport; queue_handshake(transport); @@ -334,6 +446,8 @@ void live_discovery_builds_csharp_compatible_read_only_model() { CHECK(model.coverage.data_attribute_count >= 1U); CHECK(model.coverage.data_set_count == 1U); CHECK(model.coverage.report_control_count == 1U); + CHECK(model.coverage.report_control_bound_count == 1U); + CHECK(model.coverage.report_control_unbound_count == 0U); CHECK(model.canonical_fingerprint() != 0U); CHECK(model.to_json().find("\"schemaVersion\":\"live-ied-model-v1\"") != std::string::npos); @@ -393,6 +507,7 @@ int main() { try { csharp_identity_resolver_cases_are_preserved(); csharp_control_block_inventory_cases_are_preserved(); + dynamic_rcb_binding_states_are_preserved(); live_discovery_builds_csharp_compatible_read_only_model(); pagination_without_forward_progress_is_rejected(); std::cout << "Live MMS discovery and model parity tests passed.\n"; From 4675dbf52e588d00232ae300a6561732878d2cdc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 20:41:11 +0700 Subject: [PATCH 067/179] Port C# Smart RCB selection planner API --- include/ariec61850/mms/rcb_selection.hpp | 131 +++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 include/ariec61850/mms/rcb_selection.hpp diff --git a/include/ariec61850/mms/rcb_selection.hpp b/include/ariec61850/mms/rcb_selection.hpp new file mode 100644 index 000000000..365bdc6b9 --- /dev/null +++ b/include/ariec61850/mms/rcb_selection.hpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsRcbSelectionMode : std::uint8_t { + static_data_set, + dynamic_data_set, +}; + +enum class MmsRcbAvailabilityKind : std::uint8_t { + available_static, + available_dynamic_empty, + busy_enabled, + busy_reserved, + contended_flapping, + claim_cooldown, + unknown_needs_probe, + not_applicable, + not_usable, +}; + +enum class MmsRcbSelectionDecision : std::uint8_t { + selected, + candidate, + skipped, + blocked_preferred, + filtered_out, +}; + +[[nodiscard]] std::string_view mms_rcb_selection_mode_name( + MmsRcbSelectionMode value) noexcept; +[[nodiscard]] std::string_view mms_rcb_availability_kind_name( + MmsRcbAvailabilityKind value) noexcept; +[[nodiscard]] std::string_view mms_rcb_selection_decision_name( + MmsRcbSelectionDecision value) noexcept; + +struct MmsRcbCandidateEvaluation final { + std::string reference; + std::string mode; + std::string domain; + std::string logical_node; + std::string name; + std::string data_set_reference; + std::string report_id; + std::string configuration_revision; + std::string report_enabled; + std::string reservation_state; + std::string reservation_time_seconds; + bool buffered{}; + bool preferred{}; + bool same_data_set{}; + bool same_logical_device{}; + bool has_data_set_directory{}; + int score{}; + MmsRcbAvailabilityKind availability{MmsRcbAvailabilityKind::unknown_needs_probe}; + MmsRcbSelectionDecision decision{MmsRcbSelectionDecision::skipped}; + std::string reason; + std::string recommended_action; + + [[nodiscard]] bool selectable() const noexcept { + return decision == MmsRcbSelectionDecision::candidate || + decision == MmsRcbSelectionDecision::selected; + } + [[nodiscard]] std::string summary() const; +}; + +struct MmsRcbSelectionEvidence final { + MmsRcbSelectionMode mode{MmsRcbSelectionMode::static_data_set}; + std::string preferred_rcb_reference; + bool strict_rcb{}; + bool allow_urcb_fallback{true}; + bool allow_polling_fallback{true}; + std::string requested_data_set_reference; + std::string requested_logical_device; + std::string selected_rcb_reference; + bool fallback_used{}; + std::vector candidates; + std::vector warnings; + std::vector blockers; + + [[nodiscard]] bool selected() const noexcept { + return !selected_rcb_reference.empty(); + } + [[nodiscard]] std::string summary() const; +}; + +struct MmsRcbStaticSelectionOptions final { + std::string preferred_rcb_reference; + std::string preferred_data_set_reference; + bool strict_rcb{}; + bool allow_urcb_fallback{true}; + bool allow_polling_fallback{true}; + std::vector excluded_rcb_references; +}; + +struct MmsRcbDynamicSelectionOptions final { + std::string preferred_logical_device; + std::string preferred_rcb_reference; + bool strict_rcb{}; + bool allow_urcb_fallback{true}; + bool allow_polling_fallback{true}; + std::vector excluded_rcb_references; +}; + +// Pure read-only planning layer ported from ARIEC61850 MmsRcbPoolSelector. +// It consumes already-collected discovery/Read evidence and never performs +// MMS Write, reservation, RptEna, GI, or dynamic DataSet mutation operations. +class MmsRcbPoolSelector final { +public: + [[nodiscard]] static MmsRcbSelectionEvidence build_static_selection( + const MmsLiveDiscoveryResult& discovery, + const MmsRcbStaticSelectionOptions& options = {}); + + [[nodiscard]] static MmsRcbSelectionEvidence build_dynamic_selection( + const MmsLiveDiscoveryResult& discovery, + const MmsRcbDynamicSelectionOptions& options = {}); + + [[nodiscard]] static const MmsReportControlCandidate* select_report_control( + const MmsLiveDiscoveryResult& discovery, + const MmsRcbSelectionEvidence& selection) noexcept; +}; + +} // namespace ar::iec61850::mms From 43d09b08756e422d10dffe0ac67870d59b3eaca8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 20:42:37 +0700 Subject: [PATCH 068/179] Port C# Smart RCB selector implementation --- src/mms/rcb_selection.cpp | 722 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 722 insertions(+) create mode 100644 src/mms/rcb_selection.cpp diff --git a/src/mms/rcb_selection.cpp b/src/mms/rcb_selection.cpp new file mode 100644 index 000000000..56e9947b5 --- /dev/null +++ b/src/mms/rcb_selection.cpp @@ -0,0 +1,722 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/rcb_selection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] std::string trim(std::string value) { + const auto not_space = [](const unsigned char character) { + return std::isspace(character) == 0; + }; + const auto first = std::find_if(value.begin(), value.end(), [&](const char character) { + return not_space(static_cast(character)); + }); + const auto last = std::find_if(value.rbegin(), value.rend(), [&](const char character) { + return not_space(static_cast(character)); + }).base(); + if (first >= last) { + return {}; + } + return std::string{first, last}; +} + +[[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]] std::string normalize_reference(std::string value) { + value = trim(std::move(value)); + std::replace(value.begin(), value.end(), '$', '.'); + return value; +} + +[[nodiscard]] bool same_reference( + const std::string_view left, + const std::string_view right) { + return lower_ascii(normalize_reference(std::string{left})) == + lower_ascii(normalize_reference(std::string{right})); +} + +[[nodiscard]] bool same_text( + const std::string_view left, + const std::string_view right) { + return lower_ascii(trim(std::string{left})) == + lower_ascii(trim(std::string{right})); +} + +[[nodiscard]] std::string bool_text(const std::optional value) { + if (!value) { + return {}; + } + return *value ? "true" : "false"; +} + +[[nodiscard]] std::string unsigned_text( + const std::optional value) { + return value ? std::to_string(*value) : std::string{}; +} + +[[nodiscard]] std::string text_or_dash(const std::string_view value) { + const auto text = trim(std::string{value}); + return text.empty() ? std::string{"-"} : text; +} + +[[nodiscard]] bool contains_ci( + const std::string_view value, + const std::string_view needle) { + return lower_ascii(std::string{value}).find(lower_ascii(std::string{needle})) != + std::string::npos; +} + +struct RuntimeCandidate final { + const MmsReportControlCandidate* candidate{}; + const MmsReportControlEvidence* evidence{}; + const MmsReportControlState* state{}; + bool was_probed{}; + std::string data_set_reference; + std::string report_id; + std::string configuration_revision; + std::optional report_enabled; + std::optional reserved; + std::optional reservation_time_seconds; +}; + +[[nodiscard]] const MmsReportControlEvidence* find_evidence( + const MmsLiveDiscoveryResult& discovery, + const MmsReportControlCandidate& candidate) { + const auto found = std::find_if( + discovery.report_controls.begin(), + discovery.report_controls.end(), + [&candidate](const auto& evidence) { + return same_reference( + evidence.candidate.reference, + candidate.reference); + }); + return found == discovery.report_controls.end() ? nullptr : &*found; +} + +[[nodiscard]] bool critical_probe_attribute_failed( + const MmsReportControlEvidence& evidence) { + if (!evidence.state) { + return true; + } + return std::any_of( + evidence.state->diagnostics.begin(), + evidence.state->diagnostics.end(), + [](const auto& diagnostic) { + return (contains_ci(diagnostic, "DatSet") || + contains_ci(diagnostic, "RptEna")) && + contains_ci(diagnostic, "failed"); + }); +} + +[[nodiscard]] RuntimeCandidate project_candidate( + const MmsLiveDiscoveryResult& discovery, + const MmsReportControlCandidate& candidate) { + RuntimeCandidate runtime; + runtime.candidate = &candidate; + runtime.evidence = find_evidence(discovery, candidate); + if (runtime.evidence == nullptr || !runtime.evidence->state) { + return runtime; + } + + runtime.state = &*runtime.evidence->state; + runtime.was_probed = !critical_probe_attribute_failed(*runtime.evidence); + runtime.data_set_reference = normalize_reference(runtime.state->data_set_reference); + runtime.report_id = runtime.state->report_id; + runtime.configuration_revision = unsigned_text( + runtime.state->configuration_revision); + runtime.report_enabled = runtime.state->report_enabled; + runtime.reserved = runtime.state->reserved; + runtime.reservation_time_seconds = runtime.state->reservation_time_seconds; + return runtime; +} + +[[nodiscard]] bool explicitly_enabled(const RuntimeCandidate& candidate) { + return candidate.report_enabled.value_or(false); +} + +[[nodiscard]] bool explicitly_disabled(const RuntimeCandidate& candidate) { + return candidate.report_enabled.has_value() && !*candidate.report_enabled; +} + +[[nodiscard]] bool reserved_by_other_client(const RuntimeCandidate& candidate) { + return candidate.reserved.value_or(false) || + candidate.reservation_time_seconds.value_or(0U) > 0U; +} + +// Intentionally mirrors ARIEC61850 MmsRcbPoolSelector.IsReservationFree: +// absence of a positive busy indication is enough for the score bonus. The +// actual selectable classification is still controlled by successful probe +// evidence and RptEna state; this function never performs a claim. +[[nodiscard]] bool reservation_free_for_score(const RuntimeCandidate& candidate) { + return !candidate.reserved.value_or(false) && + candidate.reservation_time_seconds.value_or(0U) == 0U; +} + +[[nodiscard]] MmsRcbAvailabilityKind classify( + const RuntimeCandidate& candidate, + const bool require_empty_data_set, + const bool require_data_set) { + if (explicitly_enabled(candidate)) { + return MmsRcbAvailabilityKind::busy_enabled; + } + if (reserved_by_other_client(candidate)) { + return MmsRcbAvailabilityKind::busy_reserved; + } + + const bool has_data_set = !candidate.data_set_reference.empty(); + const bool rpt_ena_not_true = !candidate.report_enabled.has_value() || + !*candidate.report_enabled; + + // Preserve the C# oracle ordering. A successfully populated probe snapshot + // is sufficient for the selector to classify a free static/dynamic slot; + // explicit positive busy evidence always wins above. + if (require_data_set && has_data_set && rpt_ena_not_true && + candidate.was_probed) { + return MmsRcbAvailabilityKind::available_static; + } + if (require_empty_data_set && !has_data_set && rpt_ena_not_true && + candidate.was_probed) { + return MmsRcbAvailabilityKind::available_dynamic_empty; + } + if (!candidate.was_probed || !candidate.report_enabled.has_value()) { + return MmsRcbAvailabilityKind::unknown_needs_probe; + } + if (require_data_set && !has_data_set) { + return MmsRcbAvailabilityKind::not_applicable; + } + if (require_empty_data_set && has_data_set) { + return MmsRcbAvailabilityKind::not_applicable; + } + return MmsRcbAvailabilityKind::not_usable; +} + +[[nodiscard]] std::set> build_excluded_set( + const std::vector& references) { + std::set> result; + for (const auto& reference : references) { + auto normalized = lower_ascii(normalize_reference(reference)); + if (!normalized.empty()) { + result.insert(std::move(normalized)); + } + } + return result; +} + +[[nodiscard]] bool excluded( + const std::set>& references, + const std::string_view reference) { + return references.contains( + lower_ascii(normalize_reference(std::string{reference}))); +} + +[[nodiscard]] bool has_populated_data_set_directory( + const MmsLiveDiscoveryResult& discovery, + const std::string_view data_set_reference) { + if (data_set_reference.empty()) { + return false; + } + const auto found = std::find_if( + discovery.data_set_directories.begin(), + discovery.data_set_directories.end(), + [data_set_reference](const auto& evidence) { + return same_reference( + evidence.candidate.reference, + data_set_reference); + }); + return found != discovery.data_set_directories.end() && found->success() && + !found->directory->members.empty(); +} + +[[nodiscard]] std::string build_reason( + const RuntimeCandidate& runtime, + const MmsRcbAvailabilityKind availability, + const bool has_data_set_directory, + const bool requested_matches, + const bool strict_rcb, + const bool filtered_out, + const bool excluded_by_previous_claim, + const bool static_mode) { + const auto& rcb = *runtime.candidate; + if (excluded_by_previous_claim) { + return "Excluded after a previous claim/write failure or pre-claim contention/cooldown in this command; trying the next Smart RCB candidate."; + } + if (filtered_out) { + return strict_rcb + ? "Filtered out by strict preferred RCB policy." + : "Filtered out by user policy/filter."; + } + + switch (availability) { + case MmsRcbAvailabilityKind::available_static: + return has_data_set_directory + ? "Static RCB has DatSet, RptEna=false, no active reservation, and DataSet directory is usable." + : "Static RCB is free, but DataSet directory is missing/empty; value mapping would be unsafe."; + case MmsRcbAvailabilityKind::available_dynamic_empty: + return "Dynamic slot has empty DatSet, RptEna=false, and no active reservation."; + case MmsRcbAvailabilityKind::busy_enabled: + return "RptEna=true; another client or previous session appears to own this RCB. Do not disable automatically."; + case MmsRcbAvailabilityKind::busy_reserved: + return rcb.buffered + ? "BRCB ResvTms=" + text_or_dash(unsigned_text(runtime.reservation_time_seconds)) + + " before claim; treat as reserved/busy." + : "URCB Resv=" + text_or_dash(bool_text(runtime.reserved)) + + " before claim; treat as reserved/busy."; + case MmsRcbAvailabilityKind::contended_flapping: + return "RCB state flips across probes; treat as contended/flapping and do not claim automatically."; + case MmsRcbAvailabilityKind::claim_cooldown: + return "RCB is in command-local claim cooldown after contention/write rejection; do not claim automatically."; + case MmsRcbAvailabilityKind::unknown_needs_probe: + return "RCB runtime state is not explicit; probe attributes before selecting automatically."; + case MmsRcbAvailabilityKind::not_applicable: + return static_mode + ? "RCB has no DatSet, so it is not a static report candidate." + : "RCB already has a DatSet, so it is not an empty dynamic slot."; + case MmsRcbAvailabilityKind::not_usable: + return requested_matches + ? "RCB state is incomplete or not safe for automatic claim." + : "RCB does not match the requested scope."; + } + return "RCB state is incomplete or not safe for automatic claim."; +} + +[[nodiscard]] MmsRcbCandidateEvaluation create_evaluation( + const RuntimeCandidate& runtime, + const bool preferred, + const bool same_data_set, + const bool same_logical_device, + const bool has_data_set_directory, + const int score, + const MmsRcbAvailabilityKind availability, + const MmsRcbSelectionDecision decision, + std::string reason, + std::string action) { + const auto& candidate = *runtime.candidate; + MmsRcbCandidateEvaluation result; + result.reference = candidate.reference; + result.mode = candidate.mode(); + result.domain = candidate.domain; + result.logical_node = candidate.logical_node; + result.name = candidate.name; + result.data_set_reference = runtime.data_set_reference; + result.report_id = runtime.report_id; + result.configuration_revision = runtime.configuration_revision; + result.report_enabled = bool_text(runtime.report_enabled); + result.reservation_state = bool_text(runtime.reserved); + result.reservation_time_seconds = unsigned_text( + runtime.reservation_time_seconds); + result.buffered = candidate.buffered; + result.preferred = preferred; + result.same_data_set = same_data_set; + result.same_logical_device = same_logical_device; + result.has_data_set_directory = has_data_set_directory; + result.score = score; + result.availability = availability; + result.decision = decision; + result.reason = std::move(reason); + result.recommended_action = std::move(action); + return result; +} + +void finalize_decisions( + std::vector& evaluations, + const std::string& selected_reference) { + if (selected_reference.empty()) { + return; + } + for (auto& evaluation : evaluations) { + if (same_reference(evaluation.reference, selected_reference)) { + evaluation.decision = MmsRcbSelectionDecision::selected; + evaluation.recommended_action = "Selected by Smart RCB policy."; + } else if (evaluation.decision != MmsRcbSelectionDecision::filtered_out) { + evaluation.decision = evaluation.selectable() + ? MmsRcbSelectionDecision::candidate + : MmsRcbSelectionDecision::skipped; + } + } +} + +[[nodiscard]] std::string first_selectable_reference( + const std::vector& evaluations) { + const auto found = std::find_if( + evaluations.begin(), evaluations.end(), + [](const auto& evaluation) { return evaluation.selectable(); }); + return found == evaluations.end() ? std::string{} : found->reference; +} + +} // namespace + +std::string_view mms_rcb_selection_mode_name( + const MmsRcbSelectionMode value) noexcept { + switch (value) { + case MmsRcbSelectionMode::static_data_set: return "StaticDataSet"; + case MmsRcbSelectionMode::dynamic_data_set: return "DynamicDataSet"; + } + return "Unknown"; +} + +std::string_view mms_rcb_availability_kind_name( + const MmsRcbAvailabilityKind value) noexcept { + switch (value) { + case MmsRcbAvailabilityKind::available_static: return "AvailableStatic"; + case MmsRcbAvailabilityKind::available_dynamic_empty: return "AvailableDynamicEmpty"; + case MmsRcbAvailabilityKind::busy_enabled: return "BusyEnabled"; + case MmsRcbAvailabilityKind::busy_reserved: return "BusyReserved"; + case MmsRcbAvailabilityKind::contended_flapping: return "ContendedFlapping"; + case MmsRcbAvailabilityKind::claim_cooldown: return "ClaimCooldown"; + case MmsRcbAvailabilityKind::unknown_needs_probe: return "UnknownNeedsProbe"; + case MmsRcbAvailabilityKind::not_applicable: return "NotApplicable"; + case MmsRcbAvailabilityKind::not_usable: return "NotUsable"; + } + return "Unknown"; +} + +std::string_view mms_rcb_selection_decision_name( + const MmsRcbSelectionDecision value) noexcept { + switch (value) { + case MmsRcbSelectionDecision::selected: return "Selected"; + case MmsRcbSelectionDecision::candidate: return "Candidate"; + case MmsRcbSelectionDecision::skipped: return "Skipped"; + case MmsRcbSelectionDecision::blocked_preferred: return "BlockedPreferred"; + case MmsRcbSelectionDecision::filtered_out: return "FilteredOut"; + } + return "Unknown"; +} + +std::string MmsRcbCandidateEvaluation::summary() const { + return std::string{mms_rcb_selection_decision_name(decision)} + ' ' + mode + + ' ' + reference + " score=" + std::to_string(score) + + " availability=" + std::string{mms_rcb_availability_kind_name(availability)} + + " reason=" + reason; +} + +std::string MmsRcbSelectionEvidence::summary() const { + const auto selected_reference = selected_rcb_reference.empty() + ? std::string{"-"} + : selected_rcb_reference; + const auto preferred_reference = preferred_rcb_reference.empty() + ? std::string{"-"} + : preferred_rcb_reference; + return "RCB selection: mode=" + std::string{mms_rcb_selection_mode_name(mode)} + + ", selected=" + selected_reference + + ", preferred=" + preferred_reference + + ", strict=" + (strict_rcb ? "true" : "false") + + ", fallbackUsed=" + (fallback_used ? "yes" : "no") + + ", candidates=" + std::to_string(candidates.size()); +} + +MmsRcbSelectionEvidence MmsRcbPoolSelector::build_static_selection( + const MmsLiveDiscoveryResult& discovery, + const MmsRcbStaticSelectionOptions& options) { + const auto excluded_references = build_excluded_set( + options.excluded_rcb_references); + const auto preferred_rcb = normalize_reference( + options.preferred_rcb_reference); + auto requested_data_set = normalize_reference( + options.preferred_data_set_reference); + + if (requested_data_set.empty() && !preferred_rcb.empty()) { + const auto found = std::find_if( + discovery.report_inventory.report_controls.begin(), + discovery.report_inventory.report_controls.end(), + [&preferred_rcb](const auto& candidate) { + return same_reference(candidate.reference, preferred_rcb); + }); + if (found != discovery.report_inventory.report_controls.end()) { + requested_data_set = project_candidate(discovery, *found).data_set_reference; + } + } + + std::vector evaluations; + evaluations.reserve(discovery.report_inventory.report_controls.size()); + for (const auto& candidate : discovery.report_inventory.report_controls) { + const auto runtime = project_candidate(discovery, candidate); + const bool is_preferred = !preferred_rcb.empty() && + same_reference(candidate.reference, preferred_rcb); + const bool excluded_by_previous_claim = excluded( + excluded_references, candidate.reference); + const bool requested_matches = requested_data_set.empty() || + same_reference(runtime.data_set_reference, requested_data_set); + const bool has_directory = has_populated_data_set_directory( + discovery, runtime.data_set_reference); + const auto availability = classify(runtime, false, true); + + bool filtered_out = options.strict_rcb && !preferred_rcb.empty() && + !is_preferred; + if (!options.allow_urcb_fallback && !candidate.buffered) { + filtered_out = true; + } + if (!requested_matches || excluded_by_previous_claim) { + filtered_out = true; + } + const bool selectable = !filtered_out && + availability == MmsRcbAvailabilityKind::available_static && + has_directory; + + int score = 0; + if (is_preferred) score += 500; + if (requested_matches) score += 120; + if (has_directory) score += 100; + if (candidate.buffered) score += 40; + if (same_text(candidate.logical_node, "LLN0")) score += 15; + if (explicitly_disabled(runtime)) score += 25; + if (reservation_free_for_score(runtime)) score += 25; + if (!runtime.configuration_revision.empty()) score += 10; + if (!runtime.report_id.empty()) score += 10; + if (availability == MmsRcbAvailabilityKind::busy_enabled || + availability == MmsRcbAvailabilityKind::busy_reserved) { + score -= 1'000; + } + if (!has_directory) score -= 250; + + evaluations.push_back(create_evaluation( + runtime, + is_preferred, + requested_matches, + true, + has_directory, + score, + availability, + filtered_out + ? MmsRcbSelectionDecision::filtered_out + : selectable + ? MmsRcbSelectionDecision::candidate + : MmsRcbSelectionDecision::skipped, + build_reason( + runtime, availability, has_directory, requested_matches, + options.strict_rcb, filtered_out, + excluded_by_previous_claim, true), + selectable + ? "Safe static report candidate." + : "Do not claim this RCB automatically.")); + } + + std::sort(evaluations.begin(), evaluations.end(), [](const auto& left, const auto& right) { + return std::tuple{ + left.decision == MmsRcbSelectionDecision::filtered_out ? 1 : 0, + left.selectable() ? 0 : 1, + -left.score, + left.buffered ? 0 : 1, + lower_ascii(left.domain), + same_text(left.logical_node, "LLN0") ? 0 : 1, + lower_ascii(left.logical_node), + lower_ascii(left.name)} < + std::tuple{ + right.decision == MmsRcbSelectionDecision::filtered_out ? 1 : 0, + right.selectable() ? 0 : 1, + -right.score, + right.buffered ? 0 : 1, + lower_ascii(right.domain), + same_text(right.logical_node, "LLN0") ? 0 : 1, + lower_ascii(right.logical_node), + lower_ascii(right.name)}; + }); + + const auto selected_reference = first_selectable_reference(evaluations); + finalize_decisions(evaluations, selected_reference); + + MmsRcbSelectionEvidence result; + result.mode = MmsRcbSelectionMode::static_data_set; + result.preferred_rcb_reference = options.preferred_rcb_reference; + result.strict_rcb = options.strict_rcb; + result.allow_urcb_fallback = options.allow_urcb_fallback; + result.allow_polling_fallback = options.allow_polling_fallback; + result.requested_data_set_reference = options.preferred_data_set_reference; + result.selected_rcb_reference = selected_reference; + result.fallback_used = !options.preferred_rcb_reference.empty() && + !selected_reference.empty() && + !same_reference(selected_reference, options.preferred_rcb_reference); + result.candidates = std::move(evaluations); + + if (result.selected_rcb_reference.empty()) { + result.blockers.push_back( + options.strict_rcb && !options.preferred_rcb_reference.empty() + ? "Strict RCB selection blocked the session because " + + options.preferred_rcb_reference + + " is not available for static reporting." + : "No available static RCB matched the requested DataSet/filter."); + if (options.allow_polling_fallback) { + result.warnings.push_back( + "No safe RCB was selected. Smart polling fallback is allowed by policy, but report monitor will remain blocked until polling fallback mode is implemented by the caller."); + } + } else if (!options.preferred_rcb_reference.empty() && + !same_reference( + result.selected_rcb_reference, + options.preferred_rcb_reference)) { + result.warnings.push_back( + "Preferred RCB " + options.preferred_rcb_reference + + " was not selected; smart fallback selected " + + result.selected_rcb_reference + + " to avoid an unsafe/busy RCB."); + } + return result; +} + +MmsRcbSelectionEvidence MmsRcbPoolSelector::build_dynamic_selection( + const MmsLiveDiscoveryResult& discovery, + const MmsRcbDynamicSelectionOptions& options) { + const auto excluded_references = build_excluded_set( + options.excluded_rcb_references); + const auto preferred_rcb = normalize_reference( + options.preferred_rcb_reference); + const auto requested_logical_device = trim( + options.preferred_logical_device); + + std::vector evaluations; + evaluations.reserve(discovery.report_inventory.report_controls.size()); + for (const auto& candidate : discovery.report_inventory.report_controls) { + const auto runtime = project_candidate(discovery, candidate); + const bool is_preferred = !preferred_rcb.empty() && + same_reference(candidate.reference, preferred_rcb); + const bool excluded_by_previous_claim = excluded( + excluded_references, candidate.reference); + const bool same_logical_device = requested_logical_device.empty() || + same_text(candidate.domain, requested_logical_device); + const auto availability = classify(runtime, true, false); + + bool filtered_out = options.strict_rcb && !preferred_rcb.empty() && + !is_preferred; + if (!options.allow_urcb_fallback && !candidate.buffered) { + filtered_out = true; + } + if (excluded_by_previous_claim) { + filtered_out = true; + } + const bool selectable = !filtered_out && + availability == MmsRcbAvailabilityKind::available_dynamic_empty; + + int score = 0; + if (is_preferred) score += 500; + if (runtime.data_set_reference.empty()) score += 140; + if (same_logical_device) score += 100; + if (candidate.buffered) score += 40; + if (same_text(candidate.logical_node, "LLN0")) score += 15; + if (explicitly_disabled(runtime)) score += 25; + if (reservation_free_for_score(runtime)) score += 25; + if (!runtime.configuration_revision.empty()) score += 10; + if (availability == MmsRcbAvailabilityKind::busy_enabled || + availability == MmsRcbAvailabilityKind::busy_reserved) { + score -= 1'000; + } + if (!runtime.data_set_reference.empty()) score -= 600; + + evaluations.push_back(create_evaluation( + runtime, + is_preferred, + runtime.data_set_reference.empty(), + same_logical_device, + false, + score, + availability, + filtered_out + ? MmsRcbSelectionDecision::filtered_out + : selectable + ? MmsRcbSelectionDecision::candidate + : MmsRcbSelectionDecision::skipped, + build_reason( + runtime, availability, false, same_logical_device, + options.strict_rcb, filtered_out, + excluded_by_previous_claim, false), + selectable + ? "Safe dynamic empty-slot candidate." + : "Do not bind a dynamic DataSet to this RCB automatically.")); + } + + std::sort(evaluations.begin(), evaluations.end(), [](const auto& left, const auto& right) { + return std::tuple{ + left.decision == MmsRcbSelectionDecision::filtered_out ? 1 : 0, + left.selectable() ? 0 : 1, + -left.score, + left.buffered ? 0 : 1, + left.same_logical_device ? 0 : 1, + lower_ascii(left.domain), + same_text(left.logical_node, "LLN0") ? 0 : 1, + lower_ascii(left.logical_node), + lower_ascii(left.name)} < + std::tuple{ + right.decision == MmsRcbSelectionDecision::filtered_out ? 1 : 0, + right.selectable() ? 0 : 1, + -right.score, + right.buffered ? 0 : 1, + right.same_logical_device ? 0 : 1, + lower_ascii(right.domain), + same_text(right.logical_node, "LLN0") ? 0 : 1, + lower_ascii(right.logical_node), + lower_ascii(right.name)}; + }); + + const auto selected_reference = first_selectable_reference(evaluations); + finalize_decisions(evaluations, selected_reference); + + MmsRcbSelectionEvidence result; + result.mode = MmsRcbSelectionMode::dynamic_data_set; + result.preferred_rcb_reference = options.preferred_rcb_reference; + result.strict_rcb = options.strict_rcb; + result.allow_urcb_fallback = options.allow_urcb_fallback; + result.allow_polling_fallback = options.allow_polling_fallback; + result.requested_logical_device = options.preferred_logical_device; + result.selected_rcb_reference = selected_reference; + result.fallback_used = !options.preferred_rcb_reference.empty() && + !selected_reference.empty() && + !same_reference(selected_reference, options.preferred_rcb_reference); + result.candidates = std::move(evaluations); + + if (result.selected_rcb_reference.empty()) { + result.blockers.push_back( + options.strict_rcb && !options.preferred_rcb_reference.empty() + ? "Strict RCB selection blocked the session because " + + options.preferred_rcb_reference + + " is not an available empty dynamic slot." + : "No available empty dynamic RCB slot matched the requested filter."); + if (options.allow_polling_fallback) { + result.warnings.push_back( + "No dynamic RCB slot was selected. Smart polling fallback is allowed by policy, but dynamic reporting remains blocked until the caller chooses polling fallback."); + } + } else if (!options.preferred_rcb_reference.empty() && + !same_reference( + result.selected_rcb_reference, + options.preferred_rcb_reference)) { + result.warnings.push_back( + "Preferred RCB " + options.preferred_rcb_reference + + " was not selected; smart fallback selected " + + result.selected_rcb_reference + + " to avoid an unsafe/busy RCB."); + } + return result; +} + +const MmsReportControlCandidate* MmsRcbPoolSelector::select_report_control( + const MmsLiveDiscoveryResult& discovery, + const MmsRcbSelectionEvidence& selection) noexcept { + if (selection.selected_rcb_reference.empty()) { + return nullptr; + } + const auto found = std::find_if( + discovery.report_inventory.report_controls.begin(), + discovery.report_inventory.report_controls.end(), + [&selection](const auto& candidate) { + return same_reference( + candidate.reference, + selection.selected_rcb_reference); + }); + return found == discovery.report_inventory.report_controls.end() + ? nullptr + : &*found; +} + +} // namespace ar::iec61850::mms From ce19f6c847ab090c1b3854f2f8dd9fb2d2540d8a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 20:43:30 +0700 Subject: [PATCH 069/179] Add Smart RCB selector parity regressions --- tests/test_rcb_selection.cpp | 288 +++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 tests/test_rcb_selection.cpp diff --git a/tests/test_rcb_selection.cpp b/tests/test_rcb_selection.cpp new file mode 100644 index 000000000..a5804ace4 --- /dev/null +++ b/tests/test_rcb_selection.cpp @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/rcb_selection.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::MmsReportControlCandidate make_rcb( + std::string name, + const bool buffered = true, + std::string domain = "IEDLD0", + std::string logical_node = "LLN0") { + mms::MmsReportControlCandidate candidate; + candidate.domain = std::move(domain); + candidate.logical_node = std::move(logical_node); + candidate.functional_constraint = buffered ? "BR" : "RP"; + candidate.name = std::move(name); + candidate.reference = candidate.domain + "/" + candidate.logical_node + "." + + candidate.functional_constraint + "." + candidate.name; + candidate.buffered = buffered; + candidate.attributes = buffered + ? std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "ResvTms"} + : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv"}; + return candidate; +} + +void add_state( + mms::MmsLiveDiscoveryResult& result, + const mms::MmsReportControlCandidate& candidate, + std::string data_set_reference, + const std::optional report_enabled, + const std::optional reserved = std::nullopt, + const std::optional reservation_time_seconds = std::nullopt, + const bool fail_data_set_attribute = false) { + mms::MmsReportControlEvidence evidence; + evidence.candidate = candidate; + evidence.requested_attributes = candidate.buffered + ? std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "ResvTms"} + : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv"}; + + mms::MmsReportControlState state; + state.candidate = candidate; + state.data_set_reference = std::move(data_set_reference); + state.report_id = "RPT-" + candidate.name; + state.configuration_revision = 7U; + state.report_enabled = report_enabled; + state.reserved = reserved; + state.reservation_time_seconds = reservation_time_seconds; + if (fail_data_set_attribute) { + state.diagnostics.push_back("DatSet read failed (3)"); + } + evidence.state = std::move(state); + result.report_controls.push_back(std::move(evidence)); +} + +void add_populated_directory( + mms::MmsLiveDiscoveryResult& result, + const std::string& reference) { + mms::MmsDataSetDirectoryEvidence evidence; + evidence.candidate.domain = "IEDLD0"; + evidence.candidate.logical_node = "LLN0"; + evidence.candidate.name = "DataSetA"; + evidence.candidate.reference = reference; + + mms::MmsDataSetDirectoryResponse directory; + directory.invoke_id = 1U; + directory.deletable = false; + mms::MmsDataSetDirectoryMember member; + member.object_name = mms::MmsObjectName::domain_specific( + "IEDLD0", "LLN0$ST$Mod$stVal"); + member.mms_reference = "IEDLD0/LLN0$ST$Mod$stVal"; + member.user_reference = "IEDLD0/LLN0.Mod.stVal"; + member.functional_constraint = "ST"; + member.logical_node = "LLN0"; + member.data_object_path = "Mod.stVal"; + member.confidence = 100U; + directory.members.push_back(std::move(member)); + evidence.directory = std::move(directory); + result.data_set_directories.push_back(std::move(evidence)); +} + +[[nodiscard]] const mms::MmsRcbCandidateEvaluation& find_evaluation( + const mms::MmsRcbSelectionEvidence& selection, + const std::string& name) { + const auto found = std::find_if( + selection.candidates.begin(), selection.candidates.end(), + [&name](const auto& candidate) { return candidate.name == name; }); + if (found == selection.candidates.end()) { + throw std::runtime_error("Expected RCB evaluation was not found."); + } + return *found; +} + +void dynamic_empty_slot_is_selected() { + mms::MmsLiveDiscoveryResult result; + const auto empty = make_rcb("brcbEmpty"); + result.report_inventory.report_controls = {empty}; + add_state(result, empty, {}, false, std::nullopt, 0U); + + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection(result); + CHECK(selection.selected()); + CHECK(selection.selected_rcb_reference == empty.reference); + CHECK(selection.mode == mms::MmsRcbSelectionMode::dynamic_data_set); + CHECK(selection.blockers.empty()); + const auto& evaluation = find_evaluation(selection, "brcbEmpty"); + CHECK(evaluation.availability == + mms::MmsRcbAvailabilityKind::available_dynamic_empty); + CHECK(evaluation.decision == mms::MmsRcbSelectionDecision::selected); + CHECK(evaluation.data_set_reference.empty()); + CHECK(evaluation.report_enabled == "false"); + CHECK(mms::MmsRcbPoolSelector::select_report_control(result, selection) == + &result.report_inventory.report_controls.front()); +} + +void dynamic_busy_and_bound_slots_are_not_selected() { + mms::MmsLiveDiscoveryResult result; + const auto enabled = make_rcb("brcbEnabled"); + const auto reserved = make_rcb("brcbReserved"); + const auto bound = make_rcb("brcbBound"); + const auto free = make_rcb("brcbFree"); + result.report_inventory.report_controls = {enabled, reserved, bound, free}; + + add_state(result, enabled, {}, true, std::nullopt, 0U); + add_state(result, reserved, {}, false, std::nullopt, 30U); + add_state(result, bound, "IEDLD0/LLN0$DataSetA", false, std::nullopt, 0U); + add_state(result, free, {}, false, std::nullopt, 0U); + + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection(result); + CHECK(selection.selected_rcb_reference == free.reference); + CHECK(find_evaluation(selection, "brcbEnabled").availability == + mms::MmsRcbAvailabilityKind::busy_enabled); + CHECK(find_evaluation(selection, "brcbReserved").availability == + mms::MmsRcbAvailabilityKind::busy_reserved); + CHECK(find_evaluation(selection, "brcbBound").availability == + mms::MmsRcbAvailabilityKind::not_applicable); + CHECK(find_evaluation(selection, "brcbFree").availability == + mms::MmsRcbAvailabilityKind::available_dynamic_empty); +} + +void unprobed_or_failed_slot_needs_probe() { + mms::MmsLiveDiscoveryResult result; + const auto unprobed = make_rcb("brcbUnprobed"); + const auto failed = make_rcb("brcbFailed"); + result.report_inventory.report_controls = {unprobed, failed}; + add_state(result, failed, {}, false, std::nullopt, 0U, true); + + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection(result); + CHECK(!selection.selected()); + CHECK(!selection.blockers.empty()); + CHECK(find_evaluation(selection, "brcbUnprobed").availability == + mms::MmsRcbAvailabilityKind::unknown_needs_probe); + CHECK(find_evaluation(selection, "brcbFailed").availability == + mms::MmsRcbAvailabilityKind::unknown_needs_probe); +} + +void preferred_busy_slot_uses_smart_fallback() { + mms::MmsLiveDiscoveryResult result; + const auto preferred = make_rcb("brcbPreferred"); + const auto fallback = make_rcb("brcbFallback"); + result.report_inventory.report_controls = {preferred, fallback}; + add_state(result, preferred, {}, true, std::nullopt, 0U); + add_state(result, fallback, {}, false, std::nullopt, 0U); + + mms::MmsRcbDynamicSelectionOptions options; + options.preferred_rcb_reference = preferred.reference; + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection( + result, options); + CHECK(selection.selected_rcb_reference == fallback.reference); + CHECK(selection.fallback_used); + CHECK(!selection.warnings.empty()); +} + +void strict_preferred_busy_slot_blocks_selection() { + mms::MmsLiveDiscoveryResult result; + const auto preferred = make_rcb("brcbPreferred"); + const auto otherwise_free = make_rcb("brcbOther"); + result.report_inventory.report_controls = {preferred, otherwise_free}; + add_state(result, preferred, {}, true, std::nullopt, 0U); + add_state(result, otherwise_free, {}, false, std::nullopt, 0U); + + mms::MmsRcbDynamicSelectionOptions options; + options.preferred_rcb_reference = preferred.reference; + options.strict_rcb = true; + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection( + result, options); + CHECK(!selection.selected()); + CHECK(!selection.blockers.empty()); + CHECK(find_evaluation(selection, "brcbOther").decision == + mms::MmsRcbSelectionDecision::filtered_out); +} + +void exclusion_and_urcb_policy_are_preserved() { + mms::MmsLiveDiscoveryResult result; + const auto first = make_rcb("brcbA"); + const auto second = make_rcb("brcbB"); + const auto urcb = make_rcb("urcbA", false); + result.report_inventory.report_controls = {first, second, urcb}; + add_state(result, first, {}, false, std::nullopt, 0U); + add_state(result, second, {}, false, std::nullopt, 0U); + add_state(result, urcb, {}, false, false, std::nullopt); + + mms::MmsRcbDynamicSelectionOptions options; + options.excluded_rcb_references = {first.reference}; + options.allow_urcb_fallback = false; + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection( + result, options); + CHECK(selection.selected_rcb_reference == second.reference); + CHECK(find_evaluation(selection, "brcbA").decision == + mms::MmsRcbSelectionDecision::filtered_out); + CHECK(find_evaluation(selection, "urcbA").decision == + mms::MmsRcbSelectionDecision::filtered_out); +} + +void static_selection_requires_populated_directory() { + mms::MmsLiveDiscoveryResult result; + const auto rcb = make_rcb("brcbStatic"); + result.report_inventory.report_controls = {rcb}; + add_state( + result, rcb, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, 0U); + + const auto without_directory = + mms::MmsRcbPoolSelector::build_static_selection(result); + CHECK(!without_directory.selected()); + CHECK(find_evaluation(without_directory, "brcbStatic").availability == + mms::MmsRcbAvailabilityKind::available_static); + CHECK(!find_evaluation(without_directory, "brcbStatic").has_data_set_directory); + + add_populated_directory(result, "IEDLD0/LLN0.DataSetA"); + const auto with_directory = + mms::MmsRcbPoolSelector::build_static_selection(result); + CHECK(with_directory.selected_rcb_reference == rcb.reference); + CHECK(find_evaluation(with_directory, "brcbStatic").has_data_set_directory); +} + +void requested_logical_device_affects_dynamic_ranking() { + mms::MmsLiveDiscoveryResult result; + const auto other = make_rcb("brcbA", true, "OTHERLD"); + const auto requested = make_rcb("brcbB", true, "IEDLD0"); + result.report_inventory.report_controls = {other, requested}; + add_state(result, other, {}, false, std::nullopt, 0U); + add_state(result, requested, {}, false, std::nullopt, 0U); + + mms::MmsRcbDynamicSelectionOptions options; + options.preferred_logical_device = "IEDLD0"; + const auto selection = mms::MmsRcbPoolSelector::build_dynamic_selection( + result, options); + CHECK(selection.selected_rcb_reference == requested.reference); + CHECK(find_evaluation(selection, "brcbB").same_logical_device); + CHECK(!find_evaluation(selection, "brcbA").same_logical_device); +} + +} // namespace + +int main() { + try { + dynamic_empty_slot_is_selected(); + dynamic_busy_and_bound_slots_are_not_selected(); + unprobed_or_failed_slot_needs_probe(); + preferred_busy_slot_uses_smart_fallback(); + strict_preferred_busy_slot_blocks_selection(); + exclusion_and_urcb_policy_are_preserved(); + static_selection_requires_populated_directory(); + requested_logical_device_affects_dynamic_ranking(); + std::cout << "Smart RCB selection parity tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} From 44ac7f3398c389a7ffc7836924ad451235114ab7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 20:44:32 +0700 Subject: [PATCH 070/179] Build Smart RCB selector and parity tests --- CMakeLists.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 36cba1d67..90c79e8dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ add_library(ariec61850_core src/mms/report_subscription_runtime.cpp src/mms/tcp_transport.cpp src/mms/live_discovery.cpp + src/mms/rcb_selection.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/quality.cpp @@ -216,6 +217,13 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_live_discovery_tests) add_test(NAME ariec61850_live_discovery_tests COMMAND ariec61850_live_discovery_tests) + add_executable(ariec61850_rcb_selection_tests tests/test_rcb_selection.cpp) + target_link_libraries(ariec61850_rcb_selection_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_rcb_selection_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_rcb_selection_tests) + ariec61850_apply_sanitizers(ariec61850_rcb_selection_tests) + add_test(NAME ariec61850_rcb_selection_tests COMMAND ariec61850_rcb_selection_tests) + add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE From 492b18b8075c25e20fd8aa4b2c45c09b02a84cec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 20:47:15 +0700 Subject: [PATCH 071/179] Fix GCC dangling-reference diagnostic in RCB tests --- tests/test_rcb_selection.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_rcb_selection.cpp b/tests/test_rcb_selection.cpp index a5804ace4..e6bc34328 100644 --- a/tests/test_rcb_selection.cpp +++ b/tests/test_rcb_selection.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace { @@ -98,10 +99,10 @@ void add_populated_directory( [[nodiscard]] const mms::MmsRcbCandidateEvaluation& find_evaluation( const mms::MmsRcbSelectionEvidence& selection, - const std::string& name) { + const std::string_view name) { const auto found = std::find_if( selection.candidates.begin(), selection.candidates.end(), - [&name](const auto& candidate) { return candidate.name == name; }); + [name](const auto& candidate) { return candidate.name == name; }); if (found == selection.candidates.end()) { throw std::runtime_error("Expected RCB evaluation was not found."); } From 6f492666cf69606491dd907cd1c628815b723709 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 21:00:27 +0700 Subject: [PATCH 072/179] Wire read-only Smart RCB planner into live discovery CLI --- tools/live_discover.cpp | 554 ++++++++++++++++++++-------------------- 1 file changed, 276 insertions(+), 278 deletions(-) diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp index fe1a48499..72ec3b915 100644 --- a/tools/live_discover.cpp +++ b/tools/live_discover.cpp @@ -2,293 +2,54 @@ #include "ariec61850/mms/live_discovery.hpp" #include "ariec61850/mms/live_model.hpp" +#include "ariec61850/mms/rcb_selection.hpp" #include -#include #include -#include #include #include #include #include +#include #include #include #include #include -#include #include namespace { using namespace ar::iec61850; -struct IedIdentityResolution final { - std::string name; - std::string source; - mms::MmsLiveModelConfidence confidence{mms::MmsLiveModelConfidence::unknown}; - bool ambiguous{}; - std::vector candidates; - std::vector evidence; +enum class RcbPlanMode : std::uint8_t { + none, + dynamic_data_set, + static_data_set, }; -[[nodiscard]] std::string lower(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( - const std::string_view left, - const std::string_view right) { - return lower(std::string{left}) == lower(std::string{right}); -} - -[[nodiscard]] bool starts_with_ci( - const std::string_view value, - const std::string_view prefix) { - return value.size() >= prefix.size() && same(value.substr(0U, prefix.size()), prefix); -} - -[[nodiscard]] bool ends_with_ci( - const std::string_view value, - const std::string_view suffix) { - return value.size() >= suffix.size() && - same(value.substr(value.size() - suffix.size()), suffix); -} - -[[nodiscard]] std::string trim_boundary(std::string value) { - while (!value.empty()) { - const char character = value.back(); - if (character != '_' && character != '-' && character != '.' && character != ' ') { - break; - } - value.pop_back(); - } - return value; -} - -[[nodiscard]] bool viable_ied_name(const std::string_view value) { - return std::count_if(value.begin(), value.end(), [](const char character) { - return std::isalnum(static_cast(character)) != 0; - }) >= 3; -} - -void append_unique_ci(std::vector& values, const std::string& value) { - if (std::none_of(values.begin(), values.end(), [&value](const auto& existing) { - return same(existing, value); - })) { - values.push_back(value); - } -} - -[[nodiscard]] bool try_extract_known_ld_prefix( - const std::string_view domain, - std::string& candidate) { - static constexpr std::array stems{ - "PROT", "CTRL", "MEAS", "PQM", "MET", "ANN", "BCU", - "SYS", "COM", "RLY", "BAY", "DR", "LD", "MU"}; - - std::string trimmed{domain}; - std::size_t suffix_start = trimmed.size(); - while (suffix_start > 0U && - std::isdigit(static_cast(trimmed[suffix_start - 1U])) != 0) { - --suffix_start; - } - const std::string without_index = trimmed.substr(0U, suffix_start); - for (const auto stem : stems) { - if (!ends_with_ci(without_index, stem) || without_index.size() <= stem.size()) { - continue; - } - auto prefix = trim_boundary( - without_index.substr(0U, without_index.size() - stem.size())); - if (!viable_ied_name(prefix)) { - continue; - } - candidate = std::move(prefix); - return true; - } - return false; -} - -[[nodiscard]] std::string infer_common_prefix( - const std::vector& domains) { - if (domains.size() < 2U) { - return {}; - } - - std::string prefix = domains.front(); - for (std::size_t index = 1U; index < domains.size(); ++index) { - const auto& domain = domains[index]; - std::size_t length = 0U; - while (length < prefix.size() && length < domain.size() && - std::toupper(static_cast(prefix[length])) == - std::toupper(static_cast(domain[length]))) { - ++length; - } - prefix.resize(length); - if (prefix.empty()) { - return {}; - } +struct RcbPlanCliOptions final { + RcbPlanMode mode{RcbPlanMode::none}; + std::string preferred_rcb_reference; + std::string preferred_logical_device; + std::string preferred_data_set_reference; + bool strict_rcb{}; + bool allow_urcb_fallback{true}; + bool allow_polling_fallback{true}; + std::vector excluded_rcb_references; + std::size_t maximum_candidates_to_print{10U}; + + [[nodiscard]] bool requested() const noexcept { + return mode != RcbPlanMode::none; } - prefix = trim_boundary(std::move(prefix)); - static constexpr std::array stems{ - "PROT", "CTRL", "MEAS", "PQM", "MET", "ANN", "BCU", - "SYS", "COM", "RLY", "BAY", "DR", "LD", "MU"}; - for (const auto stem : stems) { - if (ends_with_ci(prefix, stem) && prefix.size() > stem.size()) { - auto without_stem = trim_boundary( - prefix.substr(0U, prefix.size() - stem.size())); - if (viable_ied_name(without_stem)) { - prefix = std::move(without_stem); - } - break; - } - } - return viable_ied_name(prefix) ? prefix : std::string{}; -} - -[[nodiscard]] IedIdentityResolution resolve_ied_identity( - const mms::MmsLiveDiscoveryResult& discovery) { - std::vector domains; - domains.reserve(discovery.names.domain_variables.size()); - for (const auto& [domain, _] : discovery.names.domain_variables) { - if (!domain.empty()) { - append_unique_ci(domains, domain); - } - } - std::sort(domains.begin(), domains.end(), [](const auto& left, const auto& right) { - return lower(left) < lower(right); - }); - - struct SuffixMatch final { - std::string domain; - std::string candidate; - }; - std::vector suffix_matches; - std::vector distinct_candidates; - for (const auto& domain : domains) { - std::string candidate; - if (try_extract_known_ld_prefix(domain, candidate)) { - suffix_matches.push_back({domain, candidate}); - append_unique_ci(distinct_candidates, candidate); - } - } - std::sort( - distinct_candidates.begin(), distinct_candidates.end(), - [](const auto& left, const auto& right) { return lower(left) < lower(right); }); - - if (!domains.empty() && suffix_matches.size() == domains.size()) { - if (distinct_candidates.size() == 1U) { - IedIdentityResolution resolution; - resolution.name = distinct_candidates.front(); - resolution.source = "MmsDomainKnownLogicalDeviceSuffix"; - resolution.confidence = domains.size() > 1U - ? mms::MmsLiveModelConfidence::high - : mms::MmsLiveModelConfidence::medium; - resolution.candidates = distinct_candidates; - for (const auto& match : suffix_matches) { - resolution.evidence.push_back( - "MMS domain '" + match.domain + - "' matched the logical-device suffix pattern for IED '" + - resolution.name + "'."); - } - return resolution; - } - if (distinct_candidates.size() > 1U) { - return { - discovery.endpoint.host.empty() ? "DISCOVERED_IED" : discovery.endpoint.host, - "MmsDomainAmbiguous", - mms::MmsLiveModelConfidence::low, - true, - distinct_candidates, - {"MMS domains produced conflicting IED-name candidates."}}; - } - } - - if (const auto common_prefix = infer_common_prefix(domains); !common_prefix.empty()) { - return { - common_prefix, - "MmsDomainCommonPrefix", - domains.size() >= 3U - ? mms::MmsLiveModelConfidence::high - : mms::MmsLiveModelConfidence::medium, - false, - {common_prefix}, - {"IED name was derived from the common prefix of " + - std::to_string(domains.size()) + " MMS domain(s)."}}; - } - - if (distinct_candidates.size() == 1U) { - const auto& candidate = distinct_candidates.front(); - const bool shared_prefix = suffix_matches.size() >= 2U && - std::all_of(domains.begin(), domains.end(), [&candidate](const auto& domain) { - return starts_with_ci(domain, candidate); - }); - if (domains.size() == 1U || shared_prefix) { - IedIdentityResolution resolution; - resolution.name = candidate; - resolution.source = "MmsDomainKnownLogicalDeviceSuffix"; - resolution.confidence = domains.size() == 1U - ? mms::MmsLiveModelConfidence::medium - : mms::MmsLiveModelConfidence::high; - resolution.candidates = distinct_candidates; - for (const auto& match : suffix_matches) { - resolution.evidence.push_back( - "MMS domain '" + match.domain + - "' matched the logical-device suffix pattern for IED '" + - candidate + "'."); - } - return resolution; - } - } - - if (distinct_candidates.size() > 1U) { - return { - discovery.endpoint.host.empty() ? "DISCOVERED_IED" : discovery.endpoint.host, - "MmsDomainAmbiguous", - mms::MmsLiveModelConfidence::low, - true, - distinct_candidates, - {"MMS domains produced conflicting IED-name candidates."}}; - } - - return { - discovery.endpoint.host.empty() ? "DISCOVERED_IED" : discovery.endpoint.host, - "HostFallback", - mms::MmsLiveModelConfidence::low, - false, - {}, - {"No safe IED-name candidate could be derived from the live MMS domains."}}; -} - -void apply_resolved_identity( - mms::MmsLiveModelDocument& model, - const mms::MmsLiveDiscoveryResult& discovery) { - const auto resolution = resolve_ied_identity(discovery); - model.identity.ied_name = resolution.name; - model.identity.source = resolution.source; - model.identity.confidence = resolution.confidence; - model.identity.ambiguous = resolution.ambiguous; - model.identity.candidate_names = resolution.candidates; - model.identity.evidence = resolution.evidence; - model.identity.logical_device_aliases.clear(); - - for (auto& logical_device : model.logical_devices) { - std::string alias = logical_device.mms_domain; - if (starts_with_ci(logical_device.mms_domain, resolution.name) && - logical_device.mms_domain.size() > resolution.name.size()) { - alias = logical_device.mms_domain.substr(resolution.name.size()); - } - if (alias.empty()) { - alias = logical_device.mms_domain; - } - logical_device.instance = alias; - model.identity.logical_device_aliases.emplace( - logical_device.mms_domain, std::move(alias)); + [[nodiscard]] bool has_policy_options() const noexcept { + return !preferred_rcb_reference.empty() || + !preferred_logical_device.empty() || + !preferred_data_set_reference.empty() || + strict_rcb || !allow_urcb_fallback || !allow_polling_fallback || + !excluded_rcb_references.empty() || maximum_candidates_to_print != 10U; } -} +}; [[nodiscard]] std::string json_escape(const std::string_view value) { std::ostringstream output; @@ -332,6 +93,29 @@ void apply_resolved_identity( return static_cast(parsed); } +[[nodiscard]] RcbPlanMode parse_rcb_plan_mode(const std::string& value) { + if (value == "dynamic" || value == "dynamic-dataset") { + return RcbPlanMode::dynamic_data_set; + } + if (value == "static" || value == "static-dataset") { + return RcbPlanMode::static_data_set; + } + throw std::invalid_argument( + "--rcb-plan requires 'dynamic' or 'static'."); +} + +[[nodiscard]] std::string_view text_or_dash(const std::string& value) noexcept { + return value.empty() ? std::string_view{"-"} : std::string_view{value}; +} + +[[nodiscard]] std::size_t availability_count( + const mms::MmsRcbSelectionEvidence& selection, + const mms::MmsRcbAvailabilityKind kind) { + return static_cast(std::count_if( + selection.candidates.begin(), selection.candidates.end(), + [kind](const auto& candidate) { return candidate.availability == kind; })); +} + void print_usage() { std::cout << "Usage: ariec61850_live_discover [port] [options]\n" @@ -346,12 +130,88 @@ void print_usage() { << " --max-types N Bound all type probes.\n" << " --max-datasets N Bound DataSet directory reads.\n" << " --max-rcb N Bound RCB read probes.\n" - << " --timeout-ms N Connect/request timeout in milliseconds.\n"; + << " --timeout-ms N Connect/request timeout in milliseconds.\n" + << "Read-only Smart RCB planning:\n" + << " --rcb-plan MODE Analyze RCB evidence as dynamic or static.\n" + << " --rcb-ld LD Prefer this logical device for dynamic mode.\n" + << " --rcb-dataset REF Prefer this DataSet for static mode.\n" + << " --preferred-rcb REF Prefer this RCB reference.\n" + << " --strict-rcb Do not fall back from the preferred RCB.\n" + << " --no-urcb-fallback Exclude URCB fallback candidates.\n" + << " --no-polling-fallback Record polling fallback as disallowed.\n" + << " --rcb-exclude REF Exclude an RCB; may be repeated.\n" + << " --max-rcb-candidates N Limit human candidate output (default 10).\n" + << "\n" + << "RCB planning is evidence-only. It never sends Write, Resv, RptEna, GI,\n" + << "or dynamic DataSet mutation requests.\n"; +} + +void print_rcb_plan( + const mms::MmsRcbSelectionEvidence& selection, + const std::size_t maximum_candidates) { + std::cout << selection.summary() << '\n' + << "RCB availability: static=" + << availability_count(selection, mms::MmsRcbAvailabilityKind::available_static) + << ", dynamicEmpty=" + << availability_count( + selection, mms::MmsRcbAvailabilityKind::available_dynamic_empty) + << ", busyEnabled=" + << availability_count(selection, mms::MmsRcbAvailabilityKind::busy_enabled) + << ", busyReserved=" + << availability_count(selection, mms::MmsRcbAvailabilityKind::busy_reserved) + << ", unknown=" + << availability_count(selection, mms::MmsRcbAvailabilityKind::unknown_needs_probe) + << ", notApplicable=" + << availability_count(selection, mms::MmsRcbAvailabilityKind::not_applicable) + << ".\n"; + + if (selection.selected()) { + std::cout << "Selected RCB candidate: " + << selection.selected_rcb_reference << '\n'; + } else { + std::cout << "Selected RCB candidate: -\n"; + } + + const auto count = std::min(maximum_candidates, selection.candidates.size()); + if (count != 0U) { + std::cout << "RCB candidates (top " << count << " of " + << selection.candidates.size() << "):\n"; + } + for (std::size_t index = 0U; index < count; ++index) { + const auto& candidate = selection.candidates[index]; + std::cout << " [" + << mms::mms_rcb_selection_decision_name(candidate.decision) + << "] " << candidate.reference + << " score=" << candidate.score + << " availability=" + << mms::mms_rcb_availability_kind_name(candidate.availability) + << " mode=" << candidate.mode + << " DatSet=" << text_or_dash(candidate.data_set_reference) + << " RptEna=" << text_or_dash(candidate.report_enabled) + << " Resv=" << text_or_dash(candidate.reservation_state) + << " ResvTms=" << text_or_dash(candidate.reservation_time_seconds) + << " reason=" << candidate.reason << '\n'; + } + + if (!selection.warnings.empty()) { + std::cout << "RCB planner warnings:\n"; + for (const auto& warning : selection.warnings) { + std::cout << " - " << warning << '\n'; + } + } + if (!selection.blockers.empty()) { + std::cout << "RCB planner blockers:\n"; + for (const auto& blocker : selection.blockers) { + std::cout << " - " << blocker << '\n'; + } + } } void print_human( const mms::MmsLiveDiscoveryResult& result, - const mms::MmsLiveModelDocument& model) { + const mms::MmsLiveModelDocument& model, + const std::optional& rcb_selection, + const std::size_t maximum_rcb_candidates) { std::cout << result.summary() << '\n' << model.summary << '\n' << "IED identity: " << model.identity.ied_name @@ -372,11 +232,15 @@ void print_human( std::cout << " - [" << warning.code << "] " << warning.message << '\n'; } } + if (rcb_selection) { + print_rcb_plan(*rcb_selection, maximum_rcb_candidates); + } } void print_summary_json( const mms::MmsLiveDiscoveryResult& result, - const mms::MmsLiveModelDocument& model) { + const mms::MmsLiveModelDocument& model, + const std::optional& rcb_selection) { std::cout << '{' << "\"endpoint\":\"" << json_escape(result.endpoint.host) << ':' << result.endpoint.port << "\"," @@ -393,13 +257,79 @@ void print_summary_json( << "\"exactMmsTypes\":" << model.coverage.exact_mms_type_count << ',' << "\"fingerprint\":\"" << model.canonical_fingerprint_hex() << "\"," << "\"partial\":" << (result.partial() ? "true" : "false") << ',' - << "\"summary\":\"" << json_escape(model.summary) << "\"}\n"; + << "\"summary\":\"" << json_escape(model.summary) << "\""; + + if (rcb_selection) { + std::cout << ",\"rcbPlan\":{" + << "\"mode\":\"" + << mms::mms_rcb_selection_mode_name(rcb_selection->mode) << "\"," + << "\"selectedRcbReference\":\"" + << json_escape(rcb_selection->selected_rcb_reference) << "\"," + << "\"fallbackUsed\":" + << (rcb_selection->fallback_used ? "true" : "false") << ',' + << "\"candidateCount\":" << rcb_selection->candidates.size() << ',' + << "\"availableStatic\":" + << availability_count( + *rcb_selection, mms::MmsRcbAvailabilityKind::available_static) << ',' + << "\"availableDynamicEmpty\":" + << availability_count( + *rcb_selection, + mms::MmsRcbAvailabilityKind::available_dynamic_empty) << ',' + << "\"busyEnabled\":" + << availability_count( + *rcb_selection, mms::MmsRcbAvailabilityKind::busy_enabled) << ',' + << "\"busyReserved\":" + << availability_count( + *rcb_selection, mms::MmsRcbAvailabilityKind::busy_reserved) << ',' + << "\"unknownNeedsProbe\":" + << availability_count( + *rcb_selection, + mms::MmsRcbAvailabilityKind::unknown_needs_probe) << ',' + << "\"warningCount\":" << rcb_selection->warnings.size() << ',' + << "\"blockerCount\":" << rcb_selection->blockers.size() + << '}'; + } + std::cout << "}\n"; +} + +[[nodiscard]] std::optional build_rcb_plan( + const mms::MmsLiveDiscoveryResult& result, + const RcbPlanCliOptions& cli_options) { + if (!cli_options.requested()) { + return std::nullopt; + } + + if (cli_options.mode == RcbPlanMode::dynamic_data_set) { + mms::MmsRcbDynamicSelectionOptions options; + options.preferred_logical_device = cli_options.preferred_logical_device; + options.preferred_rcb_reference = cli_options.preferred_rcb_reference; + options.strict_rcb = cli_options.strict_rcb; + options.allow_urcb_fallback = cli_options.allow_urcb_fallback; + options.allow_polling_fallback = cli_options.allow_polling_fallback; + options.excluded_rcb_references = cli_options.excluded_rcb_references; + return mms::MmsRcbPoolSelector::build_dynamic_selection(result, options); + } + + mms::MmsRcbStaticSelectionOptions options; + options.preferred_rcb_reference = cli_options.preferred_rcb_reference; + options.preferred_data_set_reference = cli_options.preferred_data_set_reference; + options.strict_rcb = cli_options.strict_rcb; + options.allow_urcb_fallback = cli_options.allow_urcb_fallback; + options.allow_polling_fallback = cli_options.allow_polling_fallback; + options.excluded_rcb_references = cli_options.excluded_rcb_references; + return mms::MmsRcbPoolSelector::build_static_selection(result, options); } } // 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; + } if (argc < 2) { print_usage(); return 2; @@ -420,6 +350,7 @@ int main(int argc, char** argv) { std::chrono::milliseconds timeout{5'000}; mms::MmsLiveDiscoveryOptions discovery_options; mms::MmsLiveModelBuildOptions model_options; + RcbPlanCliOptions rcb_plan_options; while (argument < argc) { const std::string option = argv[argument++]; @@ -431,9 +362,18 @@ int main(int argc, char** argv) { discovery_options.read_data_set_directories = false; } else if (option == "--no-rcb") { discovery_options.probe_report_controls = false; + } else if (option == "--strict-rcb") { + rcb_plan_options.strict_rcb = true; + } else if (option == "--no-urcb-fallback") { + rcb_plan_options.allow_urcb_fallback = false; + } else if (option == "--no-polling-fallback") { + rcb_plan_options.allow_polling_fallback = false; } else if (option == "--ied-name" || option == "--max-types" || option == "--max-datasets" || option == "--max-rcb" || - option == "--timeout-ms") { + option == "--timeout-ms" || option == "--rcb-plan" || + option == "--rcb-ld" || option == "--rcb-dataset" || + option == "--preferred-rcb" || option == "--rcb-exclude" || + option == "--max-rcb-candidates") { if (argument >= argc) { throw std::invalid_argument(option + " requires a value."); } @@ -442,6 +382,27 @@ int main(int argc, char** argv) { model_options.explicit_ied_name = value; continue; } + if (option == "--rcb-plan") { + rcb_plan_options.mode = parse_rcb_plan_mode(value); + continue; + } + if (option == "--rcb-ld") { + rcb_plan_options.preferred_logical_device = value; + continue; + } + if (option == "--rcb-dataset") { + rcb_plan_options.preferred_data_set_reference = value; + continue; + } + if (option == "--preferred-rcb") { + rcb_plan_options.preferred_rcb_reference = value; + continue; + } + if (option == "--rcb-exclude") { + rcb_plan_options.excluded_rcb_references.push_back(value); + continue; + } + const auto parsed = parse_limit(option, value); if (option == "--max-types") { discovery_options.maximum_variable_type_probes = parsed; @@ -449,6 +410,8 @@ int main(int argc, char** argv) { discovery_options.maximum_data_set_directories = parsed; } else if (option == "--max-rcb") { discovery_options.maximum_report_control_probes = parsed; + } else if (option == "--max-rcb-candidates") { + rcb_plan_options.maximum_candidates_to_print = parsed; } else { if (parsed > static_cast( std::numeric_limits::max())) { @@ -465,6 +428,32 @@ int main(int argc, char** argv) { } } + if (!rcb_plan_options.requested() && rcb_plan_options.has_policy_options()) { + throw std::invalid_argument( + "RCB planner policy options require --rcb-plan dynamic|static."); + } + if (rcb_plan_options.requested() && !discovery_options.probe_report_controls) { + throw std::invalid_argument( + "--rcb-plan requires read-only RCB probes; remove --no-rcb."); + } + if (rcb_plan_options.mode == RcbPlanMode::dynamic_data_set && + !rcb_plan_options.preferred_data_set_reference.empty()) { + throw std::invalid_argument( + "--rcb-dataset applies only to --rcb-plan static."); + } + if (rcb_plan_options.mode == RcbPlanMode::static_data_set && + !rcb_plan_options.preferred_logical_device.empty()) { + throw std::invalid_argument( + "--rcb-ld applies only to --rcb-plan dynamic."); + } + if (rcb_plan_options.requested() && + (output_mode == OutputMode::model_json || + output_mode == OutputMode::manifest)) { + throw std::invalid_argument( + "--rcb-plan is runtime evidence and cannot be combined with " + "--model-json or --manifest structural parity output."); + } + mms::MmsAssociationOptions association_options; association_options.connect_timeout = timeout; association_options.request_timeout = timeout; @@ -472,16 +461,25 @@ int main(int argc, char** argv) { session.connect(endpoint); const auto result = session.discover(discovery_options); session.disconnect(); - auto model = mms::MmsLiveModelBuilder::build(result, model_options); - if (model_options.explicit_ied_name.empty()) { - apply_resolved_identity(model, result); - } + + const auto model = mms::MmsLiveModelBuilder::build(result, model_options); + const auto rcb_selection = build_rcb_plan(result, rcb_plan_options); switch (output_mode) { - case OutputMode::human: print_human(result, model); break; - case OutputMode::summary_json: print_summary_json(result, model); break; - case OutputMode::model_json: std::cout << model.to_json() << '\n'; break; - case OutputMode::manifest: std::cout << model.canonical_manifest(); break; + case OutputMode::human: + print_human( + result, model, rcb_selection, + rcb_plan_options.maximum_candidates_to_print); + break; + case OutputMode::summary_json: + print_summary_json(result, model, rcb_selection); + break; + case OutputMode::model_json: + std::cout << model.to_json() << '\n'; + break; + case OutputMode::manifest: + std::cout << model.canonical_manifest(); + break; } return result.partial() ? 1 : 0; } catch (const std::exception& exception) { From 302a9199817d030ca4759e8bbe7b318615aa6a88 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 21:06:24 +0700 Subject: [PATCH 073/179] Port conservative C# RCB availability evaluator --- include/ariec61850/mms/rcb_availability.hpp | 463 ++++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 include/ariec61850/mms/rcb_availability.hpp diff --git a/include/ariec61850/mms/rcb_availability.hpp b/include/ariec61850/mms/rcb_availability.hpp new file mode 100644 index 000000000..5d2f2886b --- /dev/null +++ b/include/ariec61850/mms/rcb_availability.hpp @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsRcbOperationalAvailability : std::uint8_t { + available, + in_use, + used_by_caller, + unknown, + no_data_set, + data_set_empty, + data_set_unreadable, +}; + +enum class MmsRcbOperationalAvailabilityConfidence : std::uint8_t { + exact, + reduced, + unknown, +}; + +[[nodiscard]] inline std::string_view mms_rcb_operational_availability_name( + const MmsRcbOperationalAvailability value) noexcept { + switch (value) { + case MmsRcbOperationalAvailability::available: return "Available"; + case MmsRcbOperationalAvailability::in_use: return "InUse"; + case MmsRcbOperationalAvailability::used_by_caller: return "UsedByCaller"; + case MmsRcbOperationalAvailability::unknown: return "Unknown"; + case MmsRcbOperationalAvailability::no_data_set: return "NoDataSet"; + case MmsRcbOperationalAvailability::data_set_empty: return "DataSetEmpty"; + case MmsRcbOperationalAvailability::data_set_unreadable: return "DataSetUnreadable"; + } + return "Unknown"; +} + +[[nodiscard]] inline std::string_view mms_rcb_operational_confidence_name( + const MmsRcbOperationalAvailabilityConfidence value) noexcept { + switch (value) { + case MmsRcbOperationalAvailabilityConfidence::exact: return "Exact"; + case MmsRcbOperationalAvailabilityConfidence::reduced: return "Reduced"; + case MmsRcbOperationalAvailabilityConfidence::unknown: return "Unknown"; + } + return "Unknown"; +} + +struct MmsRcbOperationalAvailabilityOptions final { + std::size_t maximum_report_controls{512U}; + std::vector caller_owned_rcb_references; +}; + +struct MmsRcbOperationalAvailabilitySnapshot final { + std::string reference; + std::string domain; + std::string logical_node; + std::string name; + std::string mode; + bool buffered{}; + std::string data_set_reference; + std::string report_id; + std::string configuration_revision; + std::string enabled_state; + std::string reservation_state; + std::string reservation_time_seconds; + std::string owner; + bool data_set_directory_read{}; + bool data_set_directory_success{}; + std::optional data_set_is_deletable; + std::size_t data_set_member_count{}; + MmsRcbOperationalAvailability availability{ + MmsRcbOperationalAvailability::unknown}; + MmsRcbOperationalAvailabilityConfidence confidence{ + MmsRcbOperationalAvailabilityConfidence::unknown}; + std::string reason; + std::vector probe_diagnostics; + + [[nodiscard]] bool selectable() const noexcept { + return availability == MmsRcbOperationalAvailability::available || + availability == MmsRcbOperationalAvailability::used_by_caller; + } + + [[nodiscard]] std::string summary() const { + return reference + ": " + + std::string{mms_rcb_operational_availability_name(availability)} + + " (" + std::string{mms_rcb_operational_confidence_name(confidence)} + + ") - " + reason; + } +}; + +struct MmsRcbOperationalAvailabilityResult final { + std::vector report_controls; + std::vector warnings; + + [[nodiscard]] std::size_t count( + const MmsRcbOperationalAvailability value) const noexcept { + return static_cast(std::count_if( + report_controls.begin(), report_controls.end(), + [value](const auto& item) { return item.availability == value; })); + } + + [[nodiscard]] std::size_t available_count() const noexcept { + return count(MmsRcbOperationalAvailability::available); + } + [[nodiscard]] std::size_t in_use_count() const noexcept { + return count(MmsRcbOperationalAvailability::in_use); + } + [[nodiscard]] std::size_t unknown_count() const noexcept { + return count(MmsRcbOperationalAvailability::unknown); + } + + [[nodiscard]] std::string summary() const { + return "RCB availability checked: total=" + + std::to_string(report_controls.size()) + + ", available=" + std::to_string(available_count()) + + ", in-use=" + std::to_string(in_use_count()) + + ", unknown=" + std::to_string(unknown_count()) + "."; + } +}; + +class MmsRcbOperationalAvailabilityEvaluator final { +public: + [[nodiscard]] static MmsRcbOperationalAvailabilityResult evaluate( + const MmsLiveDiscoveryResult& discovery, + const MmsRcbOperationalAvailabilityOptions& options = {}) { + MmsRcbOperationalAvailabilityResult result; + const auto limit = std::clamp( + options.maximum_report_controls, 1U, 4'096U); + const auto count = std::min( + limit, discovery.report_inventory.report_controls.size()); + result.report_controls.reserve(count); + + if (discovery.report_inventory.report_controls.size() > count) { + result.warnings.push_back( + "Availability evaluation was bounded to " + std::to_string(count) + + " of " + + std::to_string(discovery.report_inventory.report_controls.size()) + + " discovered RCBs."); + } + + std::vector caller_owned; + caller_owned.reserve(options.caller_owned_rcb_references.size()); + for (const auto& reference : options.caller_owned_rcb_references) { + const auto normalized = normalize_reference(reference); + if (!normalized.empty() && + std::none_of( + caller_owned.begin(), caller_owned.end(), + [&normalized](const auto& existing) { + return same(existing, normalized); + })) { + caller_owned.push_back(normalized); + } + } + + for (std::size_t index = 0U; index < count; ++index) { + const auto& candidate = discovery.report_inventory.report_controls[index]; + const auto normalized_reference = normalize_reference(candidate.reference); + const bool owned = std::any_of( + caller_owned.begin(), caller_owned.end(), + [&normalized_reference](const auto& reference) { + return same(reference, normalized_reference); + }); + result.report_controls.push_back( + evaluate_one(discovery, candidate, owned)); + } + + std::sort( + result.report_controls.begin(), result.report_controls.end(), + [](const auto& left, const auto& right) { + if ((left.data_set_member_count > 0U) != + (right.data_set_member_count > 0U)) { + return left.data_set_member_count > 0U; + } + const auto left_rank = availability_rank(left.availability); + const auto right_rank = availability_rank(right.availability); + if (left_rank != right_rank) return left_rank < right_rank; + if (left.buffered != right.buffered) return left.buffered; + return lower(left.reference) < lower(right.reference); + }); + return result; + } + +private: + [[nodiscard]] static MmsRcbOperationalAvailabilitySnapshot evaluate_one( + const MmsLiveDiscoveryResult& discovery, + const MmsReportControlCandidate& candidate, + const bool caller_owned) { + MmsRcbOperationalAvailabilitySnapshot snapshot; + snapshot.reference = candidate.reference; + snapshot.domain = candidate.domain; + snapshot.logical_node = candidate.logical_node; + snapshot.name = candidate.name; + snapshot.mode = candidate.mode(); + snapshot.buffered = candidate.buffered; + + const auto evidence = std::find_if( + discovery.report_controls.begin(), discovery.report_controls.end(), + [&candidate](const auto& item) { + return same(item.candidate.reference, candidate.reference); + }); + if (evidence == discovery.report_controls.end() || !evidence->state) { + snapshot.availability = MmsRcbOperationalAvailability::unknown; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::unknown; + snapshot.reason = evidence != discovery.report_controls.end() && + !evidence->error.empty() + ? "Runtime RCB state could not be read: " + evidence->error + : "Runtime ownership state was not read for this RCB."; + return snapshot; + } + + const auto& state = *evidence->state; + snapshot.data_set_reference = normalize_reference(state.data_set_reference); + snapshot.report_id = state.report_id; + snapshot.configuration_revision = optional_unsigned(state.configuration_revision); + snapshot.enabled_state = optional_bool(state.report_enabled); + snapshot.reservation_state = optional_bool(state.reserved); + snapshot.reservation_time_seconds = optional_unsigned( + state.reservation_time_seconds); + snapshot.owner = owner_text(state.owner); + snapshot.probe_diagnostics = state.diagnostics; + + const auto directory = find_directory( + discovery, snapshot.data_set_reference); + snapshot.data_set_directory_read = directory != nullptr; + if (directory != nullptr) { + snapshot.data_set_directory_success = directory->success(); + if (directory->success()) { + snapshot.data_set_is_deletable = directory->directory->deletable; + snapshot.data_set_member_count = directory->directory->members.size(); + } + } + + const bool has_owner_value = has_owner(state.owner); + const bool enabled = state.report_enabled == true; + const bool reserved = state.reserved == true; + const bool timed_reservation = + state.reservation_time_seconds.has_value() && + *state.reservation_time_seconds > 0U; + const bool has_data_set = !snapshot.data_set_reference.empty(); + + if (caller_owned) { + snapshot.availability = MmsRcbOperationalAvailability::used_by_caller; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::exact; + snapshot.reason = + "This RCB is active in the caller's current association/session."; + return snapshot; + } + + if (enabled || reserved || timed_reservation || has_owner_value) { + snapshot.availability = MmsRcbOperationalAvailability::in_use; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::exact; + snapshot.reason = build_busy_reason( + state, has_owner_value); + return snapshot; + } + + if (!has_data_set) { + snapshot.availability = MmsRcbOperationalAvailability::no_data_set; + snapshot.confidence = state.report_enabled == false + ? MmsRcbOperationalAvailabilityConfidence::exact + : MmsRcbOperationalAvailabilityConfidence::reduced; + snapshot.reason = + "The RCB does not reference a static DataSet and cannot be " + "exported as a populated legacy-SAS report block."; + return snapshot; + } + + if (directory != nullptr && !directory->success()) { + snapshot.availability = + MmsRcbOperationalAvailability::data_set_unreadable; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::exact; + snapshot.reason = directory->error.empty() + ? "The referenced DataSet directory could not be read." + : "The referenced DataSet directory could not be read: " + + directory->error; + return snapshot; + } + + if (directory != nullptr && directory->success() && + directory->directory->members.empty()) { + snapshot.availability = MmsRcbOperationalAvailability::data_set_empty; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::exact; + snapshot.reason = "The referenced DataSet is empty."; + return snapshot; + } + + if (state.report_enabled == false && + reservation_is_explicitly_free(candidate, state, has_owner_value)) { + snapshot.availability = MmsRcbOperationalAvailability::available; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::exact; + snapshot.reason = + "RptEna is false, reservation state is explicitly free, and " + "the RCB references a DataSet."; + return snapshot; + } + + if (state.report_enabled == false && candidate.buffered && + directory != nullptr && directory->success() && + !directory->directory->members.empty()) { + snapshot.availability = MmsRcbOperationalAvailability::unknown; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::reduced; + snapshot.reason = + "RptEna is false and the DataSet is populated, but this BRCB " + "does not expose enough reservation evidence to prove availability."; + return snapshot; + } + + if (state.report_enabled == false && !candidate.buffered && + !state.reserved.has_value()) { + snapshot.availability = MmsRcbOperationalAvailability::unknown; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::reduced; + snapshot.reason = + "RptEna is false, but the URCB Resv state was not returned."; + return snapshot; + } + + snapshot.availability = MmsRcbOperationalAvailability::unknown; + snapshot.confidence = MmsRcbOperationalAvailabilityConfidence::unknown; + snapshot.reason = + "Runtime ownership state could not be proven from the exposed RCB attributes."; + return snapshot; + } + + [[nodiscard]] static const MmsDataSetDirectoryEvidence* find_directory( + const MmsLiveDiscoveryResult& discovery, + const std::string& reference) { + if (reference.empty()) return nullptr; + const auto found = std::find_if( + discovery.data_set_directories.begin(), + discovery.data_set_directories.end(), + [&reference](const auto& item) { + return same(normalize_reference(item.candidate.reference), reference); + }); + return found == discovery.data_set_directories.end() ? nullptr : &*found; + } + + [[nodiscard]] static bool reservation_is_explicitly_free( + const MmsReportControlCandidate& candidate, + const MmsReportControlState& state, + const bool has_owner_value) noexcept { + if (has_owner_value) return false; + if (candidate.buffered) { + return state.reservation_time_seconds.has_value() && + *state.reservation_time_seconds == 0U; + } + return state.reserved.has_value() && !*state.reserved; + } + + [[nodiscard]] static bool has_owner( + const std::vector& owner) noexcept { + return std::any_of(owner.begin(), owner.end(), [](const auto byte) { + return byte != 0U; + }); + } + + [[nodiscard]] static std::string owner_text( + const std::vector& owner) { + if (!has_owner(owner)) return {}; + std::ostringstream output; + output << std::hex << std::setfill('0'); + for (std::size_t index = 0U; index < owner.size(); ++index) { + if (index != 0U) output << ':'; + output << std::setw(2) << static_cast(owner[index]); + } + return output.str(); + } + + [[nodiscard]] static std::string build_busy_reason( + const MmsReportControlState& state, + const bool has_owner_value) { + std::vector evidence; + if (state.report_enabled == true) evidence.emplace_back("RptEna=true"); + if (state.reserved == true) evidence.emplace_back("Resv=true"); + if (state.reservation_time_seconds.has_value() && + *state.reservation_time_seconds > 0U) { + evidence.push_back( + "ResvTms=" + std::to_string(*state.reservation_time_seconds)); + } + if (has_owner_value) { + evidence.push_back("Owner=" + owner_text(state.owner)); + } + std::string reason = "The RCB is in use or reserved"; + if (!evidence.empty()) { + reason += " ("; + for (std::size_t index = 0U; index < evidence.size(); ++index) { + if (index != 0U) reason += ", "; + reason += evidence[index]; + } + reason += ')'; + } + reason += '.'; + return reason; + } + + [[nodiscard]] static std::string optional_bool( + const std::optional value) { + if (!value.has_value()) return {}; + return *value ? "true" : "false"; + } + + [[nodiscard]] static std::string optional_unsigned( + const std::optional value) { + return value.has_value() ? std::to_string(*value) : std::string{}; + } + + [[nodiscard]] static std::string normalize_reference( + std::string reference) { + reference.erase( + reference.begin(), + std::find_if(reference.begin(), reference.end(), [](const char value) { + return value != ' ' && value != '\t' && value != '\r' && value != '\n'; + })); + reference.erase( + std::find_if(reference.rbegin(), reference.rend(), [](const char value) { + return value != ' ' && value != '\t' && value != '\r' && value != '\n'; + }).base(), + reference.end()); + std::replace(reference.begin(), reference.end(), '$', '.'); + return reference; + } + + [[nodiscard]] static std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char value) { + if (value >= 'A' && value <= 'Z') { + return static_cast(value - 'A' + 'a'); + } + return value; + }); + return value; + } + + [[nodiscard]] static bool same( + const std::string_view left, + const std::string_view right) { + return lower(std::string{left}) == lower(std::string{right}); + } + + [[nodiscard]] static int availability_rank( + const MmsRcbOperationalAvailability value) noexcept { + switch (value) { + case MmsRcbOperationalAvailability::available: return 0; + case MmsRcbOperationalAvailability::used_by_caller: return 1; + case MmsRcbOperationalAvailability::in_use: return 2; + case MmsRcbOperationalAvailability::unknown: return 3; + case MmsRcbOperationalAvailability::data_set_unreadable: return 4; + case MmsRcbOperationalAvailability::data_set_empty: return 5; + case MmsRcbOperationalAvailability::no_data_set: return 6; + } + return 9; + } +}; + +} // namespace ar::iec61850::mms From 7aa133059a0dfff2cdb49c9ead491401ab780d93 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 21:07:55 +0700 Subject: [PATCH 074/179] Test conservative C# RCB availability parity --- tests/test_rcb_selection.cpp | 193 +++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 6 deletions(-) diff --git a/tests/test_rcb_selection.cpp b/tests/test_rcb_selection.cpp index e6bc34328..6c31bb0f6 100644 --- a/tests/test_rcb_selection.cpp +++ b/tests/test_rcb_selection.cpp @@ -1,8 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/mms/rcb_availability.hpp" #include "ariec61850/mms/rcb_selection.hpp" #include +#include #include #include #include @@ -37,8 +39,8 @@ using namespace ar::iec61850; candidate.functional_constraint + "." + candidate.name; candidate.buffered = buffered; candidate.attributes = buffered - ? std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "ResvTms"} - : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv"}; + ? std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "ResvTms", "Owner"} + : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv", "Owner"}; return candidate; } @@ -49,12 +51,13 @@ void add_state( const std::optional report_enabled, const std::optional reserved = std::nullopt, const std::optional reservation_time_seconds = std::nullopt, - const bool fail_data_set_attribute = false) { + const bool fail_data_set_attribute = false, + std::vector owner = {}) { mms::MmsReportControlEvidence evidence; evidence.candidate = candidate; evidence.requested_attributes = candidate.buffered - ? std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "ResvTms"} - : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv"}; + ? std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "ResvTms", "Owner"} + : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv", "Owner"}; mms::MmsReportControlState state; state.candidate = candidate; @@ -64,6 +67,7 @@ void add_state( state.report_enabled = report_enabled; state.reserved = reserved; state.reservation_time_seconds = reservation_time_seconds; + state.owner = std::move(owner); if (fail_data_set_attribute) { state.diagnostics.push_back("DatSet read failed (3)"); } @@ -97,6 +101,28 @@ void add_populated_directory( result.data_set_directories.push_back(std::move(evidence)); } +void add_empty_directory( + mms::MmsLiveDiscoveryResult& result, + const std::string& reference) { + mms::MmsDataSetDirectoryEvidence evidence; + evidence.candidate.reference = reference; + mms::MmsDataSetDirectoryResponse directory; + directory.invoke_id = 1U; + directory.deletable = false; + evidence.directory = std::move(directory); + result.data_set_directories.push_back(std::move(evidence)); +} + +void add_failed_directory( + mms::MmsLiveDiscoveryResult& result, + const std::string& reference, + std::string error) { + mms::MmsDataSetDirectoryEvidence evidence; + evidence.candidate.reference = reference; + evidence.error = std::move(error); + result.data_set_directories.push_back(std::move(evidence)); +} + [[nodiscard]] const mms::MmsRcbCandidateEvaluation& find_evaluation( const mms::MmsRcbSelectionEvidence& selection, const std::string_view name) { @@ -109,6 +135,18 @@ void add_populated_directory( return *found; } +[[nodiscard]] const mms::MmsRcbOperationalAvailabilitySnapshot& find_snapshot( + const mms::MmsRcbOperationalAvailabilityResult& result, + const std::string_view name) { + const auto found = std::find_if( + result.report_controls.begin(), result.report_controls.end(), + [name](const auto& snapshot) { return snapshot.name == name; }); + if (found == result.report_controls.end()) { + throw std::runtime_error("Expected RCB availability snapshot was not found."); + } + return *found; +} + void dynamic_empty_slot_is_selected() { mms::MmsLiveDiscoveryResult result; const auto empty = make_rcb("brcbEmpty"); @@ -268,6 +306,145 @@ void requested_logical_device_affects_dynamic_ranking() { CHECK(!find_evaluation(selection, "brcbA").same_logical_device); } +void operational_brcb_requires_explicit_reservation_evidence() { + mms::MmsLiveDiscoveryResult uncertain; + const auto brcb = make_rcb("brcbUncertain"); + uncertain.report_inventory.report_controls = {brcb}; + add_state( + uncertain, brcb, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, std::nullopt); + add_populated_directory(uncertain, "IEDLD0/LLN0.DataSetA"); + + const auto uncertain_result = + mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(uncertain); + const auto& uncertain_snapshot = + find_snapshot(uncertain_result, "brcbUncertain"); + CHECK(uncertain_snapshot.availability == + mms::MmsRcbOperationalAvailability::unknown); + CHECK(uncertain_snapshot.confidence == + mms::MmsRcbOperationalAvailabilityConfidence::reduced); + + mms::MmsLiveDiscoveryResult free; + const auto free_brcb = make_rcb("brcbFree"); + free.report_inventory.report_controls = {free_brcb}; + add_state( + free, free_brcb, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, 0U); + add_populated_directory(free, "IEDLD0/LLN0.DataSetA"); + + const auto free_result = + mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(free); + const auto& free_snapshot = find_snapshot(free_result, "brcbFree"); + CHECK(free_snapshot.availability == + mms::MmsRcbOperationalAvailability::available); + CHECK(free_snapshot.confidence == + mms::MmsRcbOperationalAvailabilityConfidence::exact); +} + +void operational_busy_and_owner_evidence_are_conservative() { + mms::MmsLiveDiscoveryResult result; + const auto timed = make_rcb("brcbTimed"); + const auto owner = make_rcb("brcbOwner"); + const auto zero_owner = make_rcb("brcbZeroOwner"); + result.report_inventory.report_controls = {timed, owner, zero_owner}; + add_state( + result, timed, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, 30U); + add_state( + result, owner, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, std::nullopt, false, {0U, 0U, 1U}); + add_state( + result, zero_owner, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, 0U, false, {0U, 0U, 0U}); + add_populated_directory(result, "IEDLD0/LLN0.DataSetA"); + + const auto availability = + mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(result); + CHECK(find_snapshot(availability, "brcbTimed").availability == + mms::MmsRcbOperationalAvailability::in_use); + CHECK(find_snapshot(availability, "brcbTimed").confidence == + mms::MmsRcbOperationalAvailabilityConfidence::exact); + CHECK(find_snapshot(availability, "brcbOwner").availability == + mms::MmsRcbOperationalAvailability::in_use); + CHECK(!find_snapshot(availability, "brcbOwner").owner.empty()); + CHECK(find_snapshot(availability, "brcbZeroOwner").availability == + mms::MmsRcbOperationalAvailability::available); + CHECK(find_snapshot(availability, "brcbZeroOwner").owner.empty()); +} + +void operational_urcb_and_caller_owned_order_matches_csharp() { + mms::MmsLiveDiscoveryResult result; + const auto enabled_empty = make_rcb("urcbEnabledEmpty", false); + const auto disabled_empty = make_rcb("urcbDisabledEmpty", false); + const auto unknown_resv = make_rcb("urcbUnknownResv", false); + const auto caller = make_rcb("brcbCaller"); + result.report_inventory.report_controls = { + enabled_empty, disabled_empty, unknown_resv, caller}; + + add_state(result, enabled_empty, {}, true, std::nullopt, std::nullopt); + add_state(result, disabled_empty, {}, false, false, std::nullopt); + add_state( + result, unknown_resv, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, std::nullopt); + add_state(result, caller, {}, true, std::nullopt, 60U); + add_populated_directory(result, "IEDLD0/LLN0.DataSetA"); + + mms::MmsRcbOperationalAvailabilityOptions options; + options.caller_owned_rcb_references = {caller.reference}; + const auto availability = + mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(result, options); + + CHECK(find_snapshot(availability, "urcbEnabledEmpty").availability == + mms::MmsRcbOperationalAvailability::in_use); + CHECK(find_snapshot(availability, "urcbDisabledEmpty").availability == + mms::MmsRcbOperationalAvailability::no_data_set); + CHECK(find_snapshot(availability, "urcbDisabledEmpty").confidence == + mms::MmsRcbOperationalAvailabilityConfidence::exact); + CHECK(find_snapshot(availability, "urcbUnknownResv").availability == + mms::MmsRcbOperationalAvailability::unknown); + CHECK(find_snapshot(availability, "urcbUnknownResv").confidence == + mms::MmsRcbOperationalAvailabilityConfidence::reduced); + CHECK(find_snapshot(availability, "brcbCaller").availability == + mms::MmsRcbOperationalAvailability::used_by_caller); +} + +void operational_dataset_failure_and_empty_are_preserved() { + { + mms::MmsLiveDiscoveryResult result; + const auto rcb = make_rcb("brcbUnreadable"); + result.report_inventory.report_controls = {rcb}; + add_state( + result, rcb, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, 0U); + add_failed_directory( + result, "IEDLD0/LLN0.DataSetA", "object-access-denied"); + + const auto availability = + mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(result); + CHECK(find_snapshot(availability, "brcbUnreadable").availability == + mms::MmsRcbOperationalAvailability::data_set_unreadable); + CHECK(find_snapshot(availability, "brcbUnreadable").confidence == + mms::MmsRcbOperationalAvailabilityConfidence::exact); + } + + { + mms::MmsLiveDiscoveryResult result; + const auto rcb = make_rcb("brcbEmptyDataSet"); + result.report_inventory.report_controls = {rcb}; + add_state( + result, rcb, "IEDLD0/LLN0$DataSetA", false, + std::nullopt, 0U); + add_empty_directory(result, "IEDLD0/LLN0.DataSetA"); + + const auto availability = + mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(result); + CHECK(find_snapshot(availability, "brcbEmptyDataSet").availability == + mms::MmsRcbOperationalAvailability::data_set_empty); + CHECK(find_snapshot(availability, "brcbEmptyDataSet").confidence == + mms::MmsRcbOperationalAvailabilityConfidence::exact); + } +} + } // namespace int main() { @@ -280,7 +457,11 @@ int main() { exclusion_and_urcb_policy_are_preserved(); static_selection_requires_populated_directory(); requested_logical_device_affects_dynamic_ranking(); - std::cout << "Smart RCB selection parity tests passed.\n"; + operational_brcb_requires_explicit_reservation_evidence(); + operational_busy_and_owner_evidence_are_conservative(); + operational_urcb_and_caller_owned_order_matches_csharp(); + operational_dataset_failure_and_empty_are_preserved(); + std::cout << "Smart RCB selection and availability parity tests passed.\n"; return 0; } catch (const std::exception& exception) { std::cerr << exception.what() << '\n'; From df787df72554a48753a48494263269c5d32102a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 21:12:07 +0700 Subject: [PATCH 075/179] Expose conservative read-only RCB availability evidence --- tools/live_discover.cpp | 148 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 14 deletions(-) diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp index 72ec3b915..559a2eb4f 100644 --- a/tools/live_discover.cpp +++ b/tools/live_discover.cpp @@ -2,6 +2,7 @@ #include "ariec61850/mms/live_discovery.hpp" #include "ariec61850/mms/live_model.hpp" +#include "ariec61850/mms/rcb_availability.hpp" #include "ariec61850/mms/rcb_selection.hpp" #include @@ -51,6 +52,11 @@ struct RcbPlanCliOptions final { } }; +struct RcbAvailabilityCliOptions final { + bool requested{}; + std::size_t maximum_snapshots_to_print{10U}; +}; + [[nodiscard]] std::string json_escape(const std::string_view value) { std::ostringstream output; for (const char character : value) { @@ -141,16 +147,20 @@ void print_usage() { << " --no-polling-fallback Record polling fallback as disallowed.\n" << " --rcb-exclude REF Exclude an RCB; may be repeated.\n" << " --max-rcb-candidates N Limit human candidate output (default 10).\n" + << "Read-only operational RCB evidence:\n" + << " --rcb-availability Evaluate C#-parity ownership/availability.\n" + << " --max-rcb-availability N\n" + << " Limit human availability output (default 10).\n" << "\n" - << "RCB planning is evidence-only. It never sends Write, Resv, RptEna, GI,\n" - << "or dynamic DataSet mutation requests.\n"; + << "RCB planning/availability are evidence-only. They never send Write, Resv,\n" + << "RptEna, GI, or dynamic DataSet mutation requests.\n"; } void print_rcb_plan( const mms::MmsRcbSelectionEvidence& selection, const std::size_t maximum_candidates) { std::cout << selection.summary() << '\n' - << "RCB availability: static=" + << "RCB candidate availability: static=" << availability_count(selection, mms::MmsRcbAvailabilityKind::available_static) << ", dynamicEmpty=" << availability_count( @@ -207,11 +217,63 @@ void print_rcb_plan( } } +void print_rcb_operational_availability( + const mms::MmsRcbOperationalAvailabilityResult& availability, + const std::size_t maximum_snapshots) { + std::cout << availability.summary() << '\n' + << "RCB operational evidence: usedByCaller=" + << availability.count( + mms::MmsRcbOperationalAvailability::used_by_caller) + << ", noDataSet=" + << availability.count( + mms::MmsRcbOperationalAvailability::no_data_set) + << ", dataSetEmpty=" + << availability.count( + mms::MmsRcbOperationalAvailability::data_set_empty) + << ", dataSetUnreadable=" + << availability.count( + mms::MmsRcbOperationalAvailability::data_set_unreadable) + << ".\n"; + + const auto count = std::min(maximum_snapshots, availability.report_controls.size()); + if (count != 0U) { + std::cout << "Operational RCB snapshots (top " << count << " of " + << availability.report_controls.size() << "):\n"; + } + for (std::size_t index = 0U; index < count; ++index) { + const auto& snapshot = availability.report_controls[index]; + std::cout << " [" + << mms::mms_rcb_operational_availability_name(snapshot.availability) + << '/' << mms::mms_rcb_operational_confidence_name(snapshot.confidence) + << "] " << snapshot.reference + << " mode=" << snapshot.mode + << " DatSet=" << text_or_dash(snapshot.data_set_reference) + << " RptEna=" << text_or_dash(snapshot.enabled_state) + << " Resv=" << text_or_dash(snapshot.reservation_state) + << " ResvTms=" << text_or_dash(snapshot.reservation_time_seconds) + << " Owner=" << text_or_dash(snapshot.owner) + << " members=" << snapshot.data_set_member_count + << " reason=" << snapshot.reason << '\n'; + } + + if (availability.count(mms::MmsRcbOperationalAvailability::no_data_set) != 0U) { + std::cout + << " Note: NoDataSet is a populated/static-report availability result; " + << "empty dynamic-slot eligibility is evaluated separately by " + << "--rcb-plan dynamic.\n"; + } + for (const auto& warning : availability.warnings) { + std::cout << " - " << warning << '\n'; + } +} + void print_human( const mms::MmsLiveDiscoveryResult& result, const mms::MmsLiveModelDocument& model, const std::optional& rcb_selection, - const std::size_t maximum_rcb_candidates) { + const std::optional& rcb_availability, + const std::size_t maximum_rcb_candidates, + const std::size_t maximum_rcb_availability) { std::cout << result.summary() << '\n' << model.summary << '\n' << "IED identity: " << model.identity.ied_name @@ -235,12 +297,17 @@ void print_human( if (rcb_selection) { print_rcb_plan(*rcb_selection, maximum_rcb_candidates); } + if (rcb_availability) { + print_rcb_operational_availability( + *rcb_availability, maximum_rcb_availability); + } } void print_summary_json( const mms::MmsLiveDiscoveryResult& result, const mms::MmsLiveModelDocument& model, - const std::optional& rcb_selection) { + const std::optional& rcb_selection, + const std::optional& rcb_availability) { std::cout << '{' << "\"endpoint\":\"" << json_escape(result.endpoint.host) << ':' << result.endpoint.port << "\"," @@ -289,6 +356,32 @@ void print_summary_json( << "\"blockerCount\":" << rcb_selection->blockers.size() << '}'; } + if (rcb_availability) { + std::cout << ",\"rcbAvailability\":{" + << "\"total\":" << rcb_availability->report_controls.size() << ',' + << "\"available\":" + << rcb_availability->count( + mms::MmsRcbOperationalAvailability::available) << ',' + << "\"inUse\":" + << rcb_availability->count( + mms::MmsRcbOperationalAvailability::in_use) << ',' + << "\"usedByCaller\":" + << rcb_availability->count( + mms::MmsRcbOperationalAvailability::used_by_caller) << ',' + << "\"unknown\":" + << rcb_availability->count( + mms::MmsRcbOperationalAvailability::unknown) << ',' + << "\"noDataSet\":" + << rcb_availability->count( + mms::MmsRcbOperationalAvailability::no_data_set) << ',' + << "\"dataSetEmpty\":" + << rcb_availability->count( + mms::MmsRcbOperationalAvailability::data_set_empty) << ',' + << "\"dataSetUnreadable\":" + << rcb_availability->count( + mms::MmsRcbOperationalAvailability::data_set_unreadable) + << '}'; + } std::cout << "}\n"; } @@ -320,6 +413,17 @@ void print_summary_json( return mms::MmsRcbPoolSelector::build_static_selection(result, options); } +[[nodiscard]] std::optional +build_rcb_operational_availability( + const mms::MmsLiveDiscoveryResult& result, + const RcbAvailabilityCliOptions& cli_options) { + if (!cli_options.requested) return std::nullopt; + mms::MmsRcbOperationalAvailabilityOptions options; + options.maximum_report_controls = std::max( + 1U, result.report_inventory.report_controls.size()); + return mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(result, options); +} + } // namespace int main(int argc, char** argv) { @@ -351,6 +455,7 @@ int main(int argc, char** argv) { mms::MmsLiveDiscoveryOptions discovery_options; mms::MmsLiveModelBuildOptions model_options; RcbPlanCliOptions rcb_plan_options; + RcbAvailabilityCliOptions rcb_availability_options; while (argument < argc) { const std::string option = argv[argument++]; @@ -362,6 +467,8 @@ int main(int argc, char** argv) { discovery_options.read_data_set_directories = false; } else if (option == "--no-rcb") { discovery_options.probe_report_controls = false; + } else if (option == "--rcb-availability") { + rcb_availability_options.requested = true; } else if (option == "--strict-rcb") { rcb_plan_options.strict_rcb = true; } else if (option == "--no-urcb-fallback") { @@ -373,7 +480,8 @@ int main(int argc, char** argv) { option == "--timeout-ms" || option == "--rcb-plan" || option == "--rcb-ld" || option == "--rcb-dataset" || option == "--preferred-rcb" || option == "--rcb-exclude" || - option == "--max-rcb-candidates") { + option == "--max-rcb-candidates" || + option == "--max-rcb-availability") { if (argument >= argc) { throw std::invalid_argument(option + " requires a value."); } @@ -412,6 +520,8 @@ int main(int argc, char** argv) { discovery_options.maximum_report_control_probes = parsed; } else if (option == "--max-rcb-candidates") { rcb_plan_options.maximum_candidates_to_print = parsed; + } else if (option == "--max-rcb-availability") { + rcb_availability_options.maximum_snapshots_to_print = parsed; } else { if (parsed > static_cast( std::numeric_limits::max())) { @@ -432,9 +542,16 @@ int main(int argc, char** argv) { throw std::invalid_argument( "RCB planner policy options require --rcb-plan dynamic|static."); } - if (rcb_plan_options.requested() && !discovery_options.probe_report_controls) { + if (!rcb_availability_options.requested && + rcb_availability_options.maximum_snapshots_to_print != 10U) { + throw std::invalid_argument( + "--max-rcb-availability requires --rcb-availability."); + } + if ((rcb_plan_options.requested() || rcb_availability_options.requested) && + !discovery_options.probe_report_controls) { throw std::invalid_argument( - "--rcb-plan requires read-only RCB probes; remove --no-rcb."); + "RCB planning/availability requires read-only RCB probes; " + "remove --no-rcb."); } if (rcb_plan_options.mode == RcbPlanMode::dynamic_data_set && !rcb_plan_options.preferred_data_set_reference.empty()) { @@ -446,12 +563,12 @@ int main(int argc, char** argv) { throw std::invalid_argument( "--rcb-ld applies only to --rcb-plan dynamic."); } - if (rcb_plan_options.requested() && + if ((rcb_plan_options.requested() || rcb_availability_options.requested) && (output_mode == OutputMode::model_json || output_mode == OutputMode::manifest)) { throw std::invalid_argument( - "--rcb-plan is runtime evidence and cannot be combined with " - "--model-json or --manifest structural parity output."); + "RCB runtime evidence cannot be combined with --model-json or " + "--manifest structural parity output."); } mms::MmsAssociationOptions association_options; @@ -464,15 +581,18 @@ int main(int argc, char** argv) { const auto model = mms::MmsLiveModelBuilder::build(result, model_options); const auto rcb_selection = build_rcb_plan(result, rcb_plan_options); + const auto rcb_availability = build_rcb_operational_availability( + result, rcb_availability_options); switch (output_mode) { case OutputMode::human: print_human( - result, model, rcb_selection, - rcb_plan_options.maximum_candidates_to_print); + result, model, rcb_selection, rcb_availability, + rcb_plan_options.maximum_candidates_to_print, + rcb_availability_options.maximum_snapshots_to_print); break; case OutputMode::summary_json: - print_summary_json(result, model, rcb_selection); + print_summary_json(result, model, rcb_selection, rcb_availability); break; case OutputMode::model_json: std::cout << model.to_json() << '\n'; From d8dd951f2eeebe982ca9a79404d1debe980c6080 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:54:26 +0700 Subject: [PATCH 076/179] Add ESP32-oriented embedded core build profile --- embedded/CMakeLists.txt | 100 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 embedded/CMakeLists.txt diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt new file mode 100644 index 000000000..ee0dddb9e --- /dev/null +++ b/embedded/CMakeLists.txt @@ -0,0 +1,100 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850EmbeddedCore + VERSION 0.1.0 + DESCRIPTION "MCU-oriented build profile for arstack61850 wire protocols" + LANGUAGES CXX) + +option(ARIEC61850_EMBEDDED_NO_RTTI "Disable C++ RTTI in the embedded profile" ON) +option(ARIEC61850_EMBEDDED_NO_EXCEPTIONS "Disable C++ exceptions after legacy throw paths are removed" OFF) +option(ARIEC61850_EMBEDDED_WARNINGS_AS_ERRORS "Treat embedded-profile warnings as errors" ON) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/..") + +# Keep this source list deliberately small. It is the MCU portability boundary: +# wire codecs and protocol state only, with no TCP host transport, live discovery, +# PCAP, COMTRADE, SCL parsing, filesystem, JSON or CLI tooling. +set(ARIEC61850_EMBEDDED_SOURCES + ${ARIEC61850_ROOT}/src/asn1/ber.cpp + ${ARIEC61850_ROOT}/src/ethernet/ethernet.cpp + + ${ARIEC61850_ROOT}/src/goose/retransmission_schedule.cpp + ${ARIEC61850_ROOT}/src/goose/pdu_codec.cpp + ${ARIEC61850_ROOT}/src/goose/frame_codec.cpp + + ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/quality.cpp + ${ARIEC61850_ROOT}/src/sampled_values/payload_inspector.cpp + ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp + + ${ARIEC61850_ROOT}/src/osi/tpkt.cpp + ${ARIEC61850_ROOT}/src/osi/cotp.cpp + ${ARIEC61850_ROOT}/src/osi/session.cpp + ${ARIEC61850_ROOT}/src/osi/presentation.cpp + ${ARIEC61850_ROOT}/src/acse/association.cpp + + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/mms/data_codec.cpp + ${ARIEC61850_ROOT}/src/mms/pdu.cpp + ${ARIEC61850_ROOT}/src/mms/services.cpp) + +add_library(ariec61850_embedded_core STATIC ${ARIEC61850_EMBEDDED_SOURCES}) +add_library(ARIEC61850::embedded_core ALIAS ariec61850_embedded_core) + +target_compile_features(ariec61850_embedded_core PUBLIC cxx_std_20) +target_include_directories(ariec61850_embedded_core PUBLIC ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_core PUBLIC + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) + +function(ariec61850_apply_embedded_flags target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /permissive-) + if(ARIEC61850_EMBEDDED_NO_RTTI) + target_compile_options(${target} PRIVATE /GR-) + endif() + if(ARIEC61850_EMBEDDED_NO_EXCEPTIONS) + message(FATAL_ERROR + "ARIEC61850_EMBEDDED_NO_EXCEPTIONS is not ready on MSVC yet. " + "Legacy protocol error paths still use C++ exceptions.") + endif() + if(ARIEC61850_EMBEDDED_WARNINGS_AS_ERRORS) + target_compile_options(${target} PRIVATE /WX) + endif() + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion) + if(ARIEC61850_EMBEDDED_NO_RTTI) + target_compile_options(${target} PRIVATE -fno-rtti) + endif() + if(ARIEC61850_EMBEDDED_NO_EXCEPTIONS) + target_compile_options(${target} PRIVATE -fno-exceptions) + endif() + if(ARIEC61850_EMBEDDED_WARNINGS_AS_ERRORS) + target_compile_options(${target} PRIVATE -Werror) + endif() + endif() +endfunction() + +ariec61850_apply_embedded_flags(ariec61850_embedded_core) + +# Link every embedded source into one executable. This catches accidental +# dependencies on host-only objects that a static-library-only build can hide. +add_executable(ariec61850_embedded_link_smoke + ${CMAKE_CURRENT_LIST_DIR}/embedded_link_smoke.cpp + ${ARIEC61850_EMBEDDED_SOURCES}) +target_compile_features(ariec61850_embedded_link_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_link_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_link_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_link_smoke) + +enable_testing() +add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_smoke) + +message(STATUS "arstack61850 embedded profile: host services excluded") +message(STATUS "arstack61850 embedded profile: RTTI disabled=${ARIEC61850_EMBEDDED_NO_RTTI}") +message(STATUS "arstack61850 embedded profile: exceptions disabled=${ARIEC61850_EMBEDDED_NO_EXCEPTIONS}") From 17311d50dcabeab107a8e5b7b95e084ec8a4a221 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:54:39 +0700 Subject: [PATCH 077/179] Add embedded core full-link smoke target --- embedded/embedded_link_smoke.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 embedded/embedded_link_smoke.cpp diff --git a/embedded/embedded_link_smoke.cpp b/embedded/embedded_link_smoke.cpp new file mode 100644 index 000000000..0985dc143 --- /dev/null +++ b/embedded/embedded_link_smoke.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/osi/tpkt.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" + +#include +#include +#include + +int main() { + // Intentionally tiny: the executable is linked from every embedded source + // file by CMake, so the linker catches host-only dependencies even when + // this smoke program does not exercise every protocol at runtime. + std::array bytes{0x05U, 0x00U}; + std::size_t offset = 0U; + ar::iec61850::asn1::BerTlv tlv; + const bool parsed = ar::iec61850::asn1::BerReader::try_read_tlv(bytes, offset, tlv); + return parsed ? 0 : 0; +} From 878d703ef829e206cdee9ba55a31e047be8da864 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:55:12 +0700 Subject: [PATCH 078/179] Document ESP32 embedded architecture baseline --- embedded/README.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 embedded/README.md diff --git a/embedded/README.md b/embedded/README.md new file mode 100644 index 000000000..32187d20b --- /dev/null +++ b/embedded/README.md @@ -0,0 +1,92 @@ +# arstack61850 Embedded Profile + +This directory defines the MCU portability boundary for arstack61850. + +## Reference target + +The first reference MCU is **ESP32-class hardware**. The initial product goals are intentionally small: + +1. a simple IEC 61850 I/O IED, using MMS over TCP plus a bounded static model; or +2. an IEC 61850-9-2 Sampled Values publisher using raw Layer-2 Ethernet. + +STM32/NXP-class industrial MCUs can be added later without changing the protocol engine boundary. + +## Architecture rule + +`embedded_core` contains wire protocol and deterministic protocol-state code only. It must not depend on host tooling. + +Allowed direction: + +```text +application / ESP-IDF / FreeRTOS / lwIP / Ethernet HAL + | + v + ARIEC61850::embedded_core + | + +----------------+----------------+ + | | | + MMS/OSI GOOSE SV + | | | + +----------------+----------------+ + | + BER/Ethernet +``` + +Host-only facilities remain outside this build profile: + +- native Windows/Linux TCP transport; +- live discovery CLI; +- JSON/manifest output; +- PCAP capture/evidence tooling; +- COMTRADE; +- SCL XML parsing; +- filesystem access; +- desktop diagnostics/UI. + +## Current compiler contract + +The embedded profile currently enforces: + +- C++20; +- no RTTI by default (`-fno-rtti` or `/GR-`); +- warnings as errors; +- full-link smoke of every source in the embedded source list; +- no host-only source in the embedded target. + +`-fno-exceptions` is **not yet enabled**. Existing BER/MMS malformed-input paths still use C++ exceptions. This is explicit technical debt, not an accepted final state. New embedded work should not add new exception-dependent architecture when a bounded result/status API is practical. + +The next embedded-hardening milestone is to introduce non-throwing result APIs for the wire codecs, migrate the ESP32-facing path to them, and then turn `ARIEC61850_EMBEDDED_NO_EXCEPTIONS=ON` into a CI requirement. + +## Memory and allocation direction + +The final MCU path should be deterministic. New embedded APIs should prefer: + +- `std::span` for caller-owned buffers; +- `std::array` for fixed-capacity storage; +- explicit bounded capacities; +- status/result returns for parse failures; +- compile-time feature slicing; +- no filesystem/iostream dependency; +- no uncontrolled heap growth in steady-state protocol loops. + +Existing `std::vector`/`std::string` based codecs are retained during migration for behavioral parity with ARIEC61850. They are migration inputs, not the final MCU allocation model. + +## ESP32 deployment notes + +MMS IED operation needs TCP/IP, so the future HAL will bind the OSI/MMS runtime to the platform TCP stack (for example lwIP through ESP-IDF). + +GOOSE and Sampled Values are raw Ethernet Layer-2 services. The platform integration therefore needs a raw Ethernet transmit/receive path; Wi-Fi/TCP sockets are not substitutes for SV/GOOSE framing. + +No ESP32-specific network driver is part of `embedded_core`. Platform code owns clocks, Ethernet/TCP I/O, tasks, synchronization and persistent configuration. + +## Host-sim build + +From the repository root: + +```bash +cmake -S embedded -B build-embedded +cmake --build build-embedded --parallel +ctest --test-dir build-embedded --output-on-failure +``` + +This is a portability gate, not a claim that the stack already runs on ESP32 hardware. From 5d25d150396abf100194356000ad823538f2a487 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:55:36 +0700 Subject: [PATCH 079/179] Gate MCU embedded profile in CI --- .github/workflows/embedded-ci.yml | 106 ++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/embedded-ci.yml diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml new file mode 100644 index 000000000..eaa605ac8 --- /dev/null +++ b/.github/workflows/embedded-ci.yml @@ -0,0 +1,106 @@ +name: Embedded Profile CI + +on: + push: + branches: + - main + - 'agent/**' + pull_request: + +permissions: + contents: read + +jobs: + embedded-host-sim: + name: Embedded host-sim / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Audit embedded source boundary + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import re + import sys + + cmake = Path('embedded/CMakeLists.txt').read_text(encoding='utf-8') + match = re.search(r'set\(ARIEC61850_EMBEDDED_SOURCES\n(.*?)\n\)', cmake, re.S) + if not match: + raise SystemExit('embedded source list not found') + + paths = [] + for raw in match.group(1).splitlines(): + raw = raw.strip() + if not raw or raw.startswith('#'): + continue + raw = raw.replace('${ARIEC61850_ROOT}/', '') + paths.append(Path(raw)) + + forbidden = { + '#include ': 'filesystem', + '#include ': 'iostream', + 'winsock2.h': 'WinSock', + 'windows.h': 'Windows API', + 'sys/socket.h': 'POSIX socket API', + 'netinet/': 'POSIX network API', + 'ariec61850/capture/': 'PCAP/capture layer', + 'ariec61850/comtrade/': 'COMTRADE layer', + 'ariec61850/evidence/': 'host evidence layer', + 'ariec61850/scl/parser': 'SCL parser', + 'ariec61850/mms/tcp_transport': 'host TCP transport', + 'ariec61850/mms/live_discovery': 'host live discovery', + } + + failures = [] + throw_count = 0 + for path in paths: + text = path.read_text(encoding='utf-8') + throw_count += len(re.findall(r'\bthrow\b', text)) + for needle, label in forbidden.items(): + if needle in text: + failures.append(f'{path}: forbidden {label} dependency ({needle})') + + print(f'embedded sources={len(paths)}') + print(f'legacy throw tokens={throw_count} (migration debt; no-exceptions gate not enabled yet)') + if failures: + print('\n'.join(failures), file=sys.stderr) + raise SystemExit(1) + PY + + - name: Configure embedded profile + run: >- + cmake -S embedded -B build-embedded + -DARIEC61850_EMBEDDED_NO_RTTI=ON + -DARIEC61850_EMBEDDED_NO_EXCEPTIONS=OFF + -DARIEC61850_EMBEDDED_WARNINGS_AS_ERRORS=ON + + - name: Build embedded profile + run: cmake --build build-embedded --parallel + + - name: Test embedded full-link smoke + run: ctest --test-dir build-embedded --output-on-failure + + - name: Report host-sim footprint + shell: bash + run: | + if command -v size >/dev/null 2>&1; then + size build-embedded/ariec61850_embedded_link_smoke || true + fi From 6e6149d6b88a10c717cc192073aa0a1c944bf6f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:56:21 +0700 Subject: [PATCH 080/179] Add allocation-free embedded transport HAL --- include/ariec61850/embedded/io.hpp | 95 ++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 include/ariec61850/embedded/io.hpp diff --git a/include/ariec61850/embedded/io.hpp b/include/ariec61850/embedded/io.hpp new file mode 100644 index 000000000..d97cd930c --- /dev/null +++ b/include/ariec61850/embedded/io.hpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::embedded { + +enum class IoStatus : std::uint8_t { + ok, + would_block, + timeout, + closed, + io_error, + invalid_argument, + buffer_too_small, +}; + +struct IoResult final { + IoStatus status{IoStatus::ok}; + std::size_t transferred{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == IoStatus::ok; + } +}; + +using MonotonicMicrosecondsFn = std::uint64_t (*)(void* context) noexcept; +using UtcMillisecondsFn = std::uint64_t (*)(void* context) noexcept; +using RawEthernetTransmitFn = IoResult (*)( + void* context, + std::span frame) noexcept; +using TcpSendFn = IoResult (*)( + void* context, + std::span bytes) noexcept; +using TcpReceiveFn = IoResult (*)( + void* context, + std::span destination) noexcept; + +struct Clock final { + void* context{}; + MonotonicMicrosecondsFn monotonic_microseconds{}; + UtcMillisecondsFn utc_milliseconds{}; + + [[nodiscard]] std::uint64_t monotonic_us() const noexcept { + return monotonic_microseconds == nullptr ? 0U : monotonic_microseconds(context); + } + + [[nodiscard]] std::uint64_t utc_ms() const noexcept { + return utc_milliseconds == nullptr ? 0U : utc_milliseconds(context); + } +}; + +struct RawEthernetPort final { + void* context{}; + RawEthernetTransmitFn transmit{}; + + [[nodiscard]] IoResult send( + const std::span frame) const noexcept { + if (transmit == nullptr || frame.empty()) { + return {IoStatus::invalid_argument, 0U}; + } + return transmit(context, frame); + } +}; + +// Represents an already-connected TCP byte stream. Connection setup/listen/ +// accept remains platform-owned so the protocol engine is independent of +// lwIP, BSD sockets, WinSock, FreeRTOS and ESP-IDF. +struct TcpByteStream final { + void* context{}; + TcpSendFn send_callback{}; + TcpReceiveFn receive_callback{}; + + [[nodiscard]] IoResult send( + const std::span bytes) const noexcept { + if (send_callback == nullptr || bytes.empty()) { + return {IoStatus::invalid_argument, 0U}; + } + return send_callback(context, bytes); + } + + [[nodiscard]] IoResult receive( + const std::span destination) const noexcept { + if (receive_callback == nullptr || destination.empty()) { + return {IoStatus::invalid_argument, 0U}; + } + return receive_callback(context, destination); + } +}; + +static_assert(sizeof(IoStatus) == 1U); + +} // namespace ar::iec61850::embedded From 625c50f42a1d3eb5076ce27d5410b4c463cf929b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:56:34 +0700 Subject: [PATCH 081/179] Add configurable MCU capacity profile --- include/ariec61850/embedded/profile.hpp | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 include/ariec61850/embedded/profile.hpp diff --git a/include/ariec61850/embedded/profile.hpp b/include/ariec61850/embedded/profile.hpp new file mode 100644 index 000000000..4ecb7d5c1 --- /dev/null +++ b/include/ariec61850/embedded/profile.hpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::embedded { + +// Conservative starting capacities for a small IEC 61850 I/O IED or SV +// publisher. Applications may provide their own profile type; these values are +// not protocol limits and must not be treated as negotiated MMS capabilities. +struct Esp32SmallProfile final { + static constexpr std::size_t ethernet_frame_bytes = 1'536U; + static constexpr std::size_t mms_rx_bytes = 8'192U; + static constexpr std::size_t mms_tx_bytes = 8'192U; + static constexpr std::size_t maximum_logical_devices = 4U; + static constexpr std::size_t maximum_logical_nodes = 32U; + static constexpr std::size_t maximum_data_sets = 16U; + static constexpr std::size_t maximum_data_set_members = 64U; + static constexpr std::size_t maximum_report_controls = 16U; + static constexpr std::size_t maximum_sv_asdus_per_frame = 8U; +}; + +template +concept EmbeddedCapacityProfile = + requires { + Profile::ethernet_frame_bytes; + Profile::mms_rx_bytes; + Profile::mms_tx_bytes; + Profile::maximum_logical_devices; + Profile::maximum_logical_nodes; + Profile::maximum_data_sets; + Profile::maximum_data_set_members; + Profile::maximum_report_controls; + Profile::maximum_sv_asdus_per_frame; + }; + +static_assert(EmbeddedCapacityProfile); +static_assert(Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); + +} // namespace ar::iec61850::embedded From 1d46bbae5d9f351c8b9a77f067e8df4bbb6321f6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:56:54 +0700 Subject: [PATCH 082/179] Compile embedded HAL and ESP32 capacity profile in smoke gate --- embedded/embedded_link_smoke.cpp | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/embedded/embedded_link_smoke.cpp b/embedded/embedded_link_smoke.cpp index 0985dc143..17e3964aa 100644 --- a/embedded/embedded_link_smoke.cpp +++ b/embedded/embedded_link_smoke.cpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/asn1/ber.hpp" +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/embedded/profile.hpp" #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/goose/frame_codec.hpp" #include "ariec61850/mms/data_codec.hpp" @@ -11,13 +13,29 @@ #include #include +namespace { + +ar::iec61850::embedded::IoResult discard_frame( + void*, const std::span frame) noexcept { + return {ar::iec61850::embedded::IoStatus::ok, frame.size()}; +} + +} // namespace + int main() { - // Intentionally tiny: the executable is linked from every embedded source - // file by CMake, so the linker catches host-only dependencies even when - // this smoke program does not exercise every protocol at runtime. + // The executable is linked from every embedded source file by CMake, so + // the linker catches host-only dependencies even when this smoke program + // does not exercise every protocol at runtime. std::array bytes{0x05U, 0x00U}; std::size_t offset = 0U; ar::iec61850::asn1::BerTlv tlv; const bool parsed = ar::iec61850::asn1::BerReader::try_read_tlv(bytes, offset, tlv); - return parsed ? 0 : 0; + + ar::iec61850::embedded::RawEthernetPort ethernet; + ethernet.transmit = &discard_frame; + const auto sent = ethernet.send(bytes); + + static_assert( + ar::iec61850::embedded::Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); + return parsed && sent.success() ? 0 : 1; } From ba5a2ad6e1f9732087c5a940ec6f5356c5c7da56 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:57:37 +0700 Subject: [PATCH 083/179] Fix embedded CI source manifest audit --- .github/workflows/embedded-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index eaa605ac8..d145b8ea5 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -42,7 +42,7 @@ jobs: import sys cmake = Path('embedded/CMakeLists.txt').read_text(encoding='utf-8') - match = re.search(r'set\(ARIEC61850_EMBEDDED_SOURCES\n(.*?)\n\)', cmake, re.S) + match = re.search(r'set\(ARIEC61850_EMBEDDED_SOURCES\s*(.*?)\n\)', cmake, re.S) if not match: raise SystemExit('embedded source list not found') From ecf3759ce24bb894af56de8f60cf0f48af112096 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 04:58:38 +0700 Subject: [PATCH 084/179] Define ESP32-first embedded delivery roadmap --- embedded/ROADMAP.md | 109 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 embedded/ROADMAP.md diff --git a/embedded/ROADMAP.md b/embedded/ROADMAP.md new file mode 100644 index 000000000..38acf028d --- /dev/null +++ b/embedded/ROADMAP.md @@ -0,0 +1,109 @@ +# ESP32-first embedded roadmap + +The embedded roadmap is product-driven. The first hardware proof should be small enough to finish and measure. + +## E0 — portability boundary + +Status: in progress. + +Acceptance: + +- standalone `embedded/` CMake build; +- GCC and Clang host-sim compile; +- RTTI disabled; +- host TCP/PCAP/COMTRADE/SCL/CLI dependencies rejected by CI; +- all embedded objects participate in a full-link smoke executable; +- existing desktop C++ CI remains unchanged and green. + +Not yet accepted: + +- `-fno-exceptions`; +- deterministic/no-heap wire encoding; +- ESP-IDF compilation; +- real ESP32 Ethernet traffic. + +## E1 — allocation-bounded SV wire path + +This is the next protocol refactor because it directly enables the smallest useful ESP32 product. + +Target API direction: + +```cpp +EncodeResult encode_into( + const SampledValuesFrame& frame, + std::span destination) noexcept; +``` + +Acceptance: + +- caller-owned Ethernet frame buffer; +- no heap allocation on the steady-state SV encode/transmit path; +- no exceptions on that path; +- byte-for-byte parity with existing C++/ARIEC61850 golden SV vectors; +- existing `std::vector` host API retained as a compatibility wrapper; +- VLAN/non-VLAN framing remains explicit; +- sample counter wrap behavior remains tested. + +## E2 — ESP32 SV Publisher MVP + +Platform layer: + +- ESP-IDF/FreeRTOS owns task scheduling and network driver; +- `RawEthernetPort` binds to the platform raw Ethernet TX function; +- fixed frame buffers are owned by the application; +- sample source is pluggable (synthetic first, ADC later). + +Minimum demonstration: + +1. configure destination MAC, APPID and svID; +2. generate deterministic sampled values; +3. publish valid IEC 61850 SV Layer-2 frames; +4. decode them with the desktop arstack61850 decoder and an independent IEC 61850 tool; +5. capture PCAP and compare payload fields against the host golden path; +6. run a sustained-rate test appropriate to the selected SV profile. + +A common 50 Hz / 80 samples-per-cycle profile implies 4,000 sample instants per second. That is a useful stress target, not a blanket standards claim for every SV application. + +Timing quality must be reported honestly. A lab ESP32 publisher is not called protection-grade or time-synchronized until jitter and synchronization/PTP evidence support that claim. + +## E3 — simple ESP32 IEC 61850 I/O IED + +Start with a deliberately static device model. + +Initial server surface: + +- TCP/TPKT/COTP/session/presentation/ACSE server-side accept path; +- MMS initiate; +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `Read`; +- compile-time/static Logical Device / Logical Node / Data Object table; +- GPIO-backed SPS/DPS/measurement points. + +Then add, in controlled slices: + +- bounded `Write` for configuration points where appropriate; +- control model for outputs; +- one small static DataSet; +- URCB reporting first; +- BRCB only after reservation/buffering semantics are proven. + +Dynamic DataSet and large live-discovery structures are not initial MCU requirements. + +## E4 — hard embedded profile + +Acceptance: + +- `-fno-exceptions` enabled in CI; +- `-fno-rtti` remains enabled; +- no uncontrolled heap use in protocol steady state; +- explicit compile-time capacities; +- platform clock/TCP/raw-Ethernet HAL only; +- ESP-IDF build in CI or reproducible local toolchain build; +- RAM/flash footprint report. + +## E5 — STM32/NXP industrial target + +After the ESP32 proof is stable, port only the HAL and platform integration. The protocol core must remain unchanged. + +For higher-end protection/process-bus ambitions, select hardware based on Ethernet MAC/DMA, timer determinism, memory, and synchronization/PTP requirements rather than rewriting the stack around a vendor SDK. From 4895e0c1ef90767d19f1ee9c939d353f1e4b8922 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:47:51 +0700 Subject: [PATCH 085/179] Add allocation-free wire encode result contract --- include/ariec61850/wire/encode_result.hpp | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 include/ariec61850/wire/encode_result.hpp diff --git a/include/ariec61850/wire/encode_result.hpp b/include/ariec61850/wire/encode_result.hpp new file mode 100644 index 000000000..de70f2fbf --- /dev/null +++ b/include/ariec61850/wire/encode_result.hpp @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace ar::iec61850::wire { + +enum class EncodeStatus : std::uint8_t { + ok, + buffer_too_small, + value_out_of_range, +}; + +struct EncodeResult final { + EncodeStatus status{EncodeStatus::ok}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == EncodeStatus::ok; + } +}; + +} // namespace ar::iec61850::wire From 3a78695f8af5cdf5f48cc7691acc7077cbb58c5a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:48:05 +0700 Subject: [PATCH 086/179] Expose bounded Sampled Values PDU encoding API --- include/ariec61850/sampled_values/pdu_codec.hpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/include/ariec61850/sampled_values/pdu_codec.hpp b/include/ariec61850/sampled_values/pdu_codec.hpp index 298802aff..9fd31c8ce 100644 --- a/include/ariec61850/sampled_values/pdu_codec.hpp +++ b/include/ariec61850/sampled_values/pdu_codec.hpp @@ -3,8 +3,11 @@ #pragma once #include "ariec61850/sampled_values/asdu.hpp" +#include "ariec61850/wire/encode_result.hpp" +#include #include +#include #include #include @@ -12,7 +15,17 @@ namespace ar::iec61850::sampled_values { class SampledValuesPduCodec final { public: + [[nodiscard]] static std::optional encoded_size( + const SampledValuesPdu& pdu) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_into( + const SampledValuesPdu& pdu, + std::span destination) noexcept; + + // Host convenience wrapper. Embedded steady-state publishers should use + // encode_into with caller-owned storage to avoid per-frame allocation. [[nodiscard]] static std::vector encode(const SampledValuesPdu& pdu); + [[nodiscard]] static bool try_decode( std::span apdu, SampledValuesPdu& pdu) noexcept; }; From 7bb17755fe6013d60cd115a629216fa60e8aee16 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:48:37 +0700 Subject: [PATCH 087/179] Add allocation-free IEC 61850 UTC time writer --- include/ariec61850/mms/utc_time.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/include/ariec61850/mms/utc_time.hpp b/include/ariec61850/mms/utc_time.hpp index 89bcb7914..6b62af416 100644 --- a/include/ariec61850/mms/utc_time.hpp +++ b/include/ariec61850/mms/utc_time.hpp @@ -14,6 +14,12 @@ struct Iec61850UtcTime final { std::uint8_t quality{}; [[nodiscard]] static Iec61850UtcTime from_bytes(std::span bytes); + + // Allocation-free wire path for embedded publishers. Returns false when + // the timestamp cannot be represented by IEC 61850's 32-bit seconds field. + [[nodiscard]] bool try_write_bytes(std::span destination) const noexcept; + + // Host convenience wrapper retained for source compatibility. [[nodiscard]] std::vector to_bytes() const; friend bool operator==(const Iec61850UtcTime&, const Iec61850UtcTime&) = default; From d440b6343c350ef02039c97f34bc0958c462ba41 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:48:53 +0700 Subject: [PATCH 088/179] Use bounded UTC time serialization for embedded path --- src/mms/utc_time.cpp | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/mms/utc_time.cpp b/src/mms/utc_time.cpp index 6269a5609..0abb34c5a 100644 --- a/src/mms/utc_time.cpp +++ b/src/mms/utc_time.cpp @@ -2,10 +2,11 @@ #include "ariec61850/mms/utc_time.hpp" -#include "ariec61850/asn1/ber.hpp" - +#include #include +#include #include +#include #include namespace ar::iec61850::mms { @@ -32,8 +33,43 @@ Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span return {timestamp, bytes[7]}; } +bool Iec61850UtcTime::try_write_bytes( + const std::span destination) const noexcept { + using seconds_type = std::chrono::seconds; + const auto whole_seconds = std::chrono::floor(value); + auto seconds = whole_seconds.time_since_epoch().count(); + const auto fractional = value - whole_seconds; + auto fraction = static_cast(std::llround( + std::chrono::duration(fractional).count() * 16'777'216.0)); + + if (fraction >= 16'777'216U) { + ++seconds; + fraction = 0U; + } + if (seconds < 0 || + static_cast(seconds) > + std::numeric_limits::max()) { + return false; + } + + const auto raw_seconds = static_cast(seconds); + destination[0] = static_cast((raw_seconds >> 24U) & 0xFFU); + destination[1] = static_cast((raw_seconds >> 16U) & 0xFFU); + destination[2] = static_cast((raw_seconds >> 8U) & 0xFFU); + destination[3] = static_cast(raw_seconds & 0xFFU); + destination[4] = static_cast((fraction >> 16U) & 0xFFU); + destination[5] = static_cast((fraction >> 8U) & 0xFFU); + destination[6] = static_cast(fraction & 0xFFU); + destination[7] = quality; + return true; +} + std::vector Iec61850UtcTime::to_bytes() const { - return asn1::BerWriter::encode_utc_time(value, quality); + std::array bytes{}; + if (!try_write_bytes(bytes)) { + throw std::out_of_range("UTC time seconds are outside the IEC 61850 32-bit range."); + } + return {bytes.begin(), bytes.end()}; } } // namespace ar::iec61850::mms From 88e66489f85cf340cbbcf3022c5e3120bd4de1ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:49:49 +0700 Subject: [PATCH 089/179] Implement allocation-free Sampled Values PDU encoder --- src/sampled_values/pdu_codec.cpp | 353 ++++++++++++++++++++++++++----- 1 file changed, 300 insertions(+), 53 deletions(-) diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp index 6ddfa6d28..253b624b6 100644 --- a/src/sampled_values/pdu_codec.cpp +++ b/src/sampled_values/pdu_codec.cpp @@ -5,6 +5,9 @@ #include "ariec61850/asn1/ber.hpp" #include "ariec61850/mms/utc_time.hpp" +#include +#include +#include #include #include #include @@ -17,66 +20,250 @@ namespace ar::iec61850::sampled_values { namespace { -constexpr std::int32_t sav_pdu_application_tag = 0; -constexpr std::int32_t sequence_tag_number = 16; +constexpr std::uint8_t sav_pdu_application_tag = 0x60U; +constexpr std::uint8_t sequence_tag = 0x30U; +constexpr std::uint8_t context_primitive_base = 0x80U; +constexpr std::uint8_t context_constructed_base = 0xA0U; -void write_context_primitive( - asn1::BerWriter& writer, - const std::int32_t tag_number, - const std::span value) { - writer.write_tlv(asn1::BerClass::context_specific, false, tag_number, value); +[[nodiscard]] bool checked_add( + std::size_t& total, + const std::size_t value) noexcept { + if (value > std::numeric_limits::max() - total) { + return false; + } + total += value; + return true; } -std::vector encode_asdu(const SampledValueAsdu& asdu) { - asn1::BerWriter writer; +[[nodiscard]] std::optional ber_length_octets( + const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + return 1U; + } + if (value_length > std::numeric_limits::max()) { + return std::nullopt; + } - const auto sv_id = asn1::BerWriter::encode_ascii(asdu.sv_id); - write_context_primitive(writer, 0, sv_id); + auto value = static_cast(value_length); + std::size_t bytes = 0U; + while (value != 0U) { + ++bytes; + value >>= 8U; + } + return 1U + bytes; +} - if (!asdu.data_set_reference.empty()) { - const auto data_set = asn1::BerWriter::encode_ascii(asdu.data_set_reference); - write_context_primitive(writer, 1, data_set); +[[nodiscard]] std::optional tlv_size( + const std::size_t value_length) noexcept { + const auto length_size = ber_length_octets(value_length); + if (!length_size) { + return std::nullopt; + } + std::size_t total = 1U; + if (!checked_add(total, *length_size) || + !checked_add(total, value_length)) { + return std::nullopt; + } + return total; +} + +[[nodiscard]] std::size_t unsigned_integer_size(std::uint64_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; } + return size; +} + +[[nodiscard]] std::optional context_bytes_size( + const std::size_t value_length) noexcept { + return tlv_size(value_length); +} - const auto sample_count = asn1::BerWriter::encode_unsigned_integer(asdu.sample_count); - write_context_primitive(writer, 2, sample_count); +[[nodiscard]] std::optional context_unsigned_size( + const std::uint64_t value) noexcept { + return tlv_size(unsigned_integer_size(value)); +} - const auto configuration_revision = - asn1::BerWriter::encode_unsigned_integer(asdu.configuration_revision); - write_context_primitive(writer, 3, configuration_revision); +[[nodiscard]] std::optional asdu_content_size( + const SampledValueAsdu& asdu) noexcept { + std::size_t total = 0U; + const auto add_tlv = [&total](const std::optional size) noexcept { + return size.has_value() && checked_add(total, *size); + }; + if (!add_tlv(context_bytes_size(asdu.sv_id.size()))) { + return std::nullopt; + } + if (!asdu.data_set_reference.empty() && + !add_tlv(context_bytes_size(asdu.data_set_reference.size()))) { + return std::nullopt; + } + if (!add_tlv(context_unsigned_size(asdu.sample_count)) || + !add_tlv(context_unsigned_size(asdu.configuration_revision))) { + return std::nullopt; + } if (asdu.reference_time.has_value()) { - const auto reference_time = asdu.reference_time->to_bytes(); - write_context_primitive(writer, 4, reference_time); + std::array timestamp{}; + if (!asdu.reference_time->try_write_bytes(timestamp) || + !add_tlv(context_bytes_size(timestamp.size()))) { + return std::nullopt; + } } + if (!add_tlv(context_unsigned_size(asdu.sample_synchronization))) { + return std::nullopt; + } + if (asdu.sample_rate.has_value() && + !add_tlv(context_unsigned_size(*asdu.sample_rate))) { + return std::nullopt; + } + if (!add_tlv(context_bytes_size(asdu.sample_payload.size()))) { + return std::nullopt; + } + if (asdu.sample_mode.has_value() && + !add_tlv(context_unsigned_size(*asdu.sample_mode))) { + return std::nullopt; + } + return total; +} - const auto sample_synchronization = - asn1::BerWriter::encode_unsigned_integer(asdu.sample_synchronization); - write_context_primitive(writer, 5, sample_synchronization); +[[nodiscard]] std::optional asdu_encoded_size( + const SampledValueAsdu& asdu) noexcept { + const auto content = asdu_content_size(asdu); + return content ? tlv_size(*content) : std::nullopt; +} - if (asdu.sample_rate.has_value()) { - const auto sample_rate = asn1::BerWriter::encode_unsigned_integer(*asdu.sample_rate); - write_context_primitive(writer, 6, sample_rate); +[[nodiscard]] std::optional sequence_value_size( + const SampledValuesPdu& pdu) noexcept { + std::size_t total = 0U; + for (const auto& asdu : pdu.asdus) { + const auto size = asdu_encoded_size(asdu); + if (!size || !checked_add(total, *size)) { + return std::nullopt; + } } + return total; +} - write_context_primitive(writer, 7, asdu.sample_payload); +void write_length( + const std::span destination, + std::size_t& offset, + const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + destination[offset++] = static_cast(value_length); + return; + } - if (asdu.sample_mode.has_value()) { - const auto sample_mode = asn1::BerWriter::encode_unsigned_integer(*asdu.sample_mode); - write_context_primitive(writer, 8, sample_mode); + auto value = static_cast(value_length); + std::array bytes{}; + std::size_t count = 0U; + while (value != 0U) { + bytes[bytes.size() - 1U - count] = + static_cast(value & 0xFFU); + value >>= 8U; + ++count; + } + destination[offset++] = static_cast(0x80U | count); + const auto first = bytes.size() - count; + for (std::size_t index = first; index < bytes.size(); ++index) { + destination[offset++] = bytes[index]; + } +} + +void write_tlv_header( + const std::span destination, + std::size_t& offset, + const std::uint8_t tag, + const std::size_t value_length) noexcept { + destination[offset++] = tag; + write_length(destination, offset, value_length); +} + +void write_bytes( + const std::span destination, + std::size_t& offset, + const std::span value) noexcept { + std::copy(value.begin(), value.end(), destination.begin() + + static_cast(offset)); + offset += value.size(); +} + +void write_string( + const std::span destination, + std::size_t& offset, + const std::uint8_t tag_number, + const std::string& value) noexcept { + write_tlv_header( + destination, offset, + static_cast(context_primitive_base | tag_number), + value.size()); + for (const char character : value) { + destination[offset++] = static_cast(character); } +} - return writer.to_vector(); +void write_unsigned( + const std::span destination, + std::size_t& offset, + const std::uint8_t tag_number, + const std::uint64_t value) noexcept { + const auto value_size = unsigned_integer_size(value); + write_tlv_header( + destination, offset, + static_cast(context_primitive_base | tag_number), + value_size); + + for (std::size_t index = value_size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + destination[offset++] = static_cast((value >> shift) & 0xFFU); + } } -std::vector encode_asdu_sequence( - const std::span asdus) { - asn1::BerWriter writer; - for (const auto& asdu : asdus) { - const auto encoded = encode_asdu(asdu); - writer.write_tlv(asn1::BerClass::universal, true, sequence_tag_number, encoded); +[[nodiscard]] bool write_asdu( + const SampledValueAsdu& asdu, + const std::span destination, + std::size_t& offset) noexcept { + const auto content_size = asdu_content_size(asdu); + if (!content_size) { + return false; + } + write_tlv_header(destination, offset, sequence_tag, *content_size); + + write_string(destination, offset, 0U, asdu.sv_id); + if (!asdu.data_set_reference.empty()) { + write_string(destination, offset, 1U, asdu.data_set_reference); + } + write_unsigned(destination, offset, 2U, asdu.sample_count); + write_unsigned(destination, offset, 3U, asdu.configuration_revision); + + if (asdu.reference_time.has_value()) { + std::array timestamp{}; + if (!asdu.reference_time->try_write_bytes(timestamp)) { + return false; + } + write_tlv_header( + destination, offset, + static_cast(context_primitive_base | 4U), + timestamp.size()); + write_bytes(destination, offset, timestamp); + } + + write_unsigned(destination, offset, 5U, asdu.sample_synchronization); + if (asdu.sample_rate.has_value()) { + write_unsigned(destination, offset, 6U, *asdu.sample_rate); + } + + write_tlv_header( + destination, offset, + static_cast(context_primitive_base | 7U), + asdu.sample_payload.size()); + write_bytes(destination, offset, asdu.sample_payload); + + if (asdu.sample_mode.has_value()) { + write_unsigned(destination, offset, 8U, *asdu.sample_mode); } - return writer.to_vector(); + return true; } bool read_unsigned_exact( @@ -160,7 +347,7 @@ bool read_asdu_sequence( std::vector& asdus) { for (const auto& child : asn1::BerReader::read_children(sequence_value)) { if (child.tag_class != asn1::BerClass::universal || - child.tag_number != sequence_tag_number || + child.tag_number != 16 || !child.constructed) { return false; } @@ -176,24 +363,84 @@ bool read_asdu_sequence( } // namespace -std::vector SampledValuesPduCodec::encode(const SampledValuesPdu& pdu) { +std::optional SampledValuesPduCodec::encoded_size( + const SampledValuesPdu& pdu) noexcept { if (pdu.asdus.size() > std::numeric_limits::max()) { - throw std::out_of_range("A Sampled Values PDU contains too many ASDUs."); + return std::nullopt; } - asn1::BerWriter content; - const auto no_asdu = asn1::BerWriter::encode_unsigned_integer( + const auto sequence_size = sequence_value_size(pdu); + if (!sequence_size) { + return std::nullopt; + } + + const auto count_field_size = context_unsigned_size( static_cast(pdu.asdus.size())); - write_context_primitive(content, 0, no_asdu); + const auto sequence_field_size = tlv_size(*sequence_size); + if (!count_field_size || !sequence_field_size) { + return std::nullopt; + } + + std::size_t content_size = 0U; + if (!checked_add(content_size, *count_field_size) || + !checked_add(content_size, *sequence_field_size)) { + return std::nullopt; + } + return tlv_size(content_size); +} - const auto sequence = encode_asdu_sequence(pdu.asdus); - content.write_tlv(asn1::BerClass::context_specific, true, 2, sequence); +wire::EncodeResult SampledValuesPduCodec::encode_into( + const SampledValuesPdu& pdu, + const std::span destination) noexcept { + const auto required = encoded_size(pdu); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + const auto sequence_size = sequence_value_size(pdu); + const auto count_field_size = context_unsigned_size( + static_cast(pdu.asdus.size())); + const auto sequence_field_size = sequence_size ? tlv_size(*sequence_size) : std::nullopt; + if (!sequence_size || !count_field_size || !sequence_field_size) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } - return asn1::BerWriter::encode_tlv( - asn1::BerClass::application, - true, - sav_pdu_application_tag, - content.bytes()); + const auto content_size = *count_field_size + *sequence_field_size; + std::size_t offset = 0U; + write_tlv_header(destination, offset, sav_pdu_application_tag, content_size); + write_unsigned( + destination, offset, 0U, + static_cast(pdu.asdus.size())); + write_tlv_header( + destination, offset, + static_cast(context_constructed_base | 2U), + *sequence_size); + + for (const auto& asdu : pdu.asdus) { + if (!write_asdu(asdu, destination, offset)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + + return {wire::EncodeStatus::ok, offset, *required}; +} + +std::vector SampledValuesPduCodec::encode( + const SampledValuesPdu& pdu) { + const auto required = encoded_size(pdu); + if (!required) { + throw std::out_of_range("A Sampled Values PDU exceeds the supported BER wire range."); + } + + std::vector bytes(*required); + const auto result = encode_into(pdu, bytes); + if (!result.success() || result.bytes_written != bytes.size()) { + throw std::runtime_error("Failed to encode Sampled Values PDU into sized buffer."); + } + return bytes; } bool SampledValuesPduCodec::try_decode( @@ -207,7 +454,7 @@ bool SampledValuesPduCodec::try_decode( if (!asn1::BerReader::try_read_tlv(apdu, offset, outer) || offset != apdu.size() || outer.tag_class != asn1::BerClass::application || - outer.tag_number != sav_pdu_application_tag || + outer.tag_number != 0 || !outer.constructed) { return false; } From ed7a98c452b23abfbb2db31130899c0ca5c63313 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:50:22 +0700 Subject: [PATCH 090/179] Expose bounded Sampled Values Ethernet encoding API --- include/ariec61850/sampled_values/frame_codec.hpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/include/ariec61850/sampled_values/frame_codec.hpp b/include/ariec61850/sampled_values/frame_codec.hpp index 17d08e524..ba9ddce0d 100644 --- a/include/ariec61850/sampled_values/frame_codec.hpp +++ b/include/ariec61850/sampled_values/frame_codec.hpp @@ -3,8 +3,11 @@ #pragma once #include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/wire/encode_result.hpp" +#include #include +#include #include #include @@ -12,7 +15,17 @@ namespace ar::iec61850::sampled_values { class SampledValuesFrameCodec final { public: + [[nodiscard]] static std::optional encoded_size( + const SampledValuesFrame& frame) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_into( + const SampledValuesFrame& frame, + std::span destination) noexcept; + + // Host convenience wrapper. Embedded SV publishers should use encode_into + // with a persistent Ethernet-sized buffer. [[nodiscard]] static std::vector encode(const SampledValuesFrame& frame); + [[nodiscard]] static bool try_decode( std::span bytes, SampledValuesFrame& frame) noexcept; }; From 6b8e7075c6f142ce5c7f326d083b5d9e4f18322d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:50:43 +0700 Subject: [PATCH 091/179] Implement allocation-free Sampled Values Ethernet encoder --- src/sampled_values/frame_codec.cpp | 130 ++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 11 deletions(-) diff --git a/src/sampled_values/frame_codec.cpp b/src/sampled_values/frame_codec.cpp index 280afaa00..80f62e830 100644 --- a/src/sampled_values/frame_codec.cpp +++ b/src/sampled_values/frame_codec.cpp @@ -5,23 +5,131 @@ #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/sampled_values/pdu_codec.hpp" +#include +#include +#include +#include +#include +#include +#include #include +#include namespace ar::iec61850::sampled_values { +namespace { + +void write_u16_be( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +[[nodiscard]] std::optional vlan_tci( + const ethernet::VlanTag& vlan) noexcept { + if (vlan.priority_code_point > 7U || vlan.vlan_id > 4094U) { + return std::nullopt; + } + const auto value = + (static_cast(vlan.priority_code_point) << 13U) | + (vlan.drop_eligible ? 0x1000U : 0U) | + static_cast(vlan.vlan_id); + return static_cast(value); +} + +} // namespace + +std::optional SampledValuesFrameCodec::encoded_size( + const SampledValuesFrame& frame) noexcept { + if (frame.vlan.has_value() && !vlan_tci(*frame.vlan).has_value()) { + return std::nullopt; + } + + const auto apdu_size = SampledValuesPduCodec::encoded_size(frame.pdu); + if (!apdu_size || + *apdu_size > + static_cast(std::numeric_limits::max()) - + ethernet::ProcessBusFrameCodec::header_length) { + return std::nullopt; + } + + const std::size_t ethernet_header = frame.vlan.has_value() ? 18U : 14U; + const std::size_t process_bus_size = + ethernet::ProcessBusFrameCodec::header_length + *apdu_size; + if (process_bus_size > std::numeric_limits::max() - ethernet_header) { + return std::nullopt; + } + return ethernet_header + process_bus_size; +} + +wire::EncodeResult SampledValuesFrameCodec::encode_into( + const SampledValuesFrame& frame, + const std::span destination) noexcept { + const auto required = encoded_size(frame); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + const auto apdu_size = SampledValuesPduCodec::encoded_size(frame.pdu); + if (!apdu_size) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::copy( + frame.destination.bytes().begin(), frame.destination.bytes().end(), + destination.begin()); + std::copy( + frame.source.bytes().begin(), frame.source.bytes().end(), + destination.begin() + 6); + + std::size_t offset = 12U; + if (frame.vlan.has_value()) { + const auto tci = vlan_tci(*frame.vlan); + if (!tci) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + write_u16_be(destination, offset, ethernet::vlan_tag_ethertype); + write_u16_be(destination, offset + 2U, *tci); + offset += 4U; + } + + write_u16_be(destination, offset, ethernet::sampled_values_ethertype); + offset += 2U; + + const auto declared_length = static_cast( + ethernet::ProcessBusFrameCodec::header_length + *apdu_size); + write_u16_be(destination, offset, frame.app_id); + write_u16_be(destination, offset + 2U, declared_length); + write_u16_be(destination, offset + 4U, frame.reserved1); + write_u16_be(destination, offset + 6U, frame.reserved2); + offset += ethernet::ProcessBusFrameCodec::header_length; + + const auto pdu_result = SampledValuesPduCodec::encode_into( + frame.pdu, destination.subspan(offset, *apdu_size)); + if (!pdu_result.success() || pdu_result.bytes_written != *apdu_size) { + return {pdu_result.status, 0U, *required}; + } + offset += pdu_result.bytes_written; + return {wire::EncodeStatus::ok, offset, *required}; +} std::vector SampledValuesFrameCodec::encode( const SampledValuesFrame& frame) { - const auto apdu = SampledValuesPduCodec::encode(frame.pdu); - const auto ethernet_frame = ethernet::ProcessBusFrameCodec::encode_ethernet_frame( - frame.destination, - frame.source, - ethernet::sampled_values_ethertype, - frame.app_id, - apdu, - frame.vlan, - frame.reserved1, - frame.reserved2); - return ethernet::EthernetFrameCodec::encode(ethernet_frame); + const auto required = encoded_size(frame); + if (!required) { + throw std::out_of_range("A Sampled Values Ethernet frame exceeds the supported wire range."); + } + + std::vector bytes(*required); + const auto result = encode_into(frame, bytes); + if (!result.success() || result.bytes_written != bytes.size()) { + throw std::runtime_error("Failed to encode Sampled Values frame into sized buffer."); + } + return bytes; } bool SampledValuesFrameCodec::try_decode( From 16452329b95c488821a388f9147297fc33b355c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:51:16 +0700 Subject: [PATCH 092/179] Add Waveshare ESP32-S3 industrial board profile --- .../waveshare_esp32s3_poe_eth_8di8do.hpp | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp diff --git a/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp b/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp new file mode 100644 index 000000000..b541a80c6 --- /dev/null +++ b/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/profile.hpp" + +#include +#include +#include + +namespace ar::iec61850::embedded::boards { + +// Hardware profile for Waveshare ESP32-S3-POE-ETH-8DI-8DO (SKU 32108). +// Pin assignments follow the vendor board documentation. This type contains +// facts only; ESP-IDF/W5500 driver ownership stays in the platform adapter. +struct WaveshareEsp32S3PoeEth8Di8Do final { + using capacity_profile = Esp32SmallProfile; + + static constexpr std::size_t flash_bytes = 16U * 1024U * 1024U; + static constexpr std::size_t psram_bytes = 8U * 1024U * 1024U; + + static constexpr int w5500_interrupt_gpio = 12; + static constexpr int w5500_mosi_gpio = 13; + static constexpr int w5500_miso_gpio = 14; + static constexpr int w5500_sclk_gpio = 15; + static constexpr int w5500_chip_select_gpio = 16; + static constexpr int w5500_reset_gpio = 39; + + // W5500 MACRAW is valid on Socket 0. arstack reserves that socket for + // GOOSE/SV raw Layer-2 traffic in the reference ESP32 integration. + static constexpr std::uint8_t w5500_macraw_socket = 0U; + + static constexpr std::array digital_input_gpios{ + 4, 5, 6, 7, 8, 9, 10, 11}; + + // Digital outputs are exposed as EXIO1..EXIO8 through the TCA9554PWR. + // Do not assume bit-to-terminal mapping here; the ESP-IDF board adapter + // will verify that mapping against the vendor demo/schematic. + static constexpr std::uint8_t output_expander_i2c_address = 0x20U; + + static constexpr int can_tx_gpio = 2; + static constexpr int can_rx_gpio = 3; + + static constexpr int rs485_tx_gpio = 17; + static constexpr int rs485_rx_gpio = 18; + static constexpr int rs485_rts_gpio = 21; + + static constexpr int rtc_interrupt_gpio = 40; + static constexpr int rtc_scl_gpio = 41; + static constexpr int rtc_sda_gpio = 42; + + static constexpr int rgb_gpio = 38; + static constexpr int buzzer_gpio = 46; + static constexpr int boot_gpio = 0; + + static constexpr bool has_poe_802_3af = true; + static constexpr bool has_w5500_macraw = true; + static constexpr bool has_isolated_digital_inputs = true; + static constexpr bool has_isolated_digital_outputs = true; +}; + +static_assert(EmbeddedCapacityProfile< + WaveshareEsp32S3PoeEth8Di8Do::capacity_profile>); +static_assert(WaveshareEsp32S3PoeEth8Di8Do::digital_input_gpios.size() == 8U); + +} // namespace ar::iec61850::embedded::boards From 5dfddd836371c1f827fd2aabb51c0d76f2c9390d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:51:43 +0700 Subject: [PATCH 093/179] Exercise bounded SV encoding in embedded smoke test --- embedded/embedded_link_smoke.cpp | 87 +++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/embedded/embedded_link_smoke.cpp b/embedded/embedded_link_smoke.cpp index 17e3964aa..d9071adb2 100644 --- a/embedded/embedded_link_smoke.cpp +++ b/embedded/embedded_link_smoke.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/asn1/ber.hpp" +#include "ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp" #include "ariec61850/embedded/io.hpp" #include "ariec61850/embedded/profile.hpp" #include "ariec61850/ethernet/ethernet.hpp" @@ -8,10 +9,14 @@ #include "ariec61850/mms/data_codec.hpp" #include "ariec61850/osi/tpkt.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/wire/encode_result.hpp" #include +#include #include #include +#include +#include namespace { @@ -23,19 +28,87 @@ ar::iec61850::embedded::IoResult discard_frame( } // namespace int main() { + using namespace ar::iec61850; + // The executable is linked from every embedded source file by CMake, so // the linker catches host-only dependencies even when this smoke program // does not exercise every protocol at runtime. std::array bytes{0x05U, 0x00U}; std::size_t offset = 0U; - ar::iec61850::asn1::BerTlv tlv; - const bool parsed = ar::iec61850::asn1::BerReader::try_read_tlv(bytes, offset, tlv); - - ar::iec61850::embedded::RawEthernetPort ethernet; - ethernet.transmit = &discard_frame; - const auto sent = ethernet.send(bytes); + asn1::BerTlv tlv; + const bool parsed = asn1::BerReader::try_read_tlv(bytes, offset, tlv); static_assert( - ar::iec61850::embedded::Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); + embedded::Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); + static_assert( + embedded::boards::WaveshareEsp32S3PoeEth8Di8Do::w5500_macraw_socket == 0U); + + const std::array destination_bytes{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + const std::array source_bytes{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ESP32S3IO/LLN0$MSVCB01"; + asdu.data_set_reference = "ESP32S3IO/LLN0$PhsMeas1"; + asdu.sample_count = 120U; + asdu.configuration_revision = 1U; + asdu.reference_time = mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'781'260'260}}, + 0U}; + asdu.sample_synchronization = 2U; + asdu.sample_rate = std::uint16_t{4'000U}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload = { + 0x00U, 0x00U, 0x00U, 0x64U, + 0x00U, 0x00U, 0x00U, 0x01U, + 0x00U, 0x00U, 0x00U, 0xC8U, + 0x00U, 0x00U, 0x00U, 0x03U}; + + sampled_values::SampledValuesFrame sv_frame{ + ethernet::MacAddress{destination_bytes}, + ethernet::MacAddress{source_bytes}, + ethernet::VlanTag{4U, 200U}, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; + + std::array + frame_buffer{}; + const auto first_encode = sampled_values::SampledValuesFrameCodec::encode_into( + sv_frame, frame_buffer); + if (!first_encode.success() || first_encode.bytes_written == 0U) { + return 2; + } + + // Reuse the same caller-owned frame buffer. The encoder itself performs no + // heap allocation on this path; configuration objects are prepared once. + sv_frame.pdu.asdus.front().sample_count = 121U; + const auto second_encode = sampled_values::SampledValuesFrameCodec::encode_into( + sv_frame, frame_buffer); + if (!second_encode.success() || + second_encode.bytes_written != first_encode.bytes_written) { + return 3; + } + + if (second_encode.required_bytes <= 1U) { + return 4; + } + const auto too_small = sampled_values::SampledValuesFrameCodec::encode_into( + sv_frame, + std::span{frame_buffer}.first( + second_encode.required_bytes - 1U)); + if (too_small.status != wire::EncodeStatus::buffer_too_small || + too_small.required_bytes != second_encode.required_bytes) { + return 5; + } + + embedded::RawEthernetPort ethernet_port; + ethernet_port.transmit = &discard_frame; + const auto sent = ethernet_port.send( + std::span{frame_buffer}.first( + second_encode.bytes_written)); + return parsed && sent.success() ? 0 : 1; } From 8be6c8ce29fb4a6350ab8441cab30f1b5b86784c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:52:36 +0700 Subject: [PATCH 094/179] Define public alpha and Waveshare ESP32-S3 delivery roadmap --- ESP32_WAVESHARE_ROADMAP.md | 370 +++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 ESP32_WAVESHARE_ROADMAP.md diff --git a/ESP32_WAVESHARE_ROADMAP.md b/ESP32_WAVESHARE_ROADMAP.md new file mode 100644 index 000000000..d8e5132a7 --- /dev/null +++ b/ESP32_WAVESHARE_ROADMAP.md @@ -0,0 +1,370 @@ +# arstack61850 — Public Alpha + Waveshare ESP32-S3 Roadmap + +## Product direction + +arstack61850 is not intended to become a line-by-line C++ clone of ARIEC61850. +ARIEC61850 remains the behavioral oracle. arstack61850 becomes the portable +industrial implementation with two supported deployment classes: + +1. **Host / engineering** — Windows and Linux tools, live MMS discovery, + evidence, diagnostics, SCL/PCAP/COMTRADE integration. +2. **Embedded / runtime** — bounded protocol engine suitable for ESP32-class + MCUs first and STM32/NXP-class targets later. + +The first reference MCU is the **Waveshare ESP32-S3-POE-ETH-8DI-8DO**. + +## Reference hardware facts + +Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. + +- ESP32-S3-WROOM-1U-N16R8: 16 MB Flash, 8 MB PSRAM. +- W5500 10/100 Ethernet controller over SPI. +- PoE module, IEEE 802.3af. +- Eight isolated digital inputs and eight isolated digital outputs. +- W5500 pins: INT=GPIO12, MOSI=13, MISO=14, SCLK=15, CS=16, RST=39. +- DI1..DI8: GPIO4..GPIO11. +- Digital outputs are exposed through TCA9554PWR, I2C address 0x20. +- Isolated RS485 and CAN are available for future gateway use. +- W5500 MACRAW is available on Socket 0 and is the reference raw Layer-2 + transport for GOOSE/SV. TCP/MMS uses other W5500 sockets. + +Raw Layer-2 SV/GOOSE is an Ethernet feature. Wi-Fi is not a substitute for the +reference process-bus path. + +## Non-negotiable architecture rules + +### Embedded core + +`ARIEC61850::embedded_core` may contain wire codecs and bounded runtime state, +but must not depend on: + +- WinSock or BSD/POSIX sockets; +- filesystem, iostream or CLI code; +- PCAP/evidence tooling; +- COMTRADE; +- full SCL parsing; +- live-discovery host workflows. + +Platform ownership is injected through small callback HAL contracts. ESP-IDF, +lwIP, FreeRTOS and W5500 details stay outside the protocol engine. + +### Memory discipline + +Steady-state publisher/subscriber/runtime paths should move toward: + +- caller-owned buffers; +- bounded capacities; +- no per-frame allocation; +- no RTTI; +- eventually no C++ exceptions in the MCU core. + +Existing `std::vector`/`std::string` data models may remain as configuration-time +containers during migration. They must not force allocation on the 4 kHz SV +steady-state encode/transmit path. + +### Compatibility + +Existing host APIs remain source-compatible where practical. Allocation-free +APIs are added underneath host convenience wrappers, so Windows/Linux users do +not lose ergonomics while the same codec becomes MCU-capable. + +--- + +# Delivery plan + +## Track A — Public Alpha + +### A0. Live read-only MMS foundation — CURRENT + +Acceptance: + +- TPKT/COTP/Session/Presentation/ACSE association works against live vendor IED. +- GetNameList domains, variables and DataSets works. +- GetVariableAccessAttributes works. +- Read works. +- DataSet and RCB inventories are preserved. +- Dynamic/unbound RCB state is represented without false failure. +- RCB planner/availability evidence is read-only. +- GCC, Clang and MSVC CI pass. +- ASan/UBSan and fuzz corpus pass. + +### A1. Public discovery/model alpha + +Goal: a third-party engineer can clone the repository and use it without +knowing the C# project. + +Deliverables: + +- stable `live_discover` CLI contract; +- documented JSON/model schema; +- deterministic structural fingerprint separated from runtime evidence; +- TypeTemplates + VariableTypeDiscoveries parity slice; +- same-IED C# versus C++ model comparison fixture; +- examples for association, read, DataSet and RCB inventory; +- README quick-start for Windows/Linux; +- explicit feature matrix: implemented / partial / host-only / embedded-ready. + +Acceptance: + +- clean configure/build/test from a fresh clone; +- at least three repeat live cycles on the current test IED/simulator; +- no diagnostic regression; +- reproducible JSON parity report checked into evidence. + +### A2. Public alpha release + +Goal: tag a release that is genuinely useful even before full ARIEC61850 parity. + +Required public surface: + +- MMS client association + confirmed read services; +- read-only live model discovery; +- DataSet/RCB inspection; +- GOOSE encode/decode/runtime foundation; +- SV encode/decode foundation; +- documented portability boundary; +- examples and CI status. + +Not release-blocking: + +- full MMS file service; +- TLS; +- all dynamic DataSet mutation workflows; +- complete ARIEC61850 host-tool parity; +- full IED server implementation. + +--- + +## Track E — ESP32 reference product + +### E0. Embedded architecture baseline — IN PROGRESS + +Deliverables: + +- standalone `embedded/CMakeLists.txt`; +- `ARIEC61850::embedded_core`; +- no-RTTI host-simulation CI; +- source-boundary audit; +- callback HAL for raw Ethernet, connected TCP and clocks; +- configurable ESP32 small-capacity profile; +- Waveshare board facts/profile. + +Exit gate: + +- GCC and Clang embedded host-simulation builds link every embedded source; +- host-only dependency audit is clean; +- legacy exception debt is measured and cannot silently expand. + +### E1. Allocation-bounded SV encoder — IN PROGRESS + +Goal: the 4 kHz publisher loop does not allocate a new frame buffer per sample. + +API direction: + +```cpp +std::optional SampledValuesFrameCodec::encoded_size(...) noexcept; +wire::EncodeResult SampledValuesFrameCodec::encode_into( + const SampledValuesFrame&, + std::span destination) noexcept; +``` + +Host `encode()` remains as a convenience wrapper. + +Acceptance: + +- bounded encoder is byte-identical to existing C#/C++ golden vectors; +- buffer-too-small returns required size and does not partially report success; +- same caller-owned Ethernet buffer can be reused continuously; +- timestamp serialization has a no-allocation path; +- sanitizer/fuzzer/host regression remains green. + +### E2. W5500 MACRAW transport adapter + +Goal: map `embedded::RawEthernetPort` to W5500 Socket 0 MACRAW without leaking +W5500 APIs into protocol code. + +Responsibilities: + +- initialize W5500 SPI transport; +- reserve Socket 0 for MACRAW; +- expose raw-frame transmit and receive callbacks; +- preserve destination/source MAC, VLAN tag and EtherType exactly; +- expose PHY/link state; +- instrument TX errors, would-block and frame counts. + +Acceptance on host/mock first: + +- driver adapter can be unit-tested with fake register/SPI backend; +- protocol core does not include ESP-IDF/W5500 headers. + +Acceptance on board: + +- raw custom EtherType frame visible in Wireshark; +- VLAN-tagged frame visible without mutation; +- SV EtherType 0x88BA frame visible. + +### E3. ESP32-S3 SV Publisher hardware proof + +Start with synthetic waveform; do not block on ADC hardware. + +Reference first profile: + +- 50 Hz system; +- 80 samples/cycle = 4,000 sample instants/s; +- one ASDU initially; +- fixed APPID and multicast destination; +- sample counter wraps according to configured rate; +- configuration revision fixed during a run; +- timestamp optional for the first throughput proof, then enabled. + +Acceptance: + +- sustained 4,000 frames/s target or explicitly documented multi-ASDU packing + alternative; +- no steady-state frame-buffer allocation; +- no sample-counter discontinuity generated by publisher logic; +- packet capture decodes as IEC 61850 SV; +- 10-minute soak first, then 1-hour soak; +- CPU usage, heap delta, minimum free heap and TX errors recorded. + +### E4. Board I/O HAL + +Goal: make the Waveshare hardware useful as a real substation I/O prototype. + +Inputs: + +- DI1..DI8 from GPIO4..GPIO11; +- debounce/filter policy separated from raw input state; +- timestamped state-change queue. + +Outputs: + +- TCA9554PWR adapter at I2C address 0x20; +- EXIO1..EXIO8 mapping verified against schematic/vendor demo before hard-coding; +- safe startup state: outputs off unless application explicitly restores state; +- command watchdog/failsafe hooks. + +Acceptance: + +- deterministic read of all eight DIs; +- deterministic set/reset of all eight DOs; +- power-cycle safe-state test; +- no IEC 61850 code knows GPIO/I2C details. + +### E5. Minimal MMS server foundation + +This is required for the board to become an actual IEC 61850 I/O IED. + +Implement a static, bounded server model first. Do not port desktop dynamic +model discovery into the MCU server. + +Minimum server services: + +1. association acceptor; +2. MMS Initiate negotiation; +3. GetNameList; +4. GetVariableAccessAttributes; +5. Read. + +Initial static model proposal: + +```text +ESP32S3IO +└── LD0 + ├── LLN0 + ├── LPHD1 + ├── GGIO1 digital inputs + └── GGIO2 digital outputs/status +``` + +Acceptance: + +- IEDScout can associate; +- IEDScout can browse the static model; +- eight DI values can be read; +- server remains bounded under malformed/fuzzed MMS requests. + +### E6. Simple IEC 61850 I/O IED + +Add controlled output operation only after read-only server browse/read is +stable. + +Sequence: + +- simple Write where standards/model permit; +- IEC 61850 control model required for the exposed DOs; +- interlock/check hooks; +- operation timeout; +- command audit/result status. + +Reporting sequence: + +- static DataSet; +- URCB first; +- data-change report for DI state; +- BRCB later, not a first-I/O-IED requirement. + +Acceptance: + +- IEDScout browse/read/control proof; +- input change generates expected report; +- output command changes physical DO and reflected model state; +- negative tests for invalid control/FC/reference. + +### E7. GOOSE I/O extension + +After raw MAC transport is proven by SV, GOOSE is a natural extension. + +Targets: + +- DI state as GOOSE publisher; +- GOOSE subscriber mapped to selected DO only through application safety policy; +- retransmission schedule and TAL supervision; +- VLAN priority validation. + +### E8. Industrialization / STM32 migration + +ESP32 is the reference accessibility target, not the final certification claim. +The same embedded core should later move to STM32H7/NXP-class hardware. + +Industrialization work: + +- `-fno-exceptions` embedded core; +- replace remaining hot-path dynamic containers with bounded storage/views; +- explicit memory budget report; +- deterministic scheduler/timing hooks; +- watchdog integration; +- stronger time synchronization strategy; +- long-duration soak and fault injection; +- multi-vendor interoperability campaign. + +--- + +# Priority rule + +Until Public Alpha is tagged: + +- approximately **70% effort** goes to public-useful host protocol parity, + documentation and real-Ied evidence; +- approximately **30% effort** goes to embedded architecture and the SV hardware + path. + +Exception: embedded changes that prevent architectural debt (allocation-free +wire encoding, HAL boundaries, compile gates) are performed immediately before +new protocol features are ported. + +After Public Alpha: + +- finish E2/E3 hardware SV proof; +- then shift primary development toward E5/E6 MMS server + I/O IED. + +# Definition of success + +The project succeeds when the same protocol implementation can demonstrate all +of these without forks of the wire core: + +1. Windows/Linux engineering client can browse a real IED. +2. ESP32-S3 can publish standards-shaped SV frames over W5500 MACRAW. +3. ESP32-S3 can expose eight isolated inputs/outputs as a small IEC 61850 IED. +4. A later STM32/NXP port changes platform adapters and capacities, not protocol + semantics. From 5ef6f412eec36762bfaad6dff72096b6ea67d94a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:54:17 +0700 Subject: [PATCH 095/179] Align Waveshare W5500 profile with ESP-IDF MACRAW ownership --- .../embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp b/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp index b541a80c6..eb913ee0c 100644 --- a/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp +++ b/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp @@ -25,8 +25,11 @@ struct WaveshareEsp32S3PoeEth8Di8Do final { static constexpr int w5500_chip_select_gpio = 16; static constexpr int w5500_reset_gpio = 39; - // W5500 MACRAW is valid on Socket 0. arstack reserves that socket for - // GOOSE/SV raw Layer-2 traffic in the reference ESP32 integration. + // At chip level, W5500 MACRAW mode is valid on Socket 0. In the preferred + // ESP-IDF integration the official W5500 Ethernet driver owns MACRAW and + // feeds the software TCP/IP stack, so arstack must not independently claim + // hardware sockets behind that driver. Raw SV/GOOSE and lwIP TCP/MMS share + // the same Ethernet interface through the platform adapter. static constexpr std::uint8_t w5500_macraw_socket = 0U; static constexpr std::array digital_input_gpios{ From 6569b2e2318267cecc7f2f3b61bb8c2dded4583d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:55:03 +0700 Subject: [PATCH 096/179] Use ESP-IDF MACRAW architecture for Waveshare W5500 --- ESP32_WAVESHARE_ROADMAP.md | 39 ++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/ESP32_WAVESHARE_ROADMAP.md b/ESP32_WAVESHARE_ROADMAP.md index d8e5132a7..34ff38045 100644 --- a/ESP32_WAVESHARE_ROADMAP.md +++ b/ESP32_WAVESHARE_ROADMAP.md @@ -25,8 +25,10 @@ Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. - DI1..DI8: GPIO4..GPIO11. - Digital outputs are exposed through TCA9554PWR, I2C address 0x20. - Isolated RS485 and CAN are available for future gateway use. -- W5500 MACRAW is available on Socket 0 and is the reference raw Layer-2 - transport for GOOSE/SV. TCP/MMS uses other W5500 sockets. +- W5500 supports MACRAW at chip level. The preferred ESP-IDF W5500 driver + operates the device in MAC RAW mode and feeds the ESP software TCP/IP stack. + Raw SV/GOOSE and lwIP TCP/MMS therefore share one Ethernet interface instead + of arstack independently allocating W5500 hardware sockets. Raw Layer-2 SV/GOOSE is an Ethernet feature. Wi-Fi is not a substitute for the reference process-bus path. @@ -178,30 +180,42 @@ Acceptance: - timestamp serialization has a no-allocation path; - sanitizer/fuzzer/host regression remains green. -### E2. W5500 MACRAW transport adapter +### E2. ESP-IDF W5500 Ethernet adapter -Goal: map `embedded::RawEthernetPort` to W5500 Socket 0 MACRAW without leaking -W5500 APIs into protocol code. +Goal: map `embedded::RawEthernetPort` to the official ESP-IDF W5500 Ethernet +MACRAW path without leaking ESP-IDF/W5500 APIs into protocol code. + +Preferred architecture: + +```text +arstack raw SV/GOOSE ----> esp_eth raw Ethernet transmit ----+ + | +MMS TCP <---- lwIP / BSD socket API <---- esp_netif --------+--> ESP-IDF W5500 MACRAW +``` + +The ESP-IDF W5500 driver owns the chip-level MACRAW operation. arstack should +not independently program W5500 hardware sockets when using that driver. Responsibilities: -- initialize W5500 SPI transport; -- reserve Socket 0 for MACRAW; -- expose raw-frame transmit and receive callbacks; +- initialize the official ESP-IDF W5500 SPI Ethernet driver with board pins; +- attach the driver to `esp_netif`/lwIP for TCP/MMS; +- expose raw-frame transmit callback for GOOSE/SV; - preserve destination/source MAC, VLAN tag and EtherType exactly; - expose PHY/link state; - instrument TX errors, would-block and frame counts. Acceptance on host/mock first: -- driver adapter can be unit-tested with fake register/SPI backend; +- adapter-facing policy can be unit-tested with fake transmit backend; - protocol core does not include ESP-IDF/W5500 headers. Acceptance on board: +- Ethernet obtains link and normal IP traffic works through lwIP; - raw custom EtherType frame visible in Wireshark; - VLAN-tagged frame visible without mutation; -- SV EtherType 0x88BA frame visible. +- SV EtherType 0x88BA frame visible while normal TCP/IP remains usable. ### E3. ESP32-S3 SV Publisher hardware proof @@ -345,7 +359,7 @@ Industrialization work: Until Public Alpha is tagged: - approximately **70% effort** goes to public-useful host protocol parity, - documentation and real-Ied evidence; + documentation and real-IED evidence; - approximately **30% effort** goes to embedded architecture and the SV hardware path. @@ -364,7 +378,8 @@ The project succeeds when the same protocol implementation can demonstrate all of these without forks of the wire core: 1. Windows/Linux engineering client can browse a real IED. -2. ESP32-S3 can publish standards-shaped SV frames over W5500 MACRAW. +2. ESP32-S3 can publish standards-shaped SV frames over the ESP-IDF W5500 + MACRAW Ethernet interface. 3. ESP32-S3 can expose eight isolated inputs/outputs as a small IEC 61850 IED. 4. A later STM32/NXP port changes platform adapters and capacities, not protocol semantics. From 3cfdf97276a8c2bf4f7f86a174f94767134df5bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:57:35 +0700 Subject: [PATCH 097/179] Fix embedded source audit parser and missing-file check --- .github/workflows/embedded-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index d145b8ea5..d1cc3ca3d 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -42,7 +42,7 @@ jobs: import sys cmake = Path('embedded/CMakeLists.txt').read_text(encoding='utf-8') - match = re.search(r'set\(ARIEC61850_EMBEDDED_SOURCES\s*(.*?)\n\)', cmake, re.S) + match = re.search(r'set\(ARIEC61850_EMBEDDED_SOURCES\s*(.*?)\)', cmake, re.S) if not match: raise SystemExit('embedded source list not found') @@ -72,6 +72,9 @@ jobs: failures = [] throw_count = 0 for path in paths: + if not path.is_file(): + failures.append(f'{path}: embedded source does not exist') + continue text = path.read_text(encoding='utf-8') throw_count += len(re.findall(r'\bthrow\b', text)) for needle, label in forbidden.items(): From 61c023d18b7381878ee018574b6044ceecde4be6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:58:21 +0700 Subject: [PATCH 098/179] Avoid duplicate feature-branch C++ CI runs --- .github/workflows/cpp-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 1b7fe2f7e..ed33c2819 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -2,7 +2,7 @@ name: C++ CI on: push: - branches: [main, "agent/**"] + branches: [main] pull_request: workflow_dispatch: From d73858e020109008e78361138656c58d057d10b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:58:35 +0700 Subject: [PATCH 099/179] Avoid duplicate feature-branch security CI runs --- .github/workflows/security-evidence.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security-evidence.yml b/.github/workflows/security-evidence.yml index 9a7bb8442..01c3dc1e0 100644 --- a/.github/workflows/security-evidence.yml +++ b/.github/workflows/security-evidence.yml @@ -2,7 +2,7 @@ name: Security and Evidence on: push: - branches: [main, "agent/**"] + branches: [main] pull_request: workflow_dispatch: From f2d752c36bf061a7e1c6129e78130e375615e224 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:58:57 +0700 Subject: [PATCH 100/179] Avoid duplicate feature-branch embedded CI runs --- .github/workflows/embedded-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index d1cc3ca3d..f3682334a 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - 'agent/**' pull_request: permissions: From 8f0a8e4d0af846c8be742f17a8bc342ccf5b1165 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:01:30 +0700 Subject: [PATCH 101/179] Add C# type template and variable type projection schema --- include/ariec61850/mms/live_model.hpp | 31 ++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_model.hpp b/include/ariec61850/mms/live_model.hpp index a21d874f1..5feca8bb7 100644 --- a/include/ariec61850/mms/live_model.hpp +++ b/include/ariec61850/mms/live_model.hpp @@ -62,6 +62,7 @@ struct MmsLiveDataAttribute final { struct MmsLiveDataObject final { std::string reference; std::string name; + std::string proposed_do_type_id; std::string inferred_cdc; double cdc_confidence{}; MmsLiveModelConfidence confidence{MmsLiveModelConfidence::unknown}; @@ -159,6 +160,31 @@ struct MmsLiveControlBlock final { } }; +// Pure model projections matching ARIEC61850 LiveIedTypeTemplateCandidate and +// LiveIedVariableTypeDiscoveryModel. They never trigger additional MMS IO. +struct MmsLiveTypeTemplateCandidate final { + std::string template_kind; + std::string id; + std::string source_reference; + std::string inferred_type; + double confidence{}; + std::vector members; +}; + +struct MmsLiveVariableTypeDiscovery final { + std::string reference; + std::string domain; + std::string mms_item_name; + std::string functional_constraint; + bool success{}; + std::string mms_type; + std::string scl_basic_type; + std::string type_signature; + std::optional mms_deletable; + std::string message; + std::string source{"GetVariableAccessAttributes"}; +}; + struct MmsLiveModelCoverage final { std::size_t logical_device_count{}; std::size_t logical_node_count{}; @@ -208,6 +234,8 @@ struct MmsLiveModelDocument final { std::vector sampled_value_control_blocks; std::vector setting_group_controls; std::vector log_controls; + std::vector type_templates; + std::vector variable_type_discoveries; std::vector warnings; [[nodiscard]] std::string canonical_manifest() const; @@ -220,6 +248,7 @@ struct MmsLiveModelBuildOptions final { std::string explicit_ied_name; std::string fallback_ied_name; std::string access_point_name{"AP1"}; + bool include_low_confidence_templates{true}; }; class MmsLiveModelBuilder final { @@ -281,4 +310,4 @@ class MmsLiveModelParityComparer final { } // namespace ar::iec61850::mms -#include "ariec61850/mms/live_model.ipp" +#include "ariec61850/mms/live_model.ipp" \ No newline at end of file From 8e1436568eb7bb265f8416a4e1f67819409befc0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:01:55 +0700 Subject: [PATCH 102/179] Port C# live type template projections --- .../mms/live_model_type_projection.ipp | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 include/ariec61850/mms/live_model_type_projection.ipp diff --git a/include/ariec61850/mms/live_model_type_projection.ipp b/include/ariec61850/mms/live_model_type_projection.ipp new file mode 100644 index 000000000..0a690eab2 --- /dev/null +++ b/include/ariec61850/mms/live_model_type_projection.ipp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + + // Pure projection port of ARIEC61850 LiveIedModelDiscoveryBuilder: + // ProposedLnTypeId / ProposedDoTypeId, BuildTypeTemplates and + // BuildVariableTypeDiscoveries. This consumes evidence already present in + // MmsLiveDiscoveryResult and never causes additional MMS traffic. + const auto safe_id_part = [](const std::string_view raw) { + auto value = trim(std::string{raw}); + if (value.empty()) { + return std::string{"X"}; + } + std::transform(value.begin(), value.end(), value.begin(), [](const char character) { + return std::isalnum(static_cast(character)) != 0 + ? character + : '_'; + }); + return value; + }; + + for (auto& ld : document.logical_devices) { + for (auto& ln : ld.logical_nodes) { + const auto ln_class = ln.logical_node_class.empty() + ? ln.name + : ln.logical_node_class; + ln.proposed_type_id = + "LN_" + safe_id_part(ln_class) + "_" + safe_id_part(ln.name); + + MmsLiveTypeTemplateCandidate ln_template; + ln_template.template_kind = "LNodeType"; + ln_template.id = ln.proposed_type_id; + ln_template.source_reference = ln.name; + ln_template.inferred_type = ln_class; + ln_template.confidence = 1.0; + for (const auto& object : ln.data_objects) { + ln_template.members.push_back(object.name); + } + document.type_templates.push_back(std::move(ln_template)); + + for (auto& object : ln.data_objects) { + object.proposed_do_type_id = + "DO_" + safe_id_part(object.inferred_cdc) + "_" + + safe_id_part(ln_class) + "_" + safe_id_part(object.name); + + if (!options.include_low_confidence_templates && + (object.confidence == MmsLiveModelConfidence::low || + object.confidence == MmsLiveModelConfidence::unknown)) { + continue; + } + + MmsLiveTypeTemplateCandidate do_template; + do_template.template_kind = "DOType"; + do_template.id = object.proposed_do_type_id; + do_template.source_reference = object.reference; + do_template.inferred_type = object.inferred_cdc; + do_template.confidence = object.cdc_confidence; + for (const auto& attribute : object.attributes) { + auto type = attribute.scl_basic_type; + if (!attribute.mms_type.empty()) { + type += "/" + attribute.mms_type; + } + const auto source = + attribute.type_confidence == MmsLiveModelConfidence::exact + ? "exact" + : "heuristic"; + do_template.members.push_back( + attribute.attribute_path + " [" + attribute.functional_constraint + + "] " + type + " (" + source + ")"); + } + document.type_templates.push_back(std::move(do_template)); + } + } + } + + std::vector ordered_type_evidence; + ordered_type_evidence.reserve(discovery.variable_types.size()); + for (const auto& evidence : discovery.variable_types) { + ordered_type_evidence.push_back(&evidence); + } + std::sort( + ordered_type_evidence.begin(), + ordered_type_evidence.end(), + [](const auto* left, const auto* right) { + return lower(left->variable.reference()) < lower(right->variable.reference()); + }); + + for (const auto* evidence : ordered_type_evidence) { + MmsLiveVariableTypeDiscovery projected; + projected.reference = evidence->variable.reference(); + projected.domain = evidence->variable.domain; + projected.mms_item_name = evidence->variable.item; + projected.success = evidence->success(); + projected.message = evidence->error; + + if (const auto point = MmsLiveReferenceParser::parse_variable( + evidence->variable.domain, + evidence->variable.item, + "GetVariableAccessAttributes", + 100U)) { + projected.functional_constraint = point->functional_constraint; + } + + if (evidence->attributes) { + projected.mms_type = evidence->attributes->type.mms_type_name(); + projected.scl_basic_type = evidence->attributes->type.scl_basic_type(); + projected.type_signature = evidence->attributes->type.signature(); + projected.mms_deletable = evidence->attributes->mms_deletable; + } + + document.variable_type_discoveries.push_back(std::move(projected)); + } From f2300b4f12d8e0404d189dca76a2ce25412d1de1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:02:05 +0700 Subject: [PATCH 103/179] Wire C# type projections into live model build --- include/ariec61850/mms/live_model.ipp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_model.ipp b/include/ariec61850/mms/live_model.ipp index 976a81b05..8d82fbb79 100644 --- a/include/ariec61850/mms/live_model.ipp +++ b/include/ariec61850/mms/live_model.ipp @@ -3,6 +3,7 @@ #include "ariec61850/mms/live_model_chunk01.ipp" #include "ariec61850/mms/live_model_chunk02.ipp" #include "ariec61850/mms/live_model_chunk03.ipp" +#include "ariec61850/mms/live_model_type_projection.ipp" #include "ariec61850/mms/live_model_chunk04.ipp" #include "ariec61850/mms/live_model_chunk05.ipp" -#include "ariec61850/mms/live_model_chunk06.ipp" +#include "ariec61850/mms/live_model_chunk06.ipp" \ No newline at end of file From 5075d295f575b04b8230497f3967e1e04af8ea87 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:02:42 +0700 Subject: [PATCH 104/179] Expose C# type projections in live model JSON --- include/ariec61850/mms/live_model_chunk05.ipp | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk05.ipp b/include/ariec61850/mms/live_model_chunk05.ipp index 63e4d6336..73b1bf3bc 100644 --- a/include/ariec61850/mms/live_model_chunk05.ipp +++ b/include/ariec61850/mms/live_model_chunk05.ipp @@ -25,12 +25,15 @@ const auto& ln = ld.logical_nodes[j]; out << "{\"name\":\"" << json(ln.name) << "\",\"prefix\":\"" << json(ln.prefix) << "\",\"lnClass\":\"" << json(ln.logical_node_class) << "\",\"lnInst\":\"" - << json(ln.instance) << "\",\"dataObjects\":["; + << json(ln.instance) << "\",\"proposedLnTypeId\":\"" + << json(ln.proposed_type_id) << "\",\"dataObjects\":["; for (std::size_t k = 0; k < ln.data_objects.size(); ++k) { if (k) out << ','; const auto& object = ln.data_objects[k]; out << "{\"reference\":\"" << json(object.reference) << "\",\"name\":\"" - << json(object.name) << "\",\"inferredCdc\":\"" << json(object.inferred_cdc) + << json(object.name) << "\",\"proposedDoTypeId\":\"" + << json(object.proposed_do_type_id) << "\",\"inferredCdc\":\"" + << json(object.inferred_cdc) << "\",\"cdcConfidence\":" << object.cdc_confidence << ",\"attributes\":["; for (std::size_t m = 0; m < object.attributes.size(); ++m) { if (m) out << ','; @@ -142,7 +145,48 @@ write_control_blocks("settingGroupControls", setting_group_controls); write_control_blocks("logControls", log_controls); - out << ",\"warnings\":["; + out << ",\"typeTemplates\":["; + for (std::size_t index = 0U; index < type_templates.size(); ++index) { + if (index) out << ','; + const auto& type_template = type_templates[index]; + out << "{\"templateKind\":\"" << json(type_template.template_kind) + << "\",\"id\":\"" << json(type_template.id) + << "\",\"sourceReference\":\"" << json(type_template.source_reference) + << "\",\"inferredType\":\"" << json(type_template.inferred_type) + << "\",\"confidence\":" << type_template.confidence + << ",\"members\":["; + for (std::size_t member_index = 0U; + member_index < type_template.members.size(); + ++member_index) { + if (member_index) out << ','; + out << '"' << json(type_template.members[member_index]) << '"'; + } + out << "]}"; + } + + out << "],\"variableTypeDiscoveries\":["; + for (std::size_t index = 0U; index < variable_type_discoveries.size(); ++index) { + if (index) out << ','; + const auto& variable_type = variable_type_discoveries[index]; + out << "{\"reference\":\"" << json(variable_type.reference) + << "\",\"domain\":\"" << json(variable_type.domain) + << "\",\"mmsItemName\":\"" << json(variable_type.mms_item_name) + << "\",\"functionalConstraint\":\"" << json(variable_type.functional_constraint) + << "\",\"isSuccess\":" << (variable_type.success ? "true" : "false") + << ",\"mmsType\":\"" << json(variable_type.mms_type) + << "\",\"sclBType\":\"" << json(variable_type.scl_basic_type) + << "\",\"typeSignature\":\"" << json(variable_type.type_signature) + << "\",\"isMmsDeletable\":"; + if (variable_type.mms_deletable.has_value()) { + out << (*variable_type.mms_deletable ? "true" : "false"); + } else { + out << "null"; + } + out << ",\"message\":\"" << json(variable_type.message) + << "\",\"source\":\"" << json(variable_type.source) << "\"}"; + } + + out << "],\"warnings\":["; for (std::size_t i = 0; i < warnings.size(); ++i) { if (i) out << ','; out << "{\"code\":\"" << json(warnings[i].code) << "\",\"reference\":\"" From 2e827422c08e0afb4d03a23eec8de7201dd3cf27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:03:40 +0700 Subject: [PATCH 105/179] Regress C# live type template projections --- tests/test_main.cpp | 64 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 5afa445a4..aa8a0d6c8 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -4,6 +4,7 @@ #include "ariec61850/capture/pcap.hpp" #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/goose/retransmission_schedule.hpp" +#include "ariec61850/mms/live_model.hpp" #include #include @@ -194,6 +195,64 @@ void goose_retransmission_schedule_matches_csharp_behavior() { CHECK(defaults.next_delay_milliseconds() == 4); } +void live_type_templates_and_variable_type_projection_match_csharp_shape() { + using namespace ar::iec61850::mms; + + MmsLiveDiscoveryResult discovery; + discovery.endpoint = {"127.0.0.1", 102U}; + discovery.names.domain_variables["TESTIEDLD0"] = { + "LLN0$ST$Mod$stVal", + "LLN0$ST$Mod$q", + "LLN0$ST$Mod$t"}; + discovery.report_inventory = MmsReportInventoryBuilder::build(discovery.names); + + MmsVariableTypeEvidence type_evidence; + type_evidence.variable = MmsObjectName::domain_specific( + "TESTIEDLD0", "LLN0$ST$Mod$stVal"); + MmsVariableAccessAttributesResponse attributes; + attributes.mms_deletable = false; + attributes.type.kind = MmsTypeKind::boolean; + type_evidence.attributes = attributes; + discovery.variable_types.push_back(type_evidence); + + const auto model = MmsLiveModelBuilder::build(discovery); + CHECK(model.logical_devices.size() == 1U); + CHECK(model.logical_devices[0].logical_nodes.size() == 1U); + const auto& ln = model.logical_devices[0].logical_nodes[0]; + CHECK(ln.name == "LLN0"); + CHECK(ln.proposed_type_id == "LN_LLN0_LLN0"); + CHECK(ln.data_objects.size() == 1U); + const auto& object = ln.data_objects[0]; + CHECK(object.name == "Mod"); + CHECK(object.proposed_do_type_id == "DO_INC_LLN0_Mod"); + + CHECK(model.type_templates.size() == 2U); + CHECK(model.type_templates[0].template_kind == "LNodeType"); + CHECK(model.type_templates[0].id == "LN_LLN0_LLN0"); + CHECK(model.type_templates[0].members.size() == 1U); + CHECK(model.type_templates[0].members[0] == "Mod"); + CHECK(model.type_templates[1].template_kind == "DOType"); + CHECK(model.type_templates[1].id == "DO_INC_LLN0_Mod"); + CHECK(!model.type_templates[1].members.empty()); + + CHECK(model.variable_type_discoveries.size() == 1U); + const auto& type = model.variable_type_discoveries[0]; + CHECK(type.success); + CHECK(type.domain == "TESTIEDLD0"); + CHECK(type.mms_item_name == "LLN0$ST$Mod$stVal"); + CHECK(type.functional_constraint == "ST"); + CHECK(!type.mms_type.empty()); + CHECK(type.scl_basic_type == "BOOLEAN"); + CHECK(type.mms_deletable.has_value()); + CHECK(!*type.mms_deletable); + + const auto json = model.to_json(); + CHECK(json.find("\"proposedLnTypeId\":\"LN_LLN0_LLN0\"") != std::string::npos); + CHECK(json.find("\"proposedDoTypeId\":\"DO_INC_LLN0_Mod\"") != std::string::npos); + CHECK(json.find("\"typeTemplates\":[") != std::string::npos); + CHECK(json.find("\"variableTypeDiscoveries\":[") != std::string::npos); +} + } // namespace int main() { @@ -205,7 +264,8 @@ int main() { {"MAC value semantics", mac_address_value_semantics}, {"Ethernet/VLAN/process bus", ethernet_vlan_and_process_bus_round_trip}, {"PCAP round trip", pcap_writer_produces_classic_ethernet_pcap_and_round_trips}, - {"GOOSE retransmission schedule", goose_retransmission_schedule_matches_csharp_behavior}}; + {"GOOSE retransmission schedule", goose_retransmission_schedule_matches_csharp_behavior}, + {"C# live type projections", live_type_templates_and_variable_type_projection_match_csharp_shape}}; std::size_t passed = 0; for (const auto& [name, test] : tests) { @@ -221,4 +281,4 @@ int main() { std::cout << "Passed " << passed << "/" << tests.size() << " tests.\n"; return 0; -} +} \ No newline at end of file From d55087d5fed5c7d6dbcc0c73b8d35dfd9c5f8981 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:04:37 +0700 Subject: [PATCH 106/179] Separate structural and runtime live model fingerprints --- include/ariec61850/mms/live_model.hpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/include/ariec61850/mms/live_model.hpp b/include/ariec61850/mms/live_model.hpp index 5feca8bb7..d431bc284 100644 --- a/include/ariec61850/mms/live_model.hpp +++ b/include/ariec61850/mms/live_model.hpp @@ -238,9 +238,19 @@ struct MmsLiveModelDocument final { std::vector variable_type_discoveries; std::vector warnings; + // Legacy manifest/fingerprint retained during A1 migration. Structural + // identity and mutable runtime evidence are exposed separately below. [[nodiscard]] std::string canonical_manifest() const; [[nodiscard]] std::uint64_t canonical_fingerprint() const; [[nodiscard]] std::string canonical_fingerprint_hex() const; + + [[nodiscard]] std::string structural_manifest() const; + [[nodiscard]] std::uint64_t structural_fingerprint() const; + [[nodiscard]] std::string structural_fingerprint_hex() const; + [[nodiscard]] std::string runtime_snapshot_manifest() const; + [[nodiscard]] std::uint64_t runtime_snapshot_fingerprint() const; + [[nodiscard]] std::string runtime_snapshot_fingerprint_hex() const; + [[nodiscard]] std::string to_json() const; }; From 80812e6f6402b89cfdddf7a3a3fc026e5ac718ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:04:58 +0700 Subject: [PATCH 107/179] Add structural and runtime live model fingerprints --- .../ariec61850/mms/live_model_fingerprint.ipp | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 include/ariec61850/mms/live_model_fingerprint.ipp diff --git a/include/ariec61850/mms/live_model_fingerprint.ipp b/include/ariec61850/mms/live_model_fingerprint.ipp new file mode 100644 index 000000000..5c335b306 --- /dev/null +++ b/include/ariec61850/mms/live_model_fingerprint.ipp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace ar::iec61850::mms { +namespace live_model_fingerprint_detail { + +[[nodiscard]] inline std::uint64_t fnv1a64(const std::string_view text) noexcept { + std::uint64_t value{14695981039346656037ULL}; + for (const char raw_byte : text) { + const auto byte = static_cast(raw_byte); + value ^= byte; + value *= 1099511628211ULL; + } + return value; +} + +[[nodiscard]] inline std::string hex64(const std::uint64_t value) { + std::ostringstream out; + out << std::hex << std::setfill('0') << std::setw(16) << value; + return out.str(); +} + +[[nodiscard]] inline std::string normalized_bool(const std::optional value) { + if (!value.has_value()) return "-"; + return *value ? "1" : "0"; +} + +} // namespace live_model_fingerprint_detail + +inline std::string MmsLiveModelDocument::structural_manifest() const { + using namespace live_model_detail; + std::vector lines; + lines.push_back("ARIEC61850-LIVE-STRUCTURE|1"); + lines.push_back("IDENTITY|" + identity.ied_name); + + for (const auto& ld : logical_devices) { + lines.push_back("LD|" + ld.mms_domain); + for (const auto& ln : ld.logical_nodes) { + lines.push_back( + "LN|" + ld.mms_domain + "|" + ln.name + "|" + + ln.logical_node_class); + for (const auto& object : ln.data_objects) { + lines.push_back("DO|" + object.reference); + for (const auto& attribute : object.attributes) { + lines.push_back( + "DA|" + attribute.object_reference + "|" + + attribute.functional_constraint); + } + } + } + } + + for (const auto& control : report_controls) { + lines.push_back( + "RCB|" + control.reference + "|" + + (control.buffered ? std::string{"B"} : std::string{"U"})); + } + + const auto append_control_blocks = [&lines](const auto& controls) { + for (const auto& control : controls) { + lines.push_back( + "CB|" + control.kind + "|" + control.reference + "|" + + control.functional_constraint); + } + }; + append_control_blocks(goose_control_blocks); + append_control_blocks(sampled_value_control_blocks); + append_control_blocks(setting_group_controls); + append_control_blocks(log_controls); + + std::sort(lines.begin() + 2, lines.end(), [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + + std::ostringstream out; + for (const auto& line : lines) { + out << line << '\n'; + } + return out.str(); +} + +inline std::uint64_t MmsLiveModelDocument::structural_fingerprint() const { + return live_model_fingerprint_detail::fnv1a64(structural_manifest()); +} + +inline std::string MmsLiveModelDocument::structural_fingerprint_hex() const { + return live_model_fingerprint_detail::hex64(structural_fingerprint()); +} + +inline std::string MmsLiveModelDocument::runtime_snapshot_manifest() const { + using namespace live_model_detail; + using namespace live_model_fingerprint_detail; + std::vector lines; + lines.push_back("ARIEC61850-LIVE-RUNTIME|1"); + lines.push_back("IDENTITY|" + identity.ied_name); + + for (const auto& data_set : data_sets) { + std::string line = + "DS|" + data_set.reference + "|" + normalized_bool(data_set.deletable) + + "|" + std::to_string(data_set.members.size()); + for (const auto& member : data_set.members) { + line += "|" + member.reference + "[" + member.functional_constraint + "]"; + } + lines.push_back(std::move(line)); + } + + for (const auto& control : report_controls) { + lines.push_back( + "RCB|" + control.reference + "|" + control.data_set_binding_status + "|" + + control.data_set_reference + "|" + control.enabled_state + "|" + + control.reservation_state + "|" + control.reservation_time_seconds + "|" + + control.report_id + "|" + control.configuration_revision); + } + + std::sort(lines.begin() + 2, lines.end(), [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + + std::ostringstream out; + for (const auto& line : lines) { + out << line << '\n'; + } + return out.str(); +} + +inline std::uint64_t MmsLiveModelDocument::runtime_snapshot_fingerprint() const { + return live_model_fingerprint_detail::fnv1a64(runtime_snapshot_manifest()); +} + +inline std::string MmsLiveModelDocument::runtime_snapshot_fingerprint_hex() const { + return live_model_fingerprint_detail::hex64(runtime_snapshot_fingerprint()); +} + +} // namespace ar::iec61850::mms From e28bf90a33490afb1ee2a8425775e62e366aec0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:05:09 +0700 Subject: [PATCH 108/179] Wire structural and runtime fingerprint helpers --- include/ariec61850/mms/live_model.ipp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_model.ipp b/include/ariec61850/mms/live_model.ipp index 8d82fbb79..a94ced81d 100644 --- a/include/ariec61850/mms/live_model.ipp +++ b/include/ariec61850/mms/live_model.ipp @@ -6,4 +6,5 @@ #include "ariec61850/mms/live_model_type_projection.ipp" #include "ariec61850/mms/live_model_chunk04.ipp" #include "ariec61850/mms/live_model_chunk05.ipp" -#include "ariec61850/mms/live_model_chunk06.ipp" \ No newline at end of file +#include "ariec61850/mms/live_model_chunk06.ipp" +#include "ariec61850/mms/live_model_fingerprint.ipp" \ No newline at end of file From 5418f5c4532f6aad407d3b3d6020490a486dfd80 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:05:51 +0700 Subject: [PATCH 109/179] Regress structural versus runtime live fingerprints --- tests/test_main.cpp | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_main.cpp b/tests/test_main.cpp index aa8a0d6c8..26debfdb2 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -251,6 +251,44 @@ void live_type_templates_and_variable_type_projection_match_csharp_shape() { CHECK(json.find("\"proposedDoTypeId\":\"DO_INC_LLN0_Mod\"") != std::string::npos); CHECK(json.find("\"typeTemplates\":[") != std::string::npos); CHECK(json.find("\"variableTypeDiscoveries\":[") != std::string::npos); + + auto discovery_without_types = discovery; + discovery_without_types.variable_types.clear(); + const auto model_without_types = MmsLiveModelBuilder::build(discovery_without_types); + CHECK(model.structural_fingerprint_hex() == + model_without_types.structural_fingerprint_hex()); + + auto dynamic_data_set_snapshot = model; + MmsLiveDataSet dynamic_data_set; + dynamic_data_set.reference = "TESTIEDLD0/LLN0.DynamicSet01"; + dynamic_data_set.domain = "TESTIEDLD0"; + dynamic_data_set.logical_node = "LLN0"; + dynamic_data_set.name = "DynamicSet01"; + dynamic_data_set_snapshot.data_sets.push_back(dynamic_data_set); + CHECK(model.structural_fingerprint_hex() == + dynamic_data_set_snapshot.structural_fingerprint_hex()); + CHECK(model.runtime_snapshot_fingerprint_hex() != + dynamic_data_set_snapshot.runtime_snapshot_fingerprint_hex()); + + MmsLiveReportControl unbound_rcb; + unbound_rcb.reference = "TESTIEDLD0/LLN0.brcbA01"; + unbound_rcb.domain = "TESTIEDLD0"; + unbound_rcb.logical_node = "LLN0"; + unbound_rcb.name = "brcbA01"; + unbound_rcb.buffered = true; + unbound_rcb.data_set_binding_status = "Unbound"; + unbound_rcb.enabled_state = "false"; + + auto unbound_snapshot = model; + unbound_snapshot.report_controls.push_back(unbound_rcb); + auto bound_snapshot = unbound_snapshot; + bound_snapshot.report_controls[0].data_set_binding_status = "Bound"; + bound_snapshot.report_controls[0].data_set_reference = + "TESTIEDLD0/LLN0.DynamicSet01"; + CHECK(unbound_snapshot.structural_fingerprint_hex() == + bound_snapshot.structural_fingerprint_hex()); + CHECK(unbound_snapshot.runtime_snapshot_fingerprint_hex() != + bound_snapshot.runtime_snapshot_fingerprint_hex()); } } // namespace From dd82bff626e9ff91a6d794e6334a299a4a238d1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:08:59 +0700 Subject: [PATCH 110/179] Expose structural and runtime live fingerprints in JSON --- include/ariec61850/mms/live_model_chunk04.ipp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_model_chunk04.ipp b/include/ariec61850/mms/live_model_chunk04.ipp index e00aa88ae..6ab652fcc 100644 --- a/include/ariec61850/mms/live_model_chunk04.ipp +++ b/include/ariec61850/mms/live_model_chunk04.ipp @@ -203,4 +203,6 @@ inline std::string MmsLiveModelDocument::to_json() const { << "\"accessPointName\":\"" << json(access_point_name) << "\"," << "\"summary\":\"" << json(summary) << "\"," << "\"fingerprint\":\"" << canonical_fingerprint_hex() << "\"," - << "\"coverage\":{\"logicalDeviceCount\":" << coverage.logical_device_count + << "\"structuralFingerprint\":\"" << structural_fingerprint_hex() << "\"," + << "\"runtimeSnapshotFingerprint\":\"" << runtime_snapshot_fingerprint_hex() << "\"," + << "\"coverage\":{\"logicalDeviceCount\":" << coverage.logical_device_count \ No newline at end of file From 7f6694ce58e6bfd54a31863863f9cee9cf27284b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:10:43 +0700 Subject: [PATCH 111/179] Reject reserved VLAN ID during Ethernet decode --- src/ethernet/ethernet.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ethernet/ethernet.cpp b/src/ethernet/ethernet.cpp index e2f296f3f..b0ae6db1b 100644 --- a/src/ethernet/ethernet.cpp +++ b/src/ethernet/ethernet.cpp @@ -178,7 +178,14 @@ bool EthernetFrameCodec::try_decode(const std::span bytes, if (bytes.size() < 18U) { return false; } - vlan = VlanTag::from_tag_control_information(read_u16_be(bytes, 14U)); + const auto decoded_vlan = + VlanTag::from_tag_control_information(read_u16_be(bytes, 14U)); + // IEEE 802.1Q reserves VID 4095. Keep try_decode closed under encode: + // every successfully decoded VlanTag must be valid for re-encoding. + if (decoded_vlan.vlan_id > 4094U) { + return false; + } + vlan = decoded_vlan; ether_type = read_u16_be(bytes, 16U); payload_offset = 18U; } @@ -254,4 +261,4 @@ bool ProcessBusFrameCodec::try_decode(const EthernetFrame& ethernet, return true; } -} // namespace ar::iec61850::ethernet +} // namespace ar::iec61850::ethernet \ No newline at end of file From 47301347c84b8f98da3c4fcff146a2c72ebe32d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:11:17 +0700 Subject: [PATCH 112/179] Regress reserved VLAN GOOSE fuzz crash --- tests/test_fuzz_smoke.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/test_fuzz_smoke.cpp b/tests/test_fuzz_smoke.cpp index 64609d9db..21e9ad03d 100644 --- a/tests/test_fuzz_smoke.cpp +++ b/tests/test_fuzz_smoke.cpp @@ -120,7 +120,8 @@ void deterministic_decoder_mutation_smoke() { mutation_case_count(transport) + mutation_case_count(association) + mutation_case_count(mms_services) + - mutation_case_count(reporting); + mutation_case_count(reporting) + + 1U; std::size_t cases = 0U; cases += exercise_mutations(ber, [](const ByteVector& bytes) { @@ -129,6 +130,19 @@ void deterministic_decoder_mutation_smoke() { cases += exercise_mutations(goose, [](const ByteVector& bytes) { ar::iec61850::fuzzing::exercise_goose(bytes); }); + + // Regress libFuzzer crash 798ea9d3: VLAN TCI 0xFFFF decodes to reserved + // VID 4095. try_decode must reject it instead of returning an object whose + // subsequent encode throws std::out_of_range. + auto reserved_vlan_goose = goose; + if (reserved_vlan_goose.size() < 16U) { + throw std::runtime_error("GOOSE seed is unexpectedly short."); + } + reserved_vlan_goose[14] = 0xFFU; + reserved_vlan_goose[15] = 0xFFU; + ar::iec61850::fuzzing::exercise_goose(reserved_vlan_goose); + ++cases; + cases += exercise_mutations(sampled_values, [](const ByteVector& bytes) { ar::iec61850::fuzzing::exercise_sampled_values(bytes); }); @@ -175,4 +189,4 @@ int main() { << error.what() << '\n'; return 1; } -} +} \ No newline at end of file From 1a15fdb653bcaee988f949603b5af410ba8c0bea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:12:42 +0700 Subject: [PATCH 113/179] Add cross-language same-IED model parity tool --- tools/compare_live_model_json.py | 476 +++++++++++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 tools/compare_live_model_json.py diff --git a/tools/compare_live_model_json.py b/tools/compare_live_model_json.py new file mode 100644 index 000000000..bdad40f45 --- /dev/null +++ b/tools/compare_live_model_json.py @@ -0,0 +1,476 @@ +#!/usr/bin/env python3 +"""Compare ARIEC61850 C# and arstack61850 live model JSON exports. + +The default comparison is deliberately structural. Runtime DataSets and RCB +bindings are excluded because they can change on IEDs that support dynamic +DataSet/report-control allocation. Use --runtime to compare that mutable +snapshot separately, and --types to compare exact type evidence when both +exports contain it. + +No third-party Python packages are required. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + + +def _property_key(name: str) -> str: + return "".join(ch.lower() for ch in name if ch.isalnum()) + + +def _get(obj: Any, name: str, default: Any = None) -> Any: + if not isinstance(obj, dict): + return default + wanted = _property_key(name) + for key, value in obj.items(): + if _property_key(str(key)) == wanted: + return value + return default + + +def _text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value).strip() + + +def _norm(value: Any) -> str: + return _text(value).casefold() + + +def _items(obj: Any, name: str) -> list[Any]: + value = _get(obj, name, []) + return value if isinstance(value, list) else [] + + +def _set(values: Iterable[str]) -> set[str]: + return {_norm(value) for value in values if _text(value)} + + +def _attr_reference(attribute: dict[str, Any]) -> str: + return _text(_get(attribute, "ObjectReference")) or _text( + _get(attribute, "MmsReference") + ) + + +def _control_key(control: dict[str, Any]) -> str: + return "|".join( + ( + _norm(_get(control, "Kind")), + _norm(_get(control, "Reference")), + _norm(_get(control, "FunctionalConstraint")), + ) + ) + + +@dataclass +class ModelProjection: + ied_name: str = "" + logical_devices: set[str] = field(default_factory=set) + logical_nodes: set[str] = field(default_factory=set) + data_objects: set[str] = field(default_factory=set) + data_attributes: set[str] = field(default_factory=set) + attribute_types: dict[str, str] = field(default_factory=dict) + report_controls: set[str] = field(default_factory=set) + control_blocks: set[str] = field(default_factory=set) + type_templates: set[str] = field(default_factory=set) + data_sets: set[str] = field(default_factory=set) + data_set_members: set[str] = field(default_factory=set) + report_runtime: set[str] = field(default_factory=set) + + +def project(document: dict[str, Any]) -> ModelProjection: + projected = ModelProjection(ied_name=_norm(_get(document, "IedName"))) + + for ld in _items(document, "LogicalDevices"): + domain = _text(_get(ld, "MmsDomain")) + if domain: + projected.logical_devices.add(_norm(domain)) + for ln in _items(ld, "LogicalNodes"): + ln_name = _text(_get(ln, "Name")) + ln_class = _text(_get(ln, "LnClass")) + projected.logical_nodes.add( + "|".join((_norm(domain), _norm(ln_name), _norm(ln_class))) + ) + for data_object in _items(ln, "DataObjects"): + do_ref = _text(_get(data_object, "Reference")) + if not do_ref: + do_name = _text(_get(data_object, "Name")) + do_ref = f"{domain}/{ln_name}.{do_name}" + projected.data_objects.add(_norm(do_ref)) + for attribute in _items(data_object, "Attributes"): + reference = _attr_reference(attribute) + fc = _text(_get(attribute, "FunctionalConstraint")) + if not reference: + path = _text(_get(attribute, "AttributePath")) + reference = f"{do_ref}.{path}" if path else do_ref + key = f"{_norm(reference)}|{_norm(fc)}" + projected.data_attributes.add(key) + signature = _text(_get(attribute, "MmsTypeSignature")) + if signature: + projected.attribute_types[key] = _norm(signature) + + for rcb in _items(document, "ReportControls"): + reference = _text(_get(rcb, "Reference")) + buffered = _get(rcb, "Buffered", False) + mode = "b" if bool(buffered) else "u" + if reference: + projected.report_controls.add(f"{_norm(reference)}|{mode}") + projected.report_runtime.add( + "|".join( + ( + _norm(reference), + _norm(_get(rcb, "DataSetReference")), + _norm(_get(rcb, "DataSetBindingStatus")), + _norm(_get(rcb, "EnabledState")), + _norm(_get(rcb, "ReservationState")), + _norm(_get(rcb, "ReservationTimeSeconds")), + ) + ) + ) + + for property_name in ( + "GooseControlBlocks", + "SampledValueControlBlocks", + "SettingGroupControls", + "LogControls", + ): + for control in _items(document, property_name): + projected.control_blocks.add(_control_key(control)) + + for template in _items(document, "TypeTemplates"): + template_id = _text(_get(template, "Id")) + if template_id: + projected.type_templates.add( + "|".join( + ( + _norm(_get(template, "TemplateKind")), + _norm(template_id), + _norm(_get(template, "InferredType")), + ) + ) + ) + + for data_set in _items(document, "DataSets"): + reference = _text(_get(data_set, "Reference")) + if not reference: + continue + projected.data_sets.add(_norm(reference)) + for member in _items(data_set, "Members"): + member_ref = _text(_get(member, "Reference")) + fc = _text(_get(member, "FunctionalConstraint")) + projected.data_set_members.add( + f"{_norm(reference)}|{_norm(member_ref)}|{_norm(fc)}" + ) + + return projected + + +@dataclass +class Difference: + category: str + side: str + value: str + blocking: bool = True + + +@dataclass +class Comparison: + differences: list[Difference] = field(default_factory=list) + + @property + def blocking_count(self) -> int: + return sum(1 for item in self.differences if item.blocking) + + @property + def compatible(self) -> bool: + return self.blocking_count == 0 + + +def _compare_set( + result: Comparison, + category: str, + expected: set[str], + observed: set[str], + *, + blocking: bool = True, +) -> None: + for value in sorted(expected - observed): + result.differences.append(Difference(category, "csharp-only", value, blocking)) + for value in sorted(observed - expected): + result.differences.append(Difference(category, "cpp-only", value, blocking)) + + +def compare( + csharp: ModelProjection, + cpp: ModelProjection, + *, + compare_types: bool, + compare_runtime: bool, +) -> Comparison: + result = Comparison() + if csharp.ied_name != cpp.ied_name: + result.differences.append( + Difference( + "ied-identity", + "different", + f"C#={csharp.ied_name!r}, C++={cpp.ied_name!r}", + ) + ) + + _compare_set(result, "logical-device", csharp.logical_devices, cpp.logical_devices) + _compare_set(result, "logical-node", csharp.logical_nodes, cpp.logical_nodes) + _compare_set(result, "data-object", csharp.data_objects, cpp.data_objects) + _compare_set(result, "data-attribute", csharp.data_attributes, cpp.data_attributes) + _compare_set(result, "report-control", csharp.report_controls, cpp.report_controls) + _compare_set(result, "control-block", csharp.control_blocks, cpp.control_blocks) + + if compare_types: + # Type-template projection is useful parity evidence but not a wire-level + # structural blocker if one side was generated with type reads disabled. + _compare_set( + result, + "type-template", + csharp.type_templates, + cpp.type_templates, + blocking=False, + ) + common_attributes = csharp.data_attributes & cpp.data_attributes + for key in sorted(common_attributes): + left = csharp.attribute_types.get(key, "") + right = cpp.attribute_types.get(key, "") + if left and right and left != right: + result.differences.append( + Difference( + "mms-type-signature", + "different", + f"{key}: C#={left}, C++={right}", + ) + ) + + if compare_runtime: + # Runtime differences are reported, never structural blockers. Dynamic + # DataSets and RCB bindings are expected to vary between sessions. + _compare_set( + result, + "runtime-dataset", + csharp.data_sets, + cpp.data_sets, + blocking=False, + ) + _compare_set( + result, + "runtime-dataset-member", + csharp.data_set_members, + cpp.data_set_members, + blocking=False, + ) + _compare_set( + result, + "runtime-rcb-state", + csharp.report_runtime, + cpp.report_runtime, + blocking=False, + ) + + return result + + +def _load(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8-sig") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise ValueError(f"{path}: root JSON value must be an object") + return value + + +def _counts(projection: ModelProjection) -> dict[str, int]: + return { + "logicalDevices": len(projection.logical_devices), + "logicalNodes": len(projection.logical_nodes), + "dataObjects": len(projection.data_objects), + "dataAttributes": len(projection.data_attributes), + "reportControls": len(projection.report_controls), + "controlBlocks": len(projection.control_blocks), + "typeTemplates": len(projection.type_templates), + "dataSets": len(projection.data_sets), + "dataSetMembers": len(projection.data_set_members), + } + + +def _emit_human( + csharp: ModelProjection, + cpp: ModelProjection, + comparison: Comparison, +) -> None: + print( + "Same-IED structural parity: " + + ("PASS" if comparison.compatible else "FAIL") + + f"; blocking={comparison.blocking_count}, totalFindings={len(comparison.differences)}." + ) + print(f"IED: C#={csharp.ied_name or '-'}, C++={cpp.ied_name or '-'}") + print(f"C# counts: {_counts(csharp)}") + print(f"C++ counts: {_counts(cpp)}") + for difference in comparison.differences: + marker = "BLOCK" if difference.blocking else "INFO" + print( + f"[{marker}] {difference.category} {difference.side}: {difference.value}" + ) + + +def _self_test() -> int: + csharp = { + "IedName": "TESTIED", + "LogicalDevices": [ + { + "MmsDomain": "TESTIEDLD0", + "LogicalNodes": [ + { + "Name": "LLN0", + "LnClass": "LLN0", + "DataObjects": [ + { + "Reference": "TESTIEDLD0/LLN0.Mod", + "Attributes": [ + { + "ObjectReference": "TESTIEDLD0/LLN0.Mod.stVal", + "FunctionalConstraint": "ST", + "MmsTypeSignature": "BOOLEAN", + } + ], + } + ], + } + ], + } + ], + "ReportControls": [ + {"Reference": "TESTIEDLD0/LLN0.brcbA01", "Buffered": True} + ], + "DataSets": [{"Reference": "TESTIEDLD0/LLN0.Dynamic01", "Members": []}], + } + cpp = { + "iedName": "TESTIED", + "logicalDevices": [ + { + "mmsDomain": "TESTIEDLD0", + "logicalNodes": [ + { + "name": "LLN0", + "lnClass": "LLN0", + "dataObjects": [ + { + "reference": "TESTIEDLD0/LLN0.Mod", + "attributes": [ + { + "objectReference": "TESTIEDLD0/LLN0.Mod.stVal", + "functionalConstraint": "ST", + "mmsTypeSignature": "BOOLEAN", + } + ], + } + ], + } + ], + } + ], + "reportControls": [ + { + "reference": "TESTIEDLD0/LLN0.brcbA01", + "buffered": True, + "dataSetBindingStatus": "Unbound", + } + ], + # Deliberately different runtime DataSet inventory. + "dataSets": [], + } + result = compare(project(csharp), project(cpp), compare_types=True, compare_runtime=True) + if not result.compatible: + print("self-test: expected structural parity", file=sys.stderr) + return 1 + if not any(item.category == "runtime-dataset" for item in result.differences): + print("self-test: expected informational runtime difference", file=sys.stderr) + return 1 + print("compare_live_model_json self-test: PASS") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compare ARIEC61850 C# and arstack61850 live model JSON." + ) + parser.add_argument("csharp_json", nargs="?", type=Path) + parser.add_argument("cpp_json", nargs="?", type=Path) + parser.add_argument( + "--types", + action="store_true", + help="compare exact MMS type signatures and report template drift", + ) + parser.add_argument( + "--runtime", + action="store_true", + help="report mutable DataSet/RCB snapshot differences as informational findings", + ) + parser.add_argument("--json", action="store_true", dest="json_output") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args(argv) + + if args.self_test: + return _self_test() + if args.csharp_json is None or args.cpp_json is None: + parser.error("csharp_json and cpp_json are required unless --self-test is used") + + try: + csharp = project(_load(args.csharp_json)) + cpp = project(_load(args.cpp_json)) + result = compare( + csharp, + cpp, + compare_types=args.types, + compare_runtime=args.runtime, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"parity input error: {error}", file=sys.stderr) + return 2 + + if args.json_output: + print( + json.dumps( + { + "compatible": result.compatible, + "blockingFindingCount": result.blocking_count, + "findingCount": len(result.differences), + "csharpIedName": csharp.ied_name, + "cppIedName": cpp.ied_name, + "csharpCounts": _counts(csharp), + "cppCounts": _counts(cpp), + "findings": [ + { + "category": item.category, + "side": item.side, + "value": item.value, + "blocking": item.blocking, + } + for item in result.differences + ], + }, + indent=2, + sort_keys=True, + ) + ) + else: + _emit_human(csharp, cpp, result) + + return 0 if result.compatible else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 00c5bc9057f6643d9e288ea030ad2100684d94ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:13:04 +0700 Subject: [PATCH 114/179] Gate cross-language live model parity tool --- .github/workflows/cpp-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index ed33c2819..decf6f832 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -35,6 +35,8 @@ jobs: run: cmake --build build --parallel - name: Test run: ctest --test-dir build --output-on-failure + - name: Cross-language model parity tool self-test + run: python3 tools/compare_live_model_json.py --self-test windows: name: Windows / MSVC @@ -46,4 +48,4 @@ jobs: - name: Build run: cmake --build build --config Release --parallel - name: Test - run: ctest --test-dir build -C Release --output-on-failure + run: ctest --test-dir build -C Release --output-on-failure \ No newline at end of file From 1b059acd8de06d562279b6e805ba1c0f39e2bc8f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:14:22 +0700 Subject: [PATCH 115/179] Document same-IED C# versus C++ parity workflow --- docs/LIVE_MODEL_PARITY.md | 136 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/LIVE_MODEL_PARITY.md diff --git a/docs/LIVE_MODEL_PARITY.md b/docs/LIVE_MODEL_PARITY.md new file mode 100644 index 000000000..0a2e7c298 --- /dev/null +++ b/docs/LIVE_MODEL_PARITY.md @@ -0,0 +1,136 @@ +# Same-IED Live Model Parity + +This workflow compares the ARIEC61850 C# behavioral oracle with arstack61850 +against the **same IED endpoint**. + +The default gate is deliberately structural. Runtime DataSet inventory and +current RCB binding are not structural blockers because devices with dynamic +DataSet/report-control allocation can legitimately expose different runtime +snapshots between sessions. + +## 1. Export the C# oracle model + +From the ARIEC61850 repository: + +```powershell +dotnet run --project .\apps\AR.Iec61850.Cli -- ` + mms-model-discover 192.168.1.10 ` + --port 102 ` + --timeout-ms 120000 ` + --read-datasets false ` + --read-types true ` + --max-type-reads 256 ` + --output .artifacts\parity\csharp +``` + +The canonical C# model file is: + +```text +.artifacts\parity\csharp\ied-model.json +``` + +ARIEC61850's exporter also writes companion DataSet, RCB, control-block and +variable-access-attribute evidence files. The structural comparator only needs +`ied-model.json`. + +## 2. Export the C++ model + +From the arstack61850 repository: + +```powershell +.\build\Release\ariec61850_live_discover.exe ` + 192.168.1.10 102 ` + --timeout-ms 120000 ` + --no-datasets ` + --no-rcb ` + --max-types 256 ` + --model-json ` + | Set-Content -Encoding utf8 .artifacts\parity\cpp-model.json +``` + +`--no-datasets` and `--no-rcb` only skip deep runtime reads. DataSet/RCB name +inventory still comes from read-only MMS discovery where available. + +## 3. Compare structural parity + +```powershell +python .\tools\compare_live_model_json.py ` + .artifacts\parity\csharp\ied-model.json ` + .artifacts\parity\cpp-model.json +``` + +Exit codes: + +- `0`: structural parity passed; +- `1`: one or more blocking structural differences; +- `2`: invalid input or tool error. + +The structural gate compares: + +- IED identity; +- logical-device identities; +- logical-node identity/class; +- DataObject references; +- DataAttribute references + functional constraint; +- RCB identities + BRCB/URCB mode; +- GOOSE/SV/setting-group/log control-block identities. + +It intentionally does **not** block on: + +- current dynamic DataSet inventory; +- DataSet membership that can change at runtime; +- current RCB `DatSet` binding; +- `RptEna`, reservation, owner or reservation-time state. + +## 4. Add type evidence + +When both runs include successful `GetVariableAccessAttributes` evidence: + +```powershell +python .\tools\compare_live_model_json.py ` + .artifacts\parity\csharp\ied-model.json ` + .artifacts\parity\cpp-model.json ` + --types +``` + +Exact MMS type-signature differences on attributes present on both sides are +blocking. Type-template set drift is reported as informational evidence because +one side may have intentionally omitted low-confidence templates or type reads. + +## 5. Inspect runtime drift separately + +```powershell +python .\tools\compare_live_model_json.py ` + .artifacts\parity\csharp\ied-model.json ` + .artifacts\parity\cpp-model.json ` + --types ` + --runtime +``` + +Runtime differences are informational and do not change structural pass/fail. +This is important for IEDs where an empty RCB `DatSet` is a valid unbound dynamic +slot and where named-variable-list/DataSet count can change between sessions. + +## Fingerprints + +arstack's live model now exposes three fingerprints during the A1 migration: + +- `fingerprint`: legacy canonical fingerprint retained for compatibility; +- `structuralFingerprint`: stable device/model structure, excluding mutable + DataSets and RCB runtime bindings; +- `runtimeSnapshotFingerprint`: mutable DataSet/RCB snapshot evidence. + +A changing runtime fingerprint with an unchanged structural fingerprint is an +expected result when only dynamic report configuration changed. + +## Read-only boundary + +This parity workflow is Phase 4C evidence only. It may use: + +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `GetNamedVariableListAttributes` when explicitly enabled; +- `Read` for bounded evidence. + +It does **not** authorize or perform RCB reservation, `RptEna`, GI, Write, +control, dynamic DataSet mutation, or file-service mutation. From b2018cbed9591393b87f102672ed01bff638a251 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:15:14 +0700 Subject: [PATCH 116/179] Define ESP32-P4 and ESP32-S3 embedded target roles --- docs/EMBEDDED_TARGETS.md | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/EMBEDDED_TARGETS.md diff --git a/docs/EMBEDDED_TARGETS.md b/docs/EMBEDDED_TARGETS.md new file mode 100644 index 000000000..19deffa80 --- /dev/null +++ b/docs/EMBEDDED_TARGETS.md @@ -0,0 +1,92 @@ +# Embedded Target Strategy + +arstack61850 uses one portable protocol core across host and MCU deployments. +The embedded roadmap deliberately uses more than one board so platform details +cannot leak into IEC 61850 wire semantics. + +## Reference roles + +### ESP32-P4-ETH — protocol/performance reference + +Primary embedded protocol target after Public Alpha. + +Use it to prove: + +- native 100 Mbps Ethernet integration; +- raw Layer-2 GOOSE and Sampled Values; +- allocation-bounded SV publisher loop; +- MMS server over the software TCP/IP stack; +- reporting runtime; +- timing/jitter instrumentation; +- IEEE 1588/PTP experiments where supported by the selected silicon/ESP-IDF; +- long-duration throughput and fault-injection tests. + +PoE is not a development requirement. The low-cost non-PoE board is the +preferred first P4 target; power can be supplied separately during protocol +development. + +### Waveshare ESP32-S3-POE-ETH-8DI-8DO — application/I/O reference + +Existing real-I/O target used to prove that the same embedded core can become a +small field IED. + +Use it to prove: + +- 8 isolated digital inputs; +- 8 isolated digital outputs; +- static GGIO-based MMS server model; +- read-only browse/read first; +- controlled output operations only after server semantics are stable; +- static DataSet + URCB reporting for DI changes; +- GOOSE input publication and policy-controlled GOOSE-to-DO mapping; +- PoE deployment and power-cycle safe output behavior. + +The W5500/ESP-IDF transport stays in a platform adapter. No W5500 API may enter +the portable protocol core. + +### STM32H7 / NXP-class MCU — portability and industrialization target + +This is intentionally later. Once the protocol engine is proven on ESP32-P4 and +S3, moving to another MCU should primarily require: + +- Ethernet/TCP/raw-frame adapters; +- clocks and synchronization hooks; +- scheduler/RTOS integration; +- memory-capacity profile; +- board-specific I/O. + +A protocol semantic rewrite for STM32/NXP would be considered an architecture +failure. + +## Development priority + +Before Public Alpha: + +- approximately 70% effort: ARIEC61850 behavioral parity that makes the host + C++ repository useful to third-party engineers; +- approximately 30% effort: embedded boundaries that prevent new desktop-only + debt (bounded encoding, HAL separation, no-RTTI profile, dependency gates). + +After Public Alpha: + +1. ESP32-P4 raw Ethernet proof; +2. ESP32-P4 SV publisher proof and soak; +3. minimal bounded MMS server foundation; +4. ESP32-S3 8DI/8DO IED application; +5. GOOSE I/O extension; +6. STM32H7/NXP portability proof. + +## Non-negotiable protocol boundary + +The portable embedded core must not depend on ESP-IDF, FreeRTOS, lwIP, +W5500-specific APIs, STM32 HAL, filesystem, CLI, PCAP, COMTRADE, or host live +engineering workflows. + +Platform-specific code provides small contracts for: + +- raw Ethernet TX/RX; +- connected TCP byte streams or server socket adaptation; +- monotonic and UTC/PTP-aware clocks; +- optional synchronization/event hooks. + +The protocol engine owns IEC 61850 semantics; the platform owns hardware. From 6909902b8a10e1b385cfb260c3bf809d4828f96b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:17:47 +0700 Subject: [PATCH 117/179] Make legacy parity script a structural comparator wrapper --- scripts/compare-live-model-parity.py | 276 ++++----------------------- 1 file changed, 35 insertions(+), 241 deletions(-) diff --git a/scripts/compare-live-model-parity.py b/scripts/compare-live-model-parity.py index 7178b8dcb..f3daa33dd 100644 --- a/scripts/compare-live-model-parity.py +++ b/scripts/compare-live-model-parity.py @@ -1,258 +1,52 @@ #!/usr/bin/env python3 -"""Compare C# and C++ live-ied-model-v1 JSON without third-party packages.""" +"""Compatibility wrapper for tools/compare_live_model_json.py. + +New automation should invoke tools/compare_live_model_json.py directly. This +wrapper preserves the historical positional arguments and --output option used +by older arstack61850 evidence scripts while delegating all comparison semantics +to the single A1 structural comparator. +""" from __future__ import annotations import argparse -import json import pathlib +import subprocess import sys -from dataclasses import dataclass, asdict -from typing import Any - - -@dataclass(frozen=True) -class Finding: - severity: str - kind: str - reference: str - expected: str - observed: str - message: str - - -def text(value: Any) -> str: - return "" if value is None else str(value).strip() - - -def key(value: Any) -> str: - return text(value).replace("$", ".").casefold() - - -def load(path: pathlib.Path) -> dict[str, Any]: - with path.open("r", encoding="utf-8-sig") as stream: - data = json.load(stream) - if text(data.get("schemaVersion")) != "live-ied-model-v1": - raise ValueError(f"{path}: expected schemaVersion live-ied-model-v1") - return data - - -def flatten_attributes(document: dict[str, Any]) -> dict[str, dict[str, Any]]: - result: dict[str, dict[str, Any]] = {} - for logical_device in document.get("logicalDevices", []): - for logical_node in logical_device.get("logicalNodes", []): - for data_object in logical_node.get("dataObjects", []): - for attribute in data_object.get("attributes", []): - reference = text(attribute.get("objectReference")) - if reference: - result.setdefault(key(reference), attribute) - return result - - -def comparable_type(attribute: dict[str, Any]) -> str: - return ( - text(attribute.get("mmsTypeSignature")) - or text(attribute.get("sclBType")) - or text(attribute.get("mmsType")) - ) - - -def index_by_reference(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: - result: dict[str, dict[str, Any]] = {} - for item in items: - reference = text(item.get("reference")) - if reference: - result.setdefault(key(reference), item) - return result - - -def compare(expected: dict[str, Any], observed: dict[str, Any]) -> dict[str, Any]: - findings: list[Finding] = [] - expected_name = text(expected.get("iedName")) - observed_name = text(observed.get("iedName")) - if ( - expected_name - and observed_name - and expected_name.casefold() not in {observed_name.casefold(), "template"} - ): - findings.append( - Finding( - "Error", - "IdentityMismatch", - text(observed.get("accessPointName")), - expected_name, - observed_name, - "Connected live IED identity differs from the C# oracle.", - ) - ) - - expected_attributes = flatten_attributes(expected) - observed_attributes = flatten_attributes(observed) - matched = 0 - for reference_key, expected_attribute in sorted(expected_attributes.items()): - observed_attribute = observed_attributes.get(reference_key) - reference = text(expected_attribute.get("objectReference")) - if observed_attribute is None: - findings.append( - Finding( - "Error", - "MissingLiveAttribute", - reference, - comparable_type(expected_attribute), - "", - "C# oracle attribute is missing from the C++ live model.", - ) - ) - continue - matched += 1 - expected_fc = text(expected_attribute.get("functionalConstraint")) - observed_fc = text(observed_attribute.get("functionalConstraint")) - if expected_fc and observed_fc and expected_fc.casefold() != observed_fc.casefold(): - findings.append( - Finding( - "Error", - "FunctionalConstraintMismatch", - reference, - expected_fc, - observed_fc, - "Functional constraint differs between C# and C++.", - ) - ) - expected_type = comparable_type(expected_attribute) - observed_type = comparable_type(observed_attribute) - if expected_type and observed_type and expected_type.casefold() != observed_type.casefold(): - findings.append( - Finding( - "Error", - "TypeMismatch", - reference, - expected_type, - observed_type, - "MMS/SCL type differs between C# and C++.", - ) - ) - - for reference_key, observed_attribute in sorted(observed_attributes.items()): - if reference_key not in expected_attributes: - findings.append( - Finding( - "Info", - "UnexpectedLiveAttribute", - text(observed_attribute.get("objectReference")), - "", - comparable_type(observed_attribute), - "C++ observed an attribute not present in the C# oracle.", - ) - ) - - expected_datasets = index_by_reference(expected.get("dataSets", [])) - observed_datasets = index_by_reference(observed.get("dataSets", [])) - for dataset_key, expected_dataset in sorted(expected_datasets.items()): - observed_dataset = observed_datasets.get(dataset_key) - reference = text(expected_dataset.get("reference")) - if observed_dataset is None: - findings.append( - Finding( - "Error", - "MissingLiveDataSet", - reference, - text(expected_dataset.get("memberCount")), - "", - "C# oracle DataSet is missing from the C++ live model.", - ) - ) - continue - expected_count = int(expected_dataset.get("memberCount", 0)) - observed_count = int(observed_dataset.get("memberCount", 0)) - if expected_count != observed_count: - findings.append( - Finding( - "Error", - "DataSetMemberCountMismatch", - reference, - str(expected_count), - str(observed_count), - "DataSet member count differs between C# and C++.", - ) - ) - for dataset_key, observed_dataset in sorted(observed_datasets.items()): - if dataset_key not in expected_datasets: - findings.append( - Finding( - "Info", - "UnexpectedLiveDataSet", - text(observed_dataset.get("reference")), - "", - text(observed_dataset.get("memberCount")), - "C++ observed a DataSet not present in the C# oracle.", - ) - ) - - expected_rcbs = index_by_reference(expected.get("reportControls", [])) - observed_rcbs = index_by_reference(observed.get("reportControls", [])) - for rcb_key, expected_rcb in sorted(expected_rcbs.items()): - observed_rcb = observed_rcbs.get(rcb_key) - reference = text(expected_rcb.get("reference")) - expected_mode = "BRCB" if bool(expected_rcb.get("buffered")) else "URCB" - if observed_rcb is None: - findings.append( - Finding( - "Error", - "MissingLiveReportControl", - reference, - expected_mode, - "", - "C# oracle report control is missing from the C++ live model.", - ) - ) - continue - observed_mode = "BRCB" if bool(observed_rcb.get("buffered")) else "URCB" - if expected_mode != observed_mode: - findings.append( - Finding( - "Error", - "ReportControlModeMismatch", - reference, - expected_mode, - observed_mode, - "Report-control mode differs between C# and C++.", - ) - ) - - findings.sort(key=lambda item: ({"Error": 0, "Warning": 1, "Info": 2}[item.severity], item.kind, item.reference.casefold())) - blocking = sum(item.severity == "Error" for item in findings) - return { - "schemaVersion": "ariec61850-live-model-parity-v1", - "expectedIedName": expected_name, - "observedIedName": observed_name, - "expectedAttributeCount": len(expected_attributes), - "observedAttributeCount": len(observed_attributes), - "matchedAttributeCount": matched, - "blockingFindingCount": blocking, - "compatible": blocking == 0, - "findings": [asdict(item) for item in findings], - } def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("expected", type=pathlib.Path, help="C# live-ied-model-v1 JSON") - parser.add_argument("observed", type=pathlib.Path, help="C++ live-ied-model-v1 JSON") + parser.add_argument("expected", type=pathlib.Path, help="C# live model JSON") + parser.add_argument("observed", type=pathlib.Path, help="C++ live model JSON") parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--types", action="store_true") + parser.add_argument("--runtime", action="store_true") arguments = parser.parse_args() - try: - report = compare(load(arguments.expected), load(arguments.observed)) - except (OSError, ValueError, json.JSONDecodeError) as error: - print(f"Parity comparison failed: {error}", file=sys.stderr) - return 2 - - rendered = json.dumps(report, indent=2, ensure_ascii=False) + "\n" - if arguments.output: - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text(rendered, encoding="utf-8") - print(rendered, end="") - return 0 if report["compatible"] else 1 + root = pathlib.Path(__file__).resolve().parents[1] + comparator = root / "tools" / "compare_live_model_json.py" + command = [ + sys.executable, + str(comparator), + str(arguments.expected), + str(arguments.observed), + "--json", + ] + if arguments.types: + command.append("--types") + if arguments.runtime: + command.append("--runtime") + + process = subprocess.run(command, capture_output=True, text=True, check=False) + if process.stderr: + print(process.stderr, end="", file=sys.stderr) + if process.stdout: + if arguments.output: + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(process.stdout, encoding="utf-8") + print(process.stdout, end="") + return process.returncode if __name__ == "__main__": From 659f7837f631cbc701df917576b0b155ac7fcb6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:18:16 +0700 Subject: [PATCH 118/179] Use structural fingerprint for live interop acceptance --- scripts/run-live-readonly-interop.py | 68 +++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/scripts/run-live-readonly-interop.py b/scripts/run-live-readonly-interop.py index ae8f773ff..50504afaa 100644 --- a/scripts/run-live-readonly-interop.py +++ b/scripts/run-live-readonly-interop.py @@ -62,11 +62,17 @@ def run_cycle(binary: pathlib.Path, args: argparse.Namespace) -> tuple[dict[str, def parity(root: pathlib.Path, expected: pathlib.Path, observed: pathlib.Path, output: pathlib.Path) -> int: - script = root / "scripts" / "compare-live-model-parity.py" + script = root / "tools" / "compare_live_model_json.py" process = subprocess.run( - [sys.executable, str(script), str(expected), str(observed), "--output", str(output)], + [sys.executable, str(script), str(expected), str(observed), "--json"], + capture_output=True, + text=True, check=False, ) + if process.stdout: + output.write_text(process.stdout, encoding="utf-8") + if process.stderr: + print(process.stderr, end="", file=sys.stderr) return process.returncode @@ -100,7 +106,9 @@ def main() -> int: binary = find_binary(root, args.binary) cycles: list[dict[str, Any]] = [] - fingerprints: list[str] = [] + structural_fingerprints: list[str] = [] + runtime_fingerprints: list[str] = [] + legacy_fingerprints: list[str] = [] parity_codes: list[int] = [] for index in range(1, args.cycles + 1): evidence: dict[str, Any] = {"index": index, "success": False} @@ -110,13 +118,25 @@ def main() -> int: model_path.write_text(json.dumps(model, indent=2) + "\n", encoding="utf-8") coverage = model.get("coverage", {}) warnings = model.get("warnings", []) - fingerprint = str(model.get("fingerprint", "")) - fingerprints.append(fingerprint) + legacy_fingerprint = str(model.get("fingerprint", "")) + structural_fingerprint = str( + model.get("structuralFingerprint", legacy_fingerprint) + ) + runtime_fingerprint = str(model.get("runtimeSnapshotFingerprint", "")) + legacy_fingerprints.append(legacy_fingerprint) + structural_fingerprints.append(structural_fingerprint) + if runtime_fingerprint: + runtime_fingerprints.append(runtime_fingerprint) evidence.update({ "success": bool(coverage.get("logicalDeviceCount", 0)) and bool(coverage.get("dataAttributeCount", 0)), "elapsedMs": elapsed_ms, - "fingerprint": fingerprint, + # Compatibility field now carries the structural identity used + # by acceptance. The original canonical value remains explicit. + "fingerprint": structural_fingerprint, + "structuralFingerprint": structural_fingerprint, + "runtimeSnapshotFingerprint": runtime_fingerprint, + "legacyCanonicalFingerprint": legacy_fingerprint, "warningCount": len(warnings), "coverage": coverage, "stderr": stderr, @@ -138,25 +158,45 @@ def main() -> int: cycles.append(evidence) print( f"Cycle {index}/{args.cycles}: success={evidence['success']} " - f"fingerprint={evidence.get('fingerprint', '')} " + f"structure={evidence.get('structuralFingerprint', '')} " + f"runtime={evidence.get('runtimeSnapshotFingerprint', '')} " f"warnings={evidence.get('warningCount', 0)}" ) if index != args.cycles and args.interval_ms: time.sleep(args.interval_ms / 1000.0) - stable = bool(fingerprints) and len(set(fingerprints)) == 1 and len(fingerprints) == args.cycles + structural_stable = ( + bool(structural_fingerprints) + and len(structural_fingerprints) == args.cycles + and len(set(structural_fingerprints)) == 1 + ) + runtime_stable = ( + bool(runtime_fingerprints) + and len(runtime_fingerprints) == args.cycles + and len(set(runtime_fingerprints)) == 1 + ) + legacy_stable = ( + bool(legacy_fingerprints) + and len(legacy_fingerprints) == args.cycles + and len(set(legacy_fingerprints)) == 1 + ) all_success = all(bool(cycle.get("success")) for cycle in cycles) warnings_ok = args.allow_warnings or all(cycle.get("warningCount", 0) == 0 for cycle in cycles) parity_ok = not args.expected_csharp_model or ( len(parity_codes) == args.cycles and all(code == 0 for code in parity_codes) ) - accepted = all_success and stable and warnings_ok and parity_ok + accepted = all_success and structural_stable and warnings_ok and parity_ok summary = { - "schemaVersion": "ariec61850-live-interop-v1", + "schemaVersion": "ariec61850-live-interop-v2", "endpoint": f"{args.host}:{args.port}", "binary": os.fspath(binary), "cycleCount": args.cycles, - "stableFingerprint": stable, + # Backward-compatible key, now intentionally structural. + "stableFingerprint": structural_stable, + "fingerprintKind": "structuralFingerprint", + "stableStructuralFingerprint": structural_stable, + "stableRuntimeSnapshotFingerprint": runtime_stable, + "stableLegacyCanonicalFingerprint": legacy_stable, "warningsAccepted": warnings_ok, "parityAccepted": parity_ok, "accepted": accepted, @@ -165,7 +205,11 @@ def main() -> int: (output_dir / "interop-summary.json").write_text( json.dumps(summary, indent=2) + "\n", encoding="utf-8" ) - print(f"Read-only interoperability acceptance: {'PASS' if accepted else 'FAIL'}") + print( + "Read-only interoperability acceptance: " + + ("PASS" if accepted else "FAIL") + + f" (structuralStable={structural_stable}, runtimeStable={runtime_stable})" + ) return 0 if accepted else 1 From f5cde7e3113080b1203d96178fbda12faf62431c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:18:54 +0700 Subject: [PATCH 119/179] Refresh Public Alpha live discovery documentation --- README.md | 180 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 5994ebc63..295fad3d8 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,72 @@ -# ARIEC61850 C++ Port +# arstack61850 -Incremental C++20 migration of the ARIEC61850 C# protocol stack. The C# implementation remains -the behavioral oracle until cross-language comparison and isolated-laboratory interoperability -are complete. +Portable C++20 IEC 61850 protocol stack under active migration from the +ARIEC61850 C# implementation. ARIEC61850 remains the behavioral oracle while +arstack61850 develops an independent host + embedded implementation. -## Implemented +The project is **not yet a complete IEC 61850 replacement stack**. Current +Public Alpha work focuses on a useful read-only MMS engineering client, process +bus codecs/runtime foundations, reproducible cross-language evidence, and an +MCU-safe protocol boundary. + +## Current strengths ### Portable protocol foundation -- CMake-based C++20 static library with strict warnings. -- BER, MMS Data/UTC time, Ethernet/VLAN, classic PCAP. -- GOOSE and Sampled Values codecs plus offline supervision. -- SCL read-only parser and COMTRADE CFG/DAT readers. +- BER and MMS data/UTC-time codecs. +- Ethernet/VLAN process-bus framing and classic PCAP support. +- GOOSE and Sampled Values codecs plus offline/runtime supervision foundations. +- SCL read-only parsing and COMTRADE CFG/DAT support for host engineering tools. - RFC 1006 TPKT, COTP, ISO Session, Presentation, ACSE, and MMS Initiate. -- Confirmed MMS GetNameList, variable attributes, Read, and Write codecs. -- DataSet directory, InformationReport, RCB state, offline monitoring, and report subscription. +- Confirmed MMS GetNameList, GetVariableAccessAttributes, Read, and Write codecs. +- DataSet directory, InformationReport, RCB state, and report-subscription foundations. + +A codec existing in the library does **not** mean the corresponding mutating +operation is enabled in live Phase 4C discovery. + +### Live read-only MMS discovery + +`ariec61850_live_discover` currently provides: + +- built-in Windows/POSIX TCP transport; +- MMS association through the implemented OSI stack; +- domain and variable GetNameList discovery; +- named-variable-list/DataSet inventory; +- bounded variable-type probes; +- bounded RCB read evidence; +- LD/LN/DO/DA live-model projection; +- IED identity inference; +- CDC/type heuristics compatible with the C# oracle; +- GOOSE/SV/setting-group/log control-block name inventory; +- dynamic RCB `Bound` / `Unbound` / `NotRead` / `ReadFailed` evidence; +- read-only RCB candidate planning and conservative operational availability. -### Phase 4C — built-in TCP and read-only discovery +The live discovery surface sends only read-only services such as GetNameList, +GetVariableAccessAttributes, GetNamedVariableListAttributes and Read. It does +**not** authorize or perform Write, control, GI, RCB reservation/enable, +dynamic DataSet mutation, or file-service mutation. -- Non-blocking Windows Winsock and POSIX TCP transport. -- IPv4/IPv6 resolution, deadlines, cancellation, partial sends, receive chunking, and peer close. -- Bounded live domain, variable, DataSet, type, and RCB discovery. -- `ariec61850_live_discover` CLI. +## Public Alpha A1 model work -### Phase 4C.1 — C# live-model parity +The live model now projects C#-style: -- `live-ied-model-v1` JSON compatible with the C# exporter. -- Logical Device / Logical Node / Data Object / Data Attribute hierarchy. -- Functional constraints, MMS/SCL types, DataSets, report controls, identity, and CDC confidence. -- Deterministic canonical manifest and model fingerprint. -- C#↔C++ parity script. -- Windows/Linux repeated reconnect and physical read-only evidence runner. +- `proposedLnTypeId` and `proposedDoTypeId`; +- `typeTemplates`; +- `variableTypeDiscoveries`; +- deterministic IED identity evidence; +- DataSet/RCB/control-block inventory. -The live discovery surface sends only GetNameList, GetVariableAccessAttributes, -GetNamedVariableListAttributes, and Read. It does not construct Write, control, GI, RCB -reservation/enable, dynamic DataSet mutation, or file-service requests. +Dynamic report configuration is separated from static model identity with three +fingerprints during migration: + +- `fingerprint` — legacy canonical fingerprint retained for compatibility; +- `structuralFingerprint` — LD/LN/DO/DA + control identities, excluding mutable + DataSet/RCB runtime state; +- `runtimeSnapshotFingerprint` — current DataSet inventory and RCB binding/state. + +For IEDs with dynamic DataSets, a changed runtime fingerprint with an unchanged +structural fingerprint is expected and does not by itself mean the IED model or +firmware changed. ## Build @@ -54,7 +86,7 @@ cmake --build build --parallel ctest --test-dir build --output-on-failure ``` -## Live read-only discovery +## Live discovery quick start Human summary: @@ -62,42 +94,92 @@ Human summary: .\build\Release\ariec61850_live_discover.exe 192.168.1.10 102 ``` -C#-compatible model: +Bounded read-only RCB evidence: ```powershell -.\build\Release\ariec61850_live_discover.exe 192.168.1.10 102 ` - --model-json --ied-name IED_A > live-ied-model.json +.\build\Release\ariec61850_live_discover.exe ` + 192.168.1.10 102 ` + --timeout-ms 30000 ` + --no-types ` + --no-datasets ` + --max-rcb 50 ` + --rcb-plan dynamic ` + --rcb-availability ``` -Cross-language parity: +Model JSON with bounded type discovery: -```bash -python scripts/compare-live-model-parity.py \ - csharp-live-ied-model.json cpp-live-ied-model.json \ - --output csharp-cpp-parity.json +```powershell +New-Item -ItemType Directory -Force .artifacts\parity | Out-Null + +.\build\Release\ariec61850_live_discover.exe ` + 192.168.1.10 102 ` + --timeout-ms 120000 ` + --no-datasets ` + --no-rcb ` + --max-types 256 ` + --model-json ` + | Set-Content -Encoding utf8 .artifacts\parity\cpp-model.json ``` -Physical read-only acceptance: +Inspect fingerprint/type evidence: ```powershell -.\scripts\run-live-readonly-interop.ps1 ` - -HostName 192.168.1.10 -Cycles 10 ` - -OutputDirectory .\evidence\IED_A ` - -ExpectedCSharpModel .\csharp-live-ied-model.json +$model = Get-Content .artifacts\parity\cpp-model.json -Raw | ConvertFrom-Json +$model.fingerprint +$model.structuralFingerprint +$model.runtimeSnapshotFingerprint +$model.typeTemplates.Count +$model.variableTypeDiscoveries.Count ``` -See `PHASE_4C1_LIVE_MODEL_INTEROP.md` for acceptance rules and evidence requirements. +## Same-IED C# ↔ C++ parity + +The canonical comparator is: -## Other read-only tools +```powershell +python .\tools\compare_live_model_json.py ` + .artifacts\parity\csharp\ied-model.json ` + .artifacts\parity\cpp-model.json +``` + +Optional evidence: ```powershell -.\build\Release\ariec61850_comtrade_inspect.exe .\record.cfg --json -.\scripts\run-lab-check.ps1 -Pcap .\captures\device-session.pcap +python .\tools\compare_live_model_json.py ` + .artifacts\parity\csharp\ied-model.json ` + .artifacts\parity\cpp-model.json ` + --types ` + --runtime ``` -## Acceptance boundary +Default pass/fail is structural. Runtime DataSet/RCB drift is informational. +The historical `scripts/compare-live-model-parity.py` path remains only as a +compatibility wrapper around this comparator. + +See `docs/LIVE_MODEL_PARITY.md` for the complete C# export + comparison flow. + +## Embedded direction + +The wire core is being separated from host engineering services. Current target +roles are documented in `docs/EMBEDDED_TARGETS.md`: + +- ESP32-P4-ETH: protocol/performance reference after Public Alpha; +- Waveshare ESP32-S3-POE-ETH-8DI-8DO: real I/O application reference; +- STM32H7/NXP class: later portability/industrialization target. + +The embedded profile already excludes host TCP/live-discovery/PCAP/COMTRADE/SCL +parser tooling and provides a no-RTTI host-simulation gate. Sampled Values also +has a caller-owned `encode_into(span)` path so the eventual publisher does not +need to allocate a fresh Ethernet frame buffer per sample. + +## Validation boundary + +Implemented code and green CI do not equal full field acceptance. Current +validation includes GCC/Clang/MSVC builds, deterministic tests, sanitizer/fuzz +workflows, and selected live read-only interoperability evidence. Full +replacement claims still require same-IED C#↔C++ evidence, repeated live cycles, +packet capture review, and multi-vendor physical/simulator interoperability. -Source implementation and deterministic/simulated validation do not replace physical IED -acceptance. Before merging Phase 4C/4C.1, run the repository GCC/Clang/MSVC and security -workflows, execute the physical evidence runner against a reachable IED or vendor simulator, -and review C#↔C++ parity against the same configuration. +See `docs/FEATURE_MATRIX.md` for a conservative status table and +`PHASE_4C1_LIVE_MODEL_INTEROP.md` for the live-evidence boundary. From 2f0c9b603b41aa02eab6a7f3592cff5bc6a92ef1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:19:29 +0700 Subject: [PATCH 120/179] Add conservative Public Alpha feature matrix --- docs/FEATURE_MATRIX.md | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/FEATURE_MATRIX.md diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md new file mode 100644 index 000000000..7b8053abb --- /dev/null +++ b/docs/FEATURE_MATRIX.md @@ -0,0 +1,74 @@ +# arstack61850 Feature Matrix + +Status terms are intentionally conservative: + +- **Live-proven** — exercised against a reachable IED/vendor simulator endpoint. +- **Offline-tested** — deterministic unit/integration/fuzz evidence exists, but no + current live-device claim is made. +- **Partial** — useful implementation exists but important parity/interop gaps remain. +- **Planned** — not implemented as a usable public feature yet. + +| Area | Feature | Status | Current evidence / boundary | +|---|---|---|---| +| Transport | TCP client transport | Live-proven | Windows/POSIX transport used by live MMS discovery. | +| OSI | TPKT / COTP | Live-proven | Live association path reaches MMS endpoint. | +| OSI | Session / Presentation / ACSE | Live-proven | Current association profile + tolerant C#-compatible acceptance path interoperates with current test endpoint. | +| MMS client | Initiate / association lifecycle | Live-proven | Read-only discovery session associates successfully. Multi-profile reconnect retry is still incomplete. | +| MMS client | GetNameList domains | Live-proven | Current OCR7SR12 evidence discovers 4 domains. | +| MMS client | GetNameList variables | Live-proven | Thousands of MMS variables discovered live; inventory varies with exposed runtime/configuration state. | +| MMS client | GetVariableAccessAttributes | Live-proven | Bounded LN-root type planner has completed successful live probes on current endpoint. | +| MMS client | Read | Live-proven | Bounded RCB attribute reads complete without diagnostics on current endpoint. | +| MMS client | GetNamedVariableListAttributes | Live-proven/optional | Used when DataSet directory reads are enabled; may be deliberately skipped for structural-only runs. | +| Live model | LD/LN/DO/DA projection | Live-proven | Current endpoint resolves 4 LD and 123 LN; DO/DA inventory depends on live name exposure. | +| Live model | IED identity resolver | Live-proven | OCR7SR12 inferred from logical-device domain suffix evidence. | +| Live model | CDC inference | Live-proven + offline-tested | C# registry/pattern port used by live model; exact CDC still depends on available evidence. | +| Live model | TypeTemplates | Offline-tested | C# projection port implemented; live confirmation on target endpoint is the next evidence step. | +| Live model | VariableTypeDiscoveries | Offline-tested | Projection from existing type-read evidence; no extra MMS requests. | +| Live model | Structural fingerprint | Offline-tested | Excludes mutable DataSet/RCB runtime state. Live repeated-cycle validation pending. | +| Live model | Runtime snapshot fingerprint | Offline-tested | Includes current DataSet inventory and RCB binding/enable/reservation evidence. | +| Cross-language | Same-IED C# ↔ C++ structural comparator | Offline-tested | Case-insensitive C# PascalCase/C++ camelCase JSON reader; actual OCR7SR12 comparison pending. | +| DataSet | Name inventory | Live-proven | Dynamic inventory may legitimately change between sessions. | +| DataSet | Member directory | Live-proven/optional | Explicitly skipped with `--no-datasets`; empty member list then produces evidence warning, not structural failure. | +| Reporting | RCB inventory | Live-proven | Current endpoint exposes 286 RCB identities. | +| Reporting | Bounded RCB read | Live-proven | 50/50 read success, diagnostics=0 in latest supplied run. | +| Reporting | Bound/Unbound runtime semantics | Live-proven | Latest run observed 50 Unbound and 0 ReadFailed among first 50 probes. | +| 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 | RCB claim/reservation/enable | Planned / out of Phase 4C | No Write, reservation, `RptEna` or GI is authorized by live discovery. | +| Reporting | InformationReport decode / subscription runtime | Offline-tested / partial live integration | Core implementation exists; full live reporting acceptance is not claimed. | +| GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | +| GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical multi-vendor process-bus acceptance pending. | +| Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | +| Sampled Values | Caller-owned bounded encode path | Offline-tested | `encode_into(span)` avoids fresh frame-buffer allocation in steady-state publisher design. | +| Sampled Values | Live MCU publisher | Planned | ESP32-P4-ETH is the future protocol/performance reference after Public Alpha. | +| SCL | Read-only parser / engineering support | Offline-tested | Host-only engineering surface; not part of MCU runtime core. | +| COMTRADE | CFG/DAT read support | Offline-tested | Host engineering feature. | +| PCAP | Read/write/evidence support | Offline-tested | Host evidence feature. | +| Embedded | `ARIEC61850::embedded_core` boundary | Offline-tested | Host-only services excluded by dedicated build profile. | +| Embedded | no-RTTI profile | Offline-tested | GCC/Clang host-simulation gate. | +| Embedded | no-exceptions MCU core | Planned | Legacy protocol error paths still contain exception debt. | +| Embedded | ESP32-P4 native Ethernet adapter | Planned | Target after Public Alpha. | +| Embedded | ESP32-S3 8DI/8DO IED application | Planned | Application reference after bounded MMS server foundation exists. | +| MMS server | Association acceptor + static browse/read model | Planned | Required for the ESP32 I/O IED; not yet a public server feature. | +| MMS mutation | Write/control | Codec/offline foundation only | Live Phase 4C discovery intentionally does not send these operations. | +| File services | File directory/read/write | Planned for later parity | No file-service mutation/request is part of current live acceptance. | +| Security | ASan/UBSan | CI-gated | Security workflow; latest tranche must be green before acceptance claim. | +| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting. | +| Portability | GCC / Clang / MSVC | CI-gated | Required for host Public Alpha. | +| Acceptance | Same-IED repeated structural stability | In progress | Acceptance runner now uses `structuralFingerprint`, not mutable legacy canonical fingerprint. | +| Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before full replacement/industrial claims. | + +## Current Phase 4C mutation boundary + +The live discovery workflow may issue read-only MMS operations such as +GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes and +Read. It must not perform Write, control, RCB reservation/enable, GI, dynamic +DataSet mutation, or file-service mutation. + +## Public Alpha exit direction + +A Public Alpha can be useful before full ARIEC61850 parity if it provides a +reproducible Windows/Linux build, stable read-only live discovery/model JSON, +structural C#↔C++ same-IED comparison, documented GOOSE/SV codecs, and honest +interop evidence. Full server/control/reporting/file/TLS parity is not an A1 +requirement. From e27fc6a3cf4050e236504158f0724377216bd1fe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 08:43:10 +0700 Subject: [PATCH 121/179] Fix C# parity type-read workflow --- docs/LIVE_MODEL_PARITY.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/LIVE_MODEL_PARITY.md b/docs/LIVE_MODEL_PARITY.md index 0a2e7c298..c8f5a47a0 100644 --- a/docs/LIVE_MODEL_PARITY.md +++ b/docs/LIVE_MODEL_PARITY.md @@ -13,20 +13,28 @@ snapshots between sessions. From the ARIEC61850 repository: ```powershell +New-Item -ItemType Directory -Force .\.artifacts\parity\csharp | Out-Null + dotnet run --project .\apps\AR.Iec61850.Cli -- ` mms-model-discover 192.168.1.10 ` --port 102 ` --timeout-ms 120000 ` --read-datasets false ` --read-types true ` + --type-read-source model ` --max-type-reads 256 ` - --output .artifacts\parity\csharp + --output .\.artifacts\parity\csharp ``` +`--type-read-source model` is required in this read-only parity recipe. If type +source is left at the `datasets` default while `--read-datasets false` is used, +ARIEC61850 can legitimately report `0 candidate(s)` and `exactTypes=0/0`, which +is not useful type-parity evidence. + The canonical C# model file is: ```text -.artifacts\parity\csharp\ied-model.json +.\.artifacts\parity\csharp\ied-model.json ``` ARIEC61850's exporter also writes companion DataSet, RCB, control-block and @@ -38,6 +46,8 @@ variable-access-attribute evidence files. The structural comparator only needs From the arstack61850 repository: ```powershell +New-Item -ItemType Directory -Force .\.artifacts\parity | Out-Null + .\build\Release\ariec61850_live_discover.exe ` 192.168.1.10 102 ` --timeout-ms 120000 ` @@ -45,7 +55,7 @@ From the arstack61850 repository: --no-rcb ` --max-types 256 ` --model-json ` - | Set-Content -Encoding utf8 .artifacts\parity\cpp-model.json + | Set-Content -Encoding utf8 .\.artifacts\parity\cpp-model.json ``` `--no-datasets` and `--no-rcb` only skip deep runtime reads. DataSet/RCB name @@ -55,8 +65,8 @@ inventory still comes from read-only MMS discovery where available. ```powershell python .\tools\compare_live_model_json.py ` - .artifacts\parity\csharp\ied-model.json ` - .artifacts\parity\cpp-model.json + \.artifacts\parity\csharp\ied-model.json ` + .\.artifacts\parity\cpp-model.json ``` Exit codes: @@ -88,8 +98,8 @@ When both runs include successful `GetVariableAccessAttributes` evidence: ```powershell python .\tools\compare_live_model_json.py ` - .artifacts\parity\csharp\ied-model.json ` - .artifacts\parity\cpp-model.json ` + \.artifacts\parity\csharp\ied-model.json ` + .\.artifacts\parity\cpp-model.json ` --types ``` @@ -101,8 +111,8 @@ one side may have intentionally omitted low-confidence templates or type reads. ```powershell python .\tools\compare_live_model_json.py ` - .artifacts\parity\csharp\ied-model.json ` - .artifacts\parity\cpp-model.json ` + \.artifacts\parity\csharp\ied-model.json ` + .\.artifacts\parity\cpp-model.json ` --types ` --runtime ``` From d27261f476a3505ecddf153ae233e90ffdf38c1f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 09:04:07 +0700 Subject: [PATCH 122/179] Align live model projection with C# oracle --- include/ariec61850/mms/live_model_chunk03.ipp | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk03.ipp b/include/ariec61850/mms/live_model_chunk03.ipp index 7c0782372..5bdc4f2cc 100644 --- a/include/ariec61850/mms/live_model_chunk03.ipp +++ b/include/ariec61850/mms/live_model_chunk03.ipp @@ -1,6 +1,17 @@ + auto& object_attributes = + hierarchy[point.domain][point.logical_node][object_name]; + const auto attribute_path = + MmsLiveReferenceParser::data_attribute_path(point.data_object_path); + // Match ARIEC61850: a top-level FC point proves the DataObject exists, + // but it is not itself a DataAttribute. Keeping the empty ObjectMap entry + // preserves DO inventory without synthesizing one DA per DO. + if (attribute_path.empty()) { + continue; + } + MmsLiveDataAttribute attribute; attribute.object_reference = point.user_reference(); - attribute.attribute_path = MmsLiveReferenceParser::data_attribute_path(point.data_object_path); + attribute.attribute_path = attribute_path; attribute.functional_constraint = point.functional_constraint; attribute.mms_reference = point.mms_reference(); attribute.mms_item_name = point.mms_item_name; @@ -9,7 +20,14 @@ if (const auto* type = resolve_type(point, discovery.variable_types, type_source)) { attribute.scl_basic_type = type->scl_basic_type(); attribute.mms_type = type->mms_type_name(); - attribute.mms_type_signature = type->signature(); + // A type resolved from an LN-root probe is a child member of the + // returned structure, so its internal name is useful while walking + // the tree. The C# direct-variable oracle exports the selected type + // itself, however, without that top-level member-name prefix. Clear + // only the selected node name; nested structure member names remain. + auto projected_type = *type; + projected_type.name.clear(); + attribute.mms_type_signature = projected_type.signature(); attribute.type_discovery_status = "Exact"; attribute.type_source = type_source; attribute.type_confidence = MmsLiveModelConfidence::exact; @@ -20,7 +38,7 @@ attribute.type_source = "NameListHeuristic"; attribute.type_confidence = MmsLiveModelConfidence::low; } - hierarchy[point.domain][point.logical_node][object_name].emplace( + object_attributes.emplace( key(attribute.object_reference), std::move(attribute)); } for (auto& [domain, nodes] : hierarchy) { @@ -217,7 +235,11 @@ // a valid Unbound state, not a missing/corrupt DataSet. for (const auto& candidate : discovery.report_inventory.report_controls) { MmsLiveReportControl control; - control.reference = candidate.reference; + // Match the C# public model reference. The FC segment is part of the RCB + // identity (and distinguishes RP from BR); the internal candidate remains + // decomposed so wire requests continue to use '$' ObjectName addressing. + control.reference = candidate.domain + "/" + candidate.logical_node + "." + + candidate.functional_constraint + "." + candidate.name; control.domain = candidate.domain; control.logical_node = candidate.logical_node; control.name = candidate.name; From 9974eab89c50cd5ecccb7b1f8775bd85e75b4a2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 09:31:18 +0700 Subject: [PATCH 123/179] Compare RCB runtime evidence semantically across C# and C++ --- tools/compare_live_model_json.py | 307 ++++++++++++++++++++++++++----- 1 file changed, 263 insertions(+), 44 deletions(-) diff --git a/tools/compare_live_model_json.py b/tools/compare_live_model_json.py index bdad40f45..7b4a474c1 100644 --- a/tools/compare_live_model_json.py +++ b/tools/compare_live_model_json.py @@ -2,11 +2,13 @@ """Compare ARIEC61850 C# and arstack61850 live model JSON exports. The default comparison is deliberately structural. Runtime DataSets and RCB -bindings are excluded because they can change on IEDs that support dynamic -DataSet/report-control allocation. Use --runtime to compare that mutable +state are excluded because they can change on IEDs that support dynamic +DataSet/report-control allocation. Use --runtime to compare the mutable snapshot separately, and --types to compare exact type evidence when both exports contain it. +Runtime comparison is semantic and schema-aware: C++-only enrichment such as +DataSetBindingStatus is not placed in an equality key against the C# document. No third-party Python packages are required. """ @@ -34,6 +36,15 @@ def _get(obj: Any, name: str, default: Any = None) -> Any: return default +def _get_any(obj: Any, *names: str, default: Any = None) -> Any: + sentinel = object() + for name in names: + value = _get(obj, name, sentinel) + if value is not sentinel: + return value + return default + + def _text(value: Any) -> str: if value is None: return "" @@ -71,6 +82,25 @@ def _control_key(control: dict[str, Any]) -> str: ) +@dataclass(frozen=True) +class RcbRuntime: + reference: str + data_set_reference: str = "" + enabled_state: str = "" + reservation_state: str = "" + reservation_time_seconds: str = "" + binding_status: str = "" + + @property + def has_common_read_evidence(self) -> bool: + return bool( + self.data_set_reference + or self.enabled_state + or self.reservation_state + or self.reservation_time_seconds + ) + + @dataclass class ModelProjection: ied_name: str = "" @@ -84,7 +114,28 @@ class ModelProjection: type_templates: set[str] = field(default_factory=set) data_sets: set[str] = field(default_factory=set) data_set_members: set[str] = field(default_factory=set) - report_runtime: set[str] = field(default_factory=set) + report_runtime: dict[str, RcbRuntime] = field(default_factory=dict) + + +def _project_rcb_runtime(rcb: dict[str, Any]) -> RcbRuntime: + reference = _norm(_get(rcb, "Reference")) + return RcbRuntime( + reference=reference, + data_set_reference=_norm(_get(rcb, "DataSetReference")), + enabled_state=_norm(_get_any(rcb, "EnabledState", "ReportEnabled")), + reservation_state=_norm( + _get_any(rcb, "ReservationState", "Reserved") + ), + reservation_time_seconds=_norm( + _get_any( + rcb, + "ReservationTimeSeconds", + "ReservationTime", + "ResvTms", + ) + ), + binding_status=_norm(_get(rcb, "DataSetBindingStatus")), + ) def project(document: dict[str, Any]) -> ModelProjection: @@ -123,19 +174,9 @@ def project(document: dict[str, Any]) -> ModelProjection: buffered = _get(rcb, "Buffered", False) mode = "b" if bool(buffered) else "u" if reference: - projected.report_controls.add(f"{_norm(reference)}|{mode}") - projected.report_runtime.add( - "|".join( - ( - _norm(reference), - _norm(_get(rcb, "DataSetReference")), - _norm(_get(rcb, "DataSetBindingStatus")), - _norm(_get(rcb, "EnabledState")), - _norm(_get(rcb, "ReservationState")), - _norm(_get(rcb, "ReservationTimeSeconds")), - ) - ) - ) + normalized_reference = _norm(reference) + projected.report_controls.add(f"{normalized_reference}|{mode}") + projected.report_runtime[normalized_reference] = _project_rcb_runtime(rcb) for property_name in ( "GooseControlBlocks", @@ -209,6 +250,114 @@ def _compare_set( result.differences.append(Difference(category, "cpp-only", value, blocking)) +def _runtime_difference( + result: Comparison, + category: str, + reference: str, + csharp_value: str, + cpp_value: str, +) -> None: + result.differences.append( + Difference( + category, + "different", + f"{reference}: C#={csharp_value or ''}, " + f"C++={cpp_value or ''}", + blocking=False, + ) + ) + + +def _compare_optional_common_field( + result: Comparison, + category: str, + reference: str, + csharp_value: str, + cpp_value: str, +) -> None: + # Absence is not equality evidence. Compare only when both schemas/runs + # actually captured the field. + if csharp_value and cpp_value and csharp_value != cpp_value: + _runtime_difference(result, category, reference, csharp_value, cpp_value) + + +def _compare_rcb_runtime( + result: Comparison, + csharp: dict[str, RcbRuntime], + cpp: dict[str, RcbRuntime], +) -> None: + csharp_refs = set(csharp) + cpp_refs = set(cpp) + for reference in sorted(csharp_refs - cpp_refs): + result.differences.append( + Difference("runtime-rcb", "csharp-only", reference, blocking=False) + ) + for reference in sorted(cpp_refs - csharp_refs): + result.differences.append( + Difference("runtime-rcb", "cpp-only", reference, blocking=False) + ) + + for reference in sorted(csharp_refs & cpp_refs): + left = csharp[reference] + right = cpp[reference] + + if left.data_set_reference != right.data_set_reference: + _runtime_difference( + result, + "runtime-rcb-dataset", + reference, + left.data_set_reference, + right.data_set_reference, + ) + + _compare_optional_common_field( + result, + "runtime-rcb-enabled", + reference, + left.enabled_state, + right.enabled_state, + ) + _compare_optional_common_field( + result, + "runtime-rcb-reservation", + reference, + left.reservation_state, + right.reservation_state, + ) + _compare_optional_common_field( + result, + "runtime-rcb-reservation-time", + reference, + left.reservation_time_seconds, + right.reservation_time_seconds, + ) + + # DataSetBindingStatus is C++ enrichment and has no C# schema peer. + # We can still validate it against common C# evidence when the C# RCB + # was actually read. An empty/non-empty DatSet implies Unbound/Bound. + if right.binding_status: + if right.binding_status in {"notread", "readfailed"}: + if left.has_common_read_evidence: + result.differences.append( + Difference( + "runtime-rcb-read-evidence", + "different", + f"{reference}: C#=read, C++={right.binding_status}", + blocking=False, + ) + ) + elif left.has_common_read_evidence: + implied = "bound" if left.data_set_reference else "unbound" + if right.binding_status != implied: + _runtime_difference( + result, + "runtime-rcb-binding", + reference, + implied, + right.binding_status, + ) + + def compare( csharp: ModelProjection, cpp: ModelProjection, @@ -234,8 +383,6 @@ def compare( _compare_set(result, "control-block", csharp.control_blocks, cpp.control_blocks) if compare_types: - # Type-template projection is useful parity evidence but not a wire-level - # structural blocker if one side was generated with type reads disabled. _compare_set( result, "type-template", @@ -257,8 +404,6 @@ def compare( ) if compare_runtime: - # Runtime differences are reported, never structural blockers. Dynamic - # DataSets and RCB bindings are expected to vary between sessions. _compare_set( result, "runtime-dataset", @@ -273,13 +418,7 @@ def compare( cpp.data_set_members, blocking=False, ) - _compare_set( - result, - "runtime-rcb-state", - csharp.report_runtime, - cpp.report_runtime, - blocking=False, - ) + _compare_rcb_runtime(result, csharp.report_runtime, cpp.report_runtime) return result @@ -314,7 +453,8 @@ def _emit_human( print( "Same-IED structural parity: " + ("PASS" if comparison.compatible else "FAIL") - + f"; blocking={comparison.blocking_count}, totalFindings={len(comparison.differences)}." + + f"; blocking={comparison.blocking_count}, " + f"totalFindings={len(comparison.differences)}." ) print(f"IED: C#={csharp.ied_name or '-'}, C++={cpp.ied_name or '-'}") print(f"C# counts: {_counts(csharp)}") @@ -322,11 +462,45 @@ def _emit_human( for difference in comparison.differences: marker = "BLOCK" if difference.blocking else "INFO" print( - f"[{marker}] {difference.category} {difference.side}: {difference.value}" + f"[{marker}] {difference.category} {difference.side}: " + f"{difference.value}" ) def _self_test() -> int: + base_csharp_rcbs = [ + { + "Reference": "TESTIEDLD0/LLN0.BR.brcbA01", + "Buffered": True, + "DataSetReference": "TESTIEDLD0/LLN0.DataSetA", + "EnabledState": "false", + }, + { + "Reference": "TESTIEDLD0/LLN0.RP.urcbA01", + "Buffered": False, + "DataSetReference": "", + "EnabledState": "false", + "ReservationState": "false", + }, + ] + base_cpp_rcbs = [ + { + "reference": "TESTIEDLD0/LLN0.BR.brcbA01", + "buffered": True, + "dataSetReference": "TESTIEDLD0/LLN0.DataSetA", + "enabledState": "false", + "dataSetBindingStatus": "Bound", + }, + { + "reference": "TESTIEDLD0/LLN0.RP.urcbA01", + "buffered": False, + "dataSetReference": "", + "enabledState": "false", + "reservationState": "false", + "dataSetBindingStatus": "Unbound", + }, + ] + csharp = { "IedName": "TESTIED", "LogicalDevices": [ @@ -352,10 +526,10 @@ def _self_test() -> int: ], } ], - "ReportControls": [ - {"Reference": "TESTIEDLD0/LLN0.brcbA01", "Buffered": True} + "ReportControls": base_csharp_rcbs, + "DataSets": [ + {"Reference": "TESTIEDLD0/LLN0.Dynamic01", "Members": []} ], - "DataSets": [{"Reference": "TESTIEDLD0/LLN0.Dynamic01", "Members": []}], } cpp = { "iedName": "TESTIED", @@ -382,23 +556,63 @@ def _self_test() -> int: ], } ], - "reportControls": [ - { - "reference": "TESTIEDLD0/LLN0.brcbA01", - "buffered": True, - "dataSetBindingStatus": "Unbound", - } - ], - # Deliberately different runtime DataSet inventory. + "reportControls": base_cpp_rcbs, "dataSets": [], } - result = compare(project(csharp), project(cpp), compare_types=True, compare_runtime=True) + + result = compare( + project(csharp), + project(cpp), + compare_types=True, + compare_runtime=True, + ) if not result.compatible: print("self-test: expected structural parity", file=sys.stderr) return 1 if not any(item.category == "runtime-dataset" for item in result.differences): - print("self-test: expected informational runtime difference", file=sys.stderr) + print("self-test: expected informational DataSet drift", file=sys.stderr) + return 1 + if any(item.category.startswith("runtime-rcb") for item in result.differences): + print( + "self-test: C++ binding enrichment created false RCB drift", + file=sys.stderr, + ) + return 1 + + cpp_changed = json.loads(json.dumps(cpp)) + cpp_changed["reportControls"][1]["dataSetReference"] = ( + "TESTIEDLD0/LLN0.OtherDataSet" + ) + cpp_changed["reportControls"][1]["dataSetBindingStatus"] = "Bound" + mismatch = compare( + project(csharp), + project(cpp_changed), + compare_types=False, + compare_runtime=True, + ) + if not any( + item.category == "runtime-rcb-dataset" for item in mismatch.differences + ): + print("self-test: expected actual RCB DataSet drift", file=sys.stderr) return 1 + + cpp_not_read = json.loads(json.dumps(cpp)) + cpp_not_read["reportControls"][1]["enabledState"] = "" + cpp_not_read["reportControls"][1]["reservationState"] = "" + cpp_not_read["reportControls"][1]["dataSetBindingStatus"] = "NotRead" + evidence = compare( + project(csharp), + project(cpp_not_read), + compare_types=False, + compare_runtime=True, + ) + if not any( + item.category == "runtime-rcb-read-evidence" + for item in evidence.differences + ): + print("self-test: expected read-evidence mismatch", file=sys.stderr) + return 1 + print("compare_live_model_json self-test: PASS") return 0 @@ -417,7 +631,10 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--runtime", action="store_true", - help="report mutable DataSet/RCB snapshot differences as informational findings", + help=( + "report mutable DataSet/RCB snapshot differences using common " + "cross-schema semantics" + ), ) parser.add_argument("--json", action="store_true", dest="json_output") parser.add_argument("--self-test", action="store_true") @@ -426,7 +643,9 @@ def main(argv: list[str] | None = None) -> int: if args.self_test: return _self_test() if args.csharp_json is None or args.cpp_json is None: - parser.error("csharp_json and cpp_json are required unless --self-test is used") + parser.error( + "csharp_json and cpp_json are required unless --self-test is used" + ) try: csharp = project(_load(args.csharp_json)) From 76f18e93884e4e13dc5b6a491c78296b774b4000 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 09:32:48 +0700 Subject: [PATCH 124/179] Record live same-IED model and full RCB evidence --- docs/FEATURE_MATRIX.md | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 7b8053abb..1947d0e61 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -15,23 +15,24 @@ Status terms are intentionally conservative: | OSI | Session / Presentation / ACSE | Live-proven | Current association profile + tolerant C#-compatible acceptance path interoperates with current test endpoint. | | MMS client | Initiate / association lifecycle | Live-proven | Read-only discovery session associates successfully. Multi-profile reconnect retry is still incomplete. | | MMS client | GetNameList domains | Live-proven | Current OCR7SR12 evidence discovers 4 domains. | -| MMS client | GetNameList variables | Live-proven | Thousands of MMS variables discovered live; inventory varies with exposed runtime/configuration state. | -| MMS client | GetVariableAccessAttributes | Live-proven | Bounded LN-root type planner has completed successful live probes on current endpoint. | -| MMS client | Read | Live-proven | Bounded RCB attribute reads complete without diagnostics on current endpoint. | +| MMS client | GetNameList variables | Live-proven | Thousands of MMS variables discovered live; inventory can vary with exposed runtime/configuration state. | +| MMS client | GetVariableAccessAttributes | Live-proven | C++ LN-root planner completed 123/123 probes and projected exact MMS types across 6990 canonical DA on the current endpoint. | +| MMS client | Read | Live-proven | Full current OCR7SR12 RCB inventory was probed read-only: 286/286 represented, 0 NotRead and 0 ReadFailed in the supplied full-probe snapshot. | | MMS client | GetNamedVariableListAttributes | Live-proven/optional | Used when DataSet directory reads are enabled; may be deliberately skipped for structural-only runs. | -| Live model | LD/LN/DO/DA projection | Live-proven | Current endpoint resolves 4 LD and 123 LN; DO/DA inventory depends on live name exposure. | +| Live model | LD/LN/DO/DA projection | Live-proven | Same-IED OCR7SR12 evidence: 4 LD, 123 LN, 1186 DO, 6990 DA; C#↔C++ structural comparison has blocking=0. | | Live model | IED identity resolver | Live-proven | OCR7SR12 inferred from logical-device domain suffix evidence. | | Live model | CDC inference | Live-proven + offline-tested | C# registry/pattern port used by live model; exact CDC still depends on available evidence. | -| Live model | TypeTemplates | Offline-tested | C# projection port implemented; live confirmation on target endpoint is the next evidence step. | -| Live model | VariableTypeDiscoveries | Offline-tested | Projection from existing type-read evidence; no extra MMS requests. | -| Live model | Structural fingerprint | Offline-tested | Excludes mutable DataSet/RCB runtime state. Live repeated-cycle validation pending. | -| Live model | Runtime snapshot fingerprint | Offline-tested | Includes current DataSet inventory and RCB binding/enable/reservation evidence. | -| Cross-language | Same-IED C# ↔ C++ structural comparator | Offline-tested | Case-insensitive C# PascalCase/C++ camelCase JSON reader; actual OCR7SR12 comparison pending. | +| Live model | TypeTemplates | Live-proven | Live C# and C++ captures canonicalize to the same 471 template identities; raw projection contains 1309 candidates in both current captures. | +| Live model | VariableTypeDiscoveries | Live-proven | C# direct probes and C++ LN-root probes use different request strategies; common projected DA type signatures compare with blocking=0. | +| Live model | Structural fingerprint | Live-proven / stability pending | Current live capture emits a structure-only fingerprint excluding mutable DataSet/RCB runtime state; repeated-cycle stability remains an acceptance gate. | +| Live model | Runtime snapshot fingerprint | Live-proven | Current live capture includes mutable DataSet inventory and RCB binding/enable/reservation evidence separately from structural identity. | +| Cross-language | Same-IED C# ↔ C++ structural comparator | Live-proven | OCR7SR12 structural comparison is PASS with blocking=0 and totalFindings=0; `--types` is also PASS with blocking=0 and totalFindings=0 on common exact type evidence. | +| Cross-language | Same-IED runtime comparator | Live-proven / mutable | Runtime comparison is informational; C++-only `DataSetBindingStatus` is treated as enrichment rather than a false cross-schema mismatch. | | DataSet | Name inventory | Live-proven | Dynamic inventory may legitimately change between sessions. | | DataSet | Member directory | Live-proven/optional | Explicitly skipped with `--no-datasets`; empty member list then produces evidence warning, not structural failure. | -| Reporting | RCB inventory | Live-proven | Current endpoint exposes 286 RCB identities. | -| Reporting | Bounded RCB read | Live-proven | 50/50 read success, diagnostics=0 in latest supplied run. | -| Reporting | Bound/Unbound runtime semantics | Live-proven | Latest run observed 50 Unbound and 0 ReadFailed among first 50 probes. | +| Reporting | RCB inventory | Live-proven | Current endpoint exposes 286 RCB identities: 8 BRCB and 278 URCB in the C# oracle capture. | +| Reporting | Full RCB read-only probe | Live-proven | Current C++ full-probe snapshot classified all 286 RCBs with 0 NotRead and 0 ReadFailed. | +| 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 | RCB claim/reservation/enable | Planned / out of Phase 4C | No Write, reservation, `RptEna` or GI is authorized by live discovery. | @@ -55,7 +56,8 @@ Status terms are intentionally conservative: | Security | ASan/UBSan | CI-gated | Security workflow; latest tranche must be green before acceptance claim. | | Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting. | | Portability | GCC / Clang / MSVC | CI-gated | Required for host Public Alpha. | -| Acceptance | Same-IED repeated structural stability | In progress | Acceptance runner now uses `structuralFingerprint`, not mutable legacy canonical fingerprint. | +| Acceptance | Same-IED repeated structural stability | In progress | Acceptance runner uses `structuralFingerprint`, not mutable legacy canonical fingerprint. Multi-cycle live evidence is the next A1 acceptance tranche. | +| Acceptance | Timeout/reconnect evidence | In progress | Dedicated controlled failure/reconnect runs remain required; successful association cycles alone are not sufficient evidence. | | Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before full replacement/industrial claims. | ## Current Phase 4C mutation boundary @@ -65,6 +67,16 @@ GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes and Read. It must not perform Write, control, RCB reservation/enable, GI, dynamic DataSet mutation, or file-service mutation. +## Current live parity milestone + +On the currently exercised OCR7SR12 endpoint, C# and C++ `live-ied-model-v1` +exports now agree structurally on LD/LN/DO/DA, RCB/control-block identities and +canonical type-template projection. Common exact DA MMS type signatures also +compare with zero blocking findings. A separate C++ read-only full-RCB snapshot +classified all 286 RCBs (6 Bound, 280 Unbound at that instant) with zero +NotRead/ReadFailed entries. These facts are evidence for this endpoint and +configuration only; they are not a multi-vendor or full-stack parity claim. + ## Public Alpha exit direction A Public Alpha can be useful before full ARIEC61850 parity if it provides a From cea9a6a498d4b9a4187d322f228222003f002a01 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 09:33:09 +0700 Subject: [PATCH 125/179] Document live model parity and runtime evidence semantics --- LIVE_DISCOVERY_PROFILE.md | 47 +++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/LIVE_DISCOVERY_PROFILE.md b/LIVE_DISCOVERY_PROFILE.md index f830b4255..2cf541800 100644 --- a/LIVE_DISCOVERY_PROFILE.md +++ b/LIVE_DISCOVERY_PROFILE.md @@ -24,19 +24,48 @@ discovery request and reject service tag 5. The C++ mapper normalizes `LN$FC$DO$DA...` into LD/LN/DO/DA hierarchy, preserves FC and MMS references, resolves direct or nested TypeSpecification evidence, infers CDC with explicit -confidence, includes DataSet and RCB evidence, and produces a deterministic manifest and -fingerprint. `ariec61850_live_discover --model-json` emits `live-ied-model-v1` JSON. +confidence, includes DataSet and RCB evidence, and emits `live-ied-model-v1` JSON through +`ariec61850_live_discover --model-json`. -`scripts/compare-live-model-parity.py` compares C# and C++ documents for identity, attributes, -FC/type, DataSets, and report controls. +The model exposes separate fingerprints for separate questions: + +- `structuralFingerprint` covers structural model identity and excludes mutable dynamic + DataSet/RCB session state. +- `runtimeSnapshotFingerprint` covers mutable DataSet inventory plus RCB runtime evidence. +- the legacy `fingerprint` remains available during compatibility migration. + +`tools/compare_live_model_json.py` is the canonical same-IED C#↔C++ comparator. Its default +mode is structural; `--types` compares common exact DA MMS type signatures, and `--runtime` +reports mutable DataSet/RCB drift as informational evidence. Runtime RCB comparison is +schema-aware: C++ `DataSetBindingStatus` is an enrichment with no C# `live-ied-model-v1` +peer, so it is validated against common C# DataSet/read evidence rather than placed in a raw +equality key. `scripts/compare-live-model-parity.py` is retained only as a compatibility +wrapper. + +## Current OCR7SR12 evidence + +The currently supplied same-IED captures have established a read-only milestone on one +OCR7SR12 endpoint/configuration: + +- C# and C++ structural comparison: PASS, blocking=0, totalFindings=0. +- C# and C++ `--types` comparison: PASS, blocking=0, totalFindings=0 on common exact DA type + signatures. +- canonical model inventory: 4 LD, 123 LN, 1186 DO, 6990 DA, 286 RCB, 1 current DataSet and + 1 discovered setting-group control. +- a separate C++ full RCB read-only snapshot represented all 286 RCBs with 6 Bound and 280 + Unbound at that instant, 0 NotRead, and 0 ReadFailed. + +The 6/280 split is mutable runtime evidence from that capture. It must not be interpreted as +a permanent device capability or generalized to other IEDs. ## Physical interoperability evidence `scripts/run-live-readonly-interop.py` reconnects and performs fresh discovery for each cycle, -retains per-cycle JSON, verifies stable fingerprints and warning policy, optionally runs C# -parity, and writes `interop-summary.json`. PowerShell and Bash wrappers are included. +retains per-cycle JSON, verifies stable structural fingerprints and warning policy, optionally +runs C# parity, and writes `interop-summary.json`. PowerShell and Bash wrappers are included. Automated loopback and scripted tests do not replace physical IED evidence. Merge acceptance -still requires controlled runs against a reachable IED or vendor simulator, Windows/MSVC CI, -large-model pagination when available, timeout/reconnect evidence, and a reviewed C# parity -capture against the same IED configuration. +still requires controlled repeated runs against a reachable IED or vendor simulator, +Windows/MSVC CI, large-model pagination when available, timeout/reconnect evidence, and a +reviewed C# parity capture against the same IED configuration. Multi-vendor physical evidence +remains required before any full-replacement or industrial interoperability claim. From 0675ac8401df89b76f13154f142d58f236fdaeaf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 09:35:06 +0700 Subject: [PATCH 126/179] Extend read-only multi-cycle acceptance evidence --- scripts/run-live-readonly-interop.py | 196 ++++++++++++++++++++++----- 1 file changed, 165 insertions(+), 31 deletions(-) diff --git a/scripts/run-live-readonly-interop.py b/scripts/run-live-readonly-interop.py index 50504afaa..1ed1b3c62 100644 --- a/scripts/run-live-readonly-interop.py +++ b/scripts/run-live-readonly-interop.py @@ -33,38 +33,82 @@ def find_binary(root: pathlib.Path, override: str | None) -> pathlib.Path: ) -def run_cycle(binary: pathlib.Path, args: argparse.Namespace) -> tuple[dict[str, Any], int, str]: +def run_cycle( + binary: pathlib.Path, + args: argparse.Namespace, +) -> tuple[dict[str, Any], int, str]: command = [ - str(binary), args.host, str(args.port), "--model-json", - "--timeout-ms", str(args.timeout_ms), + str(binary), + args.host, + str(args.port), + "--model-json", + "--timeout-ms", + str(args.timeout_ms), ] if args.ied_name: command += ["--ied-name", args.ied_name] if args.no_types: command.append("--no-types") + elif args.max_types is not None: + command += ["--max-types", str(args.max_types)] if args.no_datasets: command.append("--no-datasets") if args.no_rcb: command.append("--no-rcb") + elif args.max_rcb is not None: + command += ["--max-rcb", str(args.max_rcb)] + started = time.monotonic() - process = subprocess.run(command, capture_output=True, text=True, check=False) + process = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) elapsed_ms = int((time.monotonic() - started) * 1000) if process.returncode not in (0, 1): - message = process.stderr.strip() or process.stdout.strip() or "discovery failed" + message = ( + process.stderr.strip() + or process.stdout.strip() + or "discovery failed" + ) raise RuntimeError(message) try: document = json.loads(process.stdout) except json.JSONDecodeError as exc: - raise RuntimeError(f"Discovery output is not valid JSON: {exc}") from exc + raise RuntimeError( + f"Discovery output is not valid JSON: {exc}" + ) from exc if document.get("schemaVersion") != "live-ied-model-v1": - raise RuntimeError("Discovery did not emit live-ied-model-v1 JSON.") + raise RuntimeError( + "Discovery did not emit live-ied-model-v1 JSON." + ) return document, elapsed_ms, process.stderr.strip() -def parity(root: pathlib.Path, expected: pathlib.Path, observed: pathlib.Path, output: pathlib.Path) -> int: +def parity( + root: pathlib.Path, + expected: pathlib.Path, + observed: pathlib.Path, + output: pathlib.Path, + *, + compare_types: bool, + compare_runtime: bool, +) -> int: script = root / "tools" / "compare_live_model_json.py" + command = [ + sys.executable, + str(script), + str(expected), + str(observed), + "--json", + ] + if compare_types: + command.append("--types") + if compare_runtime: + command.append("--runtime") process = subprocess.run( - [sys.executable, str(script), str(expected), str(observed), "--json"], + command, capture_output=True, text=True, check=False, @@ -76,6 +120,17 @@ def parity(root: pathlib.Path, expected: pathlib.Path, observed: pathlib.Path, o return process.returncode +def rcb_read_complete(coverage: dict[str, Any]) -> bool: + total = int(coverage.get("reportControlCount", 0) or 0) + not_read = int( + coverage.get("reportControlBindingNotReadCount", 0) or 0 + ) + read_failed = int( + coverage.get("reportControlBindingReadFailedCount", 0) or 0 + ) + return total > 0 and not_read == 0 and read_failed == 0 + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("host") @@ -83,14 +138,19 @@ def main() -> int: parser.add_argument("--output", required=True) parser.add_argument("--cycles", type=int, default=3) parser.add_argument("--interval-ms", type=int, default=1000) - parser.add_argument("--timeout-ms", type=int, default=5000) + parser.add_argument("--timeout-ms", type=int, default=120000) parser.add_argument("--ied-name", default="") parser.add_argument("--binary") parser.add_argument("--expected-csharp-model") + parser.add_argument("--parity-types", action="store_true") + parser.add_argument("--parity-runtime", action="store_true") parser.add_argument("--allow-warnings", action="store_true") parser.add_argument("--no-types", action="store_true") + parser.add_argument("--max-types", type=int) parser.add_argument("--no-datasets", action="store_true") parser.add_argument("--no-rcb", action="store_true") + parser.add_argument("--max-rcb", type=int) + parser.add_argument("--require-rcb-complete", action="store_true") args = parser.parse_args() if not 1 <= args.port <= 65535: @@ -98,7 +158,26 @@ def main() -> int: if not 1 <= args.cycles <= 10000: parser.error("cycles must be 1..10000") if args.interval_ms < 0 or args.timeout_ms <= 0: - parser.error("interval must be non-negative and timeout positive") + parser.error( + "interval must be non-negative and timeout positive" + ) + if args.max_types is not None and args.max_types <= 0: + parser.error("--max-types must be positive") + if args.max_rcb is not None and args.max_rcb <= 0: + parser.error("--max-rcb must be positive") + if args.no_types and args.max_types is not None: + parser.error("--no-types cannot be combined with --max-types") + if args.no_rcb and args.max_rcb is not None: + parser.error("--no-rcb cannot be combined with --max-rcb") + if args.require_rcb_complete and args.no_rcb: + parser.error( + "--require-rcb-complete cannot be combined with --no-rcb" + ) + if (args.parity_types or args.parity_runtime) and not args.expected_csharp_model: + parser.error( + "--parity-types/--parity-runtime require " + "--expected-csharp-model" + ) root = pathlib.Path(__file__).resolve().parents[1] output_dir = pathlib.Path(args.output).resolve() @@ -110,56 +189,86 @@ def main() -> int: runtime_fingerprints: list[str] = [] legacy_fingerprints: list[str] = [] parity_codes: list[int] = [] + rcb_complete_flags: list[bool] = [] + for index in range(1, args.cycles + 1): evidence: dict[str, Any] = {"index": index, "success": False} try: model, elapsed_ms, stderr = run_cycle(binary, args) - model_path = output_dir / f"cycle-{index:03d}-live-model.json" - model_path.write_text(json.dumps(model, indent=2) + "\n", encoding="utf-8") + model_path = output_dir / ( + f"cycle-{index:03d}-live-model.json" + ) + model_path.write_text( + json.dumps(model, indent=2) + "\n", + encoding="utf-8", + ) coverage = model.get("coverage", {}) warnings = model.get("warnings", []) legacy_fingerprint = str(model.get("fingerprint", "")) structural_fingerprint = str( - model.get("structuralFingerprint", legacy_fingerprint) + model.get( + "structuralFingerprint", + legacy_fingerprint, + ) + ) + runtime_fingerprint = str( + model.get("runtimeSnapshotFingerprint", "") ) - runtime_fingerprint = str(model.get("runtimeSnapshotFingerprint", "")) legacy_fingerprints.append(legacy_fingerprint) - structural_fingerprints.append(structural_fingerprint) + structural_fingerprints.append( + structural_fingerprint + ) if runtime_fingerprint: - runtime_fingerprints.append(runtime_fingerprint) + runtime_fingerprints.append( + runtime_fingerprint + ) + + cycle_rcb_complete = rcb_read_complete(coverage) + rcb_complete_flags.append(cycle_rcb_complete) evidence.update({ - "success": bool(coverage.get("logicalDeviceCount", 0)) - and bool(coverage.get("dataAttributeCount", 0)), + "success": bool( + coverage.get("logicalDeviceCount", 0) + ) + and bool(coverage.get("dataAttributeCount", 0)), "elapsedMs": elapsed_ms, - # Compatibility field now carries the structural identity used - # by acceptance. The original canonical value remains explicit. "fingerprint": structural_fingerprint, "structuralFingerprint": structural_fingerprint, "runtimeSnapshotFingerprint": runtime_fingerprint, "legacyCanonicalFingerprint": legacy_fingerprint, "warningCount": len(warnings), "coverage": coverage, + "rcbReadComplete": cycle_rcb_complete, "stderr": stderr, "modelFile": model_path.name, }) + if args.expected_csharp_model: - parity_path = output_dir / f"cycle-{index:03d}-csharp-cpp-parity.json" + parity_path = output_dir / ( + f"cycle-{index:03d}-csharp-cpp-parity.json" + ) code = parity( root, - pathlib.Path(args.expected_csharp_model).resolve(), + pathlib.Path( + args.expected_csharp_model + ).resolve(), model_path, parity_path, + compare_types=args.parity_types, + compare_runtime=args.parity_runtime, ) parity_codes.append(code) evidence["parityExitCode"] = code evidence["parityFile"] = parity_path.name except Exception as exc: evidence["error"] = str(exc) + cycles.append(evidence) print( - f"Cycle {index}/{args.cycles}: success={evidence['success']} " + f"Cycle {index}/{args.cycles}: " + f"success={evidence['success']} " f"structure={evidence.get('structuralFingerprint', '')} " f"runtime={evidence.get('runtimeSnapshotFingerprint', '')} " + f"rcbComplete={evidence.get('rcbReadComplete', False)} " f"warnings={evidence.get('warningCount', 0)}" ) if index != args.cycles and args.interval_ms: @@ -180,18 +289,36 @@ def main() -> int: and len(legacy_fingerprints) == args.cycles and len(set(legacy_fingerprints)) == 1 ) - all_success = all(bool(cycle.get("success")) for cycle in cycles) - warnings_ok = args.allow_warnings or all(cycle.get("warningCount", 0) == 0 for cycle in cycles) + all_success = all( + bool(cycle.get("success")) for cycle in cycles + ) + warnings_ok = args.allow_warnings or all( + cycle.get("warningCount", 0) == 0 for cycle in cycles + ) parity_ok = not args.expected_csharp_model or ( - len(parity_codes) == args.cycles and all(code == 0 for code in parity_codes) + len(parity_codes) == args.cycles + and all(code == 0 for code in parity_codes) ) - accepted = all_success and structural_stable and warnings_ok and parity_ok + rcb_complete_ok = ( + not args.require_rcb_complete + or ( + len(rcb_complete_flags) == args.cycles + and all(rcb_complete_flags) + ) + ) + accepted = ( + all_success + and structural_stable + and warnings_ok + and parity_ok + and rcb_complete_ok + ) + summary = { "schemaVersion": "ariec61850-live-interop-v2", "endpoint": f"{args.host}:{args.port}", "binary": os.fspath(binary), "cycleCount": args.cycles, - # Backward-compatible key, now intentionally structural. "stableFingerprint": structural_stable, "fingerprintKind": "structuralFingerprint", "stableStructuralFingerprint": structural_stable, @@ -199,16 +326,23 @@ def main() -> int: "stableLegacyCanonicalFingerprint": legacy_stable, "warningsAccepted": warnings_ok, "parityAccepted": parity_ok, + "parityTypesRequested": args.parity_types, + "parityRuntimeRequested": args.parity_runtime, + "rcbCompletenessRequired": args.require_rcb_complete, + "rcbCompletenessAccepted": rcb_complete_ok, "accepted": accepted, "cycles": cycles, } (output_dir / "interop-summary.json").write_text( - json.dumps(summary, indent=2) + "\n", encoding="utf-8" + json.dumps(summary, indent=2) + "\n", + encoding="utf-8", ) print( "Read-only interoperability acceptance: " + ("PASS" if accepted else "FAIL") - + f" (structuralStable={structural_stable}, runtimeStable={runtime_stable})" + + f" (structuralStable={structural_stable}, " + f"runtimeStable={runtime_stable}, " + f"rcbComplete={rcb_complete_ok})" ) return 0 if accepted else 1 From 4f2af7d7ad8bd4719a2bbefb53ae60e821c0ec82 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 09:35:32 +0700 Subject: [PATCH 127/179] Gate live evidence tooling on Linux and Windows --- .github/workflows/cpp-ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index decf6f832..511b6bc0f 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -37,6 +37,10 @@ jobs: run: ctest --test-dir build --output-on-failure - name: Cross-language model parity tool self-test run: python3 tools/compare_live_model_json.py --self-test + - name: Read-only evidence runner syntax and CLI smoke + run: | + python3 -m py_compile scripts/run-live-readonly-interop.py + python3 scripts/run-live-readonly-interop.py --help > /dev/null windows: name: Windows / MSVC @@ -48,4 +52,9 @@ jobs: - name: Build run: cmake --build build --config Release --parallel - name: Test - run: ctest --test-dir build -C Release --output-on-failure \ No newline at end of file + run: ctest --test-dir build -C Release --output-on-failure + - name: Evidence tooling self-tests + run: | + python tools/compare_live_model_json.py --self-test + python -m py_compile scripts/run-live-readonly-interop.py + python scripts/run-live-readonly-interop.py --help | Out-Null From 19439b63c18d7962821acd891fea77ec07fbcae8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:00:00 +0700 Subject: [PATCH 128/179] Expose MMS association profile attempt evidence --- include/ariec61850/mms/association_runtime.hpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/include/ariec61850/mms/association_runtime.hpp b/include/ariec61850/mms/association_runtime.hpp index ef2296abc..332df7c03 100644 --- a/include/ariec61850/mms/association_runtime.hpp +++ b/include/ariec61850/mms/association_runtime.hpp @@ -96,6 +96,15 @@ struct MmsAssociationEvent final { std::string message; }; +struct MmsAssociationAttemptEvidence final { + std::string profile_name; + bool accepted{}; + std::string message; + + friend bool operator==(const MmsAssociationAttemptEvidence&, + const MmsAssociationAttemptEvidence&) = default; +}; + struct MmsAssociationOptions final { std::chrono::milliseconds connect_timeout{5'000}; std::chrono::milliseconds request_timeout{5'000}; @@ -157,6 +166,13 @@ class MmsAssociationRuntime final { [[nodiscard]] const std::deque& events() const noexcept { return events_; } + [[nodiscard]] const std::vector& + association_attempts() const noexcept { + return association_attempts_; + } + [[nodiscard]] const std::string& active_association_profile() const noexcept { + return active_association_profile_; + } [[nodiscard]] std::size_t queued_information_report_count() const noexcept { return information_reports_.size(); } @@ -200,6 +216,8 @@ class MmsAssociationRuntime final { osi::TpktStreamDecoder tpkt_decoder_; std::deque> information_reports_; std::deque events_; + std::vector association_attempts_; + std::string active_association_profile_; std::uint32_t next_invoke_id_{}; std::uint16_t local_cotp_reference_{1U}; std::uint16_t remote_cotp_reference_{}; From 57dd43e4f1c6171d633b076f70c82ff327fbcad9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:01:27 +0700 Subject: [PATCH 129/179] Port C# association profile retry with full reconnect --- src/mms/association_runtime.cpp | 397 +++++++++++++++++++++----------- 1 file changed, 260 insertions(+), 137 deletions(-) diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index daeb76ad7..cfd1dbfe3 100644 --- a/src/mms/association_runtime.cpp +++ b/src/mms/association_runtime.cpp @@ -15,6 +15,11 @@ namespace ar::iec61850::mms { namespace { +struct AssociationAttemptProfile final { + const char* name; + std::vector payload; +}; + [[nodiscard]] std::uint8_t negotiated_tpdu_size( const osi::CotpTpdu& confirm, const std::uint8_t fallback) { @@ -81,6 +86,47 @@ namespace { } } +[[nodiscard]] std::vector csharp_legacy_minimal_association_request() { + // Exact oracle vector from ARIEC61850 + // AcseMmsInitiateRequest.BuildLegacyMinimalProfile(). Keep this profile + // byte-stable until both stacks intentionally revise their interoperability + // profile together. + return { + 0x0DU, 0xB2U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, + 0x01U, 0x02U, 0x14U, 0x02U, 0x00U, 0x02U, 0x33U, 0x02U, + 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0x9CU, + 0x31U, 0x81U, 0x99U, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, + 0xA2U, 0x81U, 0x91U, 0x81U, 0x04U, 0x00U, 0x00U, 0x00U, + 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, + 0x52U, 0x01U, 0x00U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, + 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, + 0x06U, 0x02U, 0x51U, 0x01U, 0x61U, 0x5EU, 0x30U, 0x5CU, + 0x02U, 0x01U, 0x01U, 0xA0U, 0x57U, 0x60U, 0x55U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, + 0xA2U, 0x02U, 0x06U, 0x00U, 0xA3U, 0x03U, 0x02U, 0x01U, + 0x0CU, 0xA6U, 0x07U, 0x06U, 0x05U, 0x29U, 0x87U, 0x67U, + 0x01U, 0x01U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, + 0x01U, 0x03U, 0xA0U, 0x28U, 0xA8U, 0x26U, 0x80U, 0x03U, + 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x06U, 0xA4U, 0x16U, 0x80U, 0x01U, + 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, + 0x03U, 0xEEU, 0x08U, 0x00U, 0x00U, 0x04U, 0x00U, 0x00U, + 0x00U, 0x01U, 0xE7U, 0x18U, + }; +} + +[[nodiscard]] std::array +csharp_association_profiles() { + return {{ + {"BalancedApTitle", + acse::AcseAssociationCodec::build_default_association_request()}, + {"LegacyMinimal", csharp_legacy_minimal_association_request()}, + }}; +} + } // namespace MmsAssociationRuntime::MmsAssociationRuntime( @@ -219,178 +265,255 @@ void MmsAssociationRuntime::connect( disconnect(); endpoint_ = endpoint; last_fault_.clear(); + association_attempts_.clear(); + active_association_profile_.clear(); invoke_router_.clear(); information_reports_.clear(); tpkt_decoder_.reset(); next_invoke_id_ = 0U; + remote_cotp_reference_ = 0U; negotiated_ = {}; negotiated_.tpdu_size_code = options_.tpdu_size_code; negotiated_.presentation_context_id = options_.presentation_context_id; - try { - require_not_cancelled(stop_token); - const auto deadline = deadline_after(options_.connect_timeout); - set_state(MmsAssociationRuntimeState::transport_connecting, - "Connecting byte transport to " + endpoint.host + ":" + - std::to_string(endpoint.port) + "."); - transport_.connect(endpoint, deadline, stop_token); - if (!transport_.connected()) { - throw MmsAssociationRuntimeError( - "Byte transport returned without entering the connected state."); - } - state_ = MmsAssociationRuntimeState::transport_connected; - add_event(MmsAssociationEventKind::transport_connected, - "Byte transport connected."); - - state_ = MmsAssociationRuntimeState::cotp_connecting; - add_event(MmsAssociationEventKind::state_changed, - "Sending COTP Connection Request."); - const auto connection_request = osi::CotpFrameCodec::encode_default_connection_request(); - send_tpkt_payload(connection_request, deadline, stop_token); - - std::size_t chunks = 0U; - std::optional connection_confirm; - while (!connection_confirm) { + const auto profiles = csharp_association_profiles(); + for (std::size_t profile_index = 0U; + profile_index < profiles.size(); + ++profile_index) { + const auto& profile = profiles[profile_index]; + + try { require_not_cancelled(stop_token); - if (chunks >= options_.maximum_receive_chunks_per_operation) { - throw MmsAssociationRuntimeError( - "COTP Connection Confirm did not arrive within the receive bound."); - } - auto bytes = transport_.receive(deadline, stop_token); - ++chunks; - if (bytes.empty()) { + + // Match the C# oracle: every association profile gets a fresh TCP/COTP + // transport. Do not reuse an association that was rejected, malformed, + // timed out, or otherwise faulted. + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; + negotiated_ = {}; + negotiated_.tpdu_size_code = options_.tpdu_size_code; + negotiated_.presentation_context_id = options_.presentation_context_id; + + const auto deadline = deadline_after(options_.connect_timeout); + set_state( + MmsAssociationRuntimeState::transport_connecting, + std::string{"Association profile "} + profile.name + + " (attempt " + std::to_string(profile_index + 1U) + "/" + + std::to_string(profiles.size()) + "): connecting byte transport to " + + endpoint.host + ":" + std::to_string(endpoint.port) + "."); + transport_.connect(endpoint, deadline, stop_token); + if (!transport_.connected()) { throw MmsAssociationRuntimeError( - "MMS transport returned an empty chunk during COTP connect."); + "Byte transport returned without entering the connected state."); } - tpkt_decoder_.append(bytes); - osi::TpktFrame frame; - while (tpkt_decoder_.try_pop(frame)) { - auto tpdu = osi::CotpFrameCodec::decode(frame.payload); - if (tpdu.kind == osi::CotpTpduKind::connection_confirm) { - connection_confirm = std::move(tpdu); - break; + state_ = MmsAssociationRuntimeState::transport_connected; + add_event( + MmsAssociationEventKind::transport_connected, + std::string{profile.name} + ": byte transport connected."); + + state_ = MmsAssociationRuntimeState::cotp_connecting; + add_event( + MmsAssociationEventKind::state_changed, + std::string{profile.name} + ": sending COTP Connection Request."); + const auto connection_request = + osi::CotpFrameCodec::encode_default_connection_request(); + send_tpkt_payload(connection_request, deadline, stop_token); + + std::size_t chunks = 0U; + std::optional connection_confirm; + while (!connection_confirm) { + require_not_cancelled(stop_token); + if (chunks >= options_.maximum_receive_chunks_per_operation) { + throw MmsAssociationRuntimeError( + "COTP Connection Confirm did not arrive within the receive bound."); } - if (tpdu.kind == osi::CotpTpduKind::error || - tpdu.kind == osi::CotpTpduKind::disconnect_request) { + auto bytes = transport_.receive(deadline, stop_token); + ++chunks; + if (bytes.empty()) { throw MmsAssociationRuntimeError( - "Remote endpoint rejected the COTP connection."); + "MMS transport returned an empty chunk during COTP connect."); } + tpkt_decoder_.append(bytes); + osi::TpktFrame frame; + while (tpkt_decoder_.try_pop(frame)) { + auto tpdu = osi::CotpFrameCodec::decode(frame.payload); + if (tpdu.kind == osi::CotpTpduKind::connection_confirm) { + connection_confirm = std::move(tpdu); + break; + } + if (tpdu.kind == osi::CotpTpduKind::error || + tpdu.kind == osi::CotpTpduKind::disconnect_request) { + throw MmsAssociationRuntimeError( + "Remote endpoint rejected the COTP connection."); + } + throw MmsAssociationRuntimeError( + "Unexpected TPDU received while waiting for COTP Connection Confirm."); + } + } + + if (connection_confirm->destination_reference != local_cotp_reference_) { throw MmsAssociationRuntimeError( - "Unexpected TPDU received while waiting for COTP Connection Confirm."); + "COTP Connection Confirm destination reference does not match the request."); } - } + remote_cotp_reference_ = connection_confirm->source_reference; + negotiated_.tpdu_size_code = negotiated_tpdu_size( + *connection_confirm, options_.tpdu_size_code); + state_ = MmsAssociationRuntimeState::cotp_connected; + add_event( + MmsAssociationEventKind::cotp_connected, + std::string{profile.name} + ": COTP connected with TPDU size code " + + std::to_string(negotiated_.tpdu_size_code) + "."); - if (connection_confirm->destination_reference != local_cotp_reference_) { - throw MmsAssociationRuntimeError( - "COTP Connection Confirm destination reference does not match the request."); - } - remote_cotp_reference_ = connection_confirm->source_reference; - negotiated_.tpdu_size_code = negotiated_tpdu_size( - *connection_confirm, options_.tpdu_size_code); - state_ = MmsAssociationRuntimeState::cotp_connected; - add_event(MmsAssociationEventKind::cotp_connected, - "COTP connected with TPDU size code " + - std::to_string(negotiated_.tpdu_size_code) + "."); - - state_ = MmsAssociationRuntimeState::acse_associating; - add_event(MmsAssociationEventKind::state_changed, - "Sending ISO Session/Presentation/ACSE association request."); - const auto association_request = - acse::AcseAssociationCodec::build_default_association_request(); - send_application_payload(association_request, deadline, stop_token); - const auto association_payload = - receive_application_payload(deadline, stop_token); - - bool used_csharp_compatibility = false; - std::string strict_failure; - std::optional response; - try { - response = acse::AcseAssociationCodec::decode_association_response( - association_payload); - } catch (const acse::AcseFormatError& exception) { - strict_failure = exception.what(); - } + state_ = MmsAssociationRuntimeState::acse_associating; + add_event( + MmsAssociationEventKind::state_changed, + std::string{profile.name} + + ": sending ISO Session/Presentation/ACSE association request."); + send_application_payload(profile.payload, deadline, stop_token); + const auto association_payload = + receive_application_payload(deadline, stop_token); + + bool used_csharp_compatibility = false; + std::string strict_failure; + std::optional response; + try { + response = acse::AcseAssociationCodec::decode_association_response( + association_payload); + } catch (const acse::AcseFormatError& exception) { + strict_failure = exception.what(); + } - if (response) { - if (!response->aare.accepted()) { + // Preserve the existing safety rule inside one profile: a strict, + // explicit AARE rejection is never reinterpreted as acceptance by the + // tolerant marker-based compatibility path. It is, however, eligible + // for the next C# oracle profile after a full reconnect. + if (response && !response->aare.accepted()) { throw MmsAssociationRuntimeError( "ACSE association was rejected with result=" + - std::to_string(response->aare.result) + "."); + std::to_string(response->aare.result) + + ", diagnostic=" + + std::to_string(response->aare.result_source_diagnostic) + "."); } - if (response->aare.user_information) { - try { - const auto initiate = MmsPduCodec::decode_initiate_response( - response->aare.user_information->single_asn1_type); - negotiated_.maximum_mms_pdu_size = - initiate.negotiated_maximum_mms_pdu_size; - negotiated_.maximum_outstanding_calling = - initiate.negotiated_maximum_outstanding_calling; - negotiated_.maximum_outstanding_called = - initiate.negotiated_maximum_outstanding_called; - negotiated_.data_structure_nesting_level = - initiate.negotiated_data_structure_nesting_level; - } catch (const std::exception& exception) { + if (response) { + if (response->aare.user_information) { + try { + const auto initiate = MmsPduCodec::decode_initiate_response( + response->aare.user_information->single_asn1_type); + negotiated_.maximum_mms_pdu_size = + initiate.negotiated_maximum_mms_pdu_size; + negotiated_.maximum_outstanding_calling = + initiate.negotiated_maximum_outstanding_calling; + negotiated_.maximum_outstanding_called = + initiate.negotiated_maximum_outstanding_called; + negotiated_.data_structure_nesting_level = + initiate.negotiated_data_structure_nesting_level; + } catch (const std::exception& exception) { + strict_failure = + std::string{"Strict MMS Initiate-Response decode failed: "} + + exception.what(); + } + } else { strict_failure = - std::string{"Strict MMS Initiate-Response decode failed: "} + - exception.what(); + "Accepted ACSE response did not expose MMS Initiate response data through the strict decoder."; } - } else { - strict_failure = - "Accepted ACSE response did not expose MMS Initiate response data through the strict decoder."; } - } - if (!response || !strict_failure.empty()) { - std::string compatibility_message; - if (!csharp_compatible_association_accept( - association_payload, compatibility_message)) { - if (strict_failure.empty()) { - strict_failure = compatibility_message; - } else if (!compatibility_message.empty()) { - strict_failure += " " + compatibility_message; + std::string acceptance_message; + if (!response || !strict_failure.empty()) { + std::string compatibility_message; + if (!csharp_compatible_association_accept( + association_payload, compatibility_message)) { + if (strict_failure.empty()) { + strict_failure = compatibility_message; + } else if (!compatibility_message.empty()) { + strict_failure += " " + compatibility_message; + } + throw MmsAssociationRuntimeError(strict_failure); } - throw MmsAssociationRuntimeError(strict_failure); - } - used_csharp_compatibility = true; - add_event( - MmsAssociationEventKind::association_accepted, - compatibility_message + + used_csharp_compatibility = true; + acceptance_message = + std::string{profile.name} + ": " + compatibility_message + (strict_failure.empty() ? std::string{} - : " Strict decoder diagnostic: " + strict_failure)); - } + : " Strict decoder diagnostic: " + strict_failure); + } else { + acceptance_message = + std::string{profile.name} + + ": MMS association accepted; maximum PDU=" + + std::to_string(negotiated_.maximum_mms_pdu_size) + "."; + } - negotiated_.presentation_context_id = options_.presentation_context_id; - state_ = MmsAssociationRuntimeState::associated; - if (!used_csharp_compatibility) { - add_event(MmsAssociationEventKind::association_accepted, - "MMS association accepted; maximum PDU=" + - std::to_string(negotiated_.maximum_mms_pdu_size) + "."); - } - } catch (const MmsTransportCancelledError& exception) { - state_ = MmsAssociationRuntimeState::faulted; - last_fault_ = exception.what(); - add_event(MmsAssociationEventKind::cancelled, exception.what()); - transport_.close(); - throw; - } catch (const MmsTransportTimeoutError& exception) { - state_ = MmsAssociationRuntimeState::faulted; - last_fault_ = exception.what(); - add_event(MmsAssociationEventKind::timed_out, exception.what()); - transport_.close(); - throw; - } catch (const std::exception& exception) { - if (stop_token.stop_requested()) { + negotiated_.presentation_context_id = options_.presentation_context_id; + state_ = MmsAssociationRuntimeState::associated; + active_association_profile_ = profile.name; + last_fault_.clear(); + association_attempts_.push_back( + {profile.name, true, acceptance_message}); + add_event( + MmsAssociationEventKind::association_accepted, + acceptance_message); + static_cast(used_csharp_compatibility); + return; + } catch (const MmsTransportCancelledError& exception) { state_ = MmsAssociationRuntimeState::faulted; last_fault_ = exception.what(); add_event(MmsAssociationEventKind::cancelled, exception.what()); - } else { - fail(exception.what()); + transport_.close(); + throw; + } catch (const MmsTransportTimeoutError& exception) { + const auto message = + std::string{profile.name} + ": timeout: " + exception.what(); + association_attempts_.push_back({profile.name, false, message}); + state_ = MmsAssociationRuntimeState::faulted; + last_fault_ = message; + add_event(MmsAssociationEventKind::timed_out, message); + transport_.close(); + } catch (const std::exception& exception) { + if (stop_token.stop_requested()) { + const auto message = + std::string{profile.name} + ": cancelled: " + exception.what(); + state_ = MmsAssociationRuntimeState::faulted; + last_fault_ = message; + add_event(MmsAssociationEventKind::cancelled, message); + transport_.close(); + throw MmsTransportCancelledError(message); + } + + const auto message = + std::string{profile.name} + ": " + exception.what(); + association_attempts_.push_back({profile.name, false, message}); + state_ = MmsAssociationRuntimeState::faulted; + last_fault_ = message; + add_event(MmsAssociationEventKind::faulted, message); + transport_.close(); + } + } + + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; + state_ = MmsAssociationRuntimeState::faulted; + + std::string message = "All C# MMS association profiles failed"; + if (!association_attempts_.empty()) { + message += ": "; + for (std::size_t index = 0U; index < association_attempts_.size(); ++index) { + if (index != 0U) { + message += " | "; + } + message += association_attempts_[index].message; } - transport_.close(); - throw; } + message += "."; + last_fault_ = message; + add_event(MmsAssociationEventKind::faulted, message); + throw MmsAssociationRuntimeError(message); } void MmsAssociationRuntime::disconnect(const std::stop_token stop_token) noexcept { From dcfef8140ff066a9b3c2ef1b94e2318affb75229 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:02:35 +0700 Subject: [PATCH 130/179] Regress full-reconnect association profile fallback --- tests/test_mms_runtime.cpp | 99 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index 7be150d15..e60f6519e 100644 --- a/tests/test_mms_runtime.cpp +++ b/tests/test_mms_runtime.cpp @@ -46,6 +46,45 @@ void check_throws(Callable&& callable) { throw std::runtime_error("Expected exception was not thrown."); } +ByteVector from_hex(const std::string& text) { + ByteVector result; + int high = -1; + for (const auto ch : text) { + int value = -1; + if (ch >= '0' && ch <= '9') { + value = ch - '0'; + } else if (ch >= 'A' && ch <= 'F') { + value = 10 + ch - 'A'; + } else if (ch >= 'a' && ch <= 'f') { + value = 10 + ch - 'a'; + } else { + continue; + } + if (high < 0) { + high = value; + } else { + result.push_back(static_cast((high << 4) | value)); + high = -1; + } + } + if (high >= 0) { + throw std::invalid_argument("Odd-length hexadecimal test vector."); + } + return result; +} + +[[nodiscard]] ByteVector csharp_legacy_minimal_request() { + return from_hex( + "0D B2 05 06 13 01 00 16 01 02 14 02 00 02 33 02 00 01 34 02 00 01 C1 9C " + "31 81 99 A0 03 80 01 01 A2 81 91 81 04 00 00 00 01 82 04 00 00 00 01 " + "A4 23 30 0F 02 01 01 06 04 52 01 00 01 30 04 06 02 51 01 30 10 02 01 03 " + "06 05 28 CA 22 02 01 30 04 06 02 51 01 61 5E 30 5C 02 01 01 A0 57 60 55 " + "A1 07 06 05 28 CA 22 02 03 A2 02 06 00 A3 03 02 01 0C " + "A6 07 06 05 29 87 67 01 01 A7 03 02 01 0C BE 33 28 31 06 02 51 01 02 01 03 " + "A0 28 A8 26 80 03 00 FD E8 81 01 0A 82 01 0A 83 01 06 A4 16 80 01 01 " + "81 03 05 F1 00 82 0C 03 EE 08 00 00 04 00 00 00 01 E7 18"); +} + class ScriptedTransport final : public mms::MmsByteTransport { public: void connect( @@ -120,17 +159,39 @@ class ScriptedTransport final : public mms::MmsByteTransport { return osi::TpktFrameCodec::encode(cotp); } -void queue_handshake(ScriptedTransport& transport) { +[[nodiscard]] ByteVector unwrap_sent_application(const ByteVector& tpkt_bytes) { + const auto tpkt = osi::TpktFrameCodec::decode(tpkt_bytes); + const auto cotp = osi::CotpFrameCodec::decode(tpkt.payload); + CHECK(cotp.kind == osi::CotpTpduKind::data); + CHECK(cotp.end_of_transmission); + return cotp.user_data; +} + +void queue_association_attempt( + ScriptedTransport& transport, + const std::span request_bytes, + const bool accepted) { const auto confirm = osi::CotpFrameCodec::encode_connection_confirm( 0x0001U, 0x2345U, 0x0AU); transport.push_receive(osi::TpktFrameCodec::encode(confirm)); - const auto request_bytes = acse::AcseAssociationCodec::build_default_association_request(); - const auto request = acse::AcseAssociationCodec::decode_association_request(request_bytes); - const auto response = acse::AcseAssociationCodec::build_accept_response(request); + const auto request = acse::AcseAssociationCodec::decode_association_request( + request_bytes); + auto aare = acse::AcseAssociationCodec::default_accept_aare(); + if (!accepted) { + aare.result = 1U; + aare.result_source_diagnostic = 1U; + } + const auto response = acse::AcseAssociationCodec::build_accept_response( + request, aare); transport.push_receive(wrap_application(response.payload)); } +void queue_handshake(ScriptedTransport& transport) { + const auto request_bytes = acse::AcseAssociationCodec::build_default_association_request(); + queue_association_attempt(transport, request_bytes, true); +} + [[nodiscard]] mms::MmsInformationReport make_report() { const ByteVector options{0x7FU, 0x80U}; const ByteVector time{0x01U, 0x02U, 0x03U, 0x04U, 0x05U, 0x06U}; @@ -164,6 +225,9 @@ void association_lifecycle_routes_reports_and_confirmed_results() { CHECK(runtime.associated()); CHECK(runtime.negotiated().maximum_mms_pdu_size == 65'000U); CHECK(transport.connect_count() == 1U); + CHECK(runtime.active_association_profile() == "BalancedApTitle"); + CHECK(runtime.association_attempts().size() == 1U); + CHECK(runtime.association_attempts().front().accepted); const auto report = mms::MmsInformationReportCodec::encode_p_data(make_report()); transport.push_receive(wrap_application(report)); @@ -196,6 +260,30 @@ void association_lifecycle_routes_reports_and_confirmed_results() { CHECK(transport.close_count() >= 1U); } +void association_retries_legacy_profile_after_balanced_rejection() { + ScriptedTransport transport; + const auto balanced = acse::AcseAssociationCodec::build_default_association_request(); + const auto legacy = csharp_legacy_minimal_request(); + + queue_association_attempt(transport, balanced, false); + queue_association_attempt(transport, legacy, true); + + mms::MmsAssociationRuntime runtime{transport}; + runtime.connect({"127.0.0.1", 102U}); + + CHECK(runtime.associated()); + CHECK(runtime.active_association_profile() == "LegacyMinimal"); + CHECK(runtime.association_attempts().size() == 2U); + CHECK(runtime.association_attempts()[0].profile_name == "BalancedApTitle"); + CHECK(!runtime.association_attempts()[0].accepted); + CHECK(runtime.association_attempts()[1].profile_name == "LegacyMinimal"); + CHECK(runtime.association_attempts()[1].accepted); + CHECK(transport.connect_count() == 2U); + CHECK(transport.sent().size() == 4U); + CHECK(unwrap_sent_application(transport.sent()[1]) == balanced); + CHECK(unwrap_sent_application(transport.sent()[3]) == legacy); +} + void association_rejects_cancelled_connect_and_can_reconnect() { ScriptedTransport transport; mms::MmsAssociationRuntime runtime{transport}; @@ -205,6 +293,7 @@ void association_rejects_cancelled_connect_and_can_reconnect() { runtime.connect({"127.0.0.1", 102U}, source.get_token()); }); CHECK(runtime.state() == mms::MmsAssociationRuntimeState::faulted); + CHECK(runtime.association_attempts().empty()); queue_handshake(transport); runtime.connect({"127.0.0.1", 102U}); @@ -212,7 +301,6 @@ void association_rejects_cancelled_connect_and_can_reconnect() { CHECK(transport.connect_count() == 1U); } - void confirmed_exchange_timeout_faults_association() { ScriptedTransport transport; queue_handshake(transport); @@ -384,6 +472,7 @@ void subscription_does_not_take_over_an_enabled_rcb() { int main() { const std::vector>> tests{ {"association lifecycle and routing", association_lifecycle_routes_reports_and_confirmed_results}, + {"association profile fallback", association_retries_legacy_profile_after_balanced_rejection}, {"association cancellation and reconnect", association_rejects_cancelled_connect_and_can_reconnect}, {"confirmed exchange timeout", confirmed_exchange_timeout_faults_association}, {"report subscription lifecycle", subscription_runtime_reserves_enables_receives_and_cleans_up}, From 2d8c6f05c13870b5ba700433f3b6b77f10a27a83 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:05:52 +0700 Subject: [PATCH 131/179] Carry association profile evidence in live discovery results --- include/ariec61850/mms/live_discovery.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp index dc67dbd67..2e0eac1bd 100644 --- a/include/ariec61850/mms/live_discovery.hpp +++ b/include/ariec61850/mms/live_discovery.hpp @@ -66,6 +66,8 @@ struct MmsReportControlEvidence final { struct MmsLiveDiscoveryResult final { MmsEndpoint endpoint; + std::string association_profile; + std::vector association_attempts; MmsDiscoverySnapshot names; MmsReportInventory report_inventory; std::vector variable_types; From eaa97e15df7954dae3e1a894d3ae06069b9710a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:07:02 +0700 Subject: [PATCH 132/179] Expose association profile in live discovery summary --- src/mms/live_discovery.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index f5609df13..d362fc628 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -186,7 +186,10 @@ std::string MmsLiveDiscoveryResult::summary() const { std::ostringstream stream; stream << "Live read-only MMS discovery: endpoint=" << endpoint.host << ':' - << endpoint.port << ", domains=" << domain_count() + << endpoint.port << ", associationProfile=" + << (association_profile.empty() ? std::string{"unknown"} : association_profile) + << ", associationAttempts=" << association_attempts.size() + << ", domains=" << domain_count() << ", variables=" << variable_count() << ", DataSets=" << report_inventory.data_sets.size() << ", RCBs=" << report_inventory.report_controls.size() @@ -271,6 +274,8 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( MmsLiveDiscoveryResult result; result.endpoint = association_.endpoint(); + result.association_profile = association_.active_association_profile(); + result.association_attempts = association_.association_attempts(); const auto domains = get_name_list( MmsGetNameListObjectClass::domain, {}, options, stop_token); From f7d410591ff497c0c27bf98eabf765d469abbda5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:42:50 +0700 Subject: [PATCH 133/179] Add read-only RCB contention probe parity model --- include/ariec61850/mms/rcb_contention.hpp | 341 ++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 include/ariec61850/mms/rcb_contention.hpp diff --git a/include/ariec61850/mms/rcb_contention.hpp b/include/ariec61850/mms/rcb_contention.hpp new file mode 100644 index 000000000..e8ebc163f --- /dev/null +++ b/include/ariec61850/mms/rcb_contention.hpp @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsRcbContentionProbeObservation final { + std::size_t probe_number{}; + std::chrono::system_clock::time_point captured_at{}; + std::string rcb_reference; + std::string rpt_ena; + std::string resv; + std::string resv_tms; + std::string data_set_reference; + std::string conf_rev; + std::string message; +}; + +struct MmsRcbContentionProbeResult final { + std::string rcb_reference; + bool is_contended{}; + bool is_busy_at_probe{}; + bool is_flapping{}; + int cooldown_seconds{}; + std::string decision; + std::string reason; + std::string recommended_action; + std::vector observations; +}; + +struct MmsRcbContentionProbeOptions final { + std::size_t probe_count{1U}; + std::chrono::milliseconds probe_delay{1'000}; + int cooldown_seconds{60}; +}; + +class MmsRcbContentionProbeError final : public MmsAssociationRuntimeError { +public: + using MmsAssociationRuntimeError::MmsAssociationRuntimeError; +}; + +class MmsRcbContentionProbeEvaluator final { +public: + [[nodiscard]] static MmsRcbContentionProbeResult evaluate( + std::string rcb_reference, + std::span observations, + const int cooldown_seconds = 60) { + if (cooldown_seconds < 0) { + throw std::invalid_argument( + "RCB contention cooldown must be greater than or equal to zero."); + } + + const auto rpt_ena_states = distinct_normalized( + observations, [](const auto& observation) { + return observation.rpt_ena; + }); + const auto reservation_states = distinct_normalized( + observations, [](const auto& observation) { + return normalize_probe_value(observation.resv_tms).empty() + ? observation.resv + : observation.resv_tms; + }); + const auto data_sets = distinct_normalized( + observations, [](const auto& observation) { + return observation.data_set_reference; + }); + const auto conf_revs = distinct_normalized( + observations, [](const auto& observation) { + return observation.conf_rev; + }); + + const bool is_busy = std::any_of( + observations.begin(), observations.end(), [](const auto& observation) { + return is_true_like(observation.rpt_ena) || + is_true_like(observation.resv) || + is_positive_integer(observation.resv_tms); + }); + const bool is_flapping = rpt_ena_states.size() > 1U || + reservation_states.size() > 1U || + data_sets.size() > 1U || + conf_revs.size() > 1U; + const bool is_contended = is_busy || is_flapping; + + MmsRcbContentionProbeResult result; + result.rcb_reference = std::move(rcb_reference); + result.is_contended = is_contended; + result.is_busy_at_probe = is_busy; + result.is_flapping = is_flapping; + result.cooldown_seconds = is_contended ? cooldown_seconds : 0; + result.decision = is_contended ? "CooldownSkip" : "StableProceed"; + result.reason = is_flapping + ? "RCB state changed across pre-claim probes. Treat as contended/flapping to avoid fighting another client." + : is_busy + ? "RCB became busy/reserved during pre-claim probes. Treat as owned by another client and skip." + : "RCB remained stable and free across pre-claim probes."; + result.recommended_action = is_contended + ? "Do not write RptEna/DatSet on this RCB in the current command; try the next candidate or polling fallback." + : "Safe to continue with guarded claim attempt."; + result.observations.assign(observations.begin(), observations.end()); + return result; + } + +private: + [[nodiscard]] static std::string trim(std::string value) { + const auto not_space = [](const unsigned char character) { + return std::isspace(character) == 0; + }; + const auto first = std::find_if( + value.begin(), value.end(), [&](const char character) { + return not_space(static_cast(character)); + }); + const auto last = std::find_if( + value.rbegin(), value.rend(), [&](const char character) { + return not_space(static_cast(character)); + }).base(); + if (first >= last) { + return {}; + } + return std::string{first, last}; + } + + [[nodiscard]] static 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]] static std::string normalize_probe_value( + const std::string_view value) { + auto text = trim(std::string{value}); + return text == "-" ? std::string{} : text; + } + + [[nodiscard]] static bool is_true_like(const std::string_view value) { + const auto text = lower_ascii(trim(std::string{value})); + return text == "true" || text == "1" || text == "yes" || text == "on"; + } + + [[nodiscard]] static bool is_positive_integer(const std::string_view value) { + const auto text = trim(std::string{value}); + if (text.empty()) { + return false; + } + std::uint64_t number{}; + const auto* begin = text.data(); + const auto* end = text.data() + text.size(); + const auto result = std::from_chars(begin, end, number, 10); + return result.ec == std::errc{} && result.ptr == end && number > 0U; + } + + template + [[nodiscard]] static std::vector distinct_normalized( + std::span observations, + Projection projection) { + std::vector result; + for (const auto& observation : observations) { + auto normalized = normalize_probe_value(projection(observation)); + if (normalized.empty()) { + continue; + } + const auto normalized_lower = lower_ascii(normalized); + const auto duplicate = std::any_of( + result.begin(), result.end(), [&](const auto& existing) { + return lower_ascii(existing) == normalized_lower; + }); + if (!duplicate) { + result.push_back(std::move(normalized)); + } + } + return result; + } +}; + +// Read-only runtime counterpart of ARIEC61850 ProbeSelectedRcbContentionAsync. +// It performs repeated MMS Read requests only. It never writes DatSet/RptEna, +// reserves an RCB, sends GI, or mutates a DataSet. +class MmsRcbContentionProbeClient final { +public: + explicit MmsRcbContentionProbeClient(MmsAssociationRuntime& association) + : association_{association} {} + + [[nodiscard]] MmsRcbContentionProbeResult probe( + const MmsReportControlCandidate& candidate, + const MmsRcbContentionProbeOptions& options = {}, + const std::stop_token stop_token = {}) { + validate_options(options); + if (!association_.associated()) { + throw MmsRcbContentionProbeError( + "RCB contention probe requires an active MMS association."); + } + + const auto attributes = select_attributes(candidate); + if (attributes.empty()) { + throw MmsRcbContentionProbeError( + "RCB contention probe found none of DatSet/ConfRev/RptEna/Resv/ResvTms on " + + candidate.reference + '.'); + } + + std::vector observations; + observations.reserve(options.probe_count); + for (std::size_t index = 1U; index <= options.probe_count; ++index) { + if (stop_token.stop_requested()) { + throw MmsRcbContentionProbeError("RCB contention probe cancelled."); + } + + const auto state = read_state(candidate, attributes, stop_token); + MmsRcbContentionProbeObservation observation; + observation.probe_number = index; + observation.captured_at = std::chrono::system_clock::now(); + observation.rcb_reference = candidate.reference; + observation.rpt_ena = bool_text(state.report_enabled); + observation.resv = bool_text(state.reserved); + observation.resv_tms = unsigned_text(state.reservation_time_seconds); + observation.data_set_reference = state.data_set_reference; + observation.conf_rev = unsigned_text(state.configuration_revision); + if (!state.diagnostics.empty()) { + observation.message = state.diagnostics.back(); + } + observations.push_back(std::move(observation)); + + if (index < options.probe_count && options.probe_delay.count() > 0) { + cancellable_sleep(options.probe_delay, stop_token); + } + } + + return MmsRcbContentionProbeEvaluator::evaluate( + candidate.reference, observations, options.cooldown_seconds); + } + +private: + static void validate_options(const MmsRcbContentionProbeOptions& options) { + if (options.probe_count == 0U) { + throw std::invalid_argument("RCB contention probe count must be at least one."); + } + if (options.probe_delay.count() < 0) { + throw std::invalid_argument( + "RCB contention probe delay must be greater than or equal to zero."); + } + if (options.cooldown_seconds < 0) { + throw std::invalid_argument( + "RCB contention cooldown must be greater than or equal to zero."); + } + } + + [[nodiscard]] static std::vector select_attributes( + const MmsReportControlCandidate& candidate) { + static constexpr std::string_view wanted[]{ + "DatSet", "ConfRev", "RptEna", "Resv", "ResvTms"}; + std::vector result; + for (const auto name : wanted) { + const auto found = std::find( + candidate.attributes.begin(), candidate.attributes.end(), name); + if (found != candidate.attributes.end()) { + result.emplace_back(name); + } + } + return result; + } + + [[nodiscard]] MmsReportControlState read_state( + const MmsReportControlCandidate& candidate, + const std::vector& attributes, + const std::stop_token stop_token) { + const auto invoke_id = association_.next_invoke_id(); + const auto request = MmsReportControlStateMapper::build_read_request( + invoke_id, candidate, attributes); + const auto encoded = MmsServiceCodec::encode_read_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, invoke_id, stop_token); + if (exchange.envelope.kind != MmsPduKind::confirmed_response) { + throw MmsRcbContentionProbeError( + "Read RCB contention attributes returned a non-confirmed-response MMS PDU."); + } + + std::span payload; + if (!exchange.presentation_payload.empty()) { + payload = exchange.presentation_payload; + } else if (!exchange.envelope.mms_payload.empty()) { + payload = exchange.envelope.mms_payload; + } else { + throw MmsRcbContentionProbeError( + "Read RCB contention attributes returned no decodable MMS payload."); + } + + const auto response = MmsServiceCodec::decode_read_response(payload, invoke_id); + return MmsReportControlStateMapper::map_read_response( + candidate, attributes, response); + } + + static void cancellable_sleep( + const std::chrono::milliseconds duration, + const std::stop_token stop_token) { + using namespace std::chrono_literals; + auto remaining = duration; + while (remaining.count() > 0) { + if (stop_token.stop_requested()) { + throw MmsRcbContentionProbeError("RCB contention probe cancelled."); + } + const auto slice = std::min(remaining, 10ms); + std::this_thread::sleep_for(slice); + remaining -= slice; + } + } + + [[nodiscard]] static std::string bool_text(const std::optional value) { + if (!value) { + return {}; + } + return *value ? "true" : "false"; + } + + [[nodiscard]] static std::string unsigned_text( + const std::optional value) { + return value ? std::to_string(*value) : std::string{}; + } + + MmsAssociationRuntime& association_; +}; + +} // namespace ar::iec61850::mms From f19cf0bbac86ddc850125272170a73ee4e2390f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:43:32 +0700 Subject: [PATCH 134/179] Test C# parity RCB contention decisions --- tests/test_mms.cpp | 105 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/tests/test_mms.cpp b/tests/test_mms.cpp index 565d309d3..cbf6c849e 100644 --- a/tests/test_mms.cpp +++ b/tests/test_mms.cpp @@ -3,6 +3,7 @@ #include "ariec61850/asn1/ber.hpp" #include "ariec61850/mms/data_codec.hpp" #include "ariec61850/mms/data_value.hpp" +#include "ariec61850/mms/rcb_contention.hpp" #include "ariec61850/mms/utc_time.hpp" #include @@ -136,6 +137,103 @@ void mms_data_codec_rejects_truncated_nested_tlv() { CHECK(rejected); } +[[nodiscard]] ar::iec61850::mms::MmsRcbContentionProbeObservation contention_observation( + const std::size_t number, + std::string rpt_ena, + std::string resv, + std::string resv_tms, + std::string data_set, + std::string conf_rev) { + ar::iec61850::mms::MmsRcbContentionProbeObservation observation; + observation.probe_number = number; + observation.rcb_reference = "IEDLD0/LLN0.BR.brcb01"; + observation.rpt_ena = std::move(rpt_ena); + observation.resv = std::move(resv); + observation.resv_tms = std::move(resv_tms); + observation.data_set_reference = std::move(data_set); + observation.conf_rev = std::move(conf_rev); + return observation; +} + +void rcb_contention_stable_free_matches_csharp() { + using namespace ar::iec61850::mms; + const std::vector observations{ + contention_observation(1U, "false", "-", "0", "", "7"), + contention_observation(2U, "FALSE", "", "0", "-", "7"), + contention_observation(3U, "false", "", "0", "", "7")}; + + const auto result = MmsRcbContentionProbeEvaluator::evaluate( + "IEDLD0/LLN0.BR.brcb01", observations, 60); + CHECK(!result.is_contended); + CHECK(!result.is_busy_at_probe); + CHECK(!result.is_flapping); + CHECK(result.cooldown_seconds == 0); + CHECK(result.decision == "StableProceed"); + CHECK(result.reason == "RCB remained stable and free across pre-claim probes."); + CHECK(result.observations.size() == 3U); +} + +void rcb_contention_busy_matches_csharp() { + using namespace ar::iec61850::mms; + const std::vector observations{ + contention_observation(1U, "false", "false", "0", "", "7"), + contention_observation(2U, "yes", "false", "0", "", "7")}; + + const auto result = MmsRcbContentionProbeEvaluator::evaluate( + "IEDLD0/LLN0.BR.brcb01", observations, 45); + CHECK(result.is_contended); + CHECK(result.is_busy_at_probe); + CHECK(result.is_flapping); + CHECK(result.cooldown_seconds == 45); + CHECK(result.decision == "CooldownSkip"); +} + +void rcb_contention_positive_resvtms_is_busy_without_flapping() { + using namespace ar::iec61850::mms; + const std::vector observations{ + contention_observation(1U, "false", "false", "30", "", "7"), + contention_observation(2U, "false", "true", "30", "", "7")}; + + const auto result = MmsRcbContentionProbeEvaluator::evaluate( + "IEDLD0/LLN0.BR.brcb01", observations, 60); + CHECK(result.is_contended); + CHECK(result.is_busy_at_probe); + CHECK(!result.is_flapping); + CHECK(result.cooldown_seconds == 60); + CHECK(result.decision == "CooldownSkip"); +} + +void rcb_contention_dataset_or_confrev_change_flaps() { + using namespace ar::iec61850::mms; + const std::vector observations{ + contention_observation( + 1U, "false", "false", "0", "IEDLD0/LLN0$DataSetA", "7"), + contention_observation( + 2U, "false", "false", "0", "IEDLD0/LLN0$DataSetB", "8")}; + + const auto result = MmsRcbContentionProbeEvaluator::evaluate( + "IEDLD0/LLN0.BR.brcb01", observations, 60); + CHECK(result.is_contended); + CHECK(!result.is_busy_at_probe); + CHECK(result.is_flapping); + CHECK(result.decision == "CooldownSkip"); + CHECK(result.reason == + "RCB state changed across pre-claim probes. Treat as contended/flapping to avoid fighting another client."); +} + +void rcb_contention_resvtms_precedes_resv_for_flapping() { + using namespace ar::iec61850::mms; + const std::vector observations{ + contention_observation(1U, "false", "false", "0", "", "7"), + contention_observation(2U, "false", "true", "0", "", "7")}; + + const auto result = MmsRcbContentionProbeEvaluator::evaluate( + "IEDLD0/LLN0.BR.brcb01", observations, 60); + CHECK(result.is_contended); + CHECK(result.is_busy_at_probe); + CHECK(!result.is_flapping); +} + } // namespace int main() { @@ -143,7 +241,12 @@ int main() { {"IEC 61850 UTC time", iec61850_utc_time_matches_csharp_vector}, {"MMS data golden vector", mms_data_codec_matches_csharp_golden_vector}, {"MMS bit string and unknown", mms_data_codec_preserves_bit_strings_and_unknown_tags}, - {"MMS malformed nested TLV", mms_data_codec_rejects_truncated_nested_tlv}}; + {"MMS malformed nested TLV", mms_data_codec_rejects_truncated_nested_tlv}, + {"RCB contention stable free", rcb_contention_stable_free_matches_csharp}, + {"RCB contention busy", rcb_contention_busy_matches_csharp}, + {"RCB contention ResvTms busy", rcb_contention_positive_resvtms_is_busy_without_flapping}, + {"RCB contention flapping", rcb_contention_dataset_or_confrev_change_flaps}, + {"RCB contention ResvTms precedence", rcb_contention_resvtms_precedes_resv_for_flapping}}; std::size_t passed = 0; for (const auto& [name, test] : tests) { From eeef51264ba95cf6fc0d1140335b400d58c78252 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:45:15 +0700 Subject: [PATCH 135/179] Add read-only live RCB contention probe tool --- tools/rcb_contention_probe.cpp | 245 +++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 tools/rcb_contention_probe.cpp diff --git a/tools/rcb_contention_probe.cpp b/tools/rcb_contention_probe.cpp new file mode 100644 index 000000000..371ad8054 --- /dev/null +++ b/tools/rcb_contention_probe.cpp @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/rcb_contention.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +void print_usage() { + std::cout + << "Usage: ariec61850_rcb_contention_probe [port] [options]\n" + << "Options:\n" + << " --rcb REF Probe this exact RCB reference; default is first discovered RCB.\n" + << " --probe-count N Number of read-only probes (C# default 1).\n" + << " --probe-delay-ms N Delay between probes (C# default 1000 ms).\n" + << " --cooldown-sec N Evidence cooldown if contended (C# default 60 s).\n" + << " --timeout-ms N Association/request timeout (default 5000 ms).\n" + << "\n" + << "This tool sends only discovery GetNameList plus repeated MMS Read requests.\n" + << "It never sends Write, Resv, RptEna, GI, control, or DataSet mutation.\n"; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::size_t parse_positive_size( + const std::string& option, + const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive bounded integer."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::uint64_t parse_nonnegative_u64( + const std::string& option, + const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size()) { + throw std::invalid_argument(option + " requires a non-negative integer."); + } + return parsed; +} + +[[nodiscard]] std::string trim(std::string value) { + const auto not_space = [](const unsigned char character) { + return std::isspace(character) == 0; + }; + const auto first = std::find_if( + value.begin(), value.end(), [&](const char character) { + return not_space(static_cast(character)); + }); + const auto last = std::find_if( + value.rbegin(), value.rend(), [&](const char character) { + return not_space(static_cast(character)); + }).base(); + if (first >= last) { + return {}; + } + return std::string{first, last}; +} + +[[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]] std::string normalize_reference(std::string value) { + value = trim(std::move(value)); + std::replace(value.begin(), value.end(), '$', '.'); + return lower_ascii(std::move(value)); +} + +[[nodiscard]] const mms::MmsReportControlCandidate& select_candidate( + const mms::MmsLiveDiscoveryResult& discovery, + const std::string& requested_reference) { + if (discovery.report_inventory.report_controls.empty()) { + throw std::runtime_error("No RCB was discovered on the target IED."); + } + if (requested_reference.empty()) { + return discovery.report_inventory.report_controls.front(); + } + const auto requested = normalize_reference(requested_reference); + const auto found = std::find_if( + discovery.report_inventory.report_controls.begin(), + discovery.report_inventory.report_controls.end(), + [&](const auto& candidate) { + return normalize_reference(candidate.reference) == requested; + }); + if (found == discovery.report_inventory.report_controls.end()) { + throw std::invalid_argument( + "Requested RCB was not found in live inventory: " + requested_reference); + } + return *found; +} + +[[nodiscard]] std::string_view text_or_dash(const std::string& value) noexcept { + return value.empty() ? std::string_view{"-"} : std::string_view{value}; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 2) { + print_usage(); + return 2; + } + + mms::MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + endpoint.port = parse_port(argv[argument]); + ++argument; + } + + std::string requested_rcb; + mms::MmsRcbContentionProbeOptions probe_options; + std::chrono::milliseconds timeout{5'000}; + + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") { + print_usage(); + return 0; + } + if (option != "--rcb" && option != "--probe-count" && + option != "--probe-delay-ms" && option != "--cooldown-sec" && + option != "--timeout-ms") { + throw std::invalid_argument("Unknown option: " + option); + } + if (argument >= argc) { + throw std::invalid_argument("Missing value after " + option + '.'); + } + const std::string value = argv[argument++]; + if (option == "--rcb") { + requested_rcb = value; + } else if (option == "--probe-count") { + probe_options.probe_count = parse_positive_size(option, value); + } else if (option == "--probe-delay-ms") { + const auto parsed = parse_nonnegative_u64(option, value); + if (parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("--probe-delay-ms is too large."); + } + probe_options.probe_delay = std::chrono::milliseconds{ + static_cast(parsed)}; + } else if (option == "--cooldown-sec") { + const auto parsed = parse_nonnegative_u64(option, value); + if (parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("--cooldown-sec is too large."); + } + probe_options.cooldown_seconds = static_cast(parsed); + } else { + const auto parsed = parse_positive_size(option, value); + if (parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("--timeout-ms is too large."); + } + timeout = std::chrono::milliseconds{ + static_cast(parsed)}; + } + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = timeout; + association_options.request_timeout = timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + + mms::MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = false; + discovery_options.probe_report_controls = false; + const auto discovery = session.discover(discovery_options); + const auto& candidate = select_candidate(discovery, requested_rcb); + + mms::MmsRcbContentionProbeClient probe{session.association()}; + const auto result = probe.probe(candidate, probe_options); + session.disconnect(); + + std::cout << "Read-only RCB contention probe: endpoint=" + << endpoint.host << ':' << endpoint.port + << ", associationProfile=" + << (discovery.association_profile.empty() + ? std::string{"unknown"} + : discovery.association_profile) + << ", associationAttempts=" << discovery.association_attempts.size() + << ", RCB=" << result.rcb_reference + << ", probes=" << result.observations.size() + << ", busy=" << (result.is_busy_at_probe ? "true" : "false") + << ", flapping=" << (result.is_flapping ? "true" : "false") + << ", contended=" << (result.is_contended ? "true" : "false") + << ", decision=" << result.decision + << ", cooldownSec=" << result.cooldown_seconds << ".\n"; + + for (const auto& observation : result.observations) { + std::cout << " probe=" << observation.probe_number + << " RptEna=" << text_or_dash(observation.rpt_ena) + << " Resv=" << text_or_dash(observation.resv) + << " ResvTms=" << text_or_dash(observation.resv_tms) + << " DatSet=" << text_or_dash(observation.data_set_reference) + << " ConfRev=" << text_or_dash(observation.conf_rev); + if (!observation.message.empty()) { + std::cout << " message=" << observation.message; + } + std::cout << '\n'; + } + std::cout << "Reason: " << result.reason << '\n' + << "Recommended action: " << result.recommended_action << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << "RCB contention probe failed: " << error.what() << '\n'; + return 1; + } +} From c03f56628b397e67bf1000e11df46a114df8984f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:46:16 +0700 Subject: [PATCH 136/179] Build read-only RCB contention probe tool --- CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 90c79e8dd..2e69536fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,12 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_live_discover PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_live_discover) ariec61850_apply_sanitizers(ariec61850_live_discover) + + add_executable(ariec61850_rcb_contention_probe tools/rcb_contention_probe.cpp) + target_link_libraries(ariec61850_rcb_contention_probe PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_rcb_contention_probe PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_rcb_contention_probe) + ariec61850_apply_sanitizers(ariec61850_rcb_contention_probe) endif() if(ARIEC61850_BUILD_TESTS) From b2caf3e4ce1adb16d46854a72400df40b9c3a12f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:49:53 +0700 Subject: [PATCH 137/179] Match C# ResvTms contention normalization exactly --- include/ariec61850/mms/rcb_contention.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/rcb_contention.hpp b/include/ariec61850/mms/rcb_contention.hpp index e8ebc163f..e76224eb7 100644 --- a/include/ariec61850/mms/rcb_contention.hpp +++ b/include/ariec61850/mms/rcb_contention.hpp @@ -73,7 +73,9 @@ class MmsRcbContentionProbeEvaluator final { }); const auto reservation_states = distinct_normalized( observations, [](const auto& observation) { - return normalize_probe_value(observation.resv_tms).empty() + // C# chooses ResvTms using IsNullOrWhiteSpace before + // NormalizeProbeValue converts a literal "-" to empty. + return trim(observation.resv_tms).empty() ? observation.resv : observation.resv_tms; }); From a6c2cb086efbeea18f7c572e9cfe754b110996b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:50:37 +0700 Subject: [PATCH 138/179] Cover C# dash ResvTms contention edge case --- tests/test_mms.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_mms.cpp b/tests/test_mms.cpp index cbf6c849e..7fb3074d0 100644 --- a/tests/test_mms.cpp +++ b/tests/test_mms.cpp @@ -122,6 +122,7 @@ void mms_data_codec_preserves_bit_strings_and_unknown_tags() { const auto unknown_decoded = MmsDataCodec::decode_all(unknown_encoded); CHECK(unknown_decoded.size() == 1U); CHECK(unknown_decoded[0].kind() == MmsDataKind::unknown); + CHECK(decoded[0].kind() == MmsDataKind::unknown); CHECK(unknown_decoded[0].unknown_tag_number().value() == 45); CHECK(unknown_decoded[0].raw_value() == ByteVector({0xDEU, 0xADU})); } @@ -234,6 +235,21 @@ void rcb_contention_resvtms_precedes_resv_for_flapping() { CHECK(!result.is_flapping); } +void rcb_contention_dash_resvtms_does_not_fallback_to_resv() { + using namespace ar::iec61850::mms; + const std::vector observations{ + contention_observation(1U, "false", "false", "-", "", "7"), + contention_observation(2U, "false", "off", "-", "", "7")}; + + const auto result = MmsRcbContentionProbeEvaluator::evaluate( + "IEDLD0/LLN0.BR.brcb01", observations, 60); + CHECK(!result.is_contended); + CHECK(!result.is_busy_at_probe); + CHECK(!result.is_flapping); + CHECK(result.cooldown_seconds == 0); + CHECK(result.decision == "StableProceed"); +} + } // namespace int main() { @@ -246,7 +262,8 @@ int main() { {"RCB contention busy", rcb_contention_busy_matches_csharp}, {"RCB contention ResvTms busy", rcb_contention_positive_resvtms_is_busy_without_flapping}, {"RCB contention flapping", rcb_contention_dataset_or_confrev_change_flaps}, - {"RCB contention ResvTms precedence", rcb_contention_resvtms_precedes_resv_for_flapping}}; + {"RCB contention ResvTms precedence", rcb_contention_resvtms_precedes_resv_for_flapping}, + {"RCB contention dash ResvTms", rcb_contention_dash_resvtms_does_not_fallback_to_resv}}; std::size_t passed = 0; for (const auto& [name, test] : tests) { From 6974487c12d41f280b0845fa9cc887c3c9823039 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 10:51:34 +0700 Subject: [PATCH 139/179] Fix contention regression test typo --- tests/test_mms.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_mms.cpp b/tests/test_mms.cpp index 7fb3074d0..b9c9b23d4 100644 --- a/tests/test_mms.cpp +++ b/tests/test_mms.cpp @@ -122,7 +122,6 @@ void mms_data_codec_preserves_bit_strings_and_unknown_tags() { const auto unknown_decoded = MmsDataCodec::decode_all(unknown_encoded); CHECK(unknown_decoded.size() == 1U); CHECK(unknown_decoded[0].kind() == MmsDataKind::unknown); - CHECK(decoded[0].kind() == MmsDataKind::unknown); CHECK(unknown_decoded[0].unknown_tag_number().value() == 45); CHECK(unknown_decoded[0].raw_value() == ByteVector({0xDEU, 0xADU})); } From 3f7031aa77af7b581aec776c2a683088d99d51d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:05:28 +0700 Subject: [PATCH 140/179] Add Phase 4C physical acceptance orchestrator --- scripts/run-phase4c-physical-acceptance.py | 366 +++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 scripts/run-phase4c-physical-acceptance.py diff --git a/scripts/run-phase4c-physical-acceptance.py b/scripts/run-phase4c-physical-acceptance.py new file mode 100644 index 000000000..d947c8d41 --- /dev/null +++ b/scripts/run-phase4c-physical-acceptance.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Orchestrate bounded Phase 4C physical read-only acceptance evidence.""" +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import subprocess +import sys +import time +from typing import Any + + +CONTENTION_SUMMARY_RE = re.compile( + r"^Read-only RCB contention probe: endpoint=(?P.*?), " + r"associationProfile=(?P.*?), " + r"associationAttempts=(?P\d+), " + r"RCB=(?P.*?), probes=(?P\d+), " + r"busy=(?Ptrue|false), flapping=(?Ptrue|false), " + r"contended=(?Ptrue|false), decision=(?P.*?), " + r"cooldownSec=(?P\d+)\.$" +) + + +def find_binary(root: pathlib.Path, name: str, override: str | None) -> pathlib.Path: + if override: + path = pathlib.Path(override).resolve() + if not path.is_file(): + raise FileNotFoundError(f"Binary not found: {path}") + return path + + names = [f"{name}.exe", name] + candidates: list[pathlib.Path] = [] + for executable in names: + candidates.extend([ + root / "build" / "Release" / executable, + root / "build" / executable, + ]) + for candidate in candidates: + if candidate.is_file(): + return candidate + raise FileNotFoundError( + f"Build {name} first; searched build/Release and build." + ) + + +def run_process(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + + +def parse_contention_summary(stdout: str) -> dict[str, Any]: + first_line = stdout.splitlines()[0].strip() if stdout.splitlines() else "" + match = CONTENTION_SUMMARY_RE.match(first_line) + if not match: + raise RuntimeError("Contention probe output did not match the expected summary format.") + fields = match.groupdict() + return { + "endpoint": fields["endpoint"], + "associationProfile": fields["association_profile"], + "associationAttempts": int(fields["association_attempts"]), + "rcb": fields["rcb"], + "probeCount": int(fields["probes"]), + "busy": fields["busy"] == "true", + "flapping": fields["flapping"] == "true", + "contended": fields["contended"] == "true", + "decision": fields["decision"], + "cooldownSeconds": int(fields["cooldown"]), + } + + +def build_discovery_command( + root: pathlib.Path, + args: argparse.Namespace, + output_dir: pathlib.Path, +) -> list[str]: + command = [ + sys.executable, + str(root / "scripts" / "run-live-readonly-interop.py"), + args.host, + str(args.port), + "--output", + str(output_dir), + "--cycles", + str(args.cycles), + "--interval-ms", + str(args.interval_ms), + "--timeout-ms", + str(args.timeout_ms), + ] + if args.discovery_binary: + command += ["--binary", str(pathlib.Path(args.discovery_binary).resolve())] + if args.ied_name: + command += ["--ied-name", args.ied_name] + if args.expected_csharp_model: + command += [ + "--expected-csharp-model", + str(pathlib.Path(args.expected_csharp_model).resolve()), + ] + if args.parity_types: + command.append("--parity-types") + if args.parity_runtime: + command.append("--parity-runtime") + if args.allow_warnings or args.fast_readonly: + command.append("--allow-warnings") + if args.fast_readonly or args.no_types: + command.append("--no-types") + elif args.max_types is not None: + command += ["--max-types", str(args.max_types)] + if args.fast_readonly or args.no_datasets: + command.append("--no-datasets") + if args.no_rcb: + command.append("--no-rcb") + elif args.max_rcb is not None: + command += ["--max-rcb", str(args.max_rcb)] + elif args.fast_readonly: + command += ["--max-rcb", "10"] + if args.require_rcb_complete: + command.append("--require-rcb-complete") + return command + + +def run_contention_cycle( + binary: pathlib.Path, + args: argparse.Namespace, + cycle: int, + output_dir: pathlib.Path, +) -> dict[str, Any]: + command = [ + str(binary), + args.host, + str(args.port), + "--probe-count", + str(args.contention_probe_count), + "--probe-delay-ms", + str(args.contention_probe_delay_ms), + "--cooldown-sec", + str(args.contention_cooldown_sec), + "--timeout-ms", + str(args.timeout_ms), + ] + if args.contention_rcb: + command += ["--rcb", args.contention_rcb] + + started = time.monotonic() + process = run_process(command) + elapsed_ms = int((time.monotonic() - started) * 1000) + raw_path = output_dir / f"contention-{cycle:03d}.txt" + raw_path.write_text( + process.stdout + (("\n" + process.stderr) if process.stderr else ""), + encoding="utf-8", + ) + + evidence: dict[str, Any] = { + "index": cycle, + "success": process.returncode == 0, + "elapsedMs": elapsed_ms, + "exitCode": process.returncode, + "rawFile": raw_path.name, + } + if process.returncode != 0: + evidence["error"] = process.stderr.strip() or process.stdout.strip() + return evidence + + try: + evidence.update(parse_contention_summary(process.stdout)) + except Exception as exc: + evidence["success"] = False + evidence["error"] = str(exc) + return evidence + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Run repeated read-only MMS discovery/reassociation plus bounded RCB " + "contention probes and emit Phase 4C physical acceptance evidence." + ) + ) + parser.add_argument("host") + parser.add_argument("port", nargs="?", type=int, default=102) + parser.add_argument("--output", required=True) + parser.add_argument("--cycles", type=int, default=10) + parser.add_argument("--interval-ms", type=int, default=1000) + parser.add_argument("--timeout-ms", type=int, default=30000) + parser.add_argument("--ied-name", default="") + parser.add_argument("--discovery-binary") + parser.add_argument("--expected-csharp-model") + parser.add_argument("--parity-types", action="store_true") + parser.add_argument("--parity-runtime", action="store_true") + parser.add_argument("--allow-warnings", action="store_true") + parser.add_argument( + "--fast-readonly", + action="store_true", + help="Use --no-types --no-datasets --max-rcb 10 and allow expected warnings.", + ) + parser.add_argument("--no-types", action="store_true") + parser.add_argument("--max-types", type=int) + parser.add_argument("--no-datasets", action="store_true") + parser.add_argument("--no-rcb", action="store_true") + parser.add_argument("--max-rcb", type=int) + parser.add_argument("--require-rcb-complete", action="store_true") + parser.add_argument("--contention-binary") + parser.add_argument("--contention-cycles", type=int, default=3) + parser.add_argument("--contention-rcb", default="") + parser.add_argument("--contention-probe-count", type=int, default=3) + parser.add_argument("--contention-probe-delay-ms", type=int, default=1000) + parser.add_argument("--contention-cooldown-sec", type=int, default=60) + parser.add_argument( + "--allow-contended", + action="store_true", + help="Record contention but do not fail acceptance when an RCB is busy/flapping.", + ) + args = parser.parse_args() + + if not 1 <= args.port <= 65535: + parser.error("port must be 1..65535") + if not 1 <= args.cycles <= 10000: + parser.error("--cycles must be 1..10000") + if not 1 <= args.contention_cycles <= 10000: + parser.error("--contention-cycles must be 1..10000") + if args.interval_ms < 0 or args.timeout_ms <= 0: + parser.error("interval must be non-negative and timeout positive") + if args.contention_probe_count <= 0: + parser.error("--contention-probe-count must be positive") + if args.contention_probe_delay_ms < 0 or args.contention_cooldown_sec < 0: + parser.error("contention delay/cooldown must be non-negative") + if args.no_types and args.max_types is not None: + parser.error("--no-types cannot be combined with --max-types") + if args.no_rcb and args.max_rcb is not None: + parser.error("--no-rcb cannot be combined with --max-rcb") + if args.require_rcb_complete and (args.no_rcb or args.fast_readonly): + parser.error( + "--require-rcb-complete cannot be combined with --no-rcb/--fast-readonly" + ) + if (args.parity_types or args.parity_runtime) and not args.expected_csharp_model: + parser.error( + "--parity-types/--parity-runtime require --expected-csharp-model" + ) + + root = pathlib.Path(__file__).resolve().parents[1] + output_root = pathlib.Path(args.output).resolve() + discovery_dir = output_root / "discovery" + contention_dir = output_root / "contention" + discovery_dir.mkdir(parents=True, exist_ok=True) + contention_dir.mkdir(parents=True, exist_ok=True) + + contention_binary = find_binary( + root, + "ariec61850_rcb_contention_probe", + args.contention_binary, + ) + + discovery_command = build_discovery_command(root, args, discovery_dir) + discovery_process = run_process(discovery_command) + if discovery_process.stdout: + print(discovery_process.stdout, end="") + if discovery_process.stderr: + print(discovery_process.stderr, end="", file=sys.stderr) + + discovery_summary_path = discovery_dir / "interop-summary.json" + discovery_summary: dict[str, Any] = {} + if discovery_summary_path.is_file(): + discovery_summary = json.loads( + discovery_summary_path.read_text(encoding="utf-8") + ) + + contention_cycles: list[dict[str, Any]] = [] + for index in range(1, args.contention_cycles + 1): + evidence = run_contention_cycle( + contention_binary, + args, + index, + contention_dir, + ) + contention_cycles.append(evidence) + print( + f"Contention {index}/{args.contention_cycles}: " + f"success={evidence.get('success', False)} " + f"profile={evidence.get('associationProfile', '')} " + f"attempts={evidence.get('associationAttempts', 0)} " + f"contended={evidence.get('contended', False)} " + f"decision={evidence.get('decision', '')}" + ) + if index != args.contention_cycles and args.interval_ms: + time.sleep(args.interval_ms / 1000.0) + + discovery_cycles = discovery_summary.get("cycles", []) + successful_discovery_associations = sum( + 1 for cycle in discovery_cycles if cycle.get("success") + ) + successful_contention_associations = sum( + 1 for cycle in contention_cycles if cycle.get("success") + ) + contention_stable = all( + cycle.get("success") + and not cycle.get("contended", True) + and cycle.get("decision") == "StableProceed" + for cycle in contention_cycles + ) + contention_accepted = all( + cycle.get("success") for cycle in contention_cycles + ) and (args.allow_contended or contention_stable) + discovery_accepted = ( + discovery_process.returncode == 0 + and bool(discovery_summary.get("accepted")) + ) + fresh_association_count = ( + successful_discovery_associations + successful_contention_associations + ) + accepted = discovery_accepted and contention_accepted + + summary = { + "schemaVersion": "ariec61850-phase4c-physical-acceptance-v1", + "endpoint": f"{args.host}:{args.port}", + "readOnly": True, + "discovery": { + "accepted": discovery_accepted, + "exitCode": discovery_process.returncode, + "cycleCount": args.cycles, + "successfulFreshAssociations": successful_discovery_associations, + "summaryFile": str(discovery_summary_path.relative_to(output_root)), + }, + "contention": { + "accepted": contention_accepted, + "stable": contention_stable, + "allowContended": args.allow_contended, + "cycleCount": args.contention_cycles, + "successfulFreshAssociations": successful_contention_associations, + "probeCountPerCycle": args.contention_probe_count, + "cycles": contention_cycles, + }, + "reassociationEvidence": { + "freshProcessPerDiscoveryCycle": True, + "freshProcessPerContentionCycle": True, + "successfulFreshAssociationCount": fresh_association_count, + "expectedFreshAssociationCount": args.cycles + args.contention_cycles, + "accepted": ( + fresh_association_count == args.cycles + args.contention_cycles + ), + }, + "accepted": accepted, + } + summary_path = output_root / "phase4c-physical-acceptance-summary.json" + summary_path.write_text( + json.dumps(summary, indent=2) + "\n", + encoding="utf-8", + ) + print( + "Phase 4C physical read-only acceptance: " + + ("PASS" if accepted else "FAIL") + + f" (discovery={discovery_accepted}, contention={contention_accepted}, " + f"freshAssociations={fresh_association_count}/" + f"{args.cycles + args.contention_cycles})" + ) + print(f"Summary: {summary_path}") + return 0 if accepted else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From b9fd9e91f4957907d94af0cff2e3c5819a38236d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:05:45 +0700 Subject: [PATCH 141/179] Add Windows Phase 4C physical acceptance wrapper --- scripts/run-phase4c-physical-acceptance.ps1 | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 scripts/run-phase4c-physical-acceptance.ps1 diff --git a/scripts/run-phase4c-physical-acceptance.ps1 b/scripts/run-phase4c-physical-acceptance.ps1 new file mode 100644 index 000000000..5f0cfae9b --- /dev/null +++ b/scripts/run-phase4c-physical-acceptance.ps1 @@ -0,0 +1,50 @@ +param( + [Parameter(Mandatory = $true)][string]$HostName, + [int]$Port = 102, + [string]$OutputDirectory = ".\phase4c-evidence", + [int]$Cycles = 10, + [int]$ContentionCycles = 3, + [int]$IntervalMs = 1000, + [int]$TimeoutMs = 30000, + [string]$IedName = "", + [string]$ExpectedCSharpModel = "", + [string]$ContentionRcb = "", + [int]$ContentionProbeCount = 3, + [int]$ContentionProbeDelayMs = 1000, + [int]$ContentionCooldownSec = 60, + [switch]$FastReadOnly, + [switch]$AllowWarnings, + [switch]$AllowContended, + [switch]$ParityTypes, + [switch]$ParityRuntime +) + +$ErrorActionPreference = "Stop" + +$arguments = @( + "$PSScriptRoot\run-phase4c-physical-acceptance.py", + $HostName, + $Port, + "--output", $OutputDirectory, + "--cycles", $Cycles, + "--contention-cycles", $ContentionCycles, + "--interval-ms", $IntervalMs, + "--timeout-ms", $TimeoutMs, + "--contention-probe-count", $ContentionProbeCount, + "--contention-probe-delay-ms", $ContentionProbeDelayMs, + "--contention-cooldown-sec", $ContentionCooldownSec +) + +if ($IedName) { $arguments += @("--ied-name", $IedName) } +if ($ExpectedCSharpModel) { + $arguments += @("--expected-csharp-model", $ExpectedCSharpModel) +} +if ($ContentionRcb) { $arguments += @("--contention-rcb", $ContentionRcb) } +if ($FastReadOnly) { $arguments += "--fast-readonly" } +if ($AllowWarnings) { $arguments += "--allow-warnings" } +if ($AllowContended) { $arguments += "--allow-contended" } +if ($ParityTypes) { $arguments += "--parity-types" } +if ($ParityRuntime) { $arguments += "--parity-runtime" } + +python @arguments +exit $LASTEXITCODE From a66be108580d437dff7ba8f05f54d30dfb14a2c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:06:02 +0700 Subject: [PATCH 142/179] Validate Phase 4C acceptance orchestrator in CI --- .github/workflows/cpp-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 511b6bc0f..2cb7e5bc3 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -41,6 +41,8 @@ jobs: run: | python3 -m py_compile scripts/run-live-readonly-interop.py python3 scripts/run-live-readonly-interop.py --help > /dev/null + python3 -m py_compile scripts/run-phase4c-physical-acceptance.py + python3 scripts/run-phase4c-physical-acceptance.py --help > /dev/null windows: name: Windows / MSVC @@ -58,3 +60,5 @@ jobs: python tools/compare_live_model_json.py --self-test python -m py_compile scripts/run-live-readonly-interop.py python scripts/run-live-readonly-interop.py --help | Out-Null + python -m py_compile scripts/run-phase4c-physical-acceptance.py + python scripts/run-phase4c-physical-acceptance.py --help | Out-Null From c265263c16124b5c2bda8158ee639b82563a88c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:06:48 +0700 Subject: [PATCH 143/179] Record OCR7SR12 physical read-only evidence --- docs/PHYSICAL_EVIDENCE_OCR7SR12.md | 104 +++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/PHYSICAL_EVIDENCE_OCR7SR12.md diff --git a/docs/PHYSICAL_EVIDENCE_OCR7SR12.md b/docs/PHYSICAL_EVIDENCE_OCR7SR12.md new file mode 100644 index 000000000..f39f2a383 --- /dev/null +++ b/docs/PHYSICAL_EVIDENCE_OCR7SR12.md @@ -0,0 +1,104 @@ +# OCR7SR12 Physical Read-Only Interoperability Evidence + +## Status + +This document records physical evidence observed against the reachable `OCR7SR12` IED during +Phase 4C.1. It intentionally separates what has been physically demonstrated from what remains +pending. No Write, RCB reservation/enable, GI, control, dynamic DataSet mutation, or file-service +request is part of this evidence. + +## Target + +- IED identity reported by live MMS discovery: `OCR7SR12` +- MMS endpoint used during the recorded runs: `192.168.1.10:102` +- C++ branch: `agent/phase-4c-tcp-live-discovery` +- Physical evidence date: 2026-08-08 + +## Association and bounded discovery evidence + +A bounded live discovery run completed with: + +```text +associationProfile=BalancedApTitle +associationAttempts=1 +domains=4 +variables=9067 +DataSets=1 +RCBs=286 +rcbReads=10/10 +diagnostics=0 +``` + +The resulting structural inventory included: + +```text +LD=4 +LN=123 +DO=1186 +RCB=286 +SGCB=1 +RCBReadFailed=0 +``` + +The run used `--no-types --no-datasets --max-rcb 10`; therefore only ten RCB states were read. +All ten read RCBs were observed as `Unbound`, while the remaining 276 were intentionally +`NotRead`. This evidence must not be represented as 286/286 RCBs being unbound. + +`DATASET_MEMBERS_NOT_READ` was expected because DataSet directory reads were explicitly disabled. +`CONTROL_BLOCK_VALUE_READ_PENDING` records a known implementation gap: GO/SV/SG/LG attribute +names are inventoried, while deep values such as DatSet, GoID, svID, APPID, and multicast address +are not yet read by that discovery path. + +## Read-only RCB contention evidence + +A three-probe contention run against the first discovered URCB completed as: + +```text +associationProfile=BalancedApTitle +associationAttempts=1 +RCB=OCR7SR12CTRL/BI6GGIO1.urcbA01 +probes=3 +busy=false +flapping=false +contended=false +decision=StableProceed +cooldownSec=0 +``` + +All three observations were stable: + +```text +RptEna=false +Resv=false +ResvTms=- +DatSet=- +ConfRev=1 +``` + +This physically demonstrates the C#-parity pre-claim contention evaluator on this RCB. The tool +performed discovery plus repeated MMS Read requests only. `StableProceed` is evidence that the +selected RCB remained free and stable during the observation window; it is **not** authorization +to perform a claim/write in Phase 4C.1. + +## Physically demonstrated + +- TCP/TPKT/COTP/Session/Presentation/ACSE association to the real IED. +- `BalancedApTitle` accepted on the first association attempt by this IED. +- Live read-only MMS discovery of domains, variables, LD/LN/DO hierarchy, DataSet inventory, + RCB inventory, and bounded RCB state reads. +- Ten bounded RCB reads completed 10/10 with zero read failures. +- Three repeated read-only contention probes remained stable and free on + `OCR7SR12CTRL/BI6GGIO1.urcbA01`. + +## Still pending before Phase 4C.1 physical acceptance is complete + +- Primary-vendor ten-cycle fresh association/discovery run captured by the acceptance runner. +- Explicit slow/timeout failure followed by a clean reconnect. +- C# and C++ `live-ied-model-v1` exports against the same unchanged IED configuration and parity + review. +- Packet-capture reference where practical. +- Multi-vendor or additional vendor-simulator evidence. +- Deep GO/SV/SG/LG control-block value reader. + +Use `scripts/run-phase4c-physical-acceptance.py` or the Windows PowerShell wrapper to collect the +next repeated-association evidence without expanding the Phase 4C.1 read-only service boundary. From bc1cf90d428458ef266b4609fe5e86658549eab0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:20:09 +0700 Subject: [PATCH 144/179] Add generic read-only control-block reader --- include/ariec61850/mms/control_block_read.hpp | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 include/ariec61850/mms/control_block_read.hpp diff --git a/include/ariec61850/mms/control_block_read.hpp b/include/ariec61850/mms/control_block_read.hpp new file mode 100644 index 000000000..455b8c2f8 --- /dev/null +++ b/include/ariec61850/mms/control_block_read.hpp @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/reporting.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsControlBlockKind : std::uint8_t { + goose, + sampled_value, + setting_group, + log, +}; + +[[nodiscard]] inline std::string_view mms_control_block_kind_name( + const MmsControlBlockKind kind) noexcept { + switch (kind) { + case MmsControlBlockKind::goose: return "GSEControl"; + case MmsControlBlockKind::sampled_value: return "SampledValueControl"; + case MmsControlBlockKind::setting_group: return "SettingGroupControl"; + case MmsControlBlockKind::log: return "LogControl"; + } + return "UnknownControl"; +} + +struct MmsControlBlockAttributeCandidate final { + std::string attribute_path; + MmsObjectName variable; + + friend bool operator==( + const MmsControlBlockAttributeCandidate&, + const MmsControlBlockAttributeCandidate&) = default; +}; + +struct MmsControlBlockCandidate final { + MmsControlBlockKind kind{MmsControlBlockKind::goose}; + std::string domain; + std::string logical_node; + std::string functional_constraint; + std::string name; + std::string reference; + std::vector attributes; +}; + +class MmsControlBlockInventoryBuilder final { +public: + [[nodiscard]] static std::vector build( + const MmsDiscoverySnapshot& snapshot) { + std::vector result; + for (const auto& [domain, variables] : snapshot.domain_variables) { + for (const auto& item : variables) { + const auto parts = split(item, '$'); + if (parts.size() < 4U || parts[0].empty() || parts[1].empty() || + parts[2].empty() || parts[3].empty()) { + continue; + } + + const auto kind = classify(parts[1], parts[2]); + if (!kind) { + continue; + } + + const auto attribute_path = join_attribute_path(parts, 3U); + const auto existing = std::find_if( + result.begin(), result.end(), [&](const auto& candidate) { + return same(candidate.domain, domain) && + same(candidate.logical_node, parts[0]) && + same(candidate.functional_constraint, parts[1]) && + same(candidate.name, parts[2]); + }); + + MmsControlBlockCandidate* candidate{}; + if (existing == result.end()) { + MmsControlBlockCandidate created; + created.kind = *kind; + created.domain = domain; + created.logical_node = parts[0]; + created.functional_constraint = parts[1]; + created.name = parts[2]; + created.reference = domain + "/" + parts[0] + "." + + parts[1] + "." + parts[2]; + result.push_back(std::move(created)); + candidate = &result.back(); + } else { + candidate = &*existing; + } + + const auto duplicate = std::any_of( + candidate->attributes.begin(), candidate->attributes.end(), + [&](const auto& attribute) { + return same(attribute.variable.domain, domain) && + same(attribute.variable.item, item); + }); + if (!duplicate) { + candidate->attributes.push_back({ + attribute_path, + MmsObjectName::domain_specific(domain, item)}); + } + } + } + + for (auto& candidate : result) { + std::sort( + candidate.attributes.begin(), candidate.attributes.end(), + [](const auto& left, const auto& right) { + return lower(left.attribute_path) < lower(right.attribute_path); + }); + } + std::sort(result.begin(), result.end(), [](const auto& left, const auto& right) { + return lower(left.reference) < lower(right.reference); + }); + return result; + } + +private: + [[nodiscard]] static std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char c) { + return static_cast(std::tolower(static_cast(c))); + }); + return value; + } + + [[nodiscard]] static bool same( + const std::string_view left, + const std::string_view right) { + return lower(std::string{left}) == lower(std::string{right}); + } + + [[nodiscard]] static std::vector split( + const std::string_view value, + const char separator) { + std::vector result; + std::size_t start{}; + while (start <= value.size()) { + const auto end = value.find(separator, start); + result.emplace_back(value.substr( + start, + end == std::string_view::npos + ? std::string_view::npos + : end - start)); + if (end == std::string_view::npos) { + break; + } + start = end + 1U; + } + return result; + } + + [[nodiscard]] static std::string join_attribute_path( + const std::vector& parts, + const std::size_t first) { + std::string result; + for (std::size_t index = first; index < parts.size(); ++index) { + if (!result.empty()) { + result += '.'; + } + result += parts[index]; + } + return result; + } + + [[nodiscard]] static std::optional classify( + const std::string_view functional_constraint, + const std::string_view name) { + const auto fc = lower(std::string{functional_constraint}); + if (fc == "go") { + return MmsControlBlockKind::goose; + } + if (fc == "ms" || fc == "us") { + return MmsControlBlockKind::sampled_value; + } + if (fc == "sg" || fc == "se") { + return MmsControlBlockKind::setting_group; + } + if (fc == "sp" && same(name, "SGCB")) { + return MmsControlBlockKind::setting_group; + } + if (fc == "lg") { + return MmsControlBlockKind::log; + } + return std::nullopt; + } +}; + +struct MmsControlBlockAttributeReadEvidence final { + std::string attribute_path; + MmsObjectName variable; + std::optional value; + std::optional failure_code; + + [[nodiscard]] bool success() const noexcept { return value.has_value(); } +}; + +struct MmsControlBlockReadResult final { + MmsControlBlockCandidate candidate; + std::vector attributes; + std::string error; + + [[nodiscard]] std::size_t successful_attribute_count() const noexcept { + return static_cast(std::count_if( + attributes.begin(), attributes.end(), + [](const auto& attribute) { return attribute.success(); })); + } + + [[nodiscard]] bool exchange_success() const noexcept { + return error.empty(); + } + + [[nodiscard]] bool complete() const noexcept { + return exchange_success() && !attributes.empty() && + successful_attribute_count() == attributes.size(); + } +}; + +class MmsControlBlockReadError final : public MmsAssociationRuntimeError { +public: + using MmsAssociationRuntimeError::MmsAssociationRuntimeError; +}; + +// Generic read-only reader for GO/MS/US/SG/SE/SP(SGCB)/LG control blocks. +// Candidates come from exact GetNameList item names; the reader sends only MMS +// Read requests for those exact discovered variables. It never enables or +// writes a control block and never invents SCL-only APPID/MAC/VLAN evidence. +class MmsControlBlockReadClient final { +public: + explicit MmsControlBlockReadClient(MmsAssociationRuntime& association) + : association_{association} {} + + [[nodiscard]] MmsControlBlockReadResult read( + const MmsControlBlockCandidate& candidate, + const std::size_t maximum_attributes = 64U, + const std::stop_token stop_token = {}) { + if (!association_.associated()) { + throw MmsControlBlockReadError( + "Control-block read requires an active MMS association."); + } + if (maximum_attributes == 0U) { + throw std::invalid_argument( + "Control-block attribute read bound must be positive."); + } + + MmsControlBlockReadResult result; + result.candidate = candidate; + const auto count = std::min(maximum_attributes, candidate.attributes.size()); + if (count == 0U) { + result.error = "Control block has no exact MMS attributes to read."; + return result; + } + + MmsReadRequest request; + request.invoke_id = association_.next_invoke_id(); + request.variables.reserve(count); + for (std::size_t index = 0U; index < count; ++index) { + request.variables.push_back(candidate.attributes[index].variable); + } + + const auto encoded = MmsServiceCodec::encode_read_request_p_data( + request, association_.negotiated().presentation_context_id); + const auto exchange = association_.exchange_confirmed( + encoded, request.invoke_id, stop_token); + if (exchange.envelope.kind != MmsPduKind::confirmed_response) { + result.error = "Control-block Read returned non-confirmed-response MMS PDU."; + return result; + } + + std::span payload; + if (!exchange.presentation_payload.empty()) { + payload = exchange.presentation_payload; + } else if (!exchange.envelope.mms_payload.empty()) { + payload = exchange.envelope.mms_payload; + } else { + result.error = "Control-block Read returned no decodable MMS payload."; + return result; + } + + const auto response = MmsServiceCodec::decode_read_response( + payload, request.invoke_id); + result.attributes.reserve(count); + for (std::size_t index = 0U; index < count; ++index) { + MmsControlBlockAttributeReadEvidence evidence; + evidence.attribute_path = candidate.attributes[index].attribute_path; + evidence.variable = candidate.attributes[index].variable; + if (index < response.results.size()) { + evidence.value = response.results[index].value; + evidence.failure_code = response.results[index].failure_code; + } + result.attributes.push_back(std::move(evidence)); + } + if (response.results.size() != count) { + result.error = "Control-block Read result count did not match request count."; + } + return result; + } + +private: + MmsAssociationRuntime& association_; +}; + +} // namespace ar::iec61850::mms From 7aabb3b3804a9626ec9fefe39b49335115252105 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:20:49 +0700 Subject: [PATCH 145/179] Add live read-only control-block probe --- tools/control_block_read_probe.cpp | 277 +++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 tools/control_block_read_probe.cpp diff --git a/tools/control_block_read_probe.cpp b/tools/control_block_read_probe.cpp new file mode 100644 index 000000000..658e95a77 --- /dev/null +++ b/tools/control_block_read_probe.cpp @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/control_block_read.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::size_t parse_limit( + const std::string& option, + const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive bounded integer."); + } + return static_cast(parsed); +} + +[[nodiscard]] mms::MmsControlBlockKind parse_kind(const std::string& value) { + if (value == "goose" || value == "go") { + return mms::MmsControlBlockKind::goose; + } + if (value == "sv" || value == "sampled-value") { + return mms::MmsControlBlockKind::sampled_value; + } + if (value == "sg" || value == "setting-group") { + return mms::MmsControlBlockKind::setting_group; + } + if (value == "log" || value == "lg") { + return mms::MmsControlBlockKind::log; + } + throw std::invalid_argument( + "--kind requires goose, sv, sg, or log."); +} + +[[nodiscard]] std::string hex_bytes(const std::vector& bytes) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] std::string value_text(const mms::MmsDataValue& value) { + switch (value.kind()) { + case mms::MmsDataKind::boolean: + return std::get(value.value()) ? "true" : "false"; + case mms::MmsDataKind::integer: + return std::to_string(std::get(value.value())); + case mms::MmsDataKind::unsigned_integer: + return std::to_string(std::get(value.value())); + case mms::MmsDataKind::floating_point: { + std::ostringstream output; + if (const auto* number = std::get_if(&value.value())) { + output << *number; + } else if (const auto* number = std::get_if(&value.value())) { + output << *number; + } + return output.str(); + } + case mms::MmsDataKind::visible_string: + case mms::MmsDataKind::mms_string: + return std::get(value.value()); + case mms::MmsDataKind::array: + case mms::MmsDataKind::structure: { + std::ostringstream output; + output << '['; + for (std::size_t index = 0U; index < value.children().size(); ++index) { + if (index != 0U) { + output << ','; + } + output << value_text(value.children()[index]); + } + output << ']'; + return output.str(); + } + case mms::MmsDataKind::bit_string: + case mms::MmsDataKind::octet_string: + case mms::MmsDataKind::binary_time: + case mms::MmsDataKind::bcd: + case mms::MmsDataKind::boolean_array: + case mms::MmsDataKind::object_id: + case mms::MmsDataKind::unknown: + return "0x" + hex_bytes(value.raw_value()); + case mms::MmsDataKind::utc_time: + return ""; + } + return ""; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_control_block_read_probe [port] [options]\n" + << "Options:\n" + << " --control REF Read only this discovered control reference.\n" + << " --kind KIND Filter: goose, sv, sg, or log.\n" + << " --max-control-blocks N Bound control blocks read (default 10).\n" + << " --max-attributes N Bound attributes per block (default 64).\n" + << " --timeout-ms N Connect/request timeout (default 5000).\n\n" + << "This tool sends GetNameList plus MMS Read only. It never writes/enables " + << "a control block and does not infer SCL-only APPID/MAC/VLAN values.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 2) { + print_usage(); + return 2; + } + + mms::MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + endpoint.port = parse_port(argv[argument]); + ++argument; + } + + std::string selected_reference; + std::optional selected_kind; + std::size_t maximum_controls{10U}; + std::size_t maximum_attributes{64U}; + std::chrono::milliseconds timeout{5'000}; + + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") { + print_usage(); + return 0; + } + if (argument >= argc) { + throw std::invalid_argument(option + " requires a value."); + } + const std::string value = argv[argument++]; + if (option == "--control") { + selected_reference = value; + } else if (option == "--kind") { + selected_kind = parse_kind(value); + } else if (option == "--max-control-blocks") { + maximum_controls = parse_limit(option, value); + } else if (option == "--max-attributes") { + maximum_attributes = parse_limit(option, value); + } else if (option == "--timeout-ms") { + const auto parsed = parse_limit(option, value); + if (parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("--timeout-ms is too large."); + } + timeout = std::chrono::milliseconds{ + static_cast(parsed)}; + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = timeout; + association_options.request_timeout = timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + + mms::MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = false; + discovery_options.probe_report_controls = false; + const auto discovery = session.discover(discovery_options); + auto candidates = mms::MmsControlBlockInventoryBuilder::build(discovery.names); + + candidates.erase( + std::remove_if( + candidates.begin(), candidates.end(), [&](const auto& candidate) { + if (selected_kind && candidate.kind != *selected_kind) { + return true; + } + return !selected_reference.empty() && + candidate.reference != selected_reference; + }), + candidates.end()); + + std::cout << "Read-only control-block value probe: endpoint=" + << endpoint.host << ':' << endpoint.port + << ", associationProfile=" + << (discovery.association_profile.empty() + ? std::string{"unknown"} + : discovery.association_profile) + << ", associationAttempts=" << discovery.association_attempts.size() + << ", discovered=" << candidates.size() << ".\n"; + + if (candidates.empty()) { + session.disconnect(); + std::cout << "No matching GO/SV/SG/LG control blocks were exposed by live MMS NameList.\n"; + return selected_reference.empty() ? 0 : 3; + } + + mms::MmsControlBlockReadClient reader{session.association()}; + const auto count = std::min(maximum_controls, candidates.size()); + std::size_t complete{}; + std::size_t partial{}; + std::size_t failed{}; + for (std::size_t index = 0U; index < count; ++index) { + const auto result = reader.read( + candidates[index], maximum_attributes); + if (result.complete()) { + ++complete; + } else if (result.exchange_success() && + result.successful_attribute_count() != 0U) { + ++partial; + } else { + ++failed; + } + + std::cout << "CB " << result.candidate.reference + << " kind=" << mms::mms_control_block_kind_name( + result.candidate.kind) + << " attributes=" << result.successful_attribute_count() + << '/' << result.attributes.size() + << " status=" << (result.complete() ? "Complete" + : result.successful_attribute_count() != 0U ? "Partial" + : "Failed") << '\n'; + for (const auto& attribute : result.attributes) { + std::cout << " " << attribute.attribute_path << '='; + if (attribute.value) { + std::cout << value_text(*attribute.value); + } else if (attribute.failure_code) { + std::cout << "'; + } else { + std::cout << ""; + } + std::cout << " [" << attribute.variable.item << "]\n"; + } + if (!result.error.empty()) { + std::cout << " error=" << result.error << '\n'; + } + } + + session.disconnect(); + std::cout << "Control-block read summary: attempted=" << count + << ", complete=" << complete + << ", partial=" << partial + << ", failed=" << failed << ".\n"; + return failed == count ? 4 : 0; + } catch (const std::exception& exception) { + std::cerr << "Control-block read probe failed: " << exception.what() << '\n'; + return 1; + } +} From 466ad7b8d3eafe909dc485ae58bab86993d023de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:21:45 +0700 Subject: [PATCH 146/179] Add control-block inventory regression tests --- tests/test_control_block_read.cpp | 110 ++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_control_block_read.cpp diff --git a/tests/test_control_block_read.cpp b/tests/test_control_block_read.cpp new file mode 100644 index 000000000..2928559c3 --- /dev/null +++ b/tests/test_control_block_read.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/control_block_read.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +void inventory_groups_exact_live_name_list_items() { + using namespace ar::iec61850::mms; + MmsDiscoverySnapshot snapshot; + snapshot.domain_variables["IEDLD0"] = { + "LLN0$GO$gcb01$GoEna", + "LLN0$GO$gcb01$DatSet", + "LLN0$GO$gcb01$DstAddress$APPID", + "LLN0$MS$msvcb01$SvEna", + "LLN0$MS$msvcb01$SvID", + "LLN0$SP$SGCB$ActSG", + "LLN0$SP$OtherSetting$setVal", + "LLN0$LG$lcb01$LogEna", + "MMXU1$MX$A$phsA$cVal$mag$f"}; + + const auto controls = MmsControlBlockInventoryBuilder::build(snapshot); + CHECK(controls.size() == 4U); + + CHECK(controls[0].kind == MmsControlBlockKind::goose); + CHECK(controls[0].reference == "IEDLD0/LLN0.GO.gcb01"); + CHECK(controls[0].attributes.size() == 3U); + CHECK(controls[0].attributes[0].attribute_path == "DatSet"); + CHECK(controls[0].attributes[1].attribute_path == "DstAddress.APPID"); + CHECK(controls[0].attributes[1].variable.item == + "LLN0$GO$gcb01$DstAddress$APPID"); + CHECK(controls[0].attributes[2].attribute_path == "GoEna"); + + CHECK(controls[1].kind == MmsControlBlockKind::log); + CHECK(controls[1].reference == "IEDLD0/LLN0.LG.lcb01"); + + CHECK(controls[2].kind == MmsControlBlockKind::sampled_value); + CHECK(controls[2].reference == "IEDLD0/LLN0.MS.msvcb01"); + CHECK(controls[2].attributes.size() == 2U); + + CHECK(controls[3].kind == MmsControlBlockKind::setting_group); + CHECK(controls[3].reference == "IEDLD0/LLN0.SP.SGCB"); + CHECK(controls[3].attributes.size() == 1U); + CHECK(controls[3].attributes[0].attribute_path == "ActSG"); +} + +void inventory_supports_us_sg_and_se_functional_constraints() { + using namespace ar::iec61850::mms; + MmsDiscoverySnapshot snapshot; + snapshot.domain_variables["IEDLD1"] = { + "LLN0$US$usvcb01$SvID", + "LLN0$SG$SGCB$NumOfSG", + "LLN0$SE$SGCB$EditSG"}; + + const auto controls = MmsControlBlockInventoryBuilder::build(snapshot); + CHECK(controls.size() == 3U); + CHECK(controls[0].kind == MmsControlBlockKind::setting_group); + CHECK(controls[1].kind == MmsControlBlockKind::setting_group); + CHECK(controls[2].kind == MmsControlBlockKind::sampled_value); +} + +void inventory_deduplicates_exact_variables_case_insensitively() { + using namespace ar::iec61850::mms; + MmsDiscoverySnapshot snapshot; + snapshot.domain_variables["IEDLD0"] = { + "LLN0$GO$gcb01$DatSet", + "lln0$go$GCB01$dataset"}; + + const auto controls = MmsControlBlockInventoryBuilder::build(snapshot); + CHECK(controls.size() == 1U); + CHECK(controls[0].attributes.size() == 1U); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"group exact live control blocks", inventory_groups_exact_live_name_list_items}, + {"support US SG SE control blocks", inventory_supports_us_sg_and_se_functional_constraints}, + {"deduplicate control block variables", inventory_deduplicates_exact_variables_case_insensitively}}; + + std::size_t passed{}; + for (const auto& [name, test] : tests) { + try { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } catch (const std::exception& error) { + std::cerr << "[FAIL] " << name << ": " << error.what() << '\n'; + return 1; + } + } + std::cout << "Passed " << passed << '/' << tests.size() + << " control-block read tests.\n"; + return 0; +} From 5306335ff2d3d774fa667014bc09eebeb0eb10e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:22:27 +0700 Subject: [PATCH 147/179] Build control-block read probe and regression tests --- CMakeLists.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e69536fa..d8b773074 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,6 +118,12 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_rcb_contention_probe PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_rcb_contention_probe) ariec61850_apply_sanitizers(ariec61850_rcb_contention_probe) + + add_executable(ariec61850_control_block_read_probe tools/control_block_read_probe.cpp) + target_link_libraries(ariec61850_control_block_read_probe PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_control_block_read_probe PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_control_block_read_probe) + ariec61850_apply_sanitizers(ariec61850_control_block_read_probe) endif() if(ARIEC61850_BUILD_TESTS) @@ -230,6 +236,13 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_rcb_selection_tests) add_test(NAME ariec61850_rcb_selection_tests COMMAND ariec61850_rcb_selection_tests) + add_executable(ariec61850_control_block_read_tests tests/test_control_block_read.cpp) + target_link_libraries(ariec61850_control_block_read_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_control_block_read_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_control_block_read_tests) + ariec61850_apply_sanitizers(ariec61850_control_block_read_tests) + add_test(NAME ariec61850_control_block_read_tests COMMAND ariec61850_control_block_read_tests) + add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE From de4b57eaaf0331321107f354a25c0cfeff77c87b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:27:24 +0700 Subject: [PATCH 148/179] Deduplicate control-block attributes by semantic path --- include/ariec61850/mms/control_block_read.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/mms/control_block_read.hpp b/include/ariec61850/mms/control_block_read.hpp index 455b8c2f8..010ceebdc 100644 --- a/include/ariec61850/mms/control_block_read.hpp +++ b/include/ariec61850/mms/control_block_read.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -102,8 +103,7 @@ class MmsControlBlockInventoryBuilder final { const auto duplicate = std::any_of( candidate->attributes.begin(), candidate->attributes.end(), [&](const auto& attribute) { - return same(attribute.variable.domain, domain) && - same(attribute.variable.item, item); + return same(attribute.attribute_path, attribute_path); }); if (!duplicate) { candidate->attributes.push_back({ From 56fbf0bae57e167e55ba7ee62ab17ab7300372e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:32:25 +0700 Subject: [PATCH 149/179] Fix control-block dedup test identifier typo --- tests/test_control_block_read.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_control_block_read.cpp b/tests/test_control_block_read.cpp index 2928559c3..9f53e5d48 100644 --- a/tests/test_control_block_read.cpp +++ b/tests/test_control_block_read.cpp @@ -73,16 +73,18 @@ void inventory_supports_us_sg_and_se_functional_constraints() { CHECK(controls[2].kind == MmsControlBlockKind::sampled_value); } -void inventory_deduplicates_exact_variables_case_insensitively() { +void inventory_deduplicates_attribute_paths_case_insensitively() { using namespace ar::iec61850::mms; MmsDiscoverySnapshot snapshot; snapshot.domain_variables["IEDLD0"] = { "LLN0$GO$gcb01$DatSet", - "lln0$go$GCB01$dataset"}; + "lln0$go$GCB01$datset"}; const auto controls = MmsControlBlockInventoryBuilder::build(snapshot); CHECK(controls.size() == 1U); CHECK(controls[0].attributes.size() == 1U); + CHECK(controls[0].attributes[0].attribute_path == "DatSet"); + CHECK(controls[0].attributes[0].variable.item == "LLN0$GO$gcb01$DatSet"); } } // namespace @@ -91,7 +93,7 @@ int main() { const std::vector>> tests{ {"group exact live control blocks", inventory_groups_exact_live_name_list_items}, {"support US SG SE control blocks", inventory_supports_us_sg_and_se_functional_constraints}, - {"deduplicate control block variables", inventory_deduplicates_exact_variables_case_insensitively}}; + {"deduplicate control block attributes", inventory_deduplicates_attribute_paths_case_insensitively}}; std::size_t passed{}; for (const auto& [name, test] : tests) { From 1794c8ee0ca1bebcb89a6579e238a9ef7a617373 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 11:36:40 +0700 Subject: [PATCH 150/179] Fix MSVC control-block probe shadow warning --- tools/control_block_read_probe.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/control_block_read_probe.cpp b/tools/control_block_read_probe.cpp index 658e95a77..ec7cf4e4b 100644 --- a/tools/control_block_read_probe.cpp +++ b/tools/control_block_read_probe.cpp @@ -79,10 +79,10 @@ using namespace ar::iec61850; return std::to_string(std::get(value.value())); case mms::MmsDataKind::floating_point: { std::ostringstream output; - if (const auto* number = std::get_if(&value.value())) { - output << *number; - } else if (const auto* number = std::get_if(&value.value())) { - output << *number; + if (const auto* float_value = std::get_if(&value.value())) { + output << *float_value; + } else if (const auto* double_value = std::get_if(&value.value())) { + output << *double_value; } return output.str(); } From 3de262ed88daed60a2e3f5a7bd49330e17f4e5ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:31:05 +0700 Subject: [PATCH 151/179] Carry bounded control-block runtime evidence --- include/ariec61850/mms/live_discovery.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp index 2e0eac1bd..3d74b65a5 100644 --- a/include/ariec61850/mms/live_discovery.hpp +++ b/include/ariec61850/mms/live_discovery.hpp @@ -2,6 +2,7 @@ #pragma once #include "ariec61850/mms/association_runtime.hpp" +#include "ariec61850/mms/control_block_read.hpp" #include "ariec61850/mms/reporting.hpp" #include "ariec61850/mms/tcp_transport.hpp" @@ -73,6 +74,16 @@ struct MmsLiveDiscoveryResult final { std::vector variable_types; std::vector data_set_directories; std::vector report_controls; + + // Optional C++ extension beyond the C# inventory-only control-block model. + // The live_discover CLI fills this only when the bounded read-only deep + // reader is explicitly requested. Keeping the evidence separate from the + // structural NameList inventory prevents mutable runtime values from + // changing structural parity fingerprints. + bool control_block_value_reads_requested{}; + std::size_t control_block_value_candidate_count{}; + std::vector control_block_reads; + std::vector diagnostics; [[nodiscard]] std::size_t domain_count() const noexcept; From d83234534f498f31014ccc0d942deef9cca07a14 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:32:20 +0700 Subject: [PATCH 152/179] Project deep control-block reads into live model --- .../mms/control_block_model_evidence.hpp | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 include/ariec61850/mms/control_block_model_evidence.hpp diff --git a/include/ariec61850/mms/control_block_model_evidence.hpp b/include/ariec61850/mms/control_block_model_evidence.hpp new file mode 100644 index 000000000..8283bd68e --- /dev/null +++ b/include/ariec61850/mms/control_block_model_evidence.hpp @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace control_block_model_detail { + +[[nodiscard]] inline std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char c) { + return static_cast( + std::tolower(static_cast(c))); + }); + return value; +} + +[[nodiscard]] inline bool same( + const std::string_view left, + const std::string_view right) { + return lower(std::string{left}) == lower(std::string{right}); +} + +[[nodiscard]] inline bool path_matches_leaf( + const std::string_view path, + const std::string_view leaf) { + if (same(path, leaf)) { + return true; + } + const auto lowered_path = lower(std::string{path}); + const auto lowered_leaf = lower(std::string{leaf}); + return lowered_path.size() > lowered_leaf.size() && + lowered_path.ends_with("." + lowered_leaf); +} + +[[nodiscard]] inline std::string hex_bytes( + const std::vector& bytes) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] inline std::string value_text( + const MmsDataValue& value, + const std::size_t depth = 0U) { + if (depth > 8U) { + return ""; + } + + switch (value.kind()) { + case MmsDataKind::boolean: + return std::get(value.value()) ? "true" : "false"; + case MmsDataKind::integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::unsigned_integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::floating_point: { + std::ostringstream output; + if (const auto* float_value = std::get_if(&value.value())) { + output << *float_value; + } else if (const auto* double_value = std::get_if(&value.value())) { + output << *double_value; + } + return output.str(); + } + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: + return std::get(value.value()); + case MmsDataKind::utc_time: { + const auto& utc = std::get(value.value()); + const auto milliseconds = std::chrono::duration_cast( + utc.value.time_since_epoch()).count(); + return "utcMs=" + std::to_string(milliseconds) + + ";quality=" + std::to_string(utc.quality); + } + case MmsDataKind::array: + case MmsDataKind::structure: { + std::ostringstream output; + output << '['; + for (std::size_t index = 0U; index < value.children().size(); ++index) { + if (index != 0U) { + output << ','; + } + output << value_text(value.children()[index], depth + 1U); + } + output << ']'; + return output.str(); + } + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + case MmsDataKind::bcd: + case MmsDataKind::boolean_array: + case MmsDataKind::object_id: + case MmsDataKind::unknown: + return "0x" + hex_bytes(value.raw_value()); + } + return ""; +} + +[[nodiscard]] inline const MmsControlBlockAttributeReadEvidence* find_attribute( + const MmsControlBlockReadResult& evidence, + const std::initializer_list names) { + const auto found = std::find_if( + evidence.attributes.begin(), evidence.attributes.end(), + [&](const auto& attribute) { + return std::any_of(names.begin(), names.end(), [&](const auto name) { + return path_matches_leaf(attribute.attribute_path, name); + }); + }); + return found == evidence.attributes.end() ? nullptr : &*found; +} + +[[nodiscard]] inline std::string read_text( + const MmsControlBlockReadResult& evidence, + const std::initializer_list names) { + const auto* attribute = find_attribute(evidence, names); + return attribute != nullptr && attribute->value + ? value_text(*attribute->value) + : std::string{}; +} + +[[nodiscard]] inline std::string normalize_data_set_reference(std::string value) { + std::replace(value.begin(), value.end(), '$', '.'); + return value; +} + +[[nodiscard]] inline bool is_address_attribute(const std::string_view path) { + const auto value = lower(std::string{path}); + return value == "addr" || value.starts_with("addr.") || + value == "dstaddress" || value.starts_with("dstaddress.") || + path_matches_leaf(path, "APPID") || + path_matches_leaf(path, "MAC-Address"); +} + +inline void project_one( + MmsLiveControlBlock& control, + const MmsLiveDiscoveryResult& discovery) { + if (!discovery.control_block_value_reads_requested) { + return; + } + + const auto evidence_it = std::find_if( + discovery.control_block_reads.begin(), + discovery.control_block_reads.end(), + [&](const auto& evidence) { + return same(evidence.candidate.reference, control.reference); + }); + + if (evidence_it == discovery.control_block_reads.end()) { + control.discovery_status = "ValueNotReadWithinBound"; + control.message = + control.kind + " was inventoried from live MMS names, but its values " + "were not selected by the configured bounded deep-reader."; + return; + } + + const auto& evidence = *evidence_it; + const auto successful = evidence.successful_attribute_count(); + if (evidence.complete()) { + control.discovery_status = "ValueReadComplete"; + } else if (successful != 0U) { + control.discovery_status = "ValueReadPartial"; + } else { + control.discovery_status = "ValueReadFailed"; + } + + const auto* data_set = find_attribute(evidence, {"DatSet"}); + if (data_set == nullptr) { + control.data_set_reference_status = "AttributeNotPresentInNameList"; + } else if (data_set->value) { + control.data_set_reference = normalize_data_set_reference( + value_text(*data_set->value)); + control.data_set_reference_status = "ValueRead"; + } else { + control.data_set_reference_status = "ValueReadFailed"; + } + + control.control_id = read_text(evidence, {"GoID"}); + control.app_id = read_text(evidence, {"APPID", "AppID"}); + control.smv_id = read_text(evidence, {"SvID", "svID"}); + control.configuration_revision = read_text(evidence, {"ConfRev"}); + control.minimum_time_ms = read_text(evidence, {"MinTime"}); + control.maximum_time_ms = read_text(evidence, {"MaxTime"}); + control.sample_rate = read_text(evidence, {"SmpRate"}); + control.sample_mode = read_text(evidence, {"SmpMod"}); + control.number_of_asdu = read_text( + evidence, {"noASDU", "NoASDU", "NumOfASDU"}); + + std::size_t address_attributes{}; + std::size_t address_values{}; + for (const auto& attribute : evidence.attributes) { + if (!is_address_attribute(attribute.attribute_path)) { + continue; + } + ++address_attributes; + if (attribute.success()) { + ++address_values; + } + } + if (address_attributes == 0U) { + control.address_status = "NotDiscovered"; + } else if (address_values == address_attributes) { + control.address_status = "MmsValuesRead"; + } else if (address_values != 0U) { + control.address_status = "MmsValuesPartial"; + } else { + control.address_status = "MmsValueReadFailed"; + } + + std::ostringstream message; + message << control.kind << " deep read " << successful << '/' + << evidence.attributes.size() << " attributes"; + if (!evidence.attributes.empty()) { + message << ": "; + } + for (std::size_t index = 0U; index < evidence.attributes.size(); ++index) { + if (index != 0U) { + message << ", "; + } + const auto& attribute = evidence.attributes[index]; + message << attribute.attribute_path << '='; + if (attribute.value) { + message << value_text(*attribute.value); + } else if (attribute.failure_code) { + message << "'; + } else { + message << ""; + } + } + if (!evidence.error.empty()) { + message << "; error=" << evidence.error; + } + control.message = message.str(); +} + +inline void append_control_usage( + MmsLiveDataSet& data_set, + const std::vector& controls, + std::vector& target) { + for (const auto& control : controls) { + if (!control.data_set_reference.empty() && + same(control.data_set_reference, data_set.reference)) { + target.push_back(control.reference); + } + } + std::sort(target.begin(), target.end(), [](const auto& left, const auto& right) { + return lower(left) < lower(right); + }); + target.erase( + std::unique(target.begin(), target.end(), [](const auto& left, const auto& right) { + return same(left, right); + }), + target.end()); +} + +} // namespace control_block_model_detail + +// Runtime evidence overlay for the C#-compatible structural live model. +// This deliberately runs after MmsLiveModelBuilder::build: mutable control-block +// values are therefore visible in the model without changing its canonical +// structural fingerprint or pretending that C# already performs this deep read. +class MmsControlBlockRuntimeProjector final { +public: + static void apply( + const MmsLiveDiscoveryResult& discovery, + MmsLiveModelDocument& model) { + if (!discovery.control_block_value_reads_requested) { + return; + } + + const auto project = [&](auto& controls) { + for (auto& control : controls) { + control_block_model_detail::project_one(control, discovery); + } + }; + project(model.goose_control_blocks); + project(model.sampled_value_control_blocks); + project(model.setting_group_controls); + project(model.log_controls); + + for (auto& data_set : model.data_sets) { + control_block_model_detail::append_control_usage( + data_set, + model.goose_control_blocks, + data_set.used_by_goose_controls); + control_block_model_detail::append_control_usage( + data_set, + model.sampled_value_control_blocks, + data_set.used_by_sampled_value_controls); + } + + model.warnings.erase( + std::remove_if( + model.warnings.begin(), model.warnings.end(), [](const auto& warning) { + return warning.code == "CONTROL_BLOCK_VALUE_READ_PENDING"; + }), + model.warnings.end()); + + std::size_t complete{}; + std::size_t partial{}; + std::size_t failed{}; + std::size_t not_read{}; + const auto count_status = [&](const auto& controls) { + for (const auto& control : controls) { + if (control.discovery_status == "ValueReadComplete") { + ++complete; + } else if (control.discovery_status == "ValueReadPartial") { + ++partial; + } else if (control.discovery_status == "ValueReadFailed") { + ++failed; + } else { + ++not_read; + } + } + }; + count_status(model.goose_control_blocks); + count_status(model.sampled_value_control_blocks); + count_status(model.setting_group_controls); + count_status(model.log_controls); + + if (partial != 0U) { + model.warnings.push_back({ + "CONTROL_BLOCK_VALUE_READ_PARTIAL", + {}, + std::to_string(partial) + + " control blocks returned only part of their exposed MMS attribute values."}); + } + if (failed != 0U) { + model.warnings.push_back({ + "CONTROL_BLOCK_VALUE_READ_FAILED", + {}, + std::to_string(failed) + + " control blocks could not return any exposed MMS attribute values."}); + } + if (not_read != 0U) { + model.warnings.push_back({ + "CONTROL_BLOCK_VALUE_READ_BOUNDED", + {}, + std::to_string(not_read) + + " inventoried control blocks were intentionally not read because of the configured deep-read bound."}); + } + + if (!model.summary.empty() && model.summary.back() == '.') { + model.summary.pop_back(); + } + model.summary += + ", CBValueComplete=" + std::to_string(complete) + + ", CBValuePartial=" + std::to_string(partial) + + ", CBValueFailed=" + std::to_string(failed) + + ", CBValueNotRead=" + std::to_string(not_read) + "."; + } +}; + +} // namespace ar::iec61850::mms From 5591ab03676b3c1d0293ae41058cda5d6b94c4ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:35:52 +0700 Subject: [PATCH 153/179] Wire bounded control-block values into live discovery --- tools/live_discover.cpp | 125 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp index 559a2eb4f..72b17e516 100644 --- a/tools/live_discover.cpp +++ b/tools/live_discover.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/mms/control_block_model_evidence.hpp" #include "ariec61850/mms/live_discovery.hpp" #include "ariec61850/mms/live_model.hpp" #include "ariec61850/mms/rcb_availability.hpp" @@ -57,6 +58,16 @@ struct RcbAvailabilityCliOptions final { std::size_t maximum_snapshots_to_print{10U}; }; +struct ControlBlockReadCliOptions final { + bool requested{}; + std::size_t maximum_control_blocks{32U}; + std::size_t maximum_attributes{64U}; + + [[nodiscard]] bool has_bound_options() const noexcept { + return maximum_control_blocks != 32U || maximum_attributes != 64U; + } +}; + [[nodiscard]] std::string json_escape(const std::string_view value) { std::ostringstream output; for (const char character : value) { @@ -137,6 +148,11 @@ void print_usage() { << " --max-datasets N Bound DataSet directory reads.\n" << " --max-rcb N Bound RCB read probes.\n" << " --timeout-ms N Connect/request timeout in milliseconds.\n" + << "Read-only GO/SV/SG/LG value evidence:\n" + << " --control-block-values Enable bounded deep MMS Read of exact live attributes.\n" + << " --max-control-blocks N Bound deep-read control blocks (default 32).\n" + << " --max-control-block-attributes N\n" + << " Bound attributes per control block (default 64).\n" << "Read-only Smart RCB planning:\n" << " --rcb-plan MODE Analyze RCB evidence as dynamic or static.\n" << " --rcb-ld LD Prefer this logical device for dynamic mode.\n" @@ -152,8 +168,8 @@ void print_usage() { << " --max-rcb-availability N\n" << " Limit human availability output (default 10).\n" << "\n" - << "RCB planning/availability are evidence-only. They never send Write, Resv,\n" - << "RptEna, GI, or dynamic DataSet mutation requests.\n"; + << "All control-block value and RCB modes are evidence-only. They never send\n" + << "Write, Resv, RptEna, GoEna, SvEna, GI, control, or DataSet mutation requests.\n"; } void print_rcb_plan( @@ -267,6 +283,27 @@ void print_rcb_operational_availability( } } +void print_control_block_values(const mms::MmsLiveModelDocument& model) { + bool printed_header = false; + const auto print = [&](const auto& controls) { + for (const auto& control : controls) { + if (control.discovery_status == "AttributeInventoryOnly") { + continue; + } + if (!printed_header) { + std::cout << "Control-block runtime evidence:\n"; + printed_header = true; + } + std::cout << " [" << control.discovery_status << "] " + << control.reference << " - " << control.message << '\n'; + } + }; + print(model.goose_control_blocks); + print(model.sampled_value_control_blocks); + print(model.setting_group_controls); + print(model.log_controls); +} + void print_human( const mms::MmsLiveDiscoveryResult& result, const mms::MmsLiveModelDocument& model, @@ -288,6 +325,9 @@ void print_human( << " DO=" << logical_node.data_objects.size() << '\n'; } } + if (result.control_block_value_reads_requested) { + print_control_block_values(model); + } if (!model.warnings.empty()) { std::cout << "Warnings:\n"; for (const auto& warning : model.warnings) { @@ -326,6 +366,27 @@ void print_summary_json( << "\"partial\":" << (result.partial() ? "true" : "false") << ',' << "\"summary\":\"" << json_escape(model.summary) << "\""; + if (result.control_block_value_reads_requested) { + const auto complete = static_cast(std::count_if( + result.control_block_reads.begin(), result.control_block_reads.end(), + [](const auto& evidence) { return evidence.complete(); })); + const auto partial = static_cast(std::count_if( + result.control_block_reads.begin(), result.control_block_reads.end(), + [](const auto& evidence) { + return !evidence.complete() && + evidence.successful_attribute_count() != 0U; + })); + std::cout << ",\"controlBlockValues\":{" + << "\"candidateCount\":" + << result.control_block_value_candidate_count << ',' + << "\"attempted\":" << result.control_block_reads.size() << ',' + << "\"complete\":" << complete << ',' + << "\"partial\":" << partial << ',' + << "\"failed\":" + << (result.control_block_reads.size() - complete - partial) + << '}'; + } + if (rcb_selection) { std::cout << ",\"rcbPlan\":{" << "\"mode\":\"" @@ -424,6 +485,37 @@ build_rcb_operational_availability( return mms::MmsRcbOperationalAvailabilityEvaluator::evaluate(result, options); } +void read_control_block_values( + mms::MmsTcpLiveDiscoverySession& session, + mms::MmsLiveDiscoveryResult& result, + const ControlBlockReadCliOptions& options) { + if (!options.requested) { + return; + } + + result.control_block_value_reads_requested = true; + auto candidates = mms::MmsControlBlockInventoryBuilder::build(result.names); + result.control_block_value_candidate_count = candidates.size(); + const auto count = std::min(options.maximum_control_blocks, candidates.size()); + result.control_block_reads.reserve(count); + + mms::MmsControlBlockReadClient reader{session.association()}; + for (std::size_t index = 0U; index < count; ++index) { + try { + result.control_block_reads.push_back( + reader.read(candidates[index], options.maximum_attributes)); + } catch (const std::exception& exception) { + mms::MmsControlBlockReadResult failed; + failed.candidate = candidates[index]; + failed.error = exception.what(); + result.control_block_reads.push_back(std::move(failed)); + result.diagnostics.push_back( + "Control-block deep read failed for " + candidates[index].reference + + ": " + exception.what()); + } + } +} + } // namespace int main(int argc, char** argv) { @@ -456,6 +548,7 @@ int main(int argc, char** argv) { mms::MmsLiveModelBuildOptions model_options; RcbPlanCliOptions rcb_plan_options; RcbAvailabilityCliOptions rcb_availability_options; + ControlBlockReadCliOptions control_block_read_options; while (argument < argc) { const std::string option = argv[argument++]; @@ -467,6 +560,8 @@ int main(int argc, char** argv) { discovery_options.read_data_set_directories = false; } else if (option == "--no-rcb") { discovery_options.probe_report_controls = false; + } else if (option == "--control-block-values") { + control_block_read_options.requested = true; } else if (option == "--rcb-availability") { rcb_availability_options.requested = true; } else if (option == "--strict-rcb") { @@ -481,7 +576,9 @@ int main(int argc, char** argv) { option == "--rcb-ld" || option == "--rcb-dataset" || option == "--preferred-rcb" || option == "--rcb-exclude" || option == "--max-rcb-candidates" || - option == "--max-rcb-availability") { + option == "--max-rcb-availability" || + option == "--max-control-blocks" || + option == "--max-control-block-attributes") { if (argument >= argc) { throw std::invalid_argument(option + " requires a value."); } @@ -522,6 +619,10 @@ int main(int argc, char** argv) { rcb_plan_options.maximum_candidates_to_print = parsed; } else if (option == "--max-rcb-availability") { rcb_availability_options.maximum_snapshots_to_print = parsed; + } else if (option == "--max-control-blocks") { + control_block_read_options.maximum_control_blocks = parsed; + } else if (option == "--max-control-block-attributes") { + control_block_read_options.maximum_attributes = parsed; } else { if (parsed > static_cast( std::numeric_limits::max())) { @@ -547,6 +648,12 @@ int main(int argc, char** argv) { throw std::invalid_argument( "--max-rcb-availability requires --rcb-availability."); } + if (!control_block_read_options.requested && + control_block_read_options.has_bound_options()) { + throw std::invalid_argument( + "--max-control-blocks/--max-control-block-attributes require " + "--control-block-values."); + } if ((rcb_plan_options.requested() || rcb_availability_options.requested) && !discovery_options.probe_report_controls) { throw std::invalid_argument( @@ -570,16 +677,24 @@ int main(int argc, char** argv) { "RCB runtime evidence cannot be combined with --model-json or " "--manifest structural parity output."); } + if (control_block_read_options.requested && + output_mode == OutputMode::manifest) { + throw std::invalid_argument( + "Control-block runtime values are intentionally excluded from the " + "structural manifest; use human, --json, or --model-json output."); + } mms::MmsAssociationOptions association_options; association_options.connect_timeout = timeout; association_options.request_timeout = timeout; mms::MmsTcpLiveDiscoverySession session{{}, association_options}; session.connect(endpoint); - const auto result = session.discover(discovery_options); + auto result = session.discover(discovery_options); + read_control_block_values(session, result, control_block_read_options); session.disconnect(); - const auto model = mms::MmsLiveModelBuilder::build(result, model_options); + auto model = mms::MmsLiveModelBuilder::build(result, model_options); + mms::MmsControlBlockRuntimeProjector::apply(result, model); const auto rcb_selection = build_rcb_plan(result, rcb_plan_options); const auto rcb_availability = build_rcb_operational_availability( result, rcb_availability_options); From c02f2fca86f2d13e6a756a0ee345c2c432cb543f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:37:38 +0700 Subject: [PATCH 154/179] Test SGCB runtime evidence projection --- tests/test_control_block_read.cpp | 123 +++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/tests/test_control_block_read.cpp b/tests/test_control_block_read.cpp index 9f53e5d48..2f85923a7 100644 --- a/tests/test_control_block_read.cpp +++ b/tests/test_control_block_read.cpp @@ -1,7 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/mms/control_block_model_evidence.hpp" #include "ariec61850/mms/control_block_read.hpp" +#include +#include #include #include #include @@ -87,13 +90,131 @@ void inventory_deduplicates_attribute_paths_case_insensitively() { CHECK(controls[0].attributes[0].variable.item == "LLN0$GO$gcb01$DatSet"); } +void runtime_projector_promotes_complete_ocr7sr12_sgcb_evidence() { + using namespace ar::iec61850::mms; + + MmsLiveDiscoveryResult discovery; + discovery.endpoint = {"192.168.1.10", 102U}; + discovery.names.domain_variables["OCR7SR12PROT"] = { + "LLN0$SP$SGCB$ActSG", + "LLN0$SP$SGCB$CnfEdit", + "LLN0$SP$SGCB$EditSG", + "LLN0$SP$SGCB$LActTm", + "LLN0$SP$SGCB$NumOfSG"}; + discovery.control_block_value_reads_requested = true; + + const auto candidates = MmsControlBlockInventoryBuilder::build(discovery.names); + CHECK(candidates.size() == 1U); + CHECK(candidates.front().reference == "OCR7SR12PROT/LLN0.SP.SGCB"); + discovery.control_block_value_candidate_count = candidates.size(); + + MmsControlBlockReadResult evidence; + evidence.candidate = candidates.front(); + const Iec61850UtcTime last_activation{ + std::chrono::system_clock::time_point{std::chrono::milliseconds{1'786'100'000'000LL}}, + 0U}; + evidence.attributes = { + {"ActSG", MmsObjectName::domain_specific( + "OCR7SR12PROT", "LLN0$SP$SGCB$ActSG"), + MmsDataValue::unsigned_integer(1U), std::nullopt}, + {"CnfEdit", MmsObjectName::domain_specific( + "OCR7SR12PROT", "LLN0$SP$SGCB$CnfEdit"), + MmsDataValue::boolean(false), std::nullopt}, + {"EditSG", MmsObjectName::domain_specific( + "OCR7SR12PROT", "LLN0$SP$SGCB$EditSG"), + MmsDataValue::unsigned_integer(0U), std::nullopt}, + {"LActTm", MmsObjectName::domain_specific( + "OCR7SR12PROT", "LLN0$SP$SGCB$LActTm"), + MmsDataValue::utc_time(last_activation), std::nullopt}, + {"NumOfSG", MmsObjectName::domain_specific( + "OCR7SR12PROT", "LLN0$SP$SGCB$NumOfSG"), + MmsDataValue::unsigned_integer(1U), std::nullopt}}; + CHECK(evidence.complete()); + discovery.control_block_reads.push_back(std::move(evidence)); + + auto model = MmsLiveModelBuilder::build(discovery); + CHECK(model.setting_group_controls.size() == 1U); + CHECK(std::any_of( + model.warnings.begin(), model.warnings.end(), [](const auto& warning) { + return warning.code == "CONTROL_BLOCK_VALUE_READ_PENDING"; + })); + const auto structural_before = model.canonical_fingerprint(); + + MmsControlBlockRuntimeProjector::apply(discovery, model); + + CHECK(model.canonical_fingerprint() == structural_before); + const auto& sgcb = model.setting_group_controls.front(); + CHECK(sgcb.discovery_status == "ValueReadComplete"); + CHECK(sgcb.reference == "OCR7SR12PROT/LLN0.SP.SGCB"); + CHECK(sgcb.message.find("deep read 5/5 attributes") != std::string::npos); + CHECK(sgcb.message.find("ActSG=1") != std::string::npos); + CHECK(sgcb.message.find("CnfEdit=false") != std::string::npos); + CHECK(sgcb.message.find("EditSG=0") != std::string::npos); + CHECK(sgcb.message.find("LActTm=utcMs=") != std::string::npos); + CHECK(sgcb.message.find("NumOfSG=1") != std::string::npos); + CHECK(model.summary.find("CBValueComplete=1") != std::string::npos); + CHECK(model.summary.find("CBValuePartial=0") != std::string::npos); + CHECK(std::none_of( + model.warnings.begin(), model.warnings.end(), [](const auto& warning) { + return warning.code == "CONTROL_BLOCK_VALUE_READ_PENDING"; + })); +} + +void runtime_projector_maps_goose_values_without_inventing_failed_address_data() { + using namespace ar::iec61850::mms; + + MmsLiveDiscoveryResult discovery; + discovery.endpoint = {"192.0.2.20", 102U}; + discovery.names.domain_variables["IEDLD0"] = { + "LLN0$GO$gcb01$DatSet", + "LLN0$GO$gcb01$GoID", + "LLN0$GO$gcb01$DstAddress$APPID"}; + discovery.control_block_value_reads_requested = true; + + const auto candidates = MmsControlBlockInventoryBuilder::build(discovery.names); + CHECK(candidates.size() == 1U); + discovery.control_block_value_candidate_count = candidates.size(); + + MmsControlBlockReadResult evidence; + evidence.candidate = candidates.front(); + evidence.attributes = { + {"DatSet", MmsObjectName::domain_specific( + "IEDLD0", "LLN0$GO$gcb01$DatSet"), + MmsDataValue::visible_string("IEDLD0/LLN0$DataSetA"), std::nullopt}, + {"DstAddress.APPID", MmsObjectName::domain_specific( + "IEDLD0", "LLN0$GO$gcb01$DstAddress$APPID"), + std::nullopt, 3U}, + {"GoID", MmsObjectName::domain_specific( + "IEDLD0", "LLN0$GO$gcb01$GoID"), + MmsDataValue::visible_string("GOOSE-1"), std::nullopt}}; + discovery.control_block_reads.push_back(std::move(evidence)); + + auto model = MmsLiveModelBuilder::build(discovery); + MmsControlBlockRuntimeProjector::apply(discovery, model); + + CHECK(model.goose_control_blocks.size() == 1U); + const auto& goose = model.goose_control_blocks.front(); + CHECK(goose.discovery_status == "ValueReadPartial"); + CHECK(goose.data_set_reference == "IEDLD0/LLN0.DataSetA"); + CHECK(goose.data_set_reference_status == "ValueRead"); + CHECK(goose.control_id == "GOOSE-1"); + CHECK(goose.app_id.empty()); + CHECK(goose.address_status == "MmsValueReadFailed"); + CHECK(std::any_of( + model.warnings.begin(), model.warnings.end(), [](const auto& warning) { + return warning.code == "CONTROL_BLOCK_VALUE_READ_PARTIAL"; + })); +} + } // namespace int main() { const std::vector>> tests{ {"group exact live control blocks", inventory_groups_exact_live_name_list_items}, {"support US SG SE control blocks", inventory_supports_us_sg_and_se_functional_constraints}, - {"deduplicate control block attributes", inventory_deduplicates_attribute_paths_case_insensitively}}; + {"deduplicate control block attributes", inventory_deduplicates_attribute_paths_case_insensitively}, + {"project complete OCR7SR12 SGCB evidence", runtime_projector_promotes_complete_ocr7sr12_sgcb_evidence}, + {"project partial GOOSE evidence", runtime_projector_maps_goose_values_without_inventing_failed_address_data}}; std::size_t passed{}; for (const auto& [name, test] : tests) { From 5cb7cd6223a5cf4c9ca71a1aec92f32904924356 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:45:16 +0700 Subject: [PATCH 155/179] Record physical OCR7SR12 SGCB deep-read evidence --- docs/PHYSICAL_EVIDENCE_OCR7SR12.md | 53 ++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/docs/PHYSICAL_EVIDENCE_OCR7SR12.md b/docs/PHYSICAL_EVIDENCE_OCR7SR12.md index f39f2a383..375605273 100644 --- a/docs/PHYSICAL_EVIDENCE_OCR7SR12.md +++ b/docs/PHYSICAL_EVIDENCE_OCR7SR12.md @@ -4,8 +4,8 @@ This document records physical evidence observed against the reachable `OCR7SR12` IED during Phase 4C.1. It intentionally separates what has been physically demonstrated from what remains -pending. No Write, RCB reservation/enable, GI, control, dynamic DataSet mutation, or file-service -request is part of this evidence. +pending. No Write, RCB reservation/enable, GI, control, dynamic DataSet mutation, setting-group +mutation, or file-service request is part of this evidence. ## Target @@ -45,9 +45,10 @@ All ten read RCBs were observed as `Unbound`, while the remaining 276 were inten `NotRead`. This evidence must not be represented as 286/286 RCBs being unbound. `DATASET_MEMBERS_NOT_READ` was expected because DataSet directory reads were explicitly disabled. -`CONTROL_BLOCK_VALUE_READ_PENDING` records a known implementation gap: GO/SV/SG/LG attribute -names are inventoried, while deep values such as DatSet, GoID, svID, APPID, and multicast address -are not yet read by that discovery path. +At the time of this earlier run, `CONTROL_BLOCK_VALUE_READ_PENDING` correctly recorded that the +main `live_discover` path had only inventoried GO/SV/SG/LG attribute names. The standalone deep +reader has since been implemented and physically proven for SGCB; the current integrated +`--control-block-values` path still requires one physical regression run after that wiring change. ## Read-only RCB contention evidence @@ -80,6 +81,40 @@ performed discovery plus repeated MMS Read requests only. `StableProceed` is evi selected RCB remained free and stable during the observation window; it is **not** authorization to perform a claim/write in Phase 4C.1. +## Read-only SGCB deep-value evidence + +The standalone bounded control-block reader was run against the real IED with: + +```text +ariec61850_control_block_read_probe.exe 192.168.1.10 102 \ + --kind sg --max-control-blocks 4 --max-attributes 32 --timeout-ms 30000 +``` + +The IED exposed exactly one matching Setting Group Control Block and all five discovered MMS +attributes were read successfully: + +```text +associationProfile=BalancedApTitle +associationAttempts=1 +discovered=1 +CB OCR7SR12PROT/LLN0.SP.SGCB kind=SettingGroupControl attributes=5/5 status=Complete + ActSG=1 [LLN0$SP$SGCB$ActSG] + CnfEdit=false [LLN0$SP$SGCB$CnfEdit] + EditSG=0 [LLN0$SP$SGCB$EditSG] + LActTm= [LLN0$SP$SGCB$LActTm] + NumOfSG=1 [LLN0$SP$SGCB$NumOfSG] +Control-block read summary: attempted=1, complete=1, partial=0, failed=0. +``` + +This is physical evidence that exact live NameList-derived SGCB leaf identifiers can be read by +MMS without any setting-group mutation. It proves read support for this specific SGCB on this IED; +it does not prove that all vendors expose the same attribute set or that GO/SV address parameters +such as APPID/MAC/VLAN are universally MMS-readable. + +The current C++ integration keeps these mutable values as runtime evidence overlay. Structural +control-block inventory and canonical model fingerprint remain independent of changing values such +as `ActSG`, `EditSG`, or activation time. + ## Physically demonstrated - TCP/TPKT/COTP/Session/Presentation/ACSE association to the real IED. @@ -89,16 +124,22 @@ to perform a claim/write in Phase 4C.1. - Ten bounded RCB reads completed 10/10 with zero read failures. - Three repeated read-only contention probes remained stable and free on `OCR7SR12CTRL/BI6GGIO1.urcbA01`. +- SGCB deep-value read on `OCR7SR12PROT/LLN0.SP.SGCB` completed 5/5 with zero partial/failure + attributes. ## Still pending before Phase 4C.1 physical acceptance is complete +- Physical regression of the newly integrated `live_discover --control-block-values` path on the + same OCR7SR12, confirming runtime projection reports `CBValueComplete=1` for the SGCB-only + bounded run. - Primary-vendor ten-cycle fresh association/discovery run captured by the acceptance runner. - Explicit slow/timeout failure followed by a clean reconnect. - C# and C++ `live-ied-model-v1` exports against the same unchanged IED configuration and parity review. - Packet-capture reference where practical. - Multi-vendor or additional vendor-simulator evidence. -- Deep GO/SV/SG/LG control-block value reader. +- Physical GO/SV/LG deep-value evidence when a target IED actually exposes those control blocks; + SCL-only APPID/MAC/VLAN values must not be invented as MMS evidence. Use `scripts/run-phase4c-physical-acceptance.py` or the Windows PowerShell wrapper to collect the next repeated-association evidence without expanding the Phase 4C.1 read-only service boundary. From b1770c62d5af2d48b53a152ba585c1569ecae8f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:46:28 +0700 Subject: [PATCH 156/179] Update feature matrix for SGCB deep reads --- docs/FEATURE_MATRIX.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 1947d0e61..7cd162130 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -19,6 +19,7 @@ Status terms are intentionally conservative: | MMS client | GetVariableAccessAttributes | Live-proven | C++ LN-root planner completed 123/123 probes and projected exact MMS types across 6990 canonical DA on the current endpoint. | | MMS client | Read | Live-proven | Full current OCR7SR12 RCB inventory was probed read-only: 286/286 represented, 0 NotRead and 0 ReadFailed in the supplied full-probe snapshot. | | MMS client | GetNamedVariableListAttributes | Live-proven/optional | Used when DataSet directory reads are enabled; may be deliberately skipped for structural-only runs. | +| MMS client | GO/SV/SG/LG exact-attribute deep Read | SGCB live-proven / integrated path offline-tested | Standalone bounded reader physically read `OCR7SR12PROT/LLN0.SP.SGCB` 5/5 (`ActSG`, `CnfEdit`, `EditSG`, `LActTm`, `NumOfSG`). The new `live_discover --control-block-values` overlay is CI-tested and awaits one integrated physical regression. | | Live model | LD/LN/DO/DA projection | Live-proven | Same-IED OCR7SR12 evidence: 4 LD, 123 LN, 1186 DO, 6990 DA; C#↔C++ structural comparison has blocking=0. | | Live model | IED identity resolver | Live-proven | OCR7SR12 inferred from logical-device domain suffix evidence. | | Live model | CDC inference | Live-proven + offline-tested | C# registry/pattern port used by live model; exact CDC still depends on available evidence. | @@ -26,6 +27,7 @@ Status terms are intentionally conservative: | Live model | VariableTypeDiscoveries | Live-proven | C# direct probes and C++ LN-root probes use different request strategies; common projected DA type signatures compare with blocking=0. | | Live model | Structural fingerprint | Live-proven / stability pending | Current live capture emits a structure-only fingerprint excluding mutable DataSet/RCB runtime state; repeated-cycle stability remains an acceptance gate. | | Live model | Runtime snapshot fingerprint | Live-proven | Current live capture includes mutable DataSet inventory and RCB binding/enable/reservation evidence separately from structural identity. | +| Live model | Control-block runtime evidence overlay | Offline-tested / physical integration pending | Mutable GO/SV/SG/LG values are projected after structural model build, so deep runtime evidence does not change the canonical structural fingerprint. Complete/partial/failed/bounded states are reported explicitly. | | Cross-language | Same-IED C# ↔ C++ structural comparator | Live-proven | OCR7SR12 structural comparison is PASS with blocking=0 and totalFindings=0; `--types` is also PASS with blocking=0 and totalFindings=0 on common exact type evidence. | | Cross-language | Same-IED runtime comparator | Live-proven / mutable | Runtime comparison is informational; C++-only `DataSetBindingStatus` is treated as enrichment rather than a false cross-schema mismatch. | | DataSet | Name inventory | Live-proven | Dynamic inventory may legitimately change between sessions. | @@ -35,21 +37,22 @@ 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 | 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 | RCB claim/reservation/enable | Planned / out of Phase 4C | No Write, reservation, `RptEna` or GI is authorized by live discovery. | | Reporting | InformationReport decode / subscription runtime | Offline-tested / partial live integration | Core implementation exists; full live reporting acceptance is not claimed. | | GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | | GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical multi-vendor process-bus acceptance pending. | | Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | | Sampled Values | Caller-owned bounded encode path | Offline-tested | `encode_into(span)` avoids fresh frame-buffer allocation in steady-state publisher design. | -| Sampled Values | Live MCU publisher | Planned | ESP32-P4-ETH is the future protocol/performance reference after Public Alpha. | +| Sampled Values | Live MCU publisher | Planned / embedded path staged | First reference hardware is the Waveshare ESP32-S3-POE-ETH-8DI-8DO; synthetic W5500/ESP-IDF MACRAW transmission proof is required before any timing-grade claim. | | SCL | Read-only parser / engineering support | Offline-tested | Host-only engineering surface; not part of MCU runtime core. | | COMTRADE | CFG/DAT read support | Offline-tested | Host engineering feature. | | PCAP | Read/write/evidence support | Offline-tested | Host evidence feature. | | Embedded | `ARIEC61850::embedded_core` boundary | Offline-tested | Host-only services excluded by dedicated build profile. | | Embedded | no-RTTI profile | Offline-tested | GCC/Clang host-simulation gate. | | Embedded | no-exceptions MCU core | Planned | Legacy protocol error paths still contain exception debt. | -| Embedded | ESP32-P4 native Ethernet adapter | Planned | Target after Public Alpha. | -| Embedded | ESP32-S3 8DI/8DO IED application | Planned | Application reference after bounded MMS server foundation exists. | +| Embedded | ESP32-S3 W5500 / ESP-IDF Ethernet adapter | Planned | Reference design uses ESP-IDF W5500 MACRAW integration with raw Ethernet for SV/GOOSE and `esp_netif`/lwIP for MMS TCP on the same interface. | +| Embedded | ESP32-S3 8DI/8DO IED application | Planned | Waveshare board application reference after bounded MMS server foundation exists. | | MMS server | Association acceptor + static browse/read model | Planned | Required for the ESP32 I/O IED; not yet a public server feature. | | MMS mutation | Write/control | Codec/offline foundation only | Live Phase 4C discovery intentionally does not send these operations. | | File services | File directory/read/write | Planned for later parity | No file-service mutation/request is part of current live acceptance. | @@ -58,14 +61,17 @@ Status terms are intentionally conservative: | Portability | GCC / Clang / MSVC | CI-gated | Required for host Public Alpha. | | Acceptance | Same-IED repeated structural stability | In progress | Acceptance runner uses `structuralFingerprint`, not mutable legacy canonical fingerprint. Multi-cycle live evidence is the next A1 acceptance tranche. | | Acceptance | Timeout/reconnect evidence | In progress | Dedicated controlled failure/reconnect runs remain required; successful association cycles alone are not sufficient evidence. | +| Acceptance | Integrated control-block deep-read regression | Pending one physical run | Standalone SGCB 5/5 is live-proven; the newly wired `live_discover --control-block-values` path must now reproduce the result on OCR7SR12. | | Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before full replacement/industrial claims. | ## Current Phase 4C mutation boundary The live discovery workflow may issue read-only MMS operations such as GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes and -Read. It must not perform Write, control, RCB reservation/enable, GI, dynamic -DataSet mutation, or file-service mutation. +Read. Optional `--control-block-values` performs bounded MMS Read only against +exact GO/SV/SG/LG variable names already returned by live `GetNameList`. It must +not perform Write, control, RCB reservation/enable, GoEna/SvEna mutation, GI, +setting-group mutation, dynamic DataSet mutation, or file-service mutation. ## Current live parity milestone @@ -74,7 +80,8 @@ exports now agree structurally on LD/LN/DO/DA, RCB/control-block identities and canonical type-template projection. Common exact DA MMS type signatures also compare with zero blocking findings. A separate C++ read-only full-RCB snapshot classified all 286 RCBs (6 Bound, 280 Unbound at that instant) with zero -NotRead/ReadFailed entries. These facts are evidence for this endpoint and +NotRead/ReadFailed entries. The standalone deep reader also read the real SGCB +5/5 without mutation. These facts are evidence for this endpoint and configuration only; they are not a multi-vendor or full-stack parity claim. ## Public Alpha exit direction From 20145fb740cc3ceebdfffc93482e87a23bb76e0e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:48:39 +0700 Subject: [PATCH 157/179] Expose machine-readable control-block runtime values --- include/ariec61850/mms/live_model.hpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_model.hpp b/include/ariec61850/mms/live_model.hpp index d431bc284..16feaa7b1 100644 --- a/include/ariec61850/mms/live_model.hpp +++ b/include/ariec61850/mms/live_model.hpp @@ -130,8 +130,21 @@ struct MmsLiveReportControl final { std::string status; }; +// Generic machine-readable overlay for values returned by the optional +// read-only GO/SV/SG/LG deep reader. This is intentionally runtime evidence: +// it is not part of structural C# parity and is excluded from canonical and +// structural fingerprints. +struct MmsLiveControlBlockRuntimeAttribute final { + std::string attribute_path; + std::string mms_reference; + std::string value; + std::string status{"NotRead"}; + std::optional failure_code; +}; + // Read-only inventory projection matching ARIEC61850 LiveIedControlBlockModel. -// Values stay empty until a future, explicitly bounded deep value reader exists. +// C#-parity fields remain intact; runtime_attributes is a C++ extension filled +// only when the explicit bounded deep value reader is requested. struct MmsLiveControlBlock final { std::string kind; std::string reference; @@ -153,6 +166,7 @@ struct MmsLiveControlBlock final { std::string number_of_asdu; std::string address_status{"NotDiscovered"}; std::string discovery_status{"AttributeInventoryOnly"}; + std::vector runtime_attributes; std::string message; [[nodiscard]] std::size_t attribute_count() const noexcept { From 25fbe76b0b43614666eb712c62575c07ae41757f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:49:18 +0700 Subject: [PATCH 158/179] Project control-block runtime attributes into live model --- .../mms/control_block_model_evidence.hpp | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/include/ariec61850/mms/control_block_model_evidence.hpp b/include/ariec61850/mms/control_block_model_evidence.hpp index 8283bd68e..b5bd0b74b 100644 --- a/include/ariec61850/mms/control_block_model_evidence.hpp +++ b/include/ariec61850/mms/control_block_model_evidence.hpp @@ -147,6 +147,28 @@ namespace control_block_model_detail { path_matches_leaf(path, "MAC-Address"); } +inline void project_runtime_attributes( + MmsLiveControlBlock& control, + const MmsControlBlockReadResult& evidence) { + control.runtime_attributes.clear(); + control.runtime_attributes.reserve(evidence.attributes.size()); + for (const auto& attribute : evidence.attributes) { + MmsLiveControlBlockRuntimeAttribute projected; + projected.attribute_path = attribute.attribute_path; + projected.mms_reference = attribute.variable.reference(); + projected.failure_code = attribute.failure_code; + if (attribute.value) { + projected.value = value_text(*attribute.value); + projected.status = "ValueRead"; + } else if (attribute.failure_code) { + projected.status = "AccessFailure"; + } else { + projected.status = "NotReturned"; + } + control.runtime_attributes.push_back(std::move(projected)); + } +} + inline void project_one( MmsLiveControlBlock& control, const MmsLiveDiscoveryResult& discovery) { @@ -170,6 +192,7 @@ inline void project_one( } const auto& evidence = *evidence_it; + project_runtime_attributes(control, evidence); const auto successful = evidence.successful_attribute_count(); if (evidence.complete()) { control.discovery_status = "ValueReadComplete"; From 36af94df9a727d24f403030475ebbe5fd17b6fe8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:50:49 +0700 Subject: [PATCH 159/179] Serialize control-block runtime evidence --- include/ariec61850/mms/live_model_chunk05.ipp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/mms/live_model_chunk05.ipp b/include/ariec61850/mms/live_model_chunk05.ipp index 73b1bf3bc..b285bff62 100644 --- a/include/ariec61850/mms/live_model_chunk05.ipp +++ b/include/ariec61850/mms/live_model_chunk05.ipp @@ -136,7 +136,22 @@ << "\",\"numberOfAsdu\":\"" << json(control.number_of_asdu) << "\",\"addressStatus\":\"" << json(control.address_status) << "\",\"discoveryStatus\":\"" << json(control.discovery_status) - << "\",\"message\":\"" << json(control.message) << "\"}"; + << "\",\"runtimeAttributes\":["; + for (std::size_t runtime_index = 0U; + runtime_index < control.runtime_attributes.size(); + ++runtime_index) { + if (runtime_index) out << ','; + const auto& runtime = control.runtime_attributes[runtime_index]; + out << "{\"attributePath\":\"" << json(runtime.attribute_path) + << "\",\"mmsReference\":\"" << json(runtime.mms_reference) + << "\",\"value\":\"" << json(runtime.value) + << "\",\"status\":\"" << json(runtime.status) + << "\",\"failureCode\":"; + if (runtime.failure_code) out << *runtime.failure_code; + else out << "null"; + out << '}'; + } + out << "],\"message\":\"" << json(control.message) << "\"}"; } out << ']'; }; @@ -211,4 +226,4 @@ inline MmsLiveModelParityResult MmsLiveModelParityComparer::compare( const auto expected_attributes = attributes(expected); const auto observed_attributes = attributes(observed); result.expected_attribute_count = expected_attributes.size(); - result.observed_attribute_count = observed_attributes.size(); + result.observed_attribute_count = observed_attributes.size(); \ No newline at end of file From fc14efc5cd0b25a62c2a68b911221a59935d7844 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:51:42 +0700 Subject: [PATCH 160/179] Test machine-readable control-block runtime evidence --- tests/test_control_block_read.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_control_block_read.cpp b/tests/test_control_block_read.cpp index 2f85923a7..1317e455a 100644 --- a/tests/test_control_block_read.cpp +++ b/tests/test_control_block_read.cpp @@ -146,6 +146,19 @@ void runtime_projector_promotes_complete_ocr7sr12_sgcb_evidence() { const auto& sgcb = model.setting_group_controls.front(); CHECK(sgcb.discovery_status == "ValueReadComplete"); CHECK(sgcb.reference == "OCR7SR12PROT/LLN0.SP.SGCB"); + CHECK(sgcb.runtime_attributes.size() == 5U); + CHECK(sgcb.runtime_attributes[0].attribute_path == "ActSG"); + CHECK(sgcb.runtime_attributes[0].value == "1"); + CHECK(sgcb.runtime_attributes[0].status == "ValueRead"); + CHECK(!sgcb.runtime_attributes[0].failure_code.has_value()); + CHECK(sgcb.runtime_attributes[1].attribute_path == "CnfEdit"); + CHECK(sgcb.runtime_attributes[1].value == "false"); + CHECK(sgcb.runtime_attributes[2].attribute_path == "EditSG"); + CHECK(sgcb.runtime_attributes[2].value == "0"); + CHECK(sgcb.runtime_attributes[3].attribute_path == "LActTm"); + CHECK(sgcb.runtime_attributes[3].value.find("utcMs=") == 0U); + CHECK(sgcb.runtime_attributes[4].attribute_path == "NumOfSG"); + CHECK(sgcb.runtime_attributes[4].value == "1"); CHECK(sgcb.message.find("deep read 5/5 attributes") != std::string::npos); CHECK(sgcb.message.find("ActSG=1") != std::string::npos); CHECK(sgcb.message.find("CnfEdit=false") != std::string::npos); @@ -158,6 +171,12 @@ void runtime_projector_promotes_complete_ocr7sr12_sgcb_evidence() { model.warnings.begin(), model.warnings.end(), [](const auto& warning) { return warning.code == "CONTROL_BLOCK_VALUE_READ_PENDING"; })); + + const auto json = model.to_json(); + CHECK(json.find("\"runtimeAttributes\":[") != std::string::npos); + CHECK(json.find("\"attributePath\":\"ActSG\"") != std::string::npos); + CHECK(json.find("\"value\":\"1\"") != std::string::npos); + CHECK(json.find("\"status\":\"ValueRead\"") != std::string::npos); } void runtime_projector_maps_goose_values_without_inventing_failed_address_data() { @@ -200,6 +219,16 @@ void runtime_projector_maps_goose_values_without_inventing_failed_address_data() CHECK(goose.control_id == "GOOSE-1"); CHECK(goose.app_id.empty()); CHECK(goose.address_status == "MmsValueReadFailed"); + CHECK(goose.runtime_attributes.size() == 3U); + const auto failed_address = std::find_if( + goose.runtime_attributes.begin(), goose.runtime_attributes.end(), + [](const auto& attribute) { + return attribute.attribute_path == "DstAddress.APPID"; + }); + CHECK(failed_address != goose.runtime_attributes.end()); + CHECK(failed_address->status == "AccessFailure"); + CHECK(failed_address->value.empty()); + CHECK(failed_address->failure_code == 3U); CHECK(std::any_of( model.warnings.begin(), model.warnings.end(), [](const auto& warning) { return warning.code == "CONTROL_BLOCK_VALUE_READ_PARTIAL"; From a330f78b97a5217357fc107ddf0c5d41ceb3b15e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:52:51 +0700 Subject: [PATCH 161/179] Fingerprint control-block runtime evidence --- .../ariec61850/mms/live_model_fingerprint.ipp | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/include/ariec61850/mms/live_model_fingerprint.ipp b/include/ariec61850/mms/live_model_fingerprint.ipp index 5c335b306..da0355afa 100644 --- a/include/ariec61850/mms/live_model_fingerprint.ipp +++ b/include/ariec61850/mms/live_model_fingerprint.ipp @@ -111,6 +111,30 @@ inline std::string MmsLiveModelDocument::runtime_snapshot_manifest() const { control.report_id + "|" + control.configuration_revision); } + const auto append_runtime_controls = [&lines](const auto& controls) { + for (const auto& control : controls) { + if (control.runtime_attributes.empty()) { + continue; + } + std::string line = + "CBRUNTIME|" + control.kind + "|" + control.reference + "|" + + control.discovery_status; + for (const auto& attribute : control.runtime_attributes) { + line += "|" + attribute.attribute_path + "=" + attribute.value + + "[" + attribute.status; + if (attribute.failure_code) { + line += ":" + std::to_string(*attribute.failure_code); + } + line += "]"; + } + lines.push_back(std::move(line)); + } + }; + append_runtime_controls(goose_control_blocks); + append_runtime_controls(sampled_value_control_blocks); + append_runtime_controls(setting_group_controls); + append_runtime_controls(log_controls); + std::sort(lines.begin() + 2, lines.end(), [](const auto& left, const auto& right) { return lower(left) < lower(right); }); From d789b2e90ed7d97aab62278dbb02eed90443953e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:53:21 +0700 Subject: [PATCH 162/179] Record OCR7SR12 physical read-only acceptance evidence --- PHASE4C_PHYSICAL_EVIDENCE.md | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 PHASE4C_PHYSICAL_EVIDENCE.md diff --git a/PHASE4C_PHYSICAL_EVIDENCE.md b/PHASE4C_PHYSICAL_EVIDENCE.md new file mode 100644 index 000000000..8f3274351 --- /dev/null +++ b/PHASE4C_PHYSICAL_EVIDENCE.md @@ -0,0 +1,67 @@ +# Phase 4C Physical Read-Only Evidence + +This document records laboratory evidence that has actually been observed on a physical IEC 61850 IED. It is intentionally narrower than the implementation feature matrix: a capability is listed here only when a real device run has produced evidence. + +## Device under test + +- IED identity observed through MMS discovery: `OCR7SR12`. +- MMS endpoint used in the lab: `192.168.1.10:102` (private laboratory address). +- Association profile selected by arstack61850: `BalancedApTitle`. +- Tests in this document are read-only. They do not send MMS Write, control, RCB reservation, RptEna, GoEna, SvEna, GI, DataSet mutation, or file mutation requests. + +## RCB contention probe + +Command: + +```powershell +.\build\Release\ariec61850_rcb_contention_probe.exe 192.168.1.10 102 --probe-count 3 --probe-delay-ms 1000 --cooldown-sec 60 --timeout-ms 30000 +``` + +Observed result: + +```text +Read-only RCB contention probe: endpoint=192.168.1.10:102, associationProfile=BalancedApTitle, associationAttempts=1, RCB=OCR7SR12CTRL/BI6GGIO1.urcbA01, probes=3, busy=false, flapping=false, contended=false, decision=StableProceed, cooldownSec=0. + probe=1 RptEna=false Resv=false ResvTms=- DatSet=- ConfRev=1 + probe=2 RptEna=false Resv=false ResvTms=- DatSet=- ConfRev=1 + probe=3 RptEna=false Resv=false ResvTms=- DatSet=- ConfRev=1 +Reason: RCB remained stable and free across pre-claim probes. +Recommended action: Safe to continue with guarded claim attempt. +``` + +Accepted conclusion: the selected URCB remained stable and free over three fresh read-only probes. This proves the `StableProceed` contention decision on this device for this observation window only; it does not prove that all RCBs on the IED are free or that a future claim will necessarily succeed. + +## Setting Group Control Block deep read + +Command: + +```powershell +.\build\Release\ariec61850_control_block_read_probe.exe 192.168.1.10 102 --kind sg --max-control-blocks 4 --max-attributes 32 --timeout-ms 30000 +``` + +Observed result: + +```text +Read-only control-block value probe: endpoint=192.168.1.10:102, associationProfile=BalancedApTitle, associationAttempts=1, discovered=1. +CB OCR7SR12PROT/LLN0.SP.SGCB kind=SettingGroupControl attributes=5/5 status=Complete + ActSG=1 [LLN0$SP$SGCB$ActSG] + CnfEdit=false [LLN0$SP$SGCB$CnfEdit] + EditSG=0 [LLN0$SP$SGCB$EditSG] + LActTm= [LLN0$SP$SGCB$LActTm] + NumOfSG=1 [LLN0$SP$SGCB$NumOfSG] +Control-block read summary: attempted=1, complete=1, partial=0, failed=0. +``` + +Accepted conclusion: the physical OCR7SR12 exposes one Setting Group Control Block at `OCR7SR12PROT/LLN0.SP.SGCB`, and all five exposed MMS attributes were read successfully in one bounded read-only run. The observed values were `ActSG=1`, `CnfEdit=false`, `EditSG=0`, `NumOfSG=1`, with `LActTm` decoded as IEC 61850 UTC time. + +## Implementation consequence + +The C++ live-model path now treats GO/SV/SG/LG deep-read values as runtime evidence rather than structural identity. The optional `--control-block-values` path can project successful, partial, failed, and bounded/not-read states without changing the structural fingerprint. Runtime attributes are exported as machine-readable attribute/value/status entries so applications do not need to parse the human message text. + +## Still pending + +- Integrated `ariec61850_live_discover --control-block-values` physical run on the same IED. +- Ten repeated primary-vendor discovery cycles with reconnect evidence. +- Deliberate timeout/reconnect test. +- Same-IED C# versus C++ model export comparison. +- Physical GOOSE/SV capture interoperability evidence. +- Any active RCB claim, reporting enable, control, or mutation test; these remain outside the read-only Phase 4C acceptance boundary. From ef0e6d71a346cbb95806459eafa30ac9a3320993 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 12:54:01 +0700 Subject: [PATCH 163/179] Advance Phase 4C physical acceptance gates --- MIGRATION_CHECKLIST.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index e84ef5c58..30c0d9996 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -136,9 +136,11 @@ behavioral oracle until laboratory interoperability is complete. - [x] C#↔C++ parity comparison script. - [x] Reconnect-cycle physical read-only evidence runner for Windows/Linux. - [x] Automated stability, warning-policy, coverage, and optional parity gates. -- [~] GCC/Clang strict local validation and sanitizer smoke. -- [ ] GitHub GCC/Clang/MSVC and Security/Evidence workflows accepted. -- [ ] Controlled physical IED or vendor-simulator evidence accepted. +- [x] Bounded GO/SV/SG/LG deep reader using exact live MMS NameList variables only. +- [x] Machine-readable control-block runtime attribute/value/status overlay excluded from structural parity fingerprint. +- [x] GCC/Clang strict validation, sanitizer/libFuzzer smoke, and Windows MSVC matrix accepted on the feature branch. +- [x] Controlled physical IED read-only evidence accepted: OCR7SR12 RCB contention probe and SGCB 5/5 deep read. +- [ ] Integrated `live_discover --control-block-values` physical run accepted. - [ ] Primary-vendor ten-cycle, timeout/reconnect, and pagination evidence accepted. - [ ] C# and C++ same-IED parity evidence accepted. From 48ea0f6814ecafcd658cd0188ec2e8caaa6458a4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 13:15:47 +0700 Subject: [PATCH 164/179] Gate deep control-block reads in interop runner --- scripts/run-live-readonly-interop.py | 116 ++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/scripts/run-live-readonly-interop.py b/scripts/run-live-readonly-interop.py index 1ed1b3c62..dabb2697d 100644 --- a/scripts/run-live-readonly-interop.py +++ b/scripts/run-live-readonly-interop.py @@ -12,6 +12,14 @@ from typing import Any +CONTROL_BLOCK_PROPERTIES = ( + "gooseControlBlocks", + "sampledValueControlBlocks", + "settingGroupControls", + "logControls", +) + + def find_binary(root: pathlib.Path, override: str | None) -> pathlib.Path: if override: path = pathlib.Path(override) @@ -57,6 +65,15 @@ def run_cycle( command.append("--no-rcb") elif args.max_rcb is not None: command += ["--max-rcb", str(args.max_rcb)] + if args.control_block_values: + command.append("--control-block-values") + if args.max_control_blocks is not None: + command += ["--max-control-blocks", str(args.max_control_blocks)] + if args.max_control_block_attributes is not None: + command += [ + "--max-control-block-attributes", + str(args.max_control_block_attributes), + ] started = time.monotonic() process = subprocess.run( @@ -131,6 +148,51 @@ def rcb_read_complete(coverage: dict[str, Any]) -> bool: return total > 0 and not_read == 0 and read_failed == 0 +def control_block_read_summary(model: dict[str, Any]) -> dict[str, Any]: + candidate_count = 0 + complete = 0 + partial = 0 + failed = 0 + not_read = 0 + + for property_name in CONTROL_BLOCK_PROPERTIES: + controls = model.get(property_name, []) + if not isinstance(controls, list): + continue + for control in controls: + if not isinstance(control, dict): + continue + candidate_count += 1 + status = str(control.get("discoveryStatus", "")) + if status == "ValueReadComplete": + complete += 1 + elif status == "ValueReadPartial": + partial += 1 + elif status == "ValueReadFailed": + failed += 1 + else: + not_read += 1 + + attempted = complete + partial + failed + complete_read = ( + candidate_count > 0 + and attempted == candidate_count + and complete == candidate_count + and partial == 0 + and failed == 0 + and not_read == 0 + ) + return { + "candidateCount": candidate_count, + "attempted": attempted, + "complete": complete, + "partial": partial, + "failed": failed, + "notRead": not_read, + "completeRead": complete_read, + } + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("host") @@ -151,6 +213,10 @@ def main() -> int: parser.add_argument("--no-rcb", action="store_true") parser.add_argument("--max-rcb", type=int) parser.add_argument("--require-rcb-complete", action="store_true") + parser.add_argument("--control-block-values", action="store_true") + parser.add_argument("--max-control-blocks", type=int) + parser.add_argument("--max-control-block-attributes", type=int) + parser.add_argument("--require-control-block-complete", action="store_true") args = parser.parse_args() if not 1 <= args.port <= 65535: @@ -165,6 +231,13 @@ def main() -> int: parser.error("--max-types must be positive") if args.max_rcb is not None and args.max_rcb <= 0: parser.error("--max-rcb must be positive") + if args.max_control_blocks is not None and args.max_control_blocks <= 0: + parser.error("--max-control-blocks must be positive") + if ( + args.max_control_block_attributes is not None + and args.max_control_block_attributes <= 0 + ): + parser.error("--max-control-block-attributes must be positive") if args.no_types and args.max_types is not None: parser.error("--no-types cannot be combined with --max-types") if args.no_rcb and args.max_rcb is not None: @@ -173,6 +246,15 @@ def main() -> int: parser.error( "--require-rcb-complete cannot be combined with --no-rcb" ) + if ( + args.max_control_blocks is not None + or args.max_control_block_attributes is not None + or args.require_control_block_complete + ) and not args.control_block_values: + parser.error( + "--max-control-blocks/--max-control-block-attributes/" + "--require-control-block-complete require --control-block-values" + ) if (args.parity_types or args.parity_runtime) and not args.expected_csharp_model: parser.error( "--parity-types/--parity-runtime require " @@ -190,6 +272,7 @@ def main() -> int: legacy_fingerprints: list[str] = [] parity_codes: list[int] = [] rcb_complete_flags: list[bool] = [] + control_block_complete_flags: list[bool] = [] for index in range(1, args.cycles + 1): evidence: dict[str, Any] = {"index": index, "success": False} @@ -225,6 +308,11 @@ def main() -> int: cycle_rcb_complete = rcb_read_complete(coverage) rcb_complete_flags.append(cycle_rcb_complete) + control_block_summary = control_block_read_summary(model) + if args.control_block_values: + control_block_complete_flags.append( + bool(control_block_summary["completeRead"]) + ) evidence.update({ "success": bool( coverage.get("logicalDeviceCount", 0) @@ -238,6 +326,12 @@ def main() -> int: "warningCount": len(warnings), "coverage": coverage, "rcbReadComplete": cycle_rcb_complete, + "controlBlockValues": control_block_summary, + "controlBlockReadComplete": ( + control_block_summary["completeRead"] + if args.control_block_values + else None + ), "stderr": stderr, "modelFile": model_path.name, }) @@ -263,12 +357,18 @@ def main() -> int: evidence["error"] = str(exc) cycles.append(evidence) + cb_text = ( + str(evidence.get("controlBlockReadComplete", False)) + if args.control_block_values + else "n/a" + ) print( f"Cycle {index}/{args.cycles}: " f"success={evidence['success']} " f"structure={evidence.get('structuralFingerprint', '')} " f"runtime={evidence.get('runtimeSnapshotFingerprint', '')} " f"rcbComplete={evidence.get('rcbReadComplete', False)} " + f"cbComplete={cb_text} " f"warnings={evidence.get('warningCount', 0)}" ) if index != args.cycles and args.interval_ms: @@ -306,16 +406,24 @@ def main() -> int: and all(rcb_complete_flags) ) ) + control_block_complete_ok = ( + not args.require_control_block_complete + or ( + len(control_block_complete_flags) == args.cycles + and all(control_block_complete_flags) + ) + ) accepted = ( all_success and structural_stable and warnings_ok and parity_ok and rcb_complete_ok + and control_block_complete_ok ) summary = { - "schemaVersion": "ariec61850-live-interop-v2", + "schemaVersion": "ariec61850-live-interop-v3", "endpoint": f"{args.host}:{args.port}", "binary": os.fspath(binary), "cycleCount": args.cycles, @@ -330,6 +438,9 @@ def main() -> int: "parityRuntimeRequested": args.parity_runtime, "rcbCompletenessRequired": args.require_rcb_complete, "rcbCompletenessAccepted": rcb_complete_ok, + "controlBlockValuesRequested": args.control_block_values, + "controlBlockCompletenessRequired": args.require_control_block_complete, + "controlBlockCompletenessAccepted": control_block_complete_ok, "accepted": accepted, "cycles": cycles, } @@ -342,7 +453,8 @@ def main() -> int: + ("PASS" if accepted else "FAIL") + f" (structuralStable={structural_stable}, " f"runtimeStable={runtime_stable}, " - f"rcbComplete={rcb_complete_ok})" + f"rcbComplete={rcb_complete_ok}, " + f"controlBlockComplete={control_block_complete_ok})" ) return 0 if accepted else 1 From 758db0bce66459b1c2ff5a31add32fc13d627770 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 13:16:30 +0700 Subject: [PATCH 165/179] Gate deep control-block reads in Phase 4C acceptance --- scripts/run-phase4c-physical-acceptance.py | 49 +++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/scripts/run-phase4c-physical-acceptance.py b/scripts/run-phase4c-physical-acceptance.py index d947c8d41..986270a06 100644 --- a/scripts/run-phase4c-physical-acceptance.py +++ b/scripts/run-phase4c-physical-acceptance.py @@ -122,6 +122,17 @@ def build_discovery_command( command += ["--max-rcb", "10"] if args.require_rcb_complete: command.append("--require-rcb-complete") + if args.control_block_values: + command.append("--control-block-values") + if args.max_control_blocks is not None: + command += ["--max-control-blocks", str(args.max_control_blocks)] + if args.max_control_block_attributes is not None: + command += [ + "--max-control-block-attributes", + str(args.max_control_block_attributes), + ] + if args.require_control_block_complete: + command.append("--require-control-block-complete") return command @@ -205,6 +216,10 @@ def main() -> int: parser.add_argument("--no-rcb", action="store_true") parser.add_argument("--max-rcb", type=int) parser.add_argument("--require-rcb-complete", action="store_true") + parser.add_argument("--control-block-values", action="store_true") + parser.add_argument("--max-control-blocks", type=int) + parser.add_argument("--max-control-block-attributes", type=int) + parser.add_argument("--require-control-block-complete", action="store_true") parser.add_argument("--contention-binary") parser.add_argument("--contention-cycles", type=int, default=3) parser.add_argument("--contention-rcb", default="") @@ -230,6 +245,13 @@ def main() -> int: parser.error("--contention-probe-count must be positive") if args.contention_probe_delay_ms < 0 or args.contention_cooldown_sec < 0: parser.error("contention delay/cooldown must be non-negative") + if args.max_control_blocks is not None and args.max_control_blocks <= 0: + parser.error("--max-control-blocks must be positive") + if ( + args.max_control_block_attributes is not None + and args.max_control_block_attributes <= 0 + ): + parser.error("--max-control-block-attributes must be positive") if args.no_types and args.max_types is not None: parser.error("--no-types cannot be combined with --max-types") if args.no_rcb and args.max_rcb is not None: @@ -238,6 +260,15 @@ def main() -> int: parser.error( "--require-rcb-complete cannot be combined with --no-rcb/--fast-readonly" ) + if ( + args.max_control_blocks is not None + or args.max_control_block_attributes is not None + or args.require_control_block_complete + ) and not args.control_block_values: + parser.error( + "--max-control-blocks/--max-control-block-attributes/" + "--require-control-block-complete require --control-block-values" + ) if (args.parity_types or args.parity_runtime) and not args.expected_csharp_model: parser.error( "--parity-types/--parity-runtime require --expected-csharp-model" @@ -310,13 +341,19 @@ def main() -> int: discovery_process.returncode == 0 and bool(discovery_summary.get("accepted")) ) + control_block_accepted = bool( + discovery_summary.get("controlBlockCompletenessAccepted", True) + ) fresh_association_count = ( successful_discovery_associations + successful_contention_associations ) - accepted = discovery_accepted and contention_accepted + reassociation_accepted = ( + fresh_association_count == args.cycles + args.contention_cycles + ) + accepted = discovery_accepted and contention_accepted and reassociation_accepted summary = { - "schemaVersion": "ariec61850-phase4c-physical-acceptance-v1", + "schemaVersion": "ariec61850-phase4c-physical-acceptance-v2", "endpoint": f"{args.host}:{args.port}", "readOnly": True, "discovery": { @@ -325,6 +362,9 @@ def main() -> int: "cycleCount": args.cycles, "successfulFreshAssociations": successful_discovery_associations, "summaryFile": str(discovery_summary_path.relative_to(output_root)), + "controlBlockValuesRequested": args.control_block_values, + "controlBlockCompletenessRequired": args.require_control_block_complete, + "controlBlockCompletenessAccepted": control_block_accepted, }, "contention": { "accepted": contention_accepted, @@ -340,9 +380,7 @@ def main() -> int: "freshProcessPerContentionCycle": True, "successfulFreshAssociationCount": fresh_association_count, "expectedFreshAssociationCount": args.cycles + args.contention_cycles, - "accepted": ( - fresh_association_count == args.cycles + args.contention_cycles - ), + "accepted": reassociation_accepted, }, "accepted": accepted, } @@ -355,6 +393,7 @@ def main() -> int: "Phase 4C physical read-only acceptance: " + ("PASS" if accepted else "FAIL") + f" (discovery={discovery_accepted}, contention={contention_accepted}, " + f"controlBlocks={control_block_accepted}, " f"freshAssociations={fresh_association_count}/" f"{args.cycles + args.contention_cycles})" ) From c8c4ce20dd6030a930a9891b105f8cbe8b16040f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 13:18:03 +0700 Subject: [PATCH 166/179] Record integrated Phase 4C physical evidence --- MIGRATION_CHECKLIST.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 30c0d9996..a9d736797 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -140,9 +140,11 @@ behavioral oracle until laboratory interoperability is complete. - [x] Machine-readable control-block runtime attribute/value/status overlay excluded from structural parity fingerprint. - [x] GCC/Clang strict validation, sanitizer/libFuzzer smoke, and Windows MSVC matrix accepted on the feature branch. - [x] Controlled physical IED read-only evidence accepted: OCR7SR12 RCB contention probe and SGCB 5/5 deep read. -- [ ] Integrated `live_discover --control-block-values` physical run accepted. -- [ ] Primary-vendor ten-cycle, timeout/reconnect, and pagination evidence accepted. -- [ ] C# and C++ same-IED parity evidence accepted. +- [x] Integrated `live_discover --control-block-values` physical OCR7SR12 run accepted: 1/1 SGCB complete, 5/5 attributes read, diagnostics=0, pending-value warning cleared. +- [x] C# and C++ same-IED OCR7SR12 structural/type/runtime comparison accepted with zero blocking findings. +- [ ] Primary-vendor ten-cycle acceptance with full RCB/control-block gates accepted. +- [ ] Controlled timeout/recovery evidence accepted. +- [ ] Pagination continuation evidence accepted on a target that requires more than one page. ### Later Phase 4 work From 4709e8183c18964f9a71183a1d3a84d60e9906f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 16:49:39 +0700 Subject: [PATCH 167/179] Add controlled Phase 4C timeout recovery evidence runner --- scripts/run-phase4c-timeout-recovery.py | 511 ++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 scripts/run-phase4c-timeout-recovery.py diff --git a/scripts/run-phase4c-timeout-recovery.py b/scripts/run-phase4c-timeout-recovery.py new file mode 100644 index 000000000..20b5152c9 --- /dev/null +++ b/scripts/run-phase4c-timeout-recovery.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""Run controlled read-only Phase 4C timeout/recovery evidence. + +The runner proves a healthy baseline, injects a post-association receive fault through +an in-process TCP proxy, requires a real client request timeout, then performs a +fresh direct association and requires the recovered structural model to match the +baseline. The proxy never mutates IEC 61850 payloads and never emits requests of its +own; it only forwards bytes and withholds server TPKT frames after the association +response has completed. +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import socket +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from typing import Any + + +TIMEOUT_RE = re.compile(r"(?:timeout|timed\s+out|deadline)", re.IGNORECASE) + + +def find_binary(root: pathlib.Path, override: str | None) -> pathlib.Path: + if override: + path = pathlib.Path(override).resolve() + if not path.is_file(): + raise FileNotFoundError(f"Discovery binary not found: {path}") + return path + + for name in ("ariec61850_live_discover.exe", "ariec61850_live_discover"): + for candidate in (root / "build" / "Release" / name, root / "build" / name): + if candidate.is_file(): + return candidate + raise FileNotFoundError( + "Build ariec61850_live_discover first; searched build/Release and build." + ) + + +def send_all(sock: socket.socket, data: bytes) -> None: + view = memoryview(data) + while view: + sent = sock.send(view) + if sent <= 0: + raise ConnectionError("socket send returned zero bytes") + view = view[sent:] + + +class TpktStreamGate: + def __init__(self) -> None: + self._buffer = bytearray() + + def feed(self, data: bytes) -> list[bytes]: + self._buffer.extend(data) + frames: list[bytes] = [] + while len(self._buffer) >= 4: + if self._buffer[0] != 3 or self._buffer[1] != 0: + raise RuntimeError("proxy observed a non-TPKT server stream") + length = int.from_bytes(self._buffer[2:4], "big") + if length < 4: + raise RuntimeError("proxy observed an invalid TPKT length") + if len(self._buffer) < length: + break + frames.append(bytes(self._buffer[:length])) + del self._buffer[:length] + return frames + + +def is_cotp_data_eot(tpkt: bytes) -> bool: + if len(tpkt) < 7: + return False + cotp = tpkt[4:] + if len(cotp) < 3: + return False + header_length = cotp[0] + if header_length < 2 or len(cotp) < header_length + 1: + return False + return (cotp[1] & 0xF0) == 0xF0 and (cotp[2] & 0x80) != 0 + + +@dataclass +class ProxyEvidence: + listen_host: str = "127.0.0.1" + listen_port: int = 0 + association_response_forwarded: bool = False + fault_triggered: bool = False + withheld_tpkt_count: int = 0 + withheld_bytes: int = 0 + client_to_server_bytes: int = 0 + server_to_client_forwarded_bytes: int = 0 + error: str = "" + + +class PostAssociationStallProxy: + def __init__(self, target_host: str, target_port: int) -> None: + self._target_host = target_host + self._target_port = target_port + self._stop = threading.Event() + self._ready = threading.Event() + self._thread = threading.Thread(target=self._run, daemon=True) + self._listener: socket.socket | None = None + self._client: socket.socket | None = None + self._upstream: socket.socket | None = None + self.evidence = ProxyEvidence() + + def start(self) -> None: + self._thread.start() + if not self._ready.wait(timeout=5.0): + raise RuntimeError("timeout waiting for fault-injection proxy to start") + if self.evidence.error: + raise RuntimeError(self.evidence.error) + + def stop(self) -> None: + self._stop.set() + for sock in (self._client, self._upstream, self._listener): + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + sock.close() + except OSError: + pass + self._thread.join(timeout=5.0) + + def _copy_client_to_server(self) -> None: + assert self._client is not None + assert self._upstream is not None + try: + while not self._stop.is_set(): + try: + data = self._client.recv(64 * 1024) + except socket.timeout: + continue + except OSError: + return + if not data: + return + self.evidence.client_to_server_bytes += len(data) + send_all(self._upstream, data) + except Exception as exc: # pragma: no cover - evidence is surfaced by caller + if not self._stop.is_set(): + self.evidence.error = f"client->server proxy failure: {exc}" + self._stop.set() + + def _run(self) -> None: + gate = TpktStreamGate() + try: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._listener = listener + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((self.evidence.listen_host, 0)) + listener.listen(1) + listener.settimeout(0.2) + self.evidence.listen_port = int(listener.getsockname()[1]) + self._ready.set() + + while not self._stop.is_set(): + try: + client, _ = listener.accept() + self._client = client + break + except socket.timeout: + continue + if self._stop.is_set() or self._client is None: + return + + self._client.settimeout(0.2) + upstream = socket.create_connection( + (self._target_host, self._target_port), timeout=5.0 + ) + self._upstream = upstream + upstream.settimeout(0.2) + + copier = threading.Thread(target=self._copy_client_to_server, daemon=True) + copier.start() + + while not self._stop.is_set(): + try: + data = upstream.recv(64 * 1024) + except socket.timeout: + continue + except OSError: + break + if not data: + break + + for frame in gate.feed(data): + if self.evidence.association_response_forwarded: + self.evidence.fault_triggered = True + self.evidence.withheld_tpkt_count += 1 + self.evidence.withheld_bytes += len(frame) + continue + + send_all(self._client, frame) + self.evidence.server_to_client_forwarded_bytes += len(frame) + if is_cotp_data_eot(frame): + # The first complete server COTP Data application payload after + # COTP CC is the Session/Presentation/ACSE association response. + # Forward it in full, then withhold subsequent server TPKTs so + # the first confirmed MMS service request experiences a real + # receive deadline expiry. + self.evidence.association_response_forwarded = True + + self._stop.set() + copier.join(timeout=1.0) + except Exception as exc: + self.evidence.error = str(exc) + self._ready.set() + finally: + for sock in (self._client, self._upstream, self._listener): + if sock is not None: + try: + sock.close() + except OSError: + pass + + +def run_process(command: list[str], timeout_seconds: float) -> tuple[subprocess.CompletedProcess[str], int]: + started = time.monotonic() + try: + process = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout or "" + stderr = exc.stderr or "" + if isinstance(stdout, bytes): + stdout = stdout.decode(errors="replace") + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + raise RuntimeError( + "discovery process exceeded the outer evidence-runner bound; " + f"stdout={stdout!r} stderr={stderr!r}" + ) from exc + elapsed_ms = int((time.monotonic() - started) * 1000) + return process, elapsed_ms + + +def model_command( + binary: pathlib.Path, + host: str, + port: int, + timeout_ms: int, + max_rcb: int, +) -> list[str]: + return [ + str(binary), + host, + str(port), + "--model-json", + "--timeout-ms", + str(timeout_ms), + "--no-types", + "--no-datasets", + "--max-rcb", + str(max_rcb), + "--control-block-values", + "--max-control-blocks", + "8", + "--max-control-block-attributes", + "32", + ] + + +def run_model( + binary: pathlib.Path, + host: str, + port: int, + timeout_ms: int, + max_rcb: int, + output_path: pathlib.Path, +) -> dict[str, Any]: + command = model_command(binary, host, port, timeout_ms, max_rcb) + process, elapsed_ms = run_process( + command, + max(30.0, (timeout_ms / 1000.0) * 3.0), + ) + if process.returncode not in (0, 1): + raise RuntimeError( + process.stderr.strip() or process.stdout.strip() or "model discovery failed" + ) + try: + document = json.loads(process.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"model discovery output is not JSON: {exc}") from exc + if document.get("schemaVersion") != "live-ied-model-v1": + raise RuntimeError("model discovery did not emit live-ied-model-v1") + + output_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + coverage = document.get("coverage", {}) + warnings = document.get("warnings", []) + diagnostics = document.get("diagnostics", []) + structural = str(document.get("structuralFingerprint", document.get("fingerprint", ""))) + runtime = str(document.get("runtimeSnapshotFingerprint", "")) + accepted = ( + int(coverage.get("logicalDeviceCount", 0) or 0) > 0 + and int(coverage.get("dataAttributeCount", 0) or 0) > 0 + and len(diagnostics) == 0 + and bool(structural) + ) + return { + "accepted": accepted, + "exitCode": process.returncode, + "elapsedMs": elapsed_ms, + "structuralFingerprint": structural, + "runtimeSnapshotFingerprint": runtime, + "warningCount": len(warnings), + "diagnosticCount": len(diagnostics), + "coverage": coverage, + "modelFile": output_path.name, + "stderr": process.stderr.strip(), + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Prove baseline health, inject a post-association read-only MMS timeout, " + "then prove a fresh direct recovery association and stable structure." + ) + ) + parser.add_argument("host") + parser.add_argument("port", nargs="?", type=int, default=102) + parser.add_argument("--output", required=True) + parser.add_argument("--binary") + parser.add_argument("--fault-timeout-ms", type=int, default=3000) + parser.add_argument("--recovery-timeout-ms", type=int, default=30000) + parser.add_argument("--settle-ms", type=int, default=500) + parser.add_argument("--recovery-max-rcb", type=int, default=50) + args = parser.parse_args() + + if not 1 <= args.port <= 65535: + parser.error("port must be 1..65535") + if args.fault_timeout_ms <= 0 or args.recovery_timeout_ms <= 0: + parser.error("timeouts must be positive") + if args.settle_ms < 0: + parser.error("--settle-ms must be non-negative") + if args.recovery_max_rcb <= 0: + parser.error("--recovery-max-rcb must be positive") + + root = pathlib.Path(__file__).resolve().parents[1] + output_dir = pathlib.Path(args.output).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + binary = find_binary(root, args.binary) + + baseline: dict[str, Any] = {"accepted": False} + recovery: dict[str, Any] = {"accepted": False} + fault: dict[str, Any] = {"accepted": False} + + try: + baseline = run_model( + binary, + args.host, + args.port, + args.recovery_timeout_ms, + args.recovery_max_rcb, + output_dir / "baseline-live-model.json", + ) + print( + "Baseline: " + f"accepted={baseline['accepted']} " + f"structure={baseline.get('structuralFingerprint', '')} " + f"runtime={baseline.get('runtimeSnapshotFingerprint', '')} " + f"warnings={baseline.get('warningCount', 0)}" + ) + if not baseline["accepted"]: + raise RuntimeError("healthy baseline model gate failed; refusing fault injection") + + proxy = PostAssociationStallProxy(args.host, args.port) + proxy.start() + fault_command = [ + str(binary), + proxy.evidence.listen_host, + str(proxy.evidence.listen_port), + "--timeout-ms", + str(args.fault_timeout_ms), + "--no-types", + "--no-datasets", + "--no-rcb", + ] + fault_started = time.monotonic() + try: + fault_process, fault_elapsed_ms = run_process( + fault_command, + max(20.0, (args.fault_timeout_ms / 1000.0) * 4.0), + ) + finally: + proxy.stop() + fault_elapsed_ms = int((time.monotonic() - fault_started) * 1000) + + fault_text = process_text = ( + fault_process.stdout + + (("\n" + fault_process.stderr) if fault_process.stderr else "") + ) + (output_dir / "fault-injection-client.txt").write_text( + fault_text, + encoding="utf-8", + ) + timeout_observed = bool(TIMEOUT_RE.search(process_text)) + proxy_ok = ( + proxy.evidence.association_response_forwarded + and proxy.evidence.fault_triggered + and not proxy.evidence.error + ) + fault_accepted = proxy_ok and timeout_observed and fault_process.returncode != 0 + fault = { + "accepted": fault_accepted, + "kind": "withhold-server-tpkt-after-association", + "proxyEndpoint": ( + f"{proxy.evidence.listen_host}:{proxy.evidence.listen_port}" + ), + "associationResponseForwarded": proxy.evidence.association_response_forwarded, + "firstPostAssociationResponseWithheld": proxy.evidence.fault_triggered, + "withheldTpktCount": proxy.evidence.withheld_tpkt_count, + "withheldBytes": proxy.evidence.withheld_bytes, + "clientToServerBytes": proxy.evidence.client_to_server_bytes, + "serverToClientForwardedBytes": proxy.evidence.server_to_client_forwarded_bytes, + "timeoutObserved": timeout_observed, + "exitCode": fault_process.returncode, + "elapsedMs": fault_elapsed_ms, + "proxyError": proxy.evidence.error, + "rawFile": "fault-injection-client.txt", + } + print( + "Injected timeout: " + f"accepted={fault_accepted} " + f"associationForwarded={fault['associationResponseForwarded']} " + f"responseWithheld={fault['firstPostAssociationResponseWithheld']} " + f"timeoutObserved={timeout_observed} " + f"withheldTpkt={fault['withheldTpktCount']}" + ) + + if args.settle_ms: + time.sleep(args.settle_ms / 1000.0) + + recovery = run_model( + binary, + args.host, + args.port, + args.recovery_timeout_ms, + args.recovery_max_rcb, + output_dir / "recovery-live-model.json", + ) + structural_match = ( + baseline.get("structuralFingerprint") + == recovery.get("structuralFingerprint") + ) + recovery["matchesBaselineStructuralFingerprint"] = structural_match + recovery["accepted"] = bool(recovery["accepted"] and structural_match) + print( + "Recovery: " + f"accepted={recovery['accepted']} " + f"structure={recovery.get('structuralFingerprint', '')} " + f"matchesBaseline={structural_match} " + f"warnings={recovery.get('warningCount', 0)}" + ) + except Exception as exc: + error = str(exc) + else: + error = "" + + accepted = bool( + baseline.get("accepted") + and fault.get("accepted") + and recovery.get("accepted") + ) + summary = { + "schemaVersion": "ariec61850-phase4c-timeout-recovery-v1", + "endpoint": f"{args.host}:{args.port}", + "binary": os.fspath(binary), + "readOnly": True, + "faultTimeoutMs": args.fault_timeout_ms, + "recoveryTimeoutMs": args.recovery_timeout_ms, + "baseline": baseline, + "faultInjection": fault, + "recovery": recovery, + "accepted": accepted, + "error": error, + } + summary_path = output_dir / "phase4c-timeout-recovery-summary.json" + summary_path.write_text( + json.dumps(summary, indent=2) + "\n", + encoding="utf-8", + ) + print( + "Phase 4C controlled timeout/recovery: " + + ("PASS" if accepted else "FAIL") + + f" (baseline={baseline.get('accepted', False)}, " + f"fault={fault.get('accepted', False)}, " + f"recovery={recovery.get('accepted', False)})" + ) + print(f"Summary: {summary_path}") + if error: + print(f"Error: {error}", file=sys.stderr) + return 0 if accepted else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From ed5569fea0204e2008be309cdf6e110f6ac094f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 16:50:19 +0700 Subject: [PATCH 168/179] Smoke controlled timeout recovery evidence runner --- .github/workflows/cpp-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 2cb7e5bc3..ccb9ee052 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -43,6 +43,8 @@ jobs: python3 scripts/run-live-readonly-interop.py --help > /dev/null python3 -m py_compile scripts/run-phase4c-physical-acceptance.py python3 scripts/run-phase4c-physical-acceptance.py --help > /dev/null + python3 -m py_compile scripts/run-phase4c-timeout-recovery.py + python3 scripts/run-phase4c-timeout-recovery.py --help > /dev/null windows: name: Windows / MSVC @@ -62,3 +64,5 @@ jobs: python scripts/run-live-readonly-interop.py --help | Out-Null python -m py_compile scripts/run-phase4c-physical-acceptance.py python scripts/run-phase4c-physical-acceptance.py --help | Out-Null + python -m py_compile scripts/run-phase4c-timeout-recovery.py + python scripts/run-phase4c-timeout-recovery.py --help | Out-Null From 0c9cd5fa43d49ae97728c04342597cad1987ac38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 16:51:51 +0700 Subject: [PATCH 169/179] Close faulted MMS associations immediately --- src/mms/association_runtime.cpp | 34 +++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index cfd1dbfe3..528f1a8dc 100644 --- a/src/mms/association_runtime.cpp +++ b/src/mms/association_runtime.cpp @@ -113,8 +113,8 @@ struct AssociationAttemptProfile final { 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x06U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, - 0x03U, 0xEEU, 0x08U, 0x00U, 0x00U, 0x04U, 0x00U, 0x00U, - 0x00U, 0x01U, 0xE7U, 0x18U, + 0x03U, 0xEEU, 0x08U, 0x00U, 0x04U, 0x00U, 0x00U, 0x00U, + 0x01U, 0xE7U, 0x18U, }; } @@ -594,12 +594,22 @@ MmsConfirmedExchangeResult MmsAssociationRuntime::exchange_confirmed( last_fault_ = exception.what(); add_event(MmsAssociationEventKind::cancelled, exception.what(), expected_invoke_id); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; throw; } catch (const MmsTransportTimeoutError& exception) { state_ = MmsAssociationRuntimeState::faulted; last_fault_ = exception.what(); add_event(MmsAssociationEventKind::timed_out, exception.what(), expected_invoke_id); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; throw; } catch (const std::exception& exception) { if (stop_token.stop_requested()) { @@ -610,6 +620,11 @@ MmsConfirmedExchangeResult MmsAssociationRuntime::exchange_confirmed( } else { fail(exception.what()); } + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; throw; } } @@ -625,11 +640,21 @@ MmsPduEnvelope MmsAssociationRuntime::poll_once( state_ = MmsAssociationRuntimeState::faulted; last_fault_ = exception.what(); add_event(MmsAssociationEventKind::cancelled, exception.what()); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; throw; } catch (const MmsTransportTimeoutError& exception) { state_ = MmsAssociationRuntimeState::faulted; last_fault_ = exception.what(); add_event(MmsAssociationEventKind::timed_out, exception.what()); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; throw; } catch (const std::exception& exception) { if (stop_token.stop_requested()) { @@ -639,6 +664,11 @@ MmsPduEnvelope MmsAssociationRuntime::poll_once( } else { fail(exception.what()); } + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; throw; } } From c6b40689b8754b058d3e75dc6f92c8c7c1ad9dde Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 16:53:40 +0700 Subject: [PATCH 170/179] Restore byte-exact C# association oracle vector --- src/mms/association_runtime.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index 528f1a8dc..951aeab54 100644 --- a/src/mms/association_runtime.cpp +++ b/src/mms/association_runtime.cpp @@ -113,8 +113,8 @@ struct AssociationAttemptProfile final { 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x06U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, - 0x03U, 0xEEU, 0x08U, 0x00U, 0x04U, 0x00U, 0x00U, 0x00U, - 0x01U, 0xE7U, 0x18U, + 0x03U, 0xEEU, 0x08U, 0x00U, 0x00U, 0x04U, 0x00U, 0x00U, + 0x00U, 0x01U, 0xE7U, 0x18U, }; } From ced3ef4a22c907b4b8b88c38a389420bf7c4f0ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 16:56:42 +0700 Subject: [PATCH 171/179] Prove timeout cleanup and reconnect in MMS runtime tests --- tests/test_mms_runtime.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index e60f6519e..5b1ff2f3b 100644 --- a/tests/test_mms_runtime.cpp +++ b/tests/test_mms_runtime.cpp @@ -301,7 +301,7 @@ void association_rejects_cancelled_connect_and_can_reconnect() { CHECK(transport.connect_count() == 1U); } -void confirmed_exchange_timeout_faults_association() { +void confirmed_exchange_timeout_closes_transport_and_can_reconnect() { ScriptedTransport transport; queue_handshake(transport); mms::MmsAssociationRuntime runtime{transport}; @@ -316,10 +316,18 @@ void confirmed_exchange_timeout_faults_association() { check_throws([&] { static_cast(runtime.exchange_confirmed(request_bytes, invoke_id)); }); + CHECK(runtime.state() == mms::MmsAssociationRuntimeState::faulted); + CHECK(!transport.connected()); CHECK(std::any_of(runtime.events().begin(), runtime.events().end(), [](const auto& event) { return event.kind == mms::MmsAssociationEventKind::timed_out; })); + + queue_handshake(transport); + runtime.connect({"127.0.0.1", 102U}); + CHECK(runtime.associated()); + CHECK(transport.connected()); + CHECK(transport.connect_count() == 2U); } [[nodiscard]] mms::MmsReportControlCandidate make_urcb_candidate() { @@ -474,7 +482,7 @@ int main() { {"association lifecycle and routing", association_lifecycle_routes_reports_and_confirmed_results}, {"association profile fallback", association_retries_legacy_profile_after_balanced_rejection}, {"association cancellation and reconnect", association_rejects_cancelled_connect_and_can_reconnect}, - {"confirmed exchange timeout", confirmed_exchange_timeout_faults_association}, + {"confirmed exchange timeout closes transport and reconnects", confirmed_exchange_timeout_closes_transport_and_can_reconnect}, {"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}, From 0f4daa0a77a530d254071c70b5a6ffb33a1acd2c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 16:57:50 +0700 Subject: [PATCH 172/179] Record OCR7SR12 ten-cycle physical acceptance --- MIGRATION_CHECKLIST.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index a9d736797..b102bf43f 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -142,7 +142,7 @@ behavioral oracle until laboratory interoperability is complete. - [x] Controlled physical IED read-only evidence accepted: OCR7SR12 RCB contention probe and SGCB 5/5 deep read. - [x] Integrated `live_discover --control-block-values` physical OCR7SR12 run accepted: 1/1 SGCB complete, 5/5 attributes read, diagnostics=0, pending-value warning cleared. - [x] C# and C++ same-IED OCR7SR12 structural/type/runtime comparison accepted with zero blocking findings. -- [ ] Primary-vendor ten-cycle acceptance with full RCB/control-block gates accepted. +- [x] Primary-vendor OCR7SR12 ten-cycle acceptance: 10/10 stable discovery cycles, full 286/286 RCB and control-block gates, 3/3 StableProceed contention cycles, and 13/13 fresh associations. - [ ] Controlled timeout/recovery evidence accepted. - [ ] Pagination continuation evidence accepted on a target that requires more than one page. From 8561b12d4ba858ba2a8188eddcd7f1174f6e9706 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 16:58:23 +0700 Subject: [PATCH 173/179] Record OCR7SR12 ten-cycle physical evidence --- PHASE4C_PHYSICAL_EVIDENCE.md | 53 ++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/PHASE4C_PHYSICAL_EVIDENCE.md b/PHASE4C_PHYSICAL_EVIDENCE.md index 8f3274351..f6a4d4196 100644 --- a/PHASE4C_PHYSICAL_EVIDENCE.md +++ b/PHASE4C_PHYSICAL_EVIDENCE.md @@ -53,15 +53,58 @@ Control-block read summary: attempted=1, complete=1, partial=0, failed=0. Accepted conclusion: the physical OCR7SR12 exposes one Setting Group Control Block at `OCR7SR12PROT/LLN0.SP.SGCB`, and all five exposed MMS attributes were read successfully in one bounded read-only run. The observed values were `ActSG=1`, `CnfEdit=false`, `EditSG=0`, `NumOfSG=1`, with `LActTm` decoded as IEC 61850 UTC time. +## Integrated live-discovery control-block values + +The integrated `ariec61850_live_discover --control-block-values` path was subsequently run against the same OCR7SR12. The run reported `diagnostics=0`, `CBValueComplete=1`, `CBValuePartial=0`, `CBValueFailed=0`, and `CBValueNotRead=0`. The same SGCB was projected into the live model with all five attributes complete. The prior pending-value warning was no longer emitted; the only warning in the bounded run was the intentional `DATASET_MEMBERS_NOT_READ` warning caused by `--no-datasets`. + +Accepted conclusion: deep-read evidence is not limited to the standalone probe. The integrated discovery -> runtime overlay -> human/JSON projection path has been physically observed on the target IED. + +## Same-IED C# versus C++ parity + +A same-device OCR7SR12 comparison using the C# repository as the behavioral oracle was accepted with zero blocking structural/type/runtime findings for the compared evidence profile. This establishes the accepted parity slice only; it is not a claim of complete C# feature parity. + +## Ten-cycle primary-vendor acceptance + +The bounded Phase 4C physical acceptance runner was executed for ten full discovery cycles followed by three contention cycles. Discovery used full RCB reading (`--max-rcb 286 --require-rcb-complete`), bounded integrated control-block values (`--require-control-block-complete`), and same-IED C# parity checks. DataSet member directories were intentionally skipped with `--no-datasets`, so one expected warning was allowed per discovery cycle. + +Observed discovery result across cycles 1 through 10: + +```text +success=True +structuralFingerprint=934b555dff76a46f +runtimeSnapshotFingerprint=48a836ad2d39b72e +rcbComplete=True +cbComplete=True +warnings=1 +``` + +All ten cycles reported the same structural and runtime fingerprints. Final discovery acceptance: + +```text +Read-only interoperability acceptance: PASS +(structuralStable=True, runtimeStable=True, rcbComplete=True, controlBlockComplete=True) +``` + +The three subsequent contention cycles all used `BalancedApTitle`, required one association attempt, reported `contended=False`, and returned `decision=StableProceed`. + +Final Phase 4C result: + +```text +Phase 4C physical read-only acceptance: PASS +(discovery=True, contention=True, controlBlocks=True, freshAssociations=13/13) +``` + +Accepted conclusion: on this OCR7SR12 observation window, arstack61850 completed ten fresh stable discovery associations with full 286/286 RCB read completeness and complete control-block evidence, followed by three successful fresh contention associations. This is evidence for repeated association/discovery stability on this target; it is not timeout-recovery or multi-vendor evidence. + ## Implementation consequence -The C++ live-model path now treats GO/SV/SG/LG deep-read values as runtime evidence rather than structural identity. The optional `--control-block-values` path can project successful, partial, failed, and bounded/not-read states without changing the structural fingerprint. Runtime attributes are exported as machine-readable attribute/value/status entries so applications do not need to parse the human message text. +The C++ live-model path treats GO/SV/SG/LG deep-read values as runtime evidence rather than structural identity. The optional `--control-block-values` path can project successful, partial, failed, and bounded/not-read states without changing the structural fingerprint. Runtime attributes are exported as machine-readable attribute/value/status entries so applications do not need to parse human message text. + +A dedicated controlled timeout/recovery evidence runner is now implemented separately. It establishes a healthy baseline, injects a post-association server-response stall through a local TCP proxy, requires the client-side MMS request deadline to expire, then performs a fresh direct recovery association and compares the recovered structural fingerprint with the baseline. This runner is not counted as physical evidence until it is executed successfully against the IED. ## Still pending -- Integrated `ariec61850_live_discover --control-block-values` physical run on the same IED. -- Ten repeated primary-vendor discovery cycles with reconnect evidence. -- Deliberate timeout/reconnect test. -- Same-IED C# versus C++ model export comparison. +- Controlled timeout/recovery physical acceptance on the OCR7SR12. +- Pagination continuation evidence on a target response that actually requires more than one page. - Physical GOOSE/SV capture interoperability evidence. - Any active RCB claim, reporting enable, control, or mutation test; these remain outside the read-only Phase 4C acceptance boundary. From 6fa440aa65b6dcf603c349de5d0aa6d601437fac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:28:32 +0700 Subject: [PATCH 174/179] Accept OCR7SR12 controlled timeout recovery evidence --- MIGRATION_CHECKLIST.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index b102bf43f..d2bc4d573 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -143,7 +143,7 @@ behavioral oracle until laboratory interoperability is complete. - [x] Integrated `live_discover --control-block-values` physical OCR7SR12 run accepted: 1/1 SGCB complete, 5/5 attributes read, diagnostics=0, pending-value warning cleared. - [x] C# and C++ same-IED OCR7SR12 structural/type/runtime comparison accepted with zero blocking findings. - [x] Primary-vendor OCR7SR12 ten-cycle acceptance: 10/10 stable discovery cycles, full 286/286 RCB and control-block gates, 3/3 StableProceed contention cycles, and 13/13 fresh associations. -- [ ] Controlled timeout/recovery evidence accepted. +- [x] Controlled OCR7SR12 timeout/recovery evidence accepted: healthy baseline, post-association response withholding, client request timeout observed, fresh direct recovery, and identical `934b555dff76a46f` structural fingerprint before/after recovery. - [ ] Pagination continuation evidence accepted on a target that requires more than one page. ### Later Phase 4 work From a837337c7e97758db42e382ff6d30fdb13c59677 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:29:07 +0700 Subject: [PATCH 175/179] Record OCR7SR12 timeout recovery physical evidence --- PHASE4C_PHYSICAL_EVIDENCE.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/PHASE4C_PHYSICAL_EVIDENCE.md b/PHASE4C_PHYSICAL_EVIDENCE.md index f6a4d4196..699df0108 100644 --- a/PHASE4C_PHYSICAL_EVIDENCE.md +++ b/PHASE4C_PHYSICAL_EVIDENCE.md @@ -94,17 +94,37 @@ Phase 4C physical read-only acceptance: PASS (discovery=True, contention=True, controlBlocks=True, freshAssociations=13/13) ``` -Accepted conclusion: on this OCR7SR12 observation window, arstack61850 completed ten fresh stable discovery associations with full 286/286 RCB read completeness and complete control-block evidence, followed by three successful fresh contention associations. This is evidence for repeated association/discovery stability on this target; it is not timeout-recovery or multi-vendor evidence. +Accepted conclusion: on this OCR7SR12 observation window, arstack61850 completed ten fresh stable discovery associations with full 286/286 RCB read completeness and complete control-block evidence, followed by three successful fresh contention associations. This is evidence for repeated association/discovery stability on this target; it is not multi-vendor evidence. + +## Controlled timeout and recovery acceptance + +The dedicated read-only timeout/recovery runner was executed against the same OCR7SR12 after the ten-cycle acceptance. The runner first completed a healthy direct baseline discovery, then placed a localhost TCP proxy between the client and relay. The proxy forwarded COTP and the complete Session/Presentation/ACSE association response without modification, forwarded the subsequent confirmed MMS request, and withheld the first server TPKT response after association so the client request deadline expired. After the faulted association was closed, the runner performed a fresh direct recovery association and compared its structural fingerprint with the baseline. + +Command: + +```powershell +python .\scripts\run-phase4c-timeout-recovery.py 192.168.1.10 102 --output .\.artifacts\interop\ocr7sr12-phase4c-timeout-recovery --fault-timeout-ms 3000 --recovery-timeout-ms 30000 --settle-ms 500 --recovery-max-rcb 50 +``` + +Observed result: + +```text +Baseline: accepted=True structure=934b555dff76a46f runtime=21be72da011f5c1e warnings=1 +Injected timeout: accepted=True associationForwarded=True responseWithheld=True timeoutObserved=True withheldTpkt=1 +Recovery: accepted=True structure=934b555dff76a46f matchesBaseline=True warnings=1 +Phase 4C controlled timeout/recovery: PASS (baseline=True, fault=True, recovery=True) +``` + +Accepted conclusion: the target was healthy immediately before fault injection, the association itself was successfully established before the response stall, a real client-side confirmed-request timeout was observed after one server TPKT was withheld, and a subsequent fresh direct association recovered successfully with the same `934b555dff76a46f` structural fingerprint as the baseline. This closes the controlled timeout/recovery gate for this physical target and observation window. It does not imply network-fault recovery has been validated for every failure mode or vendor. ## Implementation consequence The C++ live-model path treats GO/SV/SG/LG deep-read values as runtime evidence rather than structural identity. The optional `--control-block-values` path can project successful, partial, failed, and bounded/not-read states without changing the structural fingerprint. Runtime attributes are exported as machine-readable attribute/value/status entries so applications do not need to parse human message text. -A dedicated controlled timeout/recovery evidence runner is now implemented separately. It establishes a healthy baseline, injects a post-association server-response stall through a local TCP proxy, requires the client-side MMS request deadline to expire, then performs a fresh direct recovery association and compares the recovered structural fingerprint with the baseline. This runner is not counted as physical evidence until it is executed successfully against the IED. +Confirmed MMS exchange timeout, cancellation, and fault paths now terminate the affected association immediately by closing the transport and clearing invoke-routing, InformationReport, TPKT, and remote-COTP state before a future reconnect. Regression coverage requires that a timed-out runtime can establish a fresh association afterward. ## Still pending -- Controlled timeout/recovery physical acceptance on the OCR7SR12. - Pagination continuation evidence on a target response that actually requires more than one page. - Physical GOOSE/SV capture interoperability evidence. - Any active RCB claim, reporting enable, control, or mutation test; these remain outside the read-only Phase 4C acceptance boundary. From fe07a7ba87c4dfffc9a3a512550222fea7fc76d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:33:57 +0700 Subject: [PATCH 176/179] Add live GetNameList pagination evidence probe --- tools/pagination_probe.cpp | 424 +++++++++++++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 tools/pagination_probe.cpp diff --git a/tools/pagination_probe.cpp b/tools/pagination_probe.cpp new file mode 100644 index 000000000..eac96702f --- /dev/null +++ b/tools/pagination_probe.cpp @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/live_discovery.hpp" +#include "ariec61850/mms/services.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +struct PageEvidence final { + std::size_t page{}; + std::string request_continue_after; + std::size_t response_name_count{}; + std::string response_last_name; + bool more_follows{}; +}; + +struct QueryEvidence final { + mms::MmsGetNameListObjectClass object_class{mms::MmsGetNameListObjectClass::domain}; + std::string domain; + std::vector pages; + std::size_t unique_name_count{}; + + [[nodiscard]] bool paginated() const noexcept { + return pages.size() > 1U || std::any_of( + pages.begin(), pages.end(), [](const auto& page) { return page.more_follows; }); + } + + [[nodiscard]] std::size_t continuation_request_count() const noexcept { + return static_cast(std::count_if( + pages.begin(), pages.end(), [](const auto& page) { + return !page.request_continue_after.empty(); + })); + } +}; + +[[nodiscard]] std::string_view class_name( + const mms::MmsGetNameListObjectClass object_class) noexcept { + switch (object_class) { + case mms::MmsGetNameListObjectClass::domain: + return "Domain"; + case mms::MmsGetNameListObjectClass::named_variable: + return "NamedVariable"; + case mms::MmsGetNameListObjectClass::named_variable_list: + return "NamedVariableList"; + } + return "Unknown"; +} + +[[nodiscard]] std::string json_escape(const std::string_view value) { + std::ostringstream output; + for (const char character : value) { + switch (character) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\b': output << "\\b"; break; + case '\f': output << "\\f"; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(character) < 0x20U) output << '?'; + else output << character; + break; + } + } + return output.str(); +} + +[[nodiscard]] std::size_t parse_positive( + const std::string& option, + const std::string& value) { + std::size_t consumed = 0U; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive bounded integer."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + const auto parsed = parse_positive("port", value); + if (parsed > 65'535U) { + throw std::invalid_argument("port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::span response_payload( + const mms::MmsConfirmedExchangeResult& exchange) { + if (exchange.envelope.kind != mms::MmsPduKind::confirmed_response) { + throw std::runtime_error("GetNameList did not return a confirmed response."); + } + if (!exchange.presentation_payload.empty()) { + return exchange.presentation_payload; + } + if (!exchange.envelope.mms_payload.empty()) { + return exchange.envelope.mms_payload; + } + throw std::runtime_error("GetNameList returned no decodable response payload."); +} + +[[nodiscard]] QueryEvidence run_query( + mms::MmsAssociationRuntime& association, + const mms::MmsGetNameListObjectClass object_class, + const std::string& domain, + const std::size_t maximum_pages, + const std::size_t maximum_names) { + QueryEvidence evidence; + evidence.object_class = object_class; + evidence.domain = domain; + + std::set> unique_names; + std::string continue_after; + + for (std::size_t page_index = 0U; page_index < maximum_pages; ++page_index) { + mms::MmsGetNameListRequest request; + request.invoke_id = association.next_invoke_id(); + request.object_class = object_class; + request.scope = domain.empty() + ? mms::MmsObjectScopeKind::vmd_specific + : mms::MmsObjectScopeKind::domain_specific; + request.domain_id = domain; + request.continue_after = continue_after; + + const auto encoded = mms::MmsServiceCodec::encode_get_name_list_request_p_data( + request, association.negotiated().presentation_context_id); + const auto exchange = association.exchange_confirmed(encoded, request.invoke_id); + const auto response = mms::MmsServiceCodec::decode_get_name_list_response( + response_payload(exchange), request.invoke_id); + + PageEvidence page; + page.page = page_index + 1U; + page.request_continue_after = request.continue_after; + page.response_name_count = response.names.size(); + page.more_follows = response.more_follows; + if (!response.names.empty()) { + page.response_last_name = response.names.back(); + } + evidence.pages.push_back(page); + + const auto before = unique_names.size(); + for (const auto& name : response.names) { + if (unique_names.size() >= maximum_names && + unique_names.find(name) == unique_names.end()) { + throw std::runtime_error( + std::string{class_name(object_class)} + " name bound exceeded."); + } + unique_names.insert(name); + } + + if (!response.more_follows) { + evidence.unique_name_count = unique_names.size(); + return evidence; + } + if (response.names.empty() || unique_names.size() == before) { + throw std::runtime_error( + std::string{class_name(object_class)} + + " reported moreFollows without forward progress."); + } + + continue_after = response.names.back(); + } + + throw std::runtime_error( + std::string{class_name(object_class)} + " exceeded the page bound."); +} + +void print_usage() { + std::cout + << "Usage: ariec61850_pagination_probe [port] [options]\n" + << "Options:\n" + << " --timeout-ms N Connect/request timeout (default 30000).\n" + << " --max-pages N Maximum pages per GetNameList query (default 256).\n" + << " --max-names N Maximum unique names per query (default 65536).\n" + << " --max-domains N Maximum domains to inspect (default 4096).\n" + << " --require-pagination Return failure unless real continuation is observed.\n" + << " --json Emit machine-readable evidence JSON.\n" + << "\nThis probe sends GetNameList only; it never sends MMS Write or mutation requests.\n"; +} + +void print_human( + const mms::MmsEndpoint& endpoint, + const mms::MmsAssociationRuntime& association, + const std::vector& queries, + const bool accepted) { + const auto paginated = static_cast(std::count_if( + queries.begin(), queries.end(), [](const auto& query) { return query.paginated(); })); + std::size_t continuations = 0U; + for (const auto& query : queries) { + continuations += query.continuation_request_count(); + } + + std::cout << "Read-only GetNameList pagination probe: endpoint=" + << endpoint.host << ':' << endpoint.port + << ", associationProfile=" + << (association.active_association_profile().empty() + ? std::string{"unknown"} + : association.active_association_profile()) + << ", associationAttempts=" << association.association_attempts().size() + << ", queries=" << queries.size() + << ", paginatedQueries=" << paginated + << ", continuationRequests=" << continuations + << ", accepted=" << (accepted ? "true" : "false") << ".\n"; + + for (const auto& query : queries) { + std::cout << "Query " << class_name(query.object_class) + << " scope=" << (query.domain.empty() ? "VMD" : query.domain) + << " pages=" << query.pages.size() + << " names=" << query.unique_name_count + << " continuations=" << query.continuation_request_count() + << ".\n"; + for (const auto& page : query.pages) { + std::cout << " page=" << page.page + << " requestContinueAfter=" + << (page.request_continue_after.empty() + ? std::string{"-"} + : page.request_continue_after) + << " responseNames=" << page.response_name_count + << " responseLastName=" + << (page.response_last_name.empty() + ? std::string{"-"} + : page.response_last_name) + << " moreFollows=" << (page.more_follows ? "true" : "false") + << '\n'; + } + } +} + +void print_json( + const mms::MmsEndpoint& endpoint, + const mms::MmsAssociationRuntime& association, + const std::vector& queries, + const bool accepted) { + const auto paginated = static_cast(std::count_if( + queries.begin(), queries.end(), [](const auto& query) { return query.paginated(); })); + std::size_t continuations = 0U; + for (const auto& query : queries) { + continuations += query.continuation_request_count(); + } + + std::cout << '{' + << "\"schemaVersion\":\"ariec61850-pagination-evidence-v1\"," + << "\"endpoint\":\"" << json_escape(endpoint.host) << ':' + << endpoint.port << "\"," + << "\"readOnly\":true," + << "\"associationProfile\":\"" + << json_escape(association.active_association_profile()) << "\"," + << "\"associationAttempts\":" << association.association_attempts().size() << ',' + << "\"queryCount\":" << queries.size() << ',' + << "\"paginatedQueryCount\":" << paginated << ',' + << "\"continuationRequestCount\":" << continuations << ',' + << "\"accepted\":" << (accepted ? "true" : "false") << ',' + << "\"queries\":["; + + for (std::size_t query_index = 0U; query_index < queries.size(); ++query_index) { + if (query_index != 0U) std::cout << ','; + const auto& query = queries[query_index]; + std::cout << '{' + << "\"objectClass\":\"" << class_name(query.object_class) << "\"," + << "\"scope\":\"" + << json_escape(query.domain.empty() ? std::string{"VMD"} : query.domain) + << "\"," + << "\"pageCount\":" << query.pages.size() << ',' + << "\"uniqueNameCount\":" << query.unique_name_count << ',' + << "\"continuationRequestCount\":" + << query.continuation_request_count() << ',' + << "\"paginated\":" << (query.paginated() ? "true" : "false") << ',' + << "\"pages\":["; + for (std::size_t page_index = 0U; page_index < query.pages.size(); ++page_index) { + if (page_index != 0U) std::cout << ','; + const auto& page = query.pages[page_index]; + std::cout << '{' + << "\"page\":" << page.page << ',' + << "\"requestContinueAfter\":\"" + << json_escape(page.request_continue_after) << "\"," + << "\"responseNameCount\":" << page.response_name_count << ',' + << "\"responseLastName\":\"" + << json_escape(page.response_last_name) << "\"," + << "\"moreFollows\":" << (page.more_follows ? "true" : "false") + << '}'; + } + std::cout << "]}"; + } + std::cout << "]}\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; + } + if (argc < 2) { + print_usage(); + return 2; + } + + mms::MmsEndpoint endpoint{argv[1], 102U}; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + endpoint.port = parse_port(argv[argument++]); + } + + std::size_t timeout_ms = 30'000U; + std::size_t maximum_pages = 256U; + std::size_t maximum_names = 65'536U; + std::size_t maximum_domains = 4'096U; + bool require_pagination = false; + bool json = false; + + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--require-pagination") { + require_pagination = true; + } else if (option == "--json") { + json = true; + } else if (option == "--timeout-ms" || option == "--max-pages" || + option == "--max-names" || option == "--max-domains") { + if (argument >= argc) { + throw std::invalid_argument(option + " requires a value."); + } + const auto value = parse_positive(option, argv[argument++]); + if (option == "--timeout-ms") timeout_ms = value; + else if (option == "--max-pages") maximum_pages = value; + else if (option == "--max-names") maximum_names = value; + else maximum_domains = value; + } else if (option == "--help" || option == "-h") { + print_usage(); + return 0; + } else { + throw std::invalid_argument("Unknown option: " + option); + } + } + + if (timeout_ms > static_cast( + std::numeric_limits::max())) { + throw std::invalid_argument("--timeout-ms is too large."); + } + + mms::MmsAssociationOptions association_options; + association_options.connect_timeout = std::chrono::milliseconds{ + static_cast(timeout_ms)}; + association_options.request_timeout = association_options.connect_timeout; + mms::MmsTcpLiveDiscoverySession session{{}, association_options}; + session.connect(endpoint); + + std::vector queries; + auto domains_query = run_query( + session.association(), mms::MmsGetNameListObjectClass::domain, {}, + maximum_pages, maximum_names); + std::vector domains; + for (const auto& page : domains_query.pages) { + static_cast(page); + } + + // Fetch the domain identifiers once more from the query responses would require + // retaining every name in evidence. Instead, use the normal bounded discovery + // snapshot for the domain list only; the pagination evidence itself remains the + // direct GetNameList trace above and below. + mms::MmsLiveDiscoveryOptions domain_options; + domain_options.maximum_pages_per_query = maximum_pages; + domain_options.maximum_domains = maximum_domains; + domain_options.maximum_names_per_domain = maximum_names; + domain_options.probe_variable_types = false; + domain_options.read_data_set_directories = false; + domain_options.probe_report_controls = false; + const auto snapshot = session.discover(domain_options); + for (const auto& [domain, variables] : snapshot.names.domain_variables) { + static_cast(variables); + domains.push_back(domain); + } + if (domains.size() > maximum_domains) { + throw std::runtime_error("domain bound exceeded"); + } + + queries.push_back(std::move(domains_query)); + for (const auto& domain : domains) { + queries.push_back(run_query( + session.association(), mms::MmsGetNameListObjectClass::named_variable, + domain, maximum_pages, maximum_names)); + queries.push_back(run_query( + session.association(), + mms::MmsGetNameListObjectClass::named_variable_list, + domain, maximum_pages, maximum_names)); + } + + const bool pagination_observed = std::any_of( + queries.begin(), queries.end(), [](const auto& query) { return query.paginated(); }); + const bool accepted = !require_pagination || pagination_observed; + + if (json) print_json(endpoint, session.association(), queries, accepted); + else print_human(endpoint, session.association(), queries, accepted); + session.disconnect(); + + if (require_pagination && !pagination_observed) { + std::cerr << "Pagination gate failed: no GetNameList response required continuation.\n"; + return 1; + } + return 0; + } catch (const std::exception& exception) { + std::cerr << "Pagination probe failed: " << exception.what() << '\n'; + return 2; + } +} From 4c755b231642dd75be266135ab5693adfb7b4c55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:35:44 +0700 Subject: [PATCH 177/179] Keep pagination probe minimal and single-pass --- tools/pagination_probe.cpp | 47 ++++++++++++-------------------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/tools/pagination_probe.cpp b/tools/pagination_probe.cpp index eac96702f..3613512c6 100644 --- a/tools/pagination_probe.cpp +++ b/tools/pagination_probe.cpp @@ -32,7 +32,7 @@ struct QueryEvidence final { mms::MmsGetNameListObjectClass object_class{mms::MmsGetNameListObjectClass::domain}; std::string domain; std::vector pages; - std::size_t unique_name_count{}; + std::vector names; [[nodiscard]] bool paginated() const noexcept { return pages.size() > 1U || std::any_of( @@ -125,7 +125,7 @@ struct QueryEvidence final { evidence.object_class = object_class; evidence.domain = domain; - std::set> unique_names; + std::set> seen; std::string continue_after; for (std::size_t page_index = 0U; page_index < maximum_pages; ++page_index) { @@ -154,21 +154,22 @@ struct QueryEvidence final { } evidence.pages.push_back(page); - const auto before = unique_names.size(); + const auto count_before = evidence.names.size(); for (const auto& name : response.names) { - if (unique_names.size() >= maximum_names && - unique_names.find(name) == unique_names.end()) { + if (!seen.insert(name).second) { + continue; + } + if (evidence.names.size() >= maximum_names) { throw std::runtime_error( std::string{class_name(object_class)} + " name bound exceeded."); } - unique_names.insert(name); + evidence.names.push_back(name); } if (!response.more_follows) { - evidence.unique_name_count = unique_names.size(); return evidence; } - if (response.names.empty() || unique_names.size() == before) { + if (response.names.empty() || evidence.names.size() == count_before) { throw std::runtime_error( std::string{class_name(object_class)} + " reported moreFollows without forward progress."); @@ -222,7 +223,7 @@ void print_human( std::cout << "Query " << class_name(query.object_class) << " scope=" << (query.domain.empty() ? "VMD" : query.domain) << " pages=" << query.pages.size() - << " names=" << query.unique_name_count + << " names=" << query.names.size() << " continuations=" << query.continuation_request_count() << ".\n"; for (const auto& page : query.pages) { @@ -277,7 +278,7 @@ void print_json( << json_escape(query.domain.empty() ? std::string{"VMD"} : query.domain) << "\"," << "\"pageCount\":" << query.pages.size() << ',' - << "\"uniqueNameCount\":" << query.unique_name_count << ',' + << "\"uniqueNameCount\":" << query.names.size() << ',' << "\"continuationRequestCount\":" << query.continuation_request_count() << ',' << "\"paginated\":" << (query.paginated() ? "true" : "false") << ',' @@ -368,32 +369,12 @@ int main(int argc, char** argv) { auto domains_query = run_query( session.association(), mms::MmsGetNameListObjectClass::domain, {}, maximum_pages, maximum_names); - std::vector domains; - for (const auto& page : domains_query.pages) { - static_cast(page); - } - - // Fetch the domain identifiers once more from the query responses would require - // retaining every name in evidence. Instead, use the normal bounded discovery - // snapshot for the domain list only; the pagination evidence itself remains the - // direct GetNameList trace above and below. - mms::MmsLiveDiscoveryOptions domain_options; - domain_options.maximum_pages_per_query = maximum_pages; - domain_options.maximum_domains = maximum_domains; - domain_options.maximum_names_per_domain = maximum_names; - domain_options.probe_variable_types = false; - domain_options.read_data_set_directories = false; - domain_options.probe_report_controls = false; - const auto snapshot = session.discover(domain_options); - for (const auto& [domain, variables] : snapshot.names.domain_variables) { - static_cast(variables); - domains.push_back(domain); - } - if (domains.size() > maximum_domains) { + if (domains_query.names.size() > maximum_domains) { throw std::runtime_error("domain bound exceeded"); } - + const auto domains = domains_query.names; queries.push_back(std::move(domains_query)); + for (const auto& domain : domains) { queries.push_back(run_query( session.association(), mms::MmsGetNameListObjectClass::named_variable, From 0508f839a204361e42971713a8dca9dbe31438e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 18:36:30 +0700 Subject: [PATCH 178/179] Build pagination evidence probe --- CMakeLists.txt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d8b773074..02992fed8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -124,6 +124,12 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_control_block_read_probe PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_control_block_read_probe) ariec61850_apply_sanitizers(ariec61850_control_block_read_probe) + + add_executable(ariec61850_pagination_probe tools/pagination_probe.cpp) + target_link_libraries(ariec61850_pagination_probe PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_pagination_probe PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_pagination_probe) + ariec61850_apply_sanitizers(ariec61850_pagination_probe) endif() if(ARIEC61850_BUILD_TESTS) @@ -265,6 +271,9 @@ if(ARIEC61850_BUILD_TESTS) COMMAND ariec61850_comtrade_inspect ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/comtrade/simple_fault.cfg --json) + add_test( + NAME ariec61850_pagination_probe_help + COMMAND ariec61850_pagination_probe --help) endif() endif() @@ -295,4 +304,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() \ No newline at end of file From 3bffcb541258efc439e35c3852b48373921d1901 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 11 Aug 2026 04:17:25 +0700 Subject: [PATCH 179/179] Ignore generated runtime evidence --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 877ee0866..7d7c7b63c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ __pycache__/ *.pyc interop-evidence/ evidence/ +.artifacts/ *-live-model.json +*-live-runtime.json *-parity.json interop-summary.json