diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 1b7fe2f7e..ccb9ee052 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: @@ -35,6 +35,16 @@ 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 + - 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 + 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 @@ -47,3 +57,12 @@ jobs: run: cmake --build build --config Release --parallel - name: Test 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 + 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 diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml new file mode 100644 index 000000000..f3682334a --- /dev/null +++ b/.github/workflows/embedded-ci.yml @@ -0,0 +1,108 @@ +name: Embedded Profile CI + +on: + push: + branches: + - main + 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\s*(.*?)\)', 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: + 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(): + 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 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: diff --git a/.gitignore b/.gitignore index b879cb8f0..7d7c7b63c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,32 @@ 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/ +.artifacts/ +*-live-model.json +*-live-runtime.json +*-parity.json +interop-summary.json 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index ce4b7a73d..ed152026c 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) @@ -30,6 +30,9 @@ 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/mms/rcb_selection.cpp src/sampled_values/pdu_codec.cpp src/sampled_values/frame_codec.cpp src/sampled_values/quality.cpp @@ -57,6 +60,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-) @@ -100,6 +107,30 @@ 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) + + 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) + + 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) + + 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) @@ -198,6 +229,34 @@ 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_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_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 @@ -220,6 +279,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() @@ -250,4 +312,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 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`. diff --git a/ESP32_WAVESHARE_ROADMAP.md b/ESP32_WAVESHARE_ROADMAP.md new file mode 100644 index 000000000..34ff38045 --- /dev/null +++ b/ESP32_WAVESHARE_ROADMAP.md @@ -0,0 +1,385 @@ +# 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 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. + +## 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. ESP-IDF W5500 Ethernet adapter + +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 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: + +- 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 while normal TCP/IP remains usable. + +### 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 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. 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`. 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: diff --git a/LIVE_DISCOVERY_PROFILE.md b/LIVE_DISCOVERY_PROFILE.md new file mode 100644 index 000000000..2cf541800 --- /dev/null +++ b/LIVE_DISCOVERY_PROFILE.md @@ -0,0 +1,71 @@ +# Built-in TCP, Live Read-Only Discovery, and Live-Model Parity Profile + +## Scope + +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` 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. + +## Read-only discovery + +`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 + +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 emits `live-ied-model-v1` JSON through +`ariec61850_live_discover --model-json`. + +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 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 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. diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index b70de0286..d2bc4d573 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 @@ -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,43 @@ 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. + +### 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. +- [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. +- [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. +- [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 - [ ] Direct and SBO control state machines. @@ -124,24 +158,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. -- [ ] 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/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. 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. 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. diff --git a/PHASE4C_PHYSICAL_EVIDENCE.md b/PHASE4C_PHYSICAL_EVIDENCE.md new file mode 100644 index 000000000..699df0108 --- /dev/null +++ b/PHASE4C_PHYSICAL_EVIDENCE.md @@ -0,0 +1,130 @@ +# 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. + +## 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 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. + +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 + +- 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. 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. diff --git a/PORT_STATUS.md b/PORT_STATUS.md index fd3ebed2e..6de67e1c4 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.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,60 +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 TCP socket transport | Not included | - -## 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. - -## 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. +| 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 -- 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. +- 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 -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 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. diff --git a/README.md b/README.md index 16caab86a..23854aeb7 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,23 @@ The next timing gate is **ESP32-P4 EMAC IEEE1588v2 hardware timestamp evidence** The original work began as an incremental C++20 migration of the ARIEC61850 C# protocol stack. The C# implementation remains a behavioral oracle where cross-language equivalence is still useful, while ARStack-specific work adds deterministic RTOS/hardware execution and evidence that a normal desktop stack cannot provide. +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. + ## 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. +- 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, GetVariableAccessAttributes, Read, and Write codecs. +- DataSet directory, InformationReport, RCB state, and report-subscription foundations. ### Phase 1A–1E — deterministic process-bus core @@ -55,6 +64,9 @@ The original work began as an incremental C++20 migration of the ARIEC61850 C# p - Sanitizer, deterministic mutation-smoke, and LLVM libFuzzer coverage. - Receive-only isolated-laboratory tooling. +A codec existing in the library does **not** mean the corresponding mutating +operation is enabled in live Phase 4C discovery. + ### Phase 2A — SCL foundation - Bounded, dependency-free XML/SCL reader. @@ -92,6 +104,50 @@ The original work began as an incremental C++20 migration of the ARIEC61850 C# p - Deterministic association-response construction with session and context mirroring. - Byte-exact C# request/response golden vectors, mutation smoke, sanitizer, and libFuzzer coverage. +### Phase 4C — 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. + +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. + +#### Public Alpha A1 model work + +The live model now projects C#-style: + +- `proposedLnTypeId` and `proposedDoTypeId`; +- `typeTemplates`; +- `variableTypeDiscoveries`; +- deterministic IED identity evidence; +- DataSet/RCB/control-block inventory. + +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. + ### Phase 3C — MMS service codecs - MMS Initiate request/response and confirmed request/response/error envelopes. @@ -173,6 +229,104 @@ ctest --test-dir build-sanitizers --output-on-failure .\scripts\run-lab-check.ps1 -Pcap .\captures\device-session.pcap ``` +## Live discovery quick start + +Human summary: + +```powershell +.\build\Release\ariec61850_live_discover.exe 192.168.1.10 102 +``` + +Bounded read-only RCB evidence: + +```powershell +.\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 +``` + +Model JSON with bounded type discovery: + +```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 +``` + +Inspect fingerprint/type evidence: + +```powershell +$model = Get-Content .artifacts\parity\cpp-model.json -Raw | ConvertFrom-Json +$model.fingerprint +$model.structuralFingerprint +$model.runtimeSnapshotFingerprint +$model.typeTemplates.Count +$model.variableTypeDiscoveries.Count +``` + +## Same-IED C# ↔ C++ parity + +The canonical comparator is: + +```powershell +python .\tools\compare_live_model_json.py ` + .artifacts\parity\csharp\ied-model.json ` + .artifacts\parity\cpp-model.json +``` + +Optional evidence: + +```powershell +python .\tools\compare_live_model_json.py ` + .artifacts\parity\csharp\ied-model.json ` + .artifacts\parity\cpp-model.json ` + --types ` + --runtime +``` + +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. + +See `docs/FEATURE_MATRIX.md` for a conservative status table and +`PHASE_4C1_LIVE_MODEL_INTEROP.md` for the live-evidence boundary. + 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. ## North-star reminder @@ -192,4 +346,4 @@ The long-term product family — injector, process-bus analyzer, digital-substat ## Public-repository naming policy -Public project documentation should remain vendor-neutral. Do not add commercial competitor names, product comparisons, logos, screenshots, or vendor marketing references. Standards, protocol identifiers, and technical platform/dependency names required to build or reproduce the project are allowed. \ No newline at end of file +Public project documentation should remain vendor-neutral. Do not add commercial competitor names, product comparisons, logos, screenshots, or vendor marketing references. Standards, protocol identifiers, and technical platform/dependency names required to build or reproduce the project are allowed. 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. 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. 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. 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. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md new file mode 100644 index 000000000..7cd162130 --- /dev/null +++ b/docs/FEATURE_MATRIX.md @@ -0,0 +1,93 @@ +# 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 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. | +| 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. | +| 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. | +| 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. | +| 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: 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 | 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 / 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-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. | +| 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 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. 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 + +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. 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 + +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. diff --git a/docs/LIVE_MODEL_PARITY.md b/docs/LIVE_MODEL_PARITY.md new file mode 100644 index 000000000..c8f5a47a0 --- /dev/null +++ b/docs/LIVE_MODEL_PARITY.md @@ -0,0 +1,146 @@ +# 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 +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 +``` + +`--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 +``` + +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 +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 +``` + +`--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. diff --git a/docs/PHYSICAL_EVIDENCE_OCR7SR12.md b/docs/PHYSICAL_EVIDENCE_OCR7SR12.md new file mode 100644 index 000000000..375605273 --- /dev/null +++ b/docs/PHYSICAL_EVIDENCE_OCR7SR12.md @@ -0,0 +1,145 @@ +# 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, setting-group +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. +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 + +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. + +## 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. +- `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`. +- 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. +- 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. 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}") 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. 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. diff --git a/embedded/embedded_link_smoke.cpp b/embedded/embedded_link_smoke.cpp new file mode 100644 index 000000000..d9071adb2 --- /dev/null +++ b/embedded/embedded_link_smoke.cpp @@ -0,0 +1,114 @@ +// 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" +#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 "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#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() { + 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; + asn1::BerTlv tlv; + const bool parsed = asn1::BerReader::try_read_tlv(bytes, offset, tlv); + + static_assert( + 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; +} 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..eb913ee0c --- /dev/null +++ b/include/ariec61850/embedded/boards/waveshare_esp32s3_poe_eth_8di8do.hpp @@ -0,0 +1,68 @@ +// 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; + + // 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{ + 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 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 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 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_{}; 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..b5bd0b74b --- /dev/null +++ b/include/ariec61850/mms/control_block_model_evidence.hpp @@ -0,0 +1,392 @@ +// 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_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) { + 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; + project_runtime_attributes(control, evidence); + 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 diff --git a/include/ariec61850/mms/control_block_read.hpp b/include/ariec61850/mms/control_block_read.hpp new file mode 100644 index 000000000..010ceebdc --- /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 +#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.attribute_path, attribute_path); + }); + 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 diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp new file mode 100644 index 000000000..3d74b65a5 --- /dev/null +++ b/include/ariec61850/mms/live_discovery.hpp @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#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" + +#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; + std::string association_profile; + std::vector association_attempts; + MmsDiscoverySnapshot names; + MmsReportInventory report_inventory; + 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; + [[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/live_model.hpp b/include/ariec61850/mms/live_model.hpp new file mode 100644 index 000000000..16feaa7b1 --- /dev/null +++ b/include/ariec61850/mms/live_model.hpp @@ -0,0 +1,337 @@ +// 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 proposed_do_type_id; + 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; + std::vector used_by_goose_controls; + std::vector used_by_sampled_value_controls; +}; + +struct MmsLiveReportControl final { + std::string reference; + std::string domain; + std::string logical_node; + 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; + 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; +}; + +// 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. +// 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; + 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::vector runtime_attributes; + std::string message; + + [[nodiscard]] std::size_t attribute_count() const noexcept { + return attributes.size(); + } +}; + +// 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{}; + 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 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{}; + 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{}; + 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 goose_control_blocks; + 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; + + // 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; +}; + +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 { +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" \ No newline at end of file diff --git a/include/ariec61850/mms/live_model.ipp b/include/ariec61850/mms/live_model.ipp new file mode 100644 index 000000000..a94ced81d --- /dev/null +++ b/include/ariec61850/mms/live_model.ipp @@ -0,0 +1,10 @@ +// 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_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_fingerprint.ipp" \ No newline at end of file diff --git a/include/ariec61850/mms/live_model_chunk01.ipp b/include/ariec61850/mms/live_model_chunk01.ipp new file mode 100644 index 000000000..ce9396315 --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk01.ipp @@ -0,0 +1,305 @@ +// 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; +} + +// 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 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( + 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 (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 == "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}; +} diff --git a/include/ariec61850/mms/live_model_chunk02.ipp b/include/ariec61850/mms/live_model_chunk02.ipp new file mode 100644 index 000000000..cd9b47eb3 --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk02.ipp @@ -0,0 +1,349 @@ +[[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) { + 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) { + 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 (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()}); + } + + 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) { + 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); + 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); \ No newline at end of file diff --git a/include/ariec61850/mms/live_model_chunk03.ipp b/include/ariec61850/mms/live_model_chunk03.ipp new file mode 100644 index 000000000..5bdc4f2cc --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk03.ipp @@ -0,0 +1,346 @@ + 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 = attribute_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(); + // 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; + } else { + attribute.scl_basic_type = guess_scl_basic_type( + attribute.attribute_path, + attribute.functional_constraint); + attribute.type_source = "NameListHeuristic"; + attribute.type_confidence = MmsLiveModelConfidence::low; + } + object_attributes.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)); + } + + // 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. + for (const auto& candidate : discovery.report_inventory.data_sets) { + MmsLiveDataSet data_set; + 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)); + } + + // 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; + // 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; + 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(), + 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->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); + 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 (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); + } + } + 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); + } diff --git a/include/ariec61850/mms/live_model_chunk04.ipp b/include/ariec61850/mms/live_model_chunk04.ipp new file mode 100644 index 000000000..6ab652fcc --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk04.ipp @@ -0,0 +1,208 @@ + 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) { + 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& attribute) { + return attribute.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& 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(); + 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(), + [](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 != 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."}); + } + } + 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."}); + } + + // 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) + + ", 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) + + ", 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); + 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; +} + +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'; + 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 { + 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") << ",\"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() << "\"," + << "\"structuralFingerprint\":\"" << structural_fingerprint_hex() << "\"," + << "\"runtimeSnapshotFingerprint\":\"" << runtime_snapshot_fingerprint_hex() << "\"," + << "\"coverage\":{\"logicalDeviceCount\":" << coverage.logical_device_count \ No newline at end of file diff --git a/include/ariec61850/mms/live_model_chunk05.ipp b/include/ariec61850/mms/live_model_chunk05.ipp new file mode 100644 index 000000000..b285bff62 --- /dev/null +++ b/include/ariec61850/mms/live_model_chunk05.ipp @@ -0,0 +1,229 @@ + << ",\"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 + << ",\"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 + << ",\"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 + << ",\"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) << "\",\"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) << "\",\"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 << ','; + 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 << ','; + 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_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\":["; + 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) + << "\",\"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 << ']'; + + 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) + << "\",\"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 << ']'; + }; + 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 << ",\"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\":\"" + << 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(); \ No newline at end of file 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 diff --git a/include/ariec61850/mms/live_model_fingerprint.ipp b/include/ariec61850/mms/live_model_fingerprint.ipp new file mode 100644 index 000000000..da0355afa --- /dev/null +++ b/include/ariec61850/mms/live_model_fingerprint.ipp @@ -0,0 +1,157 @@ +// 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); + } + + 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); + }); + + 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 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)); + } 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" 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 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 diff --git a/include/ariec61850/mms/rcb_contention.hpp b/include/ariec61850/mms/rcb_contention.hpp new file mode 100644 index 000000000..e76224eb7 --- /dev/null +++ b/include/ariec61850/mms/rcb_contention.hpp @@ -0,0 +1,343 @@ +// 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) { + // C# chooses ResvTms using IsNullOrWhiteSpace before + // NormalizeProbeValue converts a literal "-" to empty. + return trim(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 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 diff --git a/include/ariec61850/mms/tcp_transport.hpp b/include/ariec61850/mms/tcp_transport.hpp new file mode 100644 index 000000000..d1ec6340d --- /dev/null +++ b/include/ariec61850/mms/tcp_transport.hpp @@ -0,0 +1,75 @@ +// 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 +#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/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; 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; }; 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; }; 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 diff --git a/patches/iedscout-cpa-responding-selector.patch b/patches/iedscout-cpa-responding-selector.patch new file mode 100644 index 000000000..0fad533aa --- /dev/null +++ b/patches/iedscout-cpa-responding-selector.patch @@ -0,0 +1,76 @@ +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."); + } + 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,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 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()); ++ ++ response.insert( ++ 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( ++ 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 +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}, ++ {"vendor CPA responding selector", vendor_cpa_accepts_responding_presentation_selector}, + {"session/context mirroring", response_mirrors_session_parameters_and_negotiated_context_ids}, diff --git a/scripts/compare-live-model-parity.py b/scripts/compare-live-model-parity.py new file mode 100644 index 000000000..f3daa33dd --- /dev/null +++ b/scripts/compare-live-model-parity.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""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 pathlib +import subprocess +import sys + + +def main() -> int: + parser = argparse.ArgumentParser() + 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() + + 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__": + raise SystemExit(main()) 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 diff --git a/scripts/run-live-readonly-interop.py b/scripts/run-live-readonly-interop.py new file mode 100644 index 000000000..dabb2697d --- /dev/null +++ b/scripts/run-live-readonly-interop.py @@ -0,0 +1,463 @@ +#!/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 + + +CONTROL_BLOCK_PROPERTIES = ( + "gooseControlBlocks", + "sampledValueControlBlocks", + "settingGroupControls", + "logControls", +) + + +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") + 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)] + 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( + 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, + *, + 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( + command, + 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 + + +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 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") + 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=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") + 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: + 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" + ) + 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.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: + 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.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" + ) + + 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]] = [] + structural_fingerprints: list[str] = [] + runtime_fingerprints: list[str] = [] + 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} + 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", []) + 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 + ) + + 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) + ) + and bool(coverage.get("dataAttributeCount", 0)), + "elapsedMs": elapsed_ms, + "fingerprint": structural_fingerprint, + "structuralFingerprint": structural_fingerprint, + "runtimeSnapshotFingerprint": runtime_fingerprint, + "legacyCanonicalFingerprint": legacy_fingerprint, + "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, + }) + + 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, + 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) + 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: + time.sleep(args.interval_ms / 1000.0) + + 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) + ) + rcb_complete_ok = ( + not args.require_rcb_complete + or ( + len(rcb_complete_flags) == args.cycles + 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-v3", + "endpoint": f"{args.host}:{args.port}", + "binary": os.fspath(binary), + "cycleCount": args.cycles, + "stableFingerprint": structural_stable, + "fingerprintKind": "structuralFingerprint", + "stableStructuralFingerprint": structural_stable, + "stableRuntimeSnapshotFingerprint": runtime_stable, + "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, + "controlBlockValuesRequested": args.control_block_values, + "controlBlockCompletenessRequired": args.require_control_block_complete, + "controlBlockCompletenessAccepted": control_block_complete_ok, + "accepted": accepted, + "cycles": cycles, + } + (output_dir / "interop-summary.json").write_text( + json.dumps(summary, indent=2) + "\n", + encoding="utf-8", + ) + print( + "Read-only interoperability acceptance: " + + ("PASS" if accepted else "FAIL") + + f" (structuralStable={structural_stable}, " + f"runtimeStable={runtime_stable}, " + f"rcbComplete={rcb_complete_ok}, " + f"controlBlockComplete={control_block_complete_ok})" + ) + return 0 if accepted else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) 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[@]}" 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 diff --git a/scripts/run-phase4c-physical-acceptance.py b/scripts/run-phase4c-physical-acceptance.py new file mode 100644 index 000000000..986270a06 --- /dev/null +++ b/scripts/run-phase4c-physical-acceptance.py @@ -0,0 +1,405 @@ +#!/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") + 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 + + +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("--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="") + 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.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: + 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.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" + ) + + 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")) + ) + control_block_accepted = bool( + discovery_summary.get("controlBlockCompletenessAccepted", True) + ) + fresh_association_count = ( + successful_discovery_associations + successful_contention_associations + ) + 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-v2", + "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)), + "controlBlockValuesRequested": args.control_block_values, + "controlBlockCompletenessRequired": args.require_control_block_complete, + "controlBlockCompletenessAccepted": control_block_accepted, + }, + "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": reassociation_accepted, + }, + "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"controlBlocks={control_block_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()) 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()) 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 diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index 9d5ba26d5..951aeab54 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 @@ -14,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) { @@ -25,7 +31,101 @@ 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; + } +} + +[[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 @@ -165,136 +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."); + } + auto bytes = transport_.receive(deadline, stop_token); + ++chunks; + if (bytes.empty()) { + throw MmsAssociationRuntimeError( + "MMS transport returned an empty chunk during COTP connect."); } - if (tpdu.kind == osi::CotpTpduKind::error || - tpdu.kind == osi::CotpTpduKind::disconnect_request) { + 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( - "Remote endpoint rejected the COTP connection."); + "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) + "."); + + 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 (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); - 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) + "."); - } - if (!response.aare.user_information) { - throw MmsAssociationRuntimeError( - "Accepted ACSE response did not contain MMS Initiate response data."); - } - 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; - - state_ = MmsAssociationRuntimeState::associated; - 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()) { + // 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) + + ", diagnostic=" + + std::to_string(response->aare.result_source_diagnostic) + "."); + } + + 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 = + "Accepted ACSE response did not expose MMS Initiate response data through the strict decoder."; + } + } + + 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); + } + used_csharp_compatibility = true; + acceptance_message = + std::string{profile.name} + ": " + compatibility_message + + (strict_failure.empty() + ? std::string{} + : " 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; + 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 { @@ -375,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()) { @@ -391,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; } } @@ -406,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()) { @@ -420,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; } } diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp new file mode 100644 index 000000000..d362fc628 --- /dev/null +++ b/src/mms/live_discovery.cpp @@ -0,0 +1,471 @@ +// 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 + +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(); +} + +[[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 { + 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 << ", 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() + << ", 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(); + result.association_profile = association_.active_association_profile(); + result.association_attempts = association_.association_attempts(); + + 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) { + 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 (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/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 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/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 diff --git a/src/osi/presentation.cpp b/src/osi/presentation.cpp index c510e8133..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,15 +594,76 @@ 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 == 0xA5U) { + 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."); + } + 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."); } 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."); @@ -602,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) { 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( 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; } diff --git a/tests/test_control_block_read.cpp b/tests/test_control_block_read.cpp new file mode 100644 index 000000000..1317e455a --- /dev/null +++ b/tests/test_control_block_read.cpp @@ -0,0 +1,262 @@ +// 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 +#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_attribute_paths_case_insensitively() { + using namespace ar::iec61850::mms; + MmsDiscoverySnapshot snapshot; + snapshot.domain_variables["IEDLD0"] = { + "LLN0$GO$gcb01$DatSet", + "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"); +} + +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.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); + 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"; + })); + + 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() { + 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(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"; + })); +} + +} // 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}, + {"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) { + 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; +} 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 diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp new file mode 100644 index 000000000..43817e457 --- /dev/null +++ b/tests/test_live_discovery.cpp @@ -0,0 +1,519 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#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 +#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, {"OLS501LD0"}, 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( + "OLS501LD0", "LLN0$ST$Mod$stVal"), + "OLS501LD0/LLN0$ST$Mod$stVal", + "OLS501LD0/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("OLS501LD0/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))); +} + +[[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 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); + } +} + +[[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); + 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.domain_count() == 1U); + 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"); + CHECK(result.data_set_directories.size() == 1U); + CHECK(result.report_controls.size() == 1U); + CHECK(!result.partial()); + + 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 == "MmsDomainKnownLogicalDeviceSuffix"); + 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); + 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); + + 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]); + 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 { + 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"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 5afa445a4..26debfdb2 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,102 @@ 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); + + 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 int main() { @@ -205,7 +302,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 +319,4 @@ int main() { std::cout << "Passed " << passed << "/" << tests.size() << " tests.\n"; return 0; -} +} \ No newline at end of file diff --git a/tests/test_mms.cpp b/tests/test_mms.cpp index 565d309d3..b9c9b23d4 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,118 @@ 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); +} + +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() { @@ -143,7 +256,13 @@ 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}, + {"RCB contention dash ResvTms", rcb_contention_dash_resvtms_does_not_fallback_to_resv}}; std::size_t passed = 0; for (const auto& [name, test] : tests) { diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index 7be150d15..5b1ff2f3b 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,8 +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}; @@ -228,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() { @@ -384,8 +480,9 @@ 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}, + {"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}, diff --git a/tests/test_osi_association.cpp b/tests/test_osi_association.cpp index 6a04fb567..2a52d2ca2 100644 --- a/tests/test_osi_association.cpp +++ b/tests/test_osi_association.cpp @@ -195,6 +195,36 @@ 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 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()); + + response.insert( + 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( + 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 +333,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}, diff --git a/tests/test_rcb_selection.cpp b/tests/test_rcb_selection.cpp new file mode 100644 index 000000000..6c31bb0f6 --- /dev/null +++ b/tests/test_rcb_selection.cpp @@ -0,0 +1,470 @@ +// 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 +#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", "Owner"} + : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv", "Owner"}; + 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, + std::vector owner = {}) { + mms::MmsReportControlEvidence evidence; + evidence.candidate = candidate; + evidence.requested_attributes = candidate.buffered + ? std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "ResvTms", "Owner"} + : std::vector{"DatSet", "RptID", "ConfRev", "RptEna", "Resv", "Owner"}; + + 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; + state.owner = std::move(owner); + 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)); +} + +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) { + 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; +} + +[[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"); + 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); +} + +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() { + 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(); + 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'; + return 1; + } +} diff --git a/tests/test_tcp_transport.cpp b/tests/test_tcp_transport.cpp new file mode 100644 index 000000000..2eeb4c6bd --- /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 < 0 || static_cast(written) != 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/compare_live_model_json.py b/tools/compare_live_model_json.py new file mode 100644 index 000000000..7b4a474c1 --- /dev/null +++ b/tools/compare_live_model_json.py @@ -0,0 +1,695 @@ +#!/usr/bin/env python3 +"""Compare ARIEC61850 C# and arstack61850 live model JSON exports. + +The default comparison is deliberately structural. Runtime DataSets and RCB +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. +""" + +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 _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 "" + 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(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 = "" + 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: 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: + 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: + 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", + "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 _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, + *, + 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: + _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: + _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_rcb_runtime(result, csharp.report_runtime, cpp.report_runtime) + + 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}, " + f"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}: " + 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": [ + { + "MmsDomain": "TESTIEDLD0", + "LogicalNodes": [ + { + "Name": "LLN0", + "LnClass": "LLN0", + "DataObjects": [ + { + "Reference": "TESTIEDLD0/LLN0.Mod", + "Attributes": [ + { + "ObjectReference": "TESTIEDLD0/LLN0.Mod.stVal", + "FunctionalConstraint": "ST", + "MmsTypeSignature": "BOOLEAN", + } + ], + } + ], + } + ], + } + ], + "ReportControls": base_csharp_rcbs, + "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": base_cpp_rcbs, + "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 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 + + +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 using common " + "cross-schema semantics" + ), + ) + 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()) diff --git a/tools/control_block_read_probe.cpp b/tools/control_block_read_probe.cpp new file mode 100644 index 000000000..ec7cf4e4b --- /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* 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 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; + } +} diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp new file mode 100644 index 000000000..72b17e516 --- /dev/null +++ b/tools/live_discover.cpp @@ -0,0 +1,724 @@ +// 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" +#include "ariec61850/mms/rcb_selection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +enum class RcbPlanMode : std::uint8_t { + none, + dynamic_data_set, + static_data_set, +}; + +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; + } + + [[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; + } +}; + +struct RcbAvailabilityCliOptions final { + bool requested{}; + 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) { + 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); +} + +[[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" + << "Options:\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 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" + << " --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" + << "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" + << " --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" + << "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" + << "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( + const mms::MmsRcbSelectionEvidence& selection, + const std::size_t maximum_candidates) { + std::cout << selection.summary() << '\n' + << "RCB candidate 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_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_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, + const std::optional& rcb_selection, + 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 + << " (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 + << " (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.control_block_value_reads_requested) { + print_control_block_values(model); + } + if (!model.warnings.empty()) { + std::cout << "Warnings:\n"; + for (const auto& warning : model.warnings) { + std::cout << " - [" << warning.code << "] " << warning.message << '\n'; + } + } + 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_availability) { + std::cout << '{' + << "\"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 << ',' + << "\"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(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\":\"" + << 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() + << '}'; + } + 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"; +} + +[[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); +} + +[[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); +} + +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) { + 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; + 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; + } + + 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; + RcbPlanCliOptions rcb_plan_options; + RcbAvailabilityCliOptions rcb_availability_options; + ControlBlockReadCliOptions control_block_read_options; + + while (argument < argc) { + const std::string option = argv[argument++]; + 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 == "--control-block-values") { + control_block_read_options.requested = true; + } 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") { + 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 == "--rcb-plan" || + option == "--rcb-ld" || option == "--rcb-dataset" || + option == "--preferred-rcb" || option == "--rcb-exclude" || + option == "--max-rcb-candidates" || + option == "--max-rcb-availability" || + option == "--max-control-blocks" || + option == "--max-control-block-attributes") { + 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; + } + 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; + } 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 (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 (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())) { + 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); + } + } + + 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_availability_options.requested && + rcb_availability_options.maximum_snapshots_to_print != 10U) { + 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( + "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()) { + 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() || rcb_availability_options.requested) && + (output_mode == OutputMode::model_json || + output_mode == OutputMode::manifest)) { + throw std::invalid_argument( + "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); + auto result = session.discover(discovery_options); + read_control_block_values(session, result, control_block_read_options); + session.disconnect(); + + 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); + + switch (output_mode) { + case OutputMode::human: + print_human( + 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, rcb_availability); + 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) { + std::cerr << "Live MMS discovery failed: " << exception.what() << '\n'; + return 2; + } +} diff --git a/tools/pagination_probe.cpp b/tools/pagination_probe.cpp new file mode 100644 index 000000000..3613512c6 --- /dev/null +++ b/tools/pagination_probe.cpp @@ -0,0 +1,405 @@ +// 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::vector names; + + [[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> seen; + 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 count_before = evidence.names.size(); + for (const auto& name : response.names) { + 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."); + } + evidence.names.push_back(name); + } + + if (!response.more_follows) { + return evidence; + } + if (response.names.empty() || evidence.names.size() == count_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.names.size() + << " 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.names.size() << ',' + << "\"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); + 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, + 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; + } +} 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; + } +}